Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ef88d696b | ||
|
|
891cf87c7f | ||
|
|
6182169b3e | ||
|
|
ab2fba868b | ||
|
|
796cee3d95 | ||
|
|
32c82e1246 | ||
|
|
fe2f64dc43 | ||
|
|
32c9109c71 | ||
|
|
7a42c83d3a | ||
|
|
8b1564eff4 | ||
|
|
b8b5583440 | ||
|
|
8f8ba3f290 | ||
|
|
fe856a8f38 | ||
|
|
583b453abf | ||
|
|
187787040b | ||
|
|
cd6e854bf1 | ||
|
|
61124f8768 | ||
|
|
6cfeac3179 | ||
|
|
acd293cc83 | ||
|
|
d2045c3441 | ||
|
|
4de41c27c7 | ||
|
|
5df2860e66 | ||
|
|
f3390b6875 | ||
|
|
55f25d8d48 | ||
|
|
67fbf65916 | ||
|
|
7f82e46adf | ||
|
|
1fb1ae3e23 | ||
|
|
fd0ab16c7f | ||
|
|
daaad35f5b | ||
|
|
953a36f4c9 | ||
|
|
e015f40ae2 | ||
|
|
84226b963c | ||
|
|
1d1c06aaae | ||
|
|
f34f9e8223 | ||
|
|
3519965285 | ||
|
|
a54ee24acb | ||
|
|
a7074bf53f | ||
|
|
21144af79b | ||
|
|
320582f98c | ||
|
|
e04ff5a12b | ||
|
|
3bdc30badb | ||
|
|
c569def067 | ||
|
|
eef94425e2 | ||
|
|
e17ffe5bff | ||
|
|
b82e975429 | ||
|
|
2808e71cb6 | ||
|
|
e83e3a3e2c | ||
|
|
584b1bd9d4 | ||
|
|
c350311048 | ||
|
|
158e7ec701 | ||
|
|
125a101487 | ||
|
|
ec99654854 | ||
|
|
5323129e5e | ||
|
|
014dea52f0 | ||
|
|
9aa76ea748 | ||
|
|
885c3fe021 | ||
|
|
d0d56cbaf5 | ||
|
|
9dbbd64f3c | ||
|
|
7ba12a8e4c | ||
|
|
dfba9546f4 | ||
|
|
20c2ff98c2 | ||
|
|
b3ba9fee97 | ||
|
|
c7ea642aef | ||
|
|
41372b0597 | ||
|
|
cffc48406c | ||
|
|
f9e3476d42 | ||
|
|
1e04e301ea | ||
|
|
5f038d440b | ||
|
|
2b48f27a4b | ||
|
|
bf79a67efe | ||
|
|
8936c20ef0 | ||
|
|
b5af90c089 | ||
|
|
a5ee9dffe2 | ||
|
|
95d262bbb2 | ||
|
|
d5c260c7c0 | ||
|
|
7179928c75 | ||
|
|
9c297d0520 | ||
|
|
37230d2d4d | ||
|
|
fde6b59074 | ||
|
|
e2abc70e84 | ||
|
|
15a27a9f83 | ||
|
|
8e48ede81a | ||
|
|
4567dd1cd6 | ||
|
|
75bb01bb64 | ||
|
|
11a7ab8b7c | ||
|
|
2e1cce8501 | ||
|
|
41e0fb381a | ||
|
|
870fba823b | ||
|
|
d083de5f84 | ||
|
|
1fb966dbff | ||
|
|
7987fb3a35 | ||
|
|
3d6a9c6232 | ||
|
|
195569ddfa | ||
|
|
7827856876 | ||
|
|
f79f41e930 | ||
|
|
bfd53df9b2 | ||
|
|
4db117893e | ||
|
|
b314749ef7 | ||
|
|
5133103a54 | ||
|
|
9faa332306 | ||
|
|
17fa1e1b34 | ||
|
|
af1f8ccd05 | ||
|
|
c26cbbbbb8 | ||
|
|
2733f510af | ||
|
|
abc62622fb |
@@ -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`,
|
||||
|
||||
@@ -6,6 +6,45 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.56-beta.2] - 2026-07-30
|
||||
|
||||
This beta adds PRs #831 and #835 on top of v1.0.56-beta.1. It separates
|
||||
Agent Product observability and IM display identity from the stable
|
||||
edition-owned PAT and routing identity, and reduces common-path Skill context
|
||||
loading without changing the public command or Runtime Schema surface.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Agent Product identity separation** (#831) — sends `DWS_AGENT_PRODUCT` through the new `x-dws-agent-product` observability Header and uses a valid non-empty value for the IM `clawType` display label whenever `--ai-tag` is enabled. Because `--ai-tag` defaults to `true`, callers that set `DWS_AGENT_PRODUCT` change the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls preserve their existing wire shape by sending an empty IM `clawType`, while shortcut calls omit the argument. Unset or empty Product values omit the Header and preserve the active edition's IM display default.
|
||||
- **Agent Host dimension convention** (#831) — new integrations should send the runtime form (`cloud` or `desktop`) through `DWS_AGENT_HOST` and report the product separately through `DWS_AGENT_PRODUCT`. Legacy combined labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
|
||||
- **Reduced common-path Skill context** (#835) — keeps the complete 97-command Chat Shortcut inventory in Runtime Catalog and leaf Schema while routing common intents through compact Skill tables and references. When an exact command path is already known, the mono Skill no longer requires eager loading of a complete product reference. The generated Skill policy now detects drift, forced full-reference loading, and context-budget regressions; the common Chat plus shared activation estimate drops from 7,301 to 4,771 `o200k_base` tokens without changing the 845-tool Schema surface.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Stable PAT/routing identity** (#831) — restores the CLI-emitted open-source HTTP `claw-type` and PAT `hostControl.clawType` to the edition-fixed `openClaw` value. `DWS_AGENT_PRODUCT` no longer changes those wire values, and the client continues to derive PAT, authentication, routing, and Discovery behaviour from the existing independent signals.
|
||||
- **Portable generated Skill validation** (#835) — resolves the mono Skill name by scanning upward from the generated target, keeping `--check` independent of the repository checkout path and preventing false drift failures when an ancestor directory resembles a Skill name.
|
||||
|
||||
## [1.0.56-beta.1] - 2026-07-30
|
||||
|
||||
This beta starts the v1.0.56 line on top of v1.0.55 and packages PRs #817,
|
||||
#806, and #834, together with release-validation fixes #838 and #839. It closes
|
||||
the remaining Agent-visible IM shortcut gaps, introduces reviewed
|
||||
command-scoped parameter normalization without guessing business identifiers
|
||||
or values, and prevents deterministic personal-event subscription failures
|
||||
from becoming unbounded retry storms.
|
||||
|
||||
### Added
|
||||
|
||||
- **Complete IM shortcut workflows** (#817) — publishes the previously excluded `+chat-messages`, `+messages-send`, `+messages-send-card`, `+search-msg`, and `+thread-replies` shortcuts in Runtime Schema. Unified send, streaming-card delivery, advanced search, thread replies, and opt-in resource downloads now share reviewed parameters, selection guidance, and runtime-aligned safety semantics.
|
||||
- **Reviewed parameter concept normalization** (#806) — adds a closed parameter-concept dictionary and generated command-level alias table, covering reviewed IM synonyms while preserving the boundaries between group, conversation, user, open-user, cursor, and paging identifiers.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Message delivery and resource handling** (#817) — resolves direct recipients through exact contact search, preserves rich and nested message resources, avoids same-name download overwrites, and prevents read shortcuts from silently returning empty results on non-interactive input.
|
||||
- **Parameter parsing safety** (#806) — rejects ambiguous, blocked, or conflicting aliases before dispatch, normalizes explicit boolean values such as `--dry-run false`, and keeps internal pre-parse handler details out of user-visible errors.
|
||||
- **Personal-event subscription retry safety** (#834) — adds cross-process attempt claims, deterministic backoff and jitter, `Retry-After` handling, terminal holds, compare-and-swap completion, and fail-closed state handling across all public personal-event subscriptions, preventing deterministic failures from causing unbounded callback retries.
|
||||
- **Scoped CI and release validation reliability** (#838, #839) — keeps scoped coverage aligned with intentionally skipped supporting profiles, gives focused race and Multi-profile E2E suites enough time for the current `internal/app` workload, and preserves hidden E2E diagnostics on failure.
|
||||
|
||||
## [1.0.55-beta.8] - 2026-07-30
|
||||
|
||||
This beta revalidates the `v1.0.55-beta.7` product baseline through a complete
|
||||
|
||||
@@ -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.8"
|
||||
version "1.0.56-beta.2"
|
||||
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.8/dws-darwin-arm64.tar.gz"
|
||||
sha256 "07fabf720fa98f82c56027df703a3ad3f0aec16c957ea684047928f9f74fa00f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "19b52b5427dbf24acfeb1da16a93e6edfd0443389a778abbbfd381ff8f656139"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-darwin-amd64.tar.gz"
|
||||
sha256 "fbec64dc5c3463a04de9720ffb1fd247196e619ea81b976b47ecbf751a17fb72"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "621da52d04f391234d160a0522d70c1fb2e36da59102ef201a9a3a11b706b3e8"
|
||||
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.8/dws-linux-arm64.tar.gz"
|
||||
sha256 "f111cdffef0188ddf954d2174fa0d318069bcec80104775483f13f645aa8089b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "4ba956463f4b583c1727f58026a6bc1fb23d27537083e3bafd541a05ab15b48b"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-linux-amd64.tar.gz"
|
||||
sha256 "d69475b7f3cec4bad4c051834df22fbfadfa7075b82347e6ca7490f67d71d0b1"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "a8156ec5b89355faf8c08a65d9c088f89a7b411a418fb4b488f3d472efc79670"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.8/dws-skills.zip"
|
||||
sha256 "be8c9267704cfef1319fc9cd2fcba5aebe45b670b4dc37d3dae15f65523356f8"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-skills.zip"
|
||||
sha256 "92c71fdeade88b3b76a00cb74ebd3223cc4110c7ee151d36d782537613129967"
|
||||
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
|
||||
|
||||
@@ -8,7 +8,7 @@ POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
|
||||
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -30,6 +30,7 @@ help:
|
||||
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
|
||||
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
|
||||
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
|
||||
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
|
||||
@printf " make cli-smoke - Verify help for every public top-level command\n"
|
||||
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
|
||||
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
|
||||
@@ -84,9 +85,13 @@ fmt:
|
||||
policy: test-auth-legacy-compat
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-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
|
||||
@@ -118,6 +123,9 @@ schema-compatibility:
|
||||
skill-command-integrity:
|
||||
@./scripts/policy/check-skill-commands.sh
|
||||
|
||||
skill-context-budget:
|
||||
@./scripts/policy/check-skill-context-budget.sh
|
||||
|
||||
cli-smoke:
|
||||
@./scripts/policy/check-cli-smoke.sh
|
||||
|
||||
@@ -130,10 +138,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 +153,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; \
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
# IM Chat Skill 精简与渐进加载优化方案
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 `im-chat-skill-hint-align` 分支已经增强了 Chat Skill 的 Shortcut 路由、执行骨架、身份边界、查询与资源处理、低频原子回退和错误导航。与 Lark IM Skill 对比后,DWS 在 Agent 路由和执行约束上更直接,但根 Skill 仍存在以下问题:
|
||||
|
||||
- 高频执行骨架与核心意图表重复。
|
||||
- Runtime Shortcut Catalog、leaf Schema 和 leaf Help 的读取规则在多处重复。
|
||||
- Shortcut 错误处理与 Workflow 错误导航重复。
|
||||
- 身份、ID 和三种置顶对象的边界分散在不同章节。
|
||||
- 缺少简短、集中且可复用的核心对象与查询结果语义。
|
||||
- Frontmatter 能力召回仍可扩展,但不能削弱 DING、邮件和班级群等产品边界。
|
||||
|
||||
本方案只调整根文件 `skills/multi/dingtalk-chat/SKILL.md` 的组织和必要语义,不把 API 手册、完整 Shortcut 清单或权限表重新放回根 Skill。
|
||||
|
||||
## 2. 修改目标
|
||||
|
||||
1. 提高高频 Chat 意图的直接命中率,减少不必要的 Catalog、Schema 和 `--help` 调用。
|
||||
2. 提前建立渐进加载顺序,避免模型在高频任务中优先进入原子命令树。
|
||||
3. 补齐身份、核心对象、ID 和查询结果的必要语义,降低错误重试和 ID 混用。
|
||||
4. 合并重复 SOP,确保新增内容不会增加根 Skill 的总体 token。
|
||||
5. 保持参数、安全和完整 API 事实由 leaf Schema、Help 和 references 按需提供。
|
||||
|
||||
## 3. 设计原则
|
||||
|
||||
### 3.1 根 Skill 只保留决策必需信息
|
||||
|
||||
根 Skill 应负责:
|
||||
|
||||
- Skill 触发范围和跨产品排除边界。
|
||||
- 渐进加载与能力选择顺序。
|
||||
- 高频意图到精确 Shortcut 的映射。
|
||||
- 身份、ID、幂等、分页、部分失败等跨命令不变量。
|
||||
- 低频能力的导航入口。
|
||||
- 错误恢复和停止条件。
|
||||
|
||||
以下内容继续留在 Schema 或 references:
|
||||
|
||||
- 完整 Shortcut Catalog。
|
||||
- API Resources 全量列表。
|
||||
- 权限 scope 表。
|
||||
- 叶级参数全集和接口字段格式。
|
||||
- 只服务单个命令的实现细节。
|
||||
|
||||
### 3.2 渐进加载规则必须早于命令骨架
|
||||
|
||||
模型应在看到具体命令前,先知道何时直接执行、何时才加载额外上下文。但完整一级命令树不应提前,以免低频原子命令干扰高频 Shortcut 选择。
|
||||
|
||||
### 3.3 同一事实只保留一个权威位置
|
||||
|
||||
- 高频 Shortcut 只出现在一张核心意图表中。
|
||||
- Catalog、Schema 和 Help 的读取顺序只定义一次。
|
||||
- 错误恢复与 reference 导航只定义一次。
|
||||
- 身份、对象和 ID 的公共边界集中定义,后续章节只引用,不重复解释。
|
||||
|
||||
## 4. 目标章节结构
|
||||
|
||||
```text
|
||||
1. Frontmatter
|
||||
2. Preconditions
|
||||
3. 加载与路由顺序
|
||||
4. 核心对象与 ID
|
||||
5. 核心意图与执行骨架
|
||||
6. 统一发送
|
||||
7. 查询、资源与卡片
|
||||
8. 低频原子路由
|
||||
├── 一级命令树
|
||||
├── branch references
|
||||
└── 低频操作回退表
|
||||
9. 错误恢复与按需 Reference
|
||||
10. 跨产品协作
|
||||
```
|
||||
|
||||
## 5. 具体修改
|
||||
|
||||
### 5.1 扩展 Frontmatter 产品能力
|
||||
|
||||
扩展 `description` 的正向能力召回,覆盖:
|
||||
|
||||
- 单聊、群聊、建群、群搜索和群成员管理。
|
||||
- 消息发送、回复、转发、撤回、查询和聊天记录搜索。
|
||||
- 图片、文件和消息资源下载。
|
||||
- 表情回应、收藏、Pin、消息置顶和会话置顶。
|
||||
- 应用机器人、Webhook 和互动卡片。
|
||||
- 未读、红点、消息已读状态和会话分类。
|
||||
|
||||
同时保留明确排除:
|
||||
|
||||
- DING、短信和电话转到 `dingtalk-ding`。
|
||||
- 邮件转到 `dingtalk-mail`。
|
||||
- 班级群转到对应的低频产品 Skill。
|
||||
- 找人本身由 `dingtalk-contact` 或 `dingtalk-aisearch` 负责,Chat 只消费真实人员 ID。
|
||||
|
||||
Frontmatter 只描述真实能力和路由边界,不加入参数、SOP 或 token 实现细节。
|
||||
|
||||
### 5.2 前移并合并渐进加载规则
|
||||
|
||||
将现有“Shortcut 发现”“Shortcut 执行契约”和“渐进加载与一级路由”的加载决策部分合并为紧随 Preconditions 的唯一章节:
|
||||
|
||||
```markdown
|
||||
## 加载与路由顺序
|
||||
|
||||
1. 已知高频意图:直接使用“核心意图与执行骨架”,不查 Help。
|
||||
2. 已有匹配 Shortcut:直接执行;参数、约束或安全不确定时才查 leaf Schema。
|
||||
3. 仅 Cobra flags 不确定时查 leaf `--help`。
|
||||
4. 现有路由无法定位低频能力时,才查 Runtime Shortcut Catalog。
|
||||
5. 没有 Shortcut 时,按需读取对应 branch reference,进入原子命令。
|
||||
```
|
||||
|
||||
同一章节保留以下公共规则:
|
||||
|
||||
- 路由优先级为 `exact recipe/runnable script > public Shortcut > atomic command`。
|
||||
- 不猜测 `cli_path` 或参数名称。
|
||||
- `confirmation=user_required` 时先确认,再添加 `--yes`。
|
||||
- 来源冲突时采用更安全的解释并报告契约漂移。
|
||||
- 命令已确定且参数清楚时直接执行,不为验证已知路径重复发现。
|
||||
|
||||
删除其他章节重复出现的 Catalog、Schema、Help 选择说明。
|
||||
|
||||
### 5.3 新增“核心对象与 ID”小表
|
||||
|
||||
增加不超过 8 行的表格,集中表达:
|
||||
|
||||
| 对象 | 核心标识与边界 |
|
||||
|---|---|
|
||||
| 人员 | 姓名必须先解析成唯一真实的 `userId` 或 `openDingTalkId`,名称不能作为 ID 传递 |
|
||||
| 会话 | 使用真实 `openConversationId` / cid;群名只能用于 Shortcut 的目标解析 |
|
||||
| 消息 | 使用真实 `openMessageId` / msgId,并保持与身份及会话一致 |
|
||||
| 发送任务 | `openTaskId` 只用于查询发送状态,不能替代消息 ID |
|
||||
| Thread | thread/topic ID 必须绑定真实会话,不跨会话复用 |
|
||||
| 身份 | current-user、app-bot 和 Webhook 是不同操作者,不能自动互换 |
|
||||
| 状态 | 收藏、消息置顶、消息 Pin 和会话置顶作用于不同对象 |
|
||||
|
||||
新增后删除后文对这些边界的重复说明。
|
||||
|
||||
### 5.4 合并高频骨架与核心意图表
|
||||
|
||||
删除独立的“高频直接执行骨架”,将其全部合入唯一的“核心意图与执行骨架”表。表格固定为三列:
|
||||
|
||||
| 用户意图 | 精确 Shortcut 骨架 | 必须保留的执行边界 |
|
||||
|---|---|---|
|
||||
|
||||
至少覆盖以下高频场景:
|
||||
|
||||
- 姓名发单聊、群名发群消息。
|
||||
- user、bot、webhook 三种身份发送。
|
||||
- 建群、改群名、拉人和成员查询。
|
||||
- 拉取会话消息、查询详情、撤回和发送状态。
|
||||
- 关键词搜索、组合搜索和查询 @ 我的消息。
|
||||
- 群邀请链接和群机器人。
|
||||
- 会话置顶和收藏列表。
|
||||
- 查和某人的聊天记录。
|
||||
- 群消息翻页导出。
|
||||
- 机器人多群广播。
|
||||
|
||||
表中直接给出正确参数骨架;命中后照抄参数名,不先调用 `--help`。同一个 Shortcut 不再在其他表中重复列出。
|
||||
|
||||
### 5.5 补充统一身份规则
|
||||
|
||||
在“统一发送”开头加入统一规则:
|
||||
|
||||
> 身份决定真实操作者、可见范围和可用能力;同一目标使用 user、bot 或 webhook 时,结果和权限可能不同,禁止自动切换身份重试。
|
||||
|
||||
继续保留:
|
||||
|
||||
- 发送前检查身份、目标、正文、标题、@、消息类型和附件路径。
|
||||
- 重试复用相同 `--idempotency-key`。
|
||||
- user、bot、webhook 的精确发送模板。
|
||||
- @ 占位符、新行和文件能力边界。
|
||||
|
||||
不加入 Lark 的 access token 类型说明。
|
||||
|
||||
### 5.6 补充查询结果与增强失败语义
|
||||
|
||||
在“查询、资源与卡片”中增加:
|
||||
|
||||
- 发送者名称缺失时保留真实 ID,不猜姓名,也不自动扩大通讯录查询。
|
||||
- 可选增强字段缺失不代表主查询失败;增强请求失败时保留主结果并写入 per-item ledger。
|
||||
|
||||
继续保留:
|
||||
|
||||
- `--page-all` 只在确需完整分页时使用。
|
||||
- 部分失败保留已有结果,禁止把不完整结果声明为完整。
|
||||
- 资源下载默认关闭,显式请求后才增加请求和本地输出。
|
||||
- 子消息资源优先使用子 `messageId`。
|
||||
- 输出路径、覆盖、HTTPS 和重定向安全限制。
|
||||
|
||||
### 5.7 拆分“渐进加载”与“一级命令树”
|
||||
|
||||
前移的只有加载决策。完整一级命令树及 branch references 改名为“低频原子路由”,保留在高频意图、统一发送和查询规则之后。
|
||||
|
||||
这样可以:
|
||||
|
||||
- 防止高频任务优先进入 atomic branch。
|
||||
- 降低不必要的 Schema 和 Help 查询。
|
||||
- 继续为没有 Shortcut 的能力提供确定导航。
|
||||
|
||||
低频原子回退表继续保留收藏、编辑、外部群升级、群昵称、分类、共同群、群公告、群身份、置顶、未读、已读、授权、退群和解散群等差异化入口。
|
||||
|
||||
### 5.8 合并错误恢复与 Workflow 导航
|
||||
|
||||
将现有“Shortcut 错误处理”和“Workflow 与错误导航”合并为:
|
||||
|
||||
```markdown
|
||||
## 错误恢复与按需 Reference
|
||||
```
|
||||
|
||||
只保留以下规则:
|
||||
|
||||
- 路径或参数错误时,按 Catalog、Schema、Help 的既定顺序校正一次。
|
||||
- 始终从实际输出重新提取下游 ID。
|
||||
- 复杂消息任务按需读取 `01-messaging.md`。
|
||||
- Onboarding 按需读取对应 workflow。
|
||||
- 命令错误按需读取 `chat-error-recovery.md`。
|
||||
- 权限不足、歧义未消除、无结果或契约冲突时停止并报告。
|
||||
|
||||
删除其他位置重复的 `01-messaging.md` 和错误恢复入口。
|
||||
|
||||
### 5.9 保留跨产品协作边界
|
||||
|
||||
继续保留根 Skill 中不可由 Chat 自己完成的路由:
|
||||
|
||||
- 人名解析到 Contact / AISearch。
|
||||
- DING、短信和电话到 Ding Skill。
|
||||
- 邮件到 Mail Skill。
|
||||
- 本地文件与已有 mediaId 的发送差异。
|
||||
|
||||
如果某项边界已经在 Frontmatter 或核心对象表中完整表达,正文只保留执行阶段真正需要的补充,不重复整段说明。
|
||||
|
||||
## 6. 删除与合并清单
|
||||
|
||||
| 当前内容 | 处理方式 |
|
||||
|---|---|
|
||||
| “Shortcut 发现(按需)” | 合入前置“加载与路由顺序” |
|
||||
| “Shortcut 执行契约” | 公共规则合入前置章节 |
|
||||
| “高频直接执行骨架” | 删除,内容合入核心意图表 |
|
||||
| “渐进加载与一级路由”中的加载说明 | 前移并去重 |
|
||||
| 完整一级命令树 | 保留,改放“低频原子路由” |
|
||||
| “Shortcut 错误处理” | 合入统一错误章节 |
|
||||
| “Workflow 与错误导航” | 合入统一错误章节 |
|
||||
| 分散的身份、ID、置顶说明 | 合入核心对象表或统一身份规则 |
|
||||
| 重复的 `01-messaging.md` 入口 | 只保留一处 |
|
||||
|
||||
## 7. 不纳入本次修改
|
||||
|
||||
- 不展开 97 个公开 Shortcut。
|
||||
- 不复制 Lark 的完整 API Resources 和权限 scope 表。
|
||||
- 不在根 Skill 中维护 leaf 参数全集。
|
||||
- 不引入与当前 CLI 不一致的新命令或参数。
|
||||
- 不改变 Schema、Help、Runtime Catalog 和 reference 的事实优先级。
|
||||
- 不通过增加默认查询、自动通讯录查询或默认资源增强来换取结果丰富度。
|
||||
|
||||
## 8. 实施顺序
|
||||
|
||||
1. 更新 Frontmatter description,确认能力召回和排除边界。
|
||||
2. 合并并前移“加载与路由顺序”。
|
||||
3. 新增“核心对象与 ID”表,删除相应重复边界。
|
||||
4. 合并高频骨架和核心意图表。
|
||||
5. 补充统一身份规则。
|
||||
6. 补充查询结果和增强失败语义。
|
||||
7. 将一级命令树调整为后置的“低频原子路由”。
|
||||
8. 合并错误恢复与 reference 导航。
|
||||
9. 全文检查重复命令、重复 reference 和冲突参数。
|
||||
10. 运行 Skill 格式及相关策略测试,并用高频场景做静态路由验证。
|
||||
|
||||
## 9. 验收标准
|
||||
|
||||
### 9.1 内容与结构
|
||||
|
||||
- 根 Skill 中只有一份 Catalog、Schema、Help 读取顺序。
|
||||
- 根 Skill 中只有一张高频意图与 Shortcut 骨架表。
|
||||
- 根 Skill 中只有一个错误恢复章节。
|
||||
- `01-messaging.md` 的同类导航不重复。
|
||||
- 核心对象与 ID 表不超过 8 行数据。
|
||||
- 一级原子命令树位于高频路由之后。
|
||||
- Frontmatter 同时覆盖主要产品能力和明确排除边界。
|
||||
|
||||
### 9.2 执行行为
|
||||
|
||||
- “发给某人”“发到某群”“查 @ 我”“改群名”等高频意图可直接选中已评审 Shortcut,不先查 Help。
|
||||
- 低频未知意图才触发 Runtime Shortcut Catalog。
|
||||
- 参数或安全不确定时读取 leaf Schema;只有 Cobra flags 不确定时读取 leaf Help。
|
||||
- user、bot、webhook 不被自动互换。
|
||||
- `openTaskId`、消息 ID、会话 ID 不混用。
|
||||
- 发送者名称或 reaction 等增强缺失时,不把主查询误判为失败。
|
||||
- 部分失败保留已有结果并明确报告 ledger/completeness。
|
||||
|
||||
### 9.3 Token 与维护成本
|
||||
|
||||
- 修改后的根 Skill 不超过当前 211 行,并以不丢失必要路由和边界为前提尽量低于 195 行。
|
||||
- 文件单词数和字符数不高于修改前基线。
|
||||
- 新增内容通过删除重复 SOP 抵消。
|
||||
- 不新增完整 Shortcut、API 或权限清单。
|
||||
|
||||
## 10. 预期收益
|
||||
|
||||
- 减少高频任务中的 `--help` 和重复 Schema 查询。
|
||||
- 降低因身份切换、ID 混用和发送者名称缺失导致的错误重试。
|
||||
- 让 Shortcut、Schema、Help、Catalog 和 references 各自保持单一职责。
|
||||
- 在不增加默认 token 和耗时的前提下,提高 Chat Skill 的选择准确率和执行成功率。
|
||||
- 降低后续新增 Shortcut 时同时维护多张表和多处规则的漂移风险。
|
||||
@@ -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
|
||||
|
||||
+47
-25
@@ -5,8 +5,8 @@
|
||||
| Variable | Purpose / 用途 |
|
||||
|---------|---------|
|
||||
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
|
||||
| `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_AGENT_PRODUCT` | Optional, caller-declared Agent product sent as `x-dws-agent-product` (for example `qwenwork`) for downstream logs/BI and used as the IM `clawType` display label when `--ai-tag` is enabled. `--ai-tag` defaults to `true`, so a configured Product changes the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls send an empty `clawType`, while shortcut calls omit the argument. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values omit the Header and use the edition's IM display default. This client never uses Product to change the separate HTTP `claw-type` PAT/routing label. / 可选、由调用方声明的 Agent 产品标识,经校验后作为 `x-dws-agent-product` 发送,并用于 IM 小尾巴;`--ai-tag` 默认为 `true`,因此配置 Product 后默认会改变展示标签。使用 `--ai-tag=false` 时,原生 `chat message send` / `reply` 发送空的 `clawType`,shortcut 调用则省略该参数。未设置时省略请求头且 IM 使用发行版默认值;本客户端不会用 Product 修改独立的 HTTP `claw-type` |
|
||||
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`) for downstream logs/BI. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. This client does not use Host for PAT, authentication, Discovery, or MCP endpoint selection. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送给下游日志/BI;本客户端不使用该值进行 PAT、鉴权、Discovery 或 MCP 端点选择,未设置时省略 |
|
||||
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
|
||||
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
|
||||
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
|
||||
@@ -14,23 +14,36 @@
|
||||
| `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 产品与运行形态的信任模型
|
||||
### Agent Product, Host, and `claw-type` / Agent 产品、运行形态与 `claw-type`
|
||||
|
||||
`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` and `DWS_AGENT_HOST` are caller-declared observation
|
||||
signals. They are not credentials, attestations, or proof of the calling
|
||||
host's identity. The CLI validates and emits `x-dws-agent-product` and
|
||||
`x-dws-agent-host`, but does not use either value to derive its authentication,
|
||||
PAT mode, Discovery behaviour, or ordinary MCP endpoint selection. Downstream
|
||||
services own and must document their own contracts for these caller-declared
|
||||
Headers.
|
||||
|
||||
`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.
|
||||
Service integrators should treat both Headers as untrusted input, allowlist
|
||||
expected values, and should not grant access, bypass authentication, or skip
|
||||
authorization solely because a Header claims a particular Product or Host.
|
||||
|
||||
The HTTP `claw-type` Header is a separate, edition-fixed PAT/routing label:
|
||||
`openClaw` in the open-source build. `DWS_AGENT_PRODUCT` never changes it or
|
||||
PAT `hostControl.clawType`. On IM send/reply operations with `--ai-tag`,
|
||||
however, a valid non-empty Product value is used as the `clawType` tool
|
||||
argument so the delivered message carries the matching “Send from AI” label.
|
||||
Because `--ai-tag` defaults to `true`, this display change is enabled by
|
||||
default for callers that set Product. With `--ai-tag=false`, native
|
||||
`chat message send` / `reply` calls serialize `clawType: ""`, while shortcut
|
||||
calls omit the argument; this client does not assume downstream services treat
|
||||
an empty value and an absent key as equivalent. The display-value precedence
|
||||
when the tag is enabled is valid non-empty `DWS_AGENT_PRODUCT`, then the active
|
||||
edition's `ClawTypeValue`, then `openClaw`.
|
||||
|
||||
Do not set arbitrary Product values that the target downstream and IM services
|
||||
have not explicitly enabled; an unknown value may be ignored or may not render
|
||||
the expected label.
|
||||
|
||||
For QwenWork, report the dimensions separately:
|
||||
|
||||
@@ -39,16 +52,25 @@ 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.
|
||||
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` 决定。
|
||||
真实宿主身份。CLI 只负责校验并发送 `x-dws-agent-product` 与 `x-dws-agent-host`,
|
||||
不会用它们派生本客户端的鉴权、PAT 模式、Discovery 行为或 MCP 端点;下游服务的
|
||||
使用契约由对应服务自行定义和说明。HTTP `claw-type` 是发行版固定的 PAT/路由标签,
|
||||
开源版固定为 `openClaw`,不受 `DWS_AGENT_PRODUCT` 影响。
|
||||
|
||||
服务集成方应将这两个请求头视为不可信输入并对白名单值做校验,不应仅因请求头声明了
|
||||
某个 Product 或 Host 就授予访问、绕过认证或跳过鉴权。
|
||||
|
||||
`--ai-tag` 默认为 `true`,因此配置合法非空 Product 后,默认发送的 IM 工具参数
|
||||
`clawType` 及小尾巴会随之改变。传入 `--ai-tag=false` 时,原生
|
||||
`chat message send` / `reply` 会发送 `clawType: ""`,shortcut 调用则省略该参数;
|
||||
本客户端不假定下游会将空值与键缺失等价处理。启用小尾巴时,展示值优先级依次为
|
||||
`DWS_AGENT_PRODUCT`、当前发行版的 `ClawTypeValue`、`openClaw`。不要传入目标下游及
|
||||
IM 服务未明确支持的 Product 值,否则可能被忽略或无法展示预期标签。
|
||||
|
||||
## Exit Codes / 退出码
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: envDWSAgentHost,
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "调用 DWS 的 Agent 运行形态标识;服务端可结合产品用于观测和 PAT 兼容策略",
|
||||
Description: "调用 DWS 的 Agent 运行形态标识;作为 x-dws-agent-host 发送供下游观测,本客户端不使用该值改变 PAT、鉴权或路由",
|
||||
Example: "cloud",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: agentproduct.EnvName,
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "调用方声明的 Agent 产品标识;覆盖 HTTP claw-type,但不是认证凭据",
|
||||
DefaultValue: "由当前发行版决定",
|
||||
Description: "调用方声明的 Agent 产品标识;作为 x-dws-agent-product 发送并用于 IM 小尾巴,本客户端不使用该值改变 HTTP claw-type/PAT",
|
||||
DefaultValue: "未设置(请求头省略,IM 使用当前发行版默认值)",
|
||||
Example: "qwenwork",
|
||||
})
|
||||
}
|
||||
@@ -47,25 +47,26 @@ func invalidAgentProductError() error {
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
// resolveEditionClawType resolves the fixed routing/PAT identity supplied by
|
||||
// the active edition. DWS_AGENT_PRODUCT is deliberately not consulted.
|
||||
func resolveEditionClawType(headers map[string]string) string {
|
||||
if value := headers["claw-type"]; value != "" {
|
||||
return value
|
||||
}
|
||||
value, err := agentproduct.ResolveFromEnv(fallback)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
return edition.DefaultOSSClawType
|
||||
}
|
||||
|
||||
func applyAgentProductOverride(headers map[string]string) map[string]string {
|
||||
value := resolveEffectiveAgentProduct(headers)
|
||||
// applyAgentProductHeader injects only a valid, non-empty caller-declared
|
||||
// product. Invalid values are omitted on library paths that bypass root
|
||||
// validation; normal CLI execution rejects them before network access.
|
||||
func applyAgentProductHeader(headers map[string]string) map[string]string {
|
||||
value, err := agentproduct.ResolveFromEnv("")
|
||||
if err != nil || value == "" {
|
||||
if headers != nil {
|
||||
delete(headers, agentproduct.HeaderName)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
if headers == nil {
|
||||
headers = make(map[string]string)
|
||||
}
|
||||
|
||||
@@ -26,13 +26,16 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestUnsetAgentProductKeepsOpenSourceDefault(t *testing.T) {
|
||||
func TestUnsetAgentProductOmitsHeaderAndKeepsOpenSourceClawType(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)
|
||||
if got := headers["claw-type"]; got != edition.DefaultOSSClawType {
|
||||
t.Fatalf("claw-type = %q, want %q", got, edition.DefaultOSSClawType)
|
||||
}
|
||||
if _, ok := headers[agentproduct.HeaderName]; ok {
|
||||
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,38 +62,46 @@ func TestParseAgentProductReturnsStableValidationError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersAgentProductPrecedence(t *testing.T) {
|
||||
func TestResolveIdentityHeadersSeparatesAgentProductFromClawType(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["claw-type"] = "wukong"
|
||||
headers[agentproduct.HeaderName] = "merge-product-must-not-win"
|
||||
headers["x-edition-header"] = "preserved"
|
||||
return headers
|
||||
},
|
||||
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
|
||||
headers[agentproduct.HeaderName] = "enterprise-default"
|
||||
headers["claw-type"] = "credential-must-not-win"
|
||||
headers[agentproduct.HeaderName] = "credential-product-must-not-win"
|
||||
headers["x-enterprise-header"] = "preserved"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
t.Run("unset keeps edition default", func(t *testing.T) {
|
||||
t.Run("unset omits Product and keeps edition claw-type", 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)
|
||||
if got := headers["claw-type"]; got != "wukong" {
|
||||
t.Fatalf("claw-type = %q, want wukong", got)
|
||||
}
|
||||
if _, ok := headers[agentproduct.HeaderName]; ok {
|
||||
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid override is final", func(t *testing.T) {
|
||||
t.Run("valid Product is final without changing claw-type", 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["claw-type"]; got != "wukong" {
|
||||
t.Fatalf("claw-type = %q, want wukong", got)
|
||||
}
|
||||
if got := headers["x-edition-header"]; got != "preserved" {
|
||||
t.Fatalf("edition header = %q, want preserved", got)
|
||||
}
|
||||
@@ -102,21 +113,48 @@ func TestResolveIdentityHeadersAgentProductPrecedence(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid library input falls back to edition", func(t *testing.T) {
|
||||
t.Run("invalid library input omits Product and keeps edition claw-type", 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)
|
||||
if got := headers["claw-type"]; got != "wukong" {
|
||||
t.Fatalf("claw-type = %q, want wukong", got)
|
||||
}
|
||||
if _, ok := headers[agentproduct.HeaderName]; ok {
|
||||
t.Fatalf("invalid Product must omit %s", agentproduct.HeaderName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyAgentProductOverrideAllocatesHeaders(t *testing.T) {
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
func TestApplyAgentProductHeader(t *testing.T) {
|
||||
t.Run("valid value allocates headers", func(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)
|
||||
headers := applyAgentProductHeader(nil)
|
||||
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
|
||||
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "empty value", value: ""},
|
||||
{name: "invalid value", value: "qwen work"},
|
||||
} {
|
||||
t.Run(tc.name+" removes inherited header", func(t *testing.T) {
|
||||
t.Setenv(agentproduct.EnvName, tc.value)
|
||||
headers := applyAgentProductHeader(map[string]string{
|
||||
agentproduct.HeaderName: "must-not-leak",
|
||||
"x-preserved": "yes",
|
||||
})
|
||||
if _, ok := headers[agentproduct.HeaderName]; ok {
|
||||
t.Fatalf("%s must be omitted", agentproduct.HeaderName)
|
||||
}
|
||||
if got := headers["x-preserved"]; got != "yes" {
|
||||
t.Fatalf("x-preserved = %q, want yes", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,17 +205,17 @@ func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T)
|
||||
hookCalled := false
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
headers[agentproduct.HeaderName] = "wukong"
|
||||
headers["claw-type"] = "wukong"
|
||||
return headers
|
||||
},
|
||||
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
|
||||
hookCalled = true
|
||||
headers[agentproduct.HeaderName] = "enterprise-default"
|
||||
headers["claw-type"] = "enterprise-default"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
if got := effectiveClawType(); got != "wukong" {
|
||||
t.Fatalf("effectiveClawType() = %q, want wukong", got)
|
||||
}
|
||||
@@ -186,7 +224,7 @@ func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProductHeaderIsSeparateFromMessageClawType(t *testing.T) {
|
||||
func TestAgentProductControlsObservabilityHeaderAndMessageClawTypeOnly(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
|
||||
@@ -195,20 +233,24 @@ func TestAgentProductHeaderIsSeparateFromMessageClawType(t *testing.T) {
|
||||
edition.Override(&edition.Hooks{
|
||||
ClawTypeValue: "message-brand",
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
headers[agentproduct.HeaderName] = "wukong"
|
||||
headers["claw-type"] = "wukong"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
if got := resolveIdentityHeaders()[agentproduct.HeaderName]; got != "qwenwork" {
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers[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)
|
||||
if got := headers["claw-type"]; got != "wukong" {
|
||||
t.Fatalf("HTTP claw-type = %q, want wukong", got)
|
||||
}
|
||||
if got := edition.ClawType(); got != "qwenwork" {
|
||||
t.Fatalf("message clawType = %q, want qwenwork", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *testing.T) {
|
||||
func TestResolveIdentityHeadersRestoresIdentityAfterNilCredentialHeaders(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
|
||||
@@ -218,7 +260,7 @@ func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *
|
||||
credentialHookCalled := false
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
headers[agentproduct.HeaderName] = "wukong"
|
||||
headers["claw-type"] = "wukong"
|
||||
return headers
|
||||
},
|
||||
EnterpriseCredentialHeaders: func(map[string]string) map[string]string {
|
||||
@@ -234,25 +276,49 @@ func TestResolveIdentityHeadersRestoresAgentProductAfterNilCredentialHeaders(t *
|
||||
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
|
||||
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
|
||||
}
|
||||
if got := headers["claw-type"]; got != "wukong" {
|
||||
t.Fatalf("claw-type = %q, want wukong", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveClawTypeUsesAgentProductOverride(t *testing.T) {
|
||||
func TestResolveIdentityHeadersRestoresDefaultsAfterNilMergeHeaders(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(map[string]string) map[string]string {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["claw-type"]; got != edition.DefaultOSSClawType {
|
||||
t.Fatalf("claw-type = %q, want %q", got, edition.DefaultOSSClawType)
|
||||
}
|
||||
if _, ok := headers[agentproduct.HeaderName]; ok {
|
||||
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveClawTypeIgnoresAgentProduct(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"
|
||||
headers["claw-type"] = "wukong"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
if got := effectiveClawType(); got != "qwenwork" {
|
||||
t.Fatalf("effectiveClawType() = %q, want qwenwork", got)
|
||||
if got := effectiveClawType(); got != "wukong" {
|
||||
t.Fatalf("effectiveClawType() = %q, want wukong", got)
|
||||
}
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agent-code")
|
||||
if got := apperrors.HostControlBlock()["clawType"]; got != "qwenwork" {
|
||||
t.Fatalf("hostControl.clawType = %q, want qwenwork", got)
|
||||
if got := apperrors.HostControlBlock()["clawType"]; got != "wukong" {
|
||||
t.Fatalf("hostControl.clawType = %q, want wukong", got)
|
||||
}
|
||||
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// 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"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func TestValidateChatWorkbookRawArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "members group flag",
|
||||
args: []string{"chat", "group", "members", "list", "--group", "cid-demo", "--format", "json"},
|
||||
want: "群成员列表命令路径或群参数不正确",
|
||||
},
|
||||
{
|
||||
name: "rename group flag",
|
||||
args: []string{"chat", "group", "rename", "--group=cid-demo", "--name", "新群名"},
|
||||
want: "群重命名命令不支持 --group",
|
||||
},
|
||||
{
|
||||
name: "image local path",
|
||||
args: []string{"chat", "message", "send", "--group", "cid-demo", "--msg-type", "image", "--file-path", "/tmp/x.png"},
|
||||
want: "image 消息不能直接使用 --file-path",
|
||||
},
|
||||
{
|
||||
name: "unsupported message type",
|
||||
args: []string{"chat", "message", "send", "--group", "cid-demo", "--msg-type=sticker"},
|
||||
want: "不支持指定的 --msg-type:sticker",
|
||||
},
|
||||
{
|
||||
name: "numeric group id required",
|
||||
args: []string{"chat", "group", "get-by-group-id", "--group-id", "cid-demo"},
|
||||
want: "--group-id 必须是数字群号",
|
||||
},
|
||||
{
|
||||
name: "file media id conflict",
|
||||
args: []string{"chat", "message", "send", "--group", "cid-demo", "--msg-type", "file", "--media-id", "media"},
|
||||
want: "文件消息不能使用 --media-id",
|
||||
},
|
||||
{
|
||||
name: "silent text media conflict",
|
||||
args: []string{"chat", "message", "send", "--group", "cid-demo", "--media-id", "media", "--text", "file.pdf"},
|
||||
want: "检测到 --media-id,但没有指定媒体消息类型",
|
||||
},
|
||||
{
|
||||
name: "dismiss numeric group id",
|
||||
args: []string{"chat", "group", "dismiss", "--group", "12345678"},
|
||||
want: "解散群命令需要 openConversationId,不是数字群号",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateChatWorkbookRawArgs(tc.args)
|
||||
var typed *apperrors.Error
|
||||
if !stderrors.As(err, &typed) {
|
||||
t.Fatalf("error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Message != tc.want || len(typed.Actions) == 0 || len(typed.Examples) == 0 {
|
||||
t.Fatalf("guidance = %#v", typed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if err := validateChatWorkbookRawArgs([]string{"chat", "group", "rename", "--id", "cid-demo"}); err != nil {
|
||||
t.Fatalf("canonical rename args rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatWorkbookHelpGuidanceCoverage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, path := range []string{
|
||||
"chat group members",
|
||||
"chat group members add",
|
||||
"chat group members remove",
|
||||
"chat group members add-bot",
|
||||
"chat group members remove-bot",
|
||||
"chat group members list-by-ids",
|
||||
"chat group create",
|
||||
"chat group rename",
|
||||
"chat message list",
|
||||
"chat message search",
|
||||
"chat message search-advanced",
|
||||
"chat message list-all",
|
||||
"chat message list-by-sender",
|
||||
} {
|
||||
guide, ok := chatWorkbookHelpGuidance[path]
|
||||
if !ok || guide.reason == "" || guide.action == "" || guide.example == "" {
|
||||
t.Fatalf("incomplete help guidance for %q: %#v", path, guide)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawArgsFlagValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if got := rawArgsFlagValue([]string{"--msg-type", "image"}, "msg-type"); got != "image" {
|
||||
t.Fatalf("separate value = %q", got)
|
||||
}
|
||||
if got := rawArgsFlagValue([]string{"--msg-type=file"}, "msg-type"); got != "file" {
|
||||
t.Fatalf("equals value = %q", got)
|
||||
}
|
||||
if got := rawArgsFlagValue([]string{"--text", "hello"}, "msg-type"); got != "" {
|
||||
t.Fatalf("missing value = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawArgsRequestJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"chat", "search", "--format", "json"},
|
||||
{"chat", "search", "--format=json"},
|
||||
{"chat", "search", "-f", "JSON"},
|
||||
{"chat", "search", "-f=json"},
|
||||
} {
|
||||
if !rawArgsRequestJSON(args) {
|
||||
t.Fatalf("rawArgsRequestJSON(%v) = false", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuppressJSONDeprecationPreamble(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := NewRootCommand()
|
||||
cmd := mustFindCommand(t, root, "chat", "media", "upload")
|
||||
if cmd.Deprecated == "" {
|
||||
t.Fatal("fixture command is not deprecated")
|
||||
}
|
||||
suppressJSONDeprecationPreamble(root, []string{"chat", "media", "upload", "--format", "json"})
|
||||
if cmd.Deprecated != "" {
|
||||
t.Fatalf("JSON execution kept deprecation preamble: %q", cmd.Deprecated)
|
||||
}
|
||||
|
||||
plainRoot := NewRootCommand()
|
||||
plain := mustFindCommand(t, plainRoot, "chat", "media", "upload")
|
||||
suppressJSONDeprecationPreamble(plainRoot, []string{"chat", "media", "upload"})
|
||||
if plain.Deprecated == "" {
|
||||
t.Fatal("human execution unexpectedly removed deprecation metadata")
|
||||
}
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
@@ -569,9 +569,9 @@ func handlePatAuthCheck(
|
||||
// In host-controlled PAT mode (driven solely by DINGTALK_DWS_AGENTCODE),
|
||||
// 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
|
||||
// the edition default / DWS_AGENT_PRODUCT override and surfaced in
|
||||
// hostControl for traceability.
|
||||
// used for this decision — its edition-fixed value is forwarded on the
|
||||
// wire and surfaced in hostControl for traceability. DWS_AGENT_PRODUCT
|
||||
// does not affect this PAT contract.
|
||||
if hostOwnedPAT || patData.Data.FlowID == "" {
|
||||
if hostOwnedPAT {
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorForHostControl(patErr.RawJSON)}
|
||||
|
||||
@@ -1007,11 +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. With no DWS_AGENT_PRODUCT override the
|
||||
// open-source edition default remains "openClaw".
|
||||
// hostControl.clawType. DWS_AGENT_PRODUCT is also set to demonstrate
|
||||
// that Product does not change the open-source fixed "openClaw" value.
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
|
||||
t.Setenv("DINGTALK_AGENT", "sales-copilot")
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
|
||||
mock := &mockRunner{
|
||||
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
|
||||
|
||||
@@ -29,9 +29,8 @@ import (
|
||||
// - 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. 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.
|
||||
// its fixed value; DWS_AGENT_PRODUCT is a separate observability and IM
|
||||
// message-display signal and never affects this PAT value.
|
||||
// - When DINGTALK_DWS_AGENTCODE is empty the provider returns "" so
|
||||
// HostControlBlock yields nil and no hostControl block is emitted.
|
||||
func init() {
|
||||
@@ -59,5 +58,5 @@ func effectiveClawType() string {
|
||||
headers = h.MergeHeaders(headers)
|
||||
}
|
||||
}
|
||||
return resolveEffectiveAgentProduct(headers)
|
||||
return resolveEditionClawType(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)
|
||||
}
|
||||
}
|
||||
+573
-6
@@ -24,6 +24,7 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -76,6 +77,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.
|
||||
@@ -111,10 +113,24 @@ func Execute() (exitCode int) {
|
||||
root := rootNewRootCommandWithEngine(ctx, engine)
|
||||
timing.Record("cmd_init", time.Since(initStart))
|
||||
|
||||
if err := validateChatWorkbookRawArgs(os.Args[1:]); err != nil {
|
||||
if rawArgsRequestJSON(os.Args[1:]) {
|
||||
_ = apperrors.PrintJSON(os.Stderr, err)
|
||||
} else {
|
||||
_ = apperrors.PrintHumanAt(os.Stderr, err, resolveVerbosity(root))
|
||||
}
|
||||
return apperrors.ExitCode(err)
|
||||
}
|
||||
suppressJSONDeprecationPreamble(root, os.Args[1:])
|
||||
|
||||
// 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 {
|
||||
@@ -122,6 +138,7 @@ func Execute() (exitCode int) {
|
||||
executed = root
|
||||
}
|
||||
err = rewordRequiredFlagError(err)
|
||||
err = enrichChatWorkbookError(executed, err)
|
||||
if isUnknownCommandError(err) {
|
||||
executed.SetOut(os.Stderr)
|
||||
_ = executed.Help()
|
||||
@@ -136,6 +153,469 @@ func Execute() (exitCode int) {
|
||||
return 0
|
||||
}
|
||||
|
||||
func suppressJSONDeprecationPreamble(root *cobra.Command, args []string) {
|
||||
if root == nil || !rawArgsRequestJSON(args) || len(args) < 3 {
|
||||
return
|
||||
}
|
||||
if args[0] != "chat" || args[1] != "media" || args[2] != "upload" {
|
||||
return
|
||||
}
|
||||
if cmd, _, err := root.Find([]string{"chat", "media", "upload"}); err == nil && cmd != nil {
|
||||
cmd.Deprecated = ""
|
||||
}
|
||||
}
|
||||
|
||||
func validateChatWorkbookRawArgs(args []string) error {
|
||||
path := strings.Join(args, " ")
|
||||
switch {
|
||||
case strings.HasPrefix(path, "chat message send ") && rawArgsFlagValue(args, "msg-type") == "file" &&
|
||||
rawArgsContainFlag(args, "media-id"):
|
||||
return apperrors.NewValidation(
|
||||
"文件消息不能使用 --media-id",
|
||||
apperrors.WithReason("PDF、DOCX、XLSX 和本地图片等文件通过 --file-path 上传发送;mediaId 仅用于已有媒体标识的 image 消息"),
|
||||
apperrors.WithActions("移除 --media-id", "补充 --file-path 并保留 --msg-type file"),
|
||||
apperrors.WithExamples(`dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json`),
|
||||
)
|
||||
case strings.HasPrefix(path, "chat message send ") && rawArgsContainFlag(args, "media-id") &&
|
||||
rawArgsFlagValue(args, "msg-type") == "":
|
||||
return apperrors.NewValidation(
|
||||
"检测到 --media-id,但没有指定媒体消息类型",
|
||||
apperrors.WithReason("未指定 --msg-type 时命令会进入文本分支,可能把文件名当成普通文字发送"),
|
||||
apperrors.WithActions("已有图片 mediaId 时补充 --msg-type image", "发送 PDF/DOCX/XLSX 时移除 --media-id,改用 --msg-type file --file-path"),
|
||||
apperrors.WithExamples(`dws chat message send --group <openConversationId> --msg-type image --media-id <mediaId> --format json`, `dws chat message send --group <openConversationId> --msg-type file --file-path ./thesis.pdf --format json`),
|
||||
)
|
||||
case strings.HasPrefix(path, "chat message send ") && rawArgsFlagValue(args, "msg-type") == "image" &&
|
||||
rawArgsContainFlag(args, "file-path") && !rawArgsContainFlag(args, "media-id"):
|
||||
filePath := rawArgsFlagValue(args, "file-path")
|
||||
return apperrors.NewValidation(
|
||||
"image 消息不能直接使用 --file-path",
|
||||
apperrors.WithReason("msg-type=image 只接受已有 mediaId;本地图片路径不能自动转换为 mediaId"),
|
||||
apperrors.WithActions("发送本地图片时改用 --msg-type file", "保留原路径并通过 --file-path 发送为文件附件"),
|
||||
apperrors.WithExamples(fmt.Sprintf(`dws chat message send --group <openConversationId> --msg-type file --file-path %q --format json`, filePath)),
|
||||
)
|
||||
case strings.HasPrefix(path, "chat message send ") && rawArgsFlagValue(args, "msg-type") == "image" &&
|
||||
!rawArgsContainFlag(args, "media-id"):
|
||||
return apperrors.NewValidation(
|
||||
"图片消息缺少 --media-id",
|
||||
apperrors.WithReason("msg-type=image 只接受上游已经获得的有效 mediaId,不能把本地文件名当作 mediaId"),
|
||||
apperrors.WithActions("已有 mediaId 时补充 --media-id", "发送本地图片时改用 --msg-type file --file-path"),
|
||||
apperrors.WithExamples(`dws chat message send --group <openConversationId> --msg-type image --media-id <mediaId> --format json`, `dws chat message send --group <openConversationId> --msg-type file --file-path ./image.png --format json`),
|
||||
)
|
||||
case strings.HasPrefix(path, "chat message send "):
|
||||
msgType := rawArgsFlagValue(args, "msg-type")
|
||||
switch msgType {
|
||||
case "", "text", "markdown", "image", "file", "audio", "video", "location", "profile":
|
||||
default:
|
||||
return apperrors.NewValidation(
|
||||
"不支持指定的 --msg-type:"+msgType,
|
||||
apperrors.WithReason("当前命令不支持 sticker/card 等消息类型;文本或 Markdown 消息无需传 --msg-type"),
|
||||
apperrors.WithActions("文本消息移除 --msg-type 并使用 --text", "媒体消息使用 image、file、audio、video、location 或 profile"),
|
||||
apperrors.WithExamples(`dws chat message send --group <openConversationId> --text "hi" --format json`),
|
||||
)
|
||||
}
|
||||
case strings.HasPrefix(path, "chat group get-by-group-id "):
|
||||
value := rawArgsFlagValue(args, "group-id")
|
||||
if value != "" {
|
||||
if _, err := strconv.ParseInt(value, 10, 64); err != nil {
|
||||
return apperrors.NewValidation(
|
||||
"--group-id 必须是数字群号",
|
||||
apperrors.WithReason("cid 开头的值是 openConversationId,不是 get-by-group-id 所需的数字群号"),
|
||||
apperrors.WithActions("如果已有 openConversationId,请改用接受 --group 的群查询命令", "只有拿到数字群号时才调用 get-by-group-id"),
|
||||
apperrors.WithExamples(`dws chat group get-by-group-id --group-id 12345678 --format json`),
|
||||
)
|
||||
}
|
||||
}
|
||||
case strings.HasPrefix(path, "chat group dismiss ") && rawArgsContainFlag(args, "group"):
|
||||
value := rawArgsFlagValue(args, "group")
|
||||
if _, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
return apperrors.NewValidation(
|
||||
"解散群命令需要 openConversationId,不是数字群号",
|
||||
apperrors.WithReason("--group 应传 cid 开头或服务端返回的 openConversationId;数字群号只用于 get-by-group-id"),
|
||||
apperrors.WithActions("先通过 chat search 获取 openConversationId", "确认目标群及不可逆影响后再执行解散"),
|
||||
apperrors.WithExamples(`dws chat group dismiss --group <openConversationId> --format json`),
|
||||
)
|
||||
}
|
||||
case strings.HasPrefix(path, "chat group members ") && rawArgsContainFlag(args, "group"):
|
||||
return apperrors.NewValidation(
|
||||
"群成员列表命令路径或群参数不正确",
|
||||
apperrors.WithReason("群成员列表的可执行命令是 chat group members,群 ID 参数名为 --id;不存在 members list --group 这一组合"),
|
||||
apperrors.WithActions("移除多余的 list 子命令", "将 --group 改为 --id"),
|
||||
apperrors.WithExamples(`dws chat group members --id <openConversationId> --format json`),
|
||||
)
|
||||
case strings.HasPrefix(path, "chat group rename ") && rawArgsContainFlag(args, "group"):
|
||||
return apperrors.NewValidation(
|
||||
"群重命名命令不支持 --group",
|
||||
apperrors.WithReason("chat group rename 使用 --id 接收群 openConversationId,而不是 --group"),
|
||||
apperrors.WithActions("将 --group 改为 --id", "群 ID 不确定时先用 chat search 查询"),
|
||||
apperrors.WithExamples(`dws chat group rename --id <openConversationId> --name "新群名" --format json`),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rawArgsContainFlag(args []string, name string) bool {
|
||||
prefix := "--" + name
|
||||
for _, arg := range args {
|
||||
if arg == prefix || strings.HasPrefix(arg, prefix+"=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func rawArgsFlagValue(args []string, name string) string {
|
||||
prefix := "--" + name
|
||||
for i, arg := range args {
|
||||
if strings.HasPrefix(arg, prefix+"=") {
|
||||
return strings.TrimPrefix(arg, prefix+"=")
|
||||
}
|
||||
if arg == prefix && i+1 < len(args) {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func rawArgsRequestJSON(args []string) bool {
|
||||
for i, arg := range args {
|
||||
if arg == "--format=json" || arg == "-f=json" {
|
||||
return true
|
||||
}
|
||||
if (arg == "--format" || arg == "-f") && i+1 < len(args) && strings.EqualFold(args[i+1], "json") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type chatWorkbookGuidance struct {
|
||||
message string
|
||||
reason string
|
||||
actions []string
|
||||
examples []string
|
||||
}
|
||||
|
||||
var chatRequiredGuidance = map[string]chatWorkbookGuidance{
|
||||
"chat message send-by-webhook": {
|
||||
"Webhook 发送参数不完整",
|
||||
"Webhook 消息必须同时提供机器人地址中的 access_token、标题和正文;不能降级为普通群消息",
|
||||
[]string{"从自定义机器人 Webhook 地址提取 token", "同时补齐 --title 和 --text,并确保包含机器人安全关键词"},
|
||||
[]string{`dws chat message send-by-webhook --token <access_token> --title "dws测试通知" --text "dws测试:评测结果已出" --format json`},
|
||||
},
|
||||
"chat group rename": {
|
||||
"群重命名缺少群 ID 或新名称", "--id 必须是群 openConversationId,--name 是新的群名称",
|
||||
[]string{"先用 chat search 获取群 openConversationId", "同时提供 --id 和 --name"},
|
||||
[]string{`dws chat group rename --id <openConversationId> --name "新群名" --format json`},
|
||||
},
|
||||
"chat group dismiss": {
|
||||
"解散群缺少目标群 ID", "解散群不可逆且需要群主权限,--group 必须是 openConversationId",
|
||||
[]string{"先确认目标群和影响范围", "获取 openConversationId 后再执行,并按运行时要求确认"},
|
||||
[]string{`dws chat group dismiss --group <openConversationId> --format json`},
|
||||
},
|
||||
"chat group quit": {
|
||||
"退出群缺少目标群 ID", "quit 表示当前用户退出群聊,不会解散整个群;--group 必须是 openConversationId",
|
||||
[]string{"确认你要退出而不是解散群", "先获取目标群 openConversationId"},
|
||||
[]string{`dws chat group quit --group <openConversationId> --format json`},
|
||||
},
|
||||
"chat group set-admin": {
|
||||
"设置群管理员参数不完整", "需要目标群以及一个或多个成员;默认设为管理员,--off 表示取消管理员",
|
||||
[]string{"补充 --group", "通过 --user 或 --users 指定成员,取消管理员时增加 --off"},
|
||||
[]string{`dws chat group set-admin --group <openConversationId> --users <userId1>,<userId2> --format json`},
|
||||
},
|
||||
"chat group transfer-owner": {
|
||||
"转让群主参数不完整", "--group 指定群,--new-owner 使用 openDingTalkId,--user 使用 userId",
|
||||
[]string{"补充群 openConversationId", "在 --new-owner 和 --user 中选择一个新群主标识"},
|
||||
[]string{`dws chat group transfer-owner --group <openConversationId> --new-owner <openDingTalkId> --format json`},
|
||||
},
|
||||
"chat group update-nick": {
|
||||
"修改本人群昵称参数不完整", "update-nick 只修改当前登录用户在指定群里的昵称,需要群 ID 和新昵称",
|
||||
[]string{"补充 --group openConversationId", "补充新的昵称参数"},
|
||||
[]string{`dws chat group update-nick --group <openConversationId> --nick "新昵称" --format json`},
|
||||
},
|
||||
"chat group update-icon": {
|
||||
"更新群头像参数不完整", "需要群 openConversationId 和上游已经获得的有效图片 mediaId",
|
||||
[]string{"补充 --group", "从上游媒体能力获取 mediaId 后传入 --icon-media-id"},
|
||||
[]string{`dws chat group update-icon --group <openConversationId> --icon-media-id <mediaId> --format json`},
|
||||
},
|
||||
"chat group share-invite": {
|
||||
"分享群邀请参数不完整", "--source 是被分享群,--target 是接收分享的会话,--receiver 是接收分享的单聊用户",
|
||||
[]string{"补充 --source", "在 --target 和 --receiver 中选择一个接收目标"},
|
||||
[]string{`dws chat group share-invite --source <源群ID> --target <目标会话ID> --format json`},
|
||||
},
|
||||
"chat message reply": {
|
||||
"引用回复参数不完整", "会话 ID、原消息 ID、原发送者和回复正文必须来自或对应同一条原消息",
|
||||
[]string{"先拉取目标消息", "补齐 conversation-id、ref-msg-id、ref-sender 和 text"},
|
||||
[]string{`dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到" --format json`},
|
||||
},
|
||||
"chat message forward": {
|
||||
"转发消息参数不完整", "消息 ID 必须属于源会话,并需要明确源会话和目标会话",
|
||||
[]string{"先从源会话拉取真实消息 ID", "确认 src 和 dest 没有写反"},
|
||||
[]string{`dws chat message forward --src-conversation-id <源会话ID> --msg-id <openMessageId> --dest-conversation-id <目标会话ID> --format json`},
|
||||
},
|
||||
"chat message recall": {
|
||||
"撤回消息参数不完整", "用户消息撤回需要会话 ID 和本人发送的消息 ID;机器人消息应使用 recall-by-bot",
|
||||
[]string{"确认消息由当前用户发送", "补齐 conversation-id 和 msg-id"},
|
||||
[]string{`dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId> --format json`},
|
||||
},
|
||||
"chat message read-status": {
|
||||
"查询消息已读状态参数不完整", "只能查询当前用户发出消息的已读状态,需要会话和消息标识",
|
||||
[]string{"补齐会话和消息 ID", "人员筛选时区分 userId 与 openDingTalkId"},
|
||||
[]string{`dws chat message read-status --conversation-id <openConversationId> --message-id <openMessageId> --format json`},
|
||||
},
|
||||
"chat message list-by-ids": {
|
||||
"缺少消息 ID 列表", "--msg-ids 使用逗号分隔的真实 openMessageId,单次最多 50 条",
|
||||
[]string{"先拉取真实消息 ID", "将不超过 50 条 ID 用逗号连接"},
|
||||
[]string{`dws chat message list-by-ids --msg-ids <id1>,<id2> --format json`},
|
||||
},
|
||||
"chat message download-media": {
|
||||
"媒体下载参数不完整", "type、resource-id、message-id、open-conversation-id 和 output 必须完整,且资源与消息来自同一条消息",
|
||||
[]string{"先拉取目标媒体消息", "从同一条消息取得资源、消息和会话标识"},
|
||||
[]string{`dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/ --format json`},
|
||||
},
|
||||
"chat message add-emoji": {
|
||||
"添加表情回应参数不完整", "需要真实会话 ID、消息 ID 和 emoji 名称",
|
||||
[]string{"先拉取目标消息", "补齐 conversation-id、msg-id 和 emoji"},
|
||||
[]string{`dws chat message add-emoji --conversation-id <openConversationId> --msg-id <openMessageId> --emoji "赞" --format json`},
|
||||
},
|
||||
"chat message remove-emoji": {
|
||||
"移除表情回应参数不完整", "只能移除当前用户已添加的同名回应,需要会话、消息和 emoji 名称完全匹配",
|
||||
[]string{"确认当前用户添加过该回应", "补齐 conversation-id、msg-id 和 emoji"},
|
||||
[]string{`dws chat message remove-emoji --conversation-id <openConversationId> --msg-id <openMessageId> --emoji "赞" --format json`},
|
||||
},
|
||||
"chat message list-by-sender": {
|
||||
"按发送者查询参数不完整", "必须提供开始时间以及发送者 userId/openDingTalkId 二选一,可选 end 和 cursor",
|
||||
[]string{"补充 --start", "在 sender-user-id 和 sender-open-dingtalk-id 中选择一个"},
|
||||
[]string{`dws chat message list-by-sender --sender-user-id <userId> --start "2026-07-14T00:00:00+08:00" --format json`},
|
||||
},
|
||||
"chat message query-send-status": {
|
||||
"缺少发送任务 ID", "--open-task-id 来自 message send 返回的 openTaskId,不是消息 ID",
|
||||
[]string{"先执行 message send", "从发送结果读取 openTaskId"},
|
||||
[]string{`dws chat message query-send-status --open-task-id <openTaskId> --format json`},
|
||||
},
|
||||
}
|
||||
|
||||
func enrichChatWorkbookError(cmd *cobra.Command, err error) error {
|
||||
if cmd == nil || err == nil {
|
||||
return err
|
||||
}
|
||||
path := cmd.CommandPath()
|
||||
if fields := strings.Fields(path); len(fields) > 1 {
|
||||
path = strings.Join(fields[1:], " ")
|
||||
}
|
||||
message := err.Error()
|
||||
var guide chatWorkbookGuidance
|
||||
switch {
|
||||
case path == "chat message send" &&
|
||||
(strings.Contains(message, "unknown flag: --at-user-ids") ||
|
||||
strings.Contains(message, "unknown flag: --at-users") ||
|
||||
strings.Contains(message, "unknown flag: --mention")):
|
||||
guide = chatWorkbookGuidance{
|
||||
"群消息 @成员参数不正确",
|
||||
"当前用户身份发送群消息时使用 --at-open-dingtalk-ids,参数值必须是成员的 openDingTalkId;--at-user-ids、--at-users、--mention 均不是有效参数",
|
||||
[]string{"先查询目标成员的 openDingTalkId", "改用 --at-open-dingtalk-ids,并在正文中写入 <@openDingTalkId>"},
|
||||
[]string{`dws chat message send --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --text "<@openDingTalkId> 请关注" --format json`},
|
||||
}
|
||||
case path == "chat media upload":
|
||||
guide = chatWorkbookGuidance{
|
||||
"chat media upload 已下线",
|
||||
"当前 CLI 不再通过该命令把本地文件转换为 mediaId,本地图片和文件统一由 message send 的 file 路径上传并发送",
|
||||
[]string{"发送本地图片或文件时使用 --msg-type file --file-path", "只有上游已提供 mediaId 时才使用 --msg-type image --media-id"},
|
||||
[]string{`dws chat message send --group <openConversationId> --msg-type file --file-path ./image.png --format json`},
|
||||
}
|
||||
case path == "chat group members" && strings.Contains(message, "unknown flag: --group"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"群成员列表命令路径或群参数不正确",
|
||||
"群成员列表的可执行命令是 chat group members,群 ID 参数名为 --id;不存在 members list --group 这一组合",
|
||||
[]string{"移除多余的 list 子命令", "将 --group 改为 --id"},
|
||||
[]string{`dws chat group members --id <openConversationId> --format json`},
|
||||
}
|
||||
case path == "chat group rename" && strings.Contains(message, "unknown flag: --group"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"群重命名命令不支持 --group",
|
||||
"chat group rename 使用 --id 接收群 openConversationId,而不是 --group",
|
||||
[]string{"将 --group 改为 --id", "群 ID 不确定时先用 chat search 查询"},
|
||||
[]string{`dws chat group rename --id <openConversationId> --name "新群名" --format json`},
|
||||
}
|
||||
case path == "chat group create" && strings.Contains(message, "unknown flag: --members"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"建群命令不支持 --members",
|
||||
"chat group create 使用 --users 接收逗号分隔的成员 userId;--members 是其他命令的参数名",
|
||||
[]string{"将 --members 改为 --users", "成员标识不确定时先查询 userId"},
|
||||
[]string{`dws chat group create --name "V2评审小组" --users 489149,550582 --format json`},
|
||||
}
|
||||
case path == "chat group bots" && strings.Contains(message, "unknown flag: --id"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"群机器人列表命令不支持 --id",
|
||||
"chat group bots 使用 --group 接收群 openConversationId;该参数名与 members、rename 命令不同",
|
||||
[]string{"将 --id 改为 --group", "群 ID 不确定时先用 chat search 查询"},
|
||||
[]string{`dws chat group bots --group <openConversationId> --format json`},
|
||||
}
|
||||
case path == "chat message list-mentions" && strings.Contains(message, "required flag"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"缺少必填参数:--start、--end",
|
||||
"查询 @我 消息必须同时提供 ISO-8601 格式的开始和结束时间;只提供分页参数不能确定查询范围",
|
||||
[]string{"同时补充 --start 和 --end,不要逐个参数反复试错", "按本地时区设置明确的查询时间窗"},
|
||||
[]string{`dws chat message list-mentions --start "2026-07-23T00:00:00+08:00" --end "2026-07-30T23:59:59+08:00" --limit 50 --format json`},
|
||||
}
|
||||
case path == "chat message send" && strings.Contains(message, "--group, --user or --open-dingtalk-id is required"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"缺少消息接收目标",
|
||||
"发送消息必须在 --group、--user、--open-dingtalk-id 中选择且只选择一个接收目标",
|
||||
[]string{"发群消息时先查询并传入群 openConversationId", "发单聊时先查询并传入 userId 或 openDingTalkId"},
|
||||
[]string{`dws chat message send --group <openConversationId> --text "评测消息" --format json`, `dws chat message send --open-dingtalk-id <openDingTalkId> --text "评测消息" --format json`},
|
||||
}
|
||||
case path == "chat search" && strings.Contains(message, "query"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"缺少群聊搜索关键词:--query",
|
||||
"群聊搜索需要关键词才能定位候选群,不能使用空查询",
|
||||
[]string{"使用 --query 传入群名称或名称片段", "从结果中读取 openConversationId 供后续群命令使用"},
|
||||
[]string{`dws chat search --query "项目群" --format json`},
|
||||
}
|
||||
case path == "chat message search-advanced":
|
||||
guide = chatWorkbookGuidance{
|
||||
"高级消息搜索至少需要一个搜索条件",
|
||||
"空条件搜索无法限定目标消息,必须提供关键词、人员、@我状态或会话范围中的至少一种",
|
||||
[]string{"按内容搜索时传入 --query", "也可通过 --user、--at-me 或 --conversation-ids 缩小范围"},
|
||||
[]string{`dws chat message search-advanced --query "评审" --format json`},
|
||||
}
|
||||
case path == "chat message search" && strings.Contains(message, "required flag"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"关键词消息搜索缺少完整查询条件",
|
||||
"关键词消息搜索需要 --query、--start 和 --end;当前命令没有提供完整的关键词和时间范围",
|
||||
[]string{"补充搜索关键词", "同时提供 ISO-8601 格式的开始和结束时间"},
|
||||
[]string{`dws chat message search --query "评审" --start "2026-07-23T00:00:00+08:00" --end "2026-07-30T23:59:59+08:00" --format json`},
|
||||
}
|
||||
case path == "chat message list-all" && strings.Contains(message, "required flag"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"跨会话消息查询缺少时间范围",
|
||||
"拉取全部会话消息必须使用 --start 和 --end 限定范围,避免无边界查询历史消息",
|
||||
[]string{"同时补充 --start 和 --end", "结果存在 hasMore 时使用 nextCursor 继续翻页"},
|
||||
[]string{`dws chat message list-all --start "2026-07-23T00:00:00+08:00" --end "2026-07-30T23:59:59+08:00" --limit 50 --format json`},
|
||||
}
|
||||
case path == "chat message list-topic-replies" && strings.Contains(message, "topic-id"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"缺少话题定位参数:--topic-id",
|
||||
"topic-id 不能臆造,必须来自同一群聊消息列表中目标话题消息的 openConvThreadId",
|
||||
[]string{"先执行 chat message list 拉取目标群消息", "从目标话题消息读取 openConvThreadId 并作为 --topic-id"},
|
||||
[]string{`dws chat message list --group <openConversationId> --time "2026-07-30 23:59:59" --direction older --format json`, `dws chat message list-topic-replies --group <openConversationId> --topic-id <openConvThreadId> --limit 50 --format json`},
|
||||
}
|
||||
case path == "chat message send-by-bot" && strings.Contains(message, "required flag"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"机器人发送消息缺少必填参数",
|
||||
"机器人发送需要 robotCode、标题、正文以及群聊或单聊目标,当前参数不完整",
|
||||
[]string{"补充 --robot-code 和 --title", "通过 --group 或用户参数指定接收目标"},
|
||||
[]string{`dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "通知" --text "hello" --format json`},
|
||||
}
|
||||
case path == "chat message recall-by-bot" && strings.Contains(message, "required flag"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"机器人撤回消息缺少 robotCode 或 processQueryKey",
|
||||
"--keys 的 processQueryKey 来自机器人发送消息的返回结果,不能凭空构造",
|
||||
[]string{"补充发送该消息的 --robot-code", "从发送结果读取 processQueryKey 并传给 --keys"},
|
||||
[]string{`dws chat message recall-by-bot --robot-code <robotCode> --group <openConversationId> --keys <processQueryKey> --format json`},
|
||||
}
|
||||
case path == "chat message list" && strings.Contains(message, "required flag") && strings.Contains(message, "--time"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"拉取会话消息缺少时间锚点:--time",
|
||||
"消息列表按时间向前或向后拉取,必须提供一个明确的时间锚点",
|
||||
[]string{"补充格式为 YYYY-MM-DD HH:mm:ss 的 --time", "使用 --direction older 或 newer 明确查询方向"},
|
||||
[]string{`dws chat message list --group <openConversationId> --time "2026-07-30 10:00:00" --direction older --format json`},
|
||||
}
|
||||
case path == "chat message list" && strings.Contains(message, "--group, --user or --open-dingtalk-id is required"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"拉取消息时缺少会话目标",
|
||||
"必须在群聊 openConversationId、单聊 userId、单聊 openDingTalkId 中选择且只选择一个目标",
|
||||
[]string{"群聊先用 chat search 获取 openConversationId", "单聊先查询人员标识,再传 --user 或 --open-dingtalk-id"},
|
||||
[]string{`dws chat message list --group <openConversationId> --time "2026-07-15 10:00:00" --format json`},
|
||||
}
|
||||
case path == "chat message send" && strings.Contains(message, "media-id is required"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"图片消息缺少 --media-id",
|
||||
"msg-type=image 只接受上游已经获得的有效 mediaId,不能把本地文件名当作 mediaId",
|
||||
[]string{"已有 mediaId 时补充 --media-id", "发送本地图片时改用 --msg-type file --file-path"},
|
||||
[]string{`dws chat message send --group <openConversationId> --msg-type image --media-id <mediaId> --format json`, `dws chat message send --group <openConversationId> --msg-type file --file-path ./image.png --format json`},
|
||||
}
|
||||
case path == "chat message send" && strings.Contains(message, "readable local --file-path is required"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"文件消息缺少可读的本地文件",
|
||||
"file、audio、video 消息需要可读的 --file-path;旧版 dentry 参数则必须成组提供",
|
||||
[]string{"优先传入当前机器上可读的 --file-path", "使用旧参数时同时提供 dentry-id、space-id 和 file-name"},
|
||||
[]string{`dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json`},
|
||||
}
|
||||
case path == "chat message send" && strings.Contains(message, "--file-path must be a readable local file"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"--file-path 指向的文件不可读",
|
||||
"指定路径不存在、不是普通文件或当前进程没有读取权限,因此无法上传并发送",
|
||||
[]string{"检查路径拼写并确认文件存在", "改用当前用户可读取的绝对路径或工作目录相对路径"},
|
||||
[]string{`dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json`},
|
||||
}
|
||||
case path == "chat message send" && strings.Contains(message, "unsupported --msg-type"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"不支持指定的 --msg-type",
|
||||
"card 不是当前命令支持的消息类型;文本或 Markdown 消息无需传 --msg-type",
|
||||
[]string{"文本消息移除 --msg-type 并使用 --text", "媒体消息仅使用 image、file、audio、video、location 或 profile"},
|
||||
[]string{`dws chat message send --group <openConversationId> --text "消息正文" --format json`},
|
||||
}
|
||||
case path == "chat message send" && strings.Contains(message, "message content required"):
|
||||
guide = chatWorkbookGuidance{
|
||||
"群消息缺少正文内容",
|
||||
"未提供 --text 或位置参数,同时也没有选择需要专用参数的媒体消息类型",
|
||||
[]string{"发送文字时补充 --text", "发送文件时使用 --msg-type file --file-path"},
|
||||
[]string{`dws chat message send --group <openConversationId> --text "消息正文" --format json`},
|
||||
}
|
||||
}
|
||||
if guide.message == "" {
|
||||
if required, ok := chatRequiredGuidance[path]; ok &&
|
||||
(strings.Contains(message, "required") || strings.Contains(message, "缺少")) {
|
||||
guide = required
|
||||
} else if strings.HasPrefix(path, "chat ") &&
|
||||
(strings.Contains(message, "required") ||
|
||||
strings.Contains(message, "invalid") ||
|
||||
strings.Contains(message, "unsupported") ||
|
||||
strings.Contains(message, "unknown flag") ||
|
||||
strings.Contains(message, "must be")) {
|
||||
example := fmt.Sprintf("dws %s --help", path)
|
||||
if meta, ok := cli.ResolveMeta(path); ok && len(meta.Selection.Examples) > 0 {
|
||||
example = meta.Selection.Examples[0]
|
||||
if !strings.Contains(example, "--format") {
|
||||
example += " --format json"
|
||||
}
|
||||
}
|
||||
guide = chatWorkbookGuidance{
|
||||
"Chat 命令参数校验失败",
|
||||
message,
|
||||
[]string{"根据错误补齐或修正参数", fmt.Sprintf("运行 dws %s --help 核对当前命令参数", path)},
|
||||
[]string{example},
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
guide.message,
|
||||
apperrors.WithReason(guide.reason),
|
||||
apperrors.WithHint(guide.actions[0]),
|
||||
apperrors.WithActions(guide.actions...),
|
||||
apperrors.WithExamples(guide.examples...),
|
||||
apperrors.WithCause(err),
|
||||
)
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
@@ -183,6 +663,25 @@ 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)...),
|
||||
)
|
||||
}
|
||||
if enriched := enrichChatWorkbookError(cmd, err); enriched != err {
|
||||
return enriched
|
||||
}
|
||||
|
||||
// Common flag aliases and suggestions
|
||||
suggestions := map[string]string{
|
||||
@@ -231,6 +730,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) {
|
||||
@@ -339,7 +865,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",
|
||||
@@ -453,11 +979,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++ {
|
||||
@@ -1252,13 +1805,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 }
|
||||
|
||||
@@ -43,12 +43,153 @@ func configureRootHelp(root *cobra.Command) {
|
||||
if cmd != root {
|
||||
defaultHelpFunc(cmd, args)
|
||||
cli.RenderSafetyAnnotation(cmd)
|
||||
renderChatAgentSelectionHint(cmd)
|
||||
return
|
||||
}
|
||||
renderRootHelp(root)
|
||||
})
|
||||
}
|
||||
|
||||
type chatHelpGuidance struct {
|
||||
reason string
|
||||
action string
|
||||
example string
|
||||
}
|
||||
|
||||
var chatWorkbookHelpGuidance = map[string]chatHelpGuidance{
|
||||
"chat group members": {
|
||||
"群成员列表固定使用 --id 传群 openConversationId,不使用消息命令的 --group。",
|
||||
"先查群 ID,再直接执行 members;不要追加多余的 list 子命令。",
|
||||
`dws chat group members --id <openConversationId> --format json`,
|
||||
},
|
||||
"chat group members add": {
|
||||
"添加群成员固定使用 --id 指定群、--users 指定成员。",
|
||||
"先查询群 ID 和成员 userId/openDingTalkId,再执行添加。",
|
||||
`dws chat group members add --id <openConversationId> --users <userId1>,<userId2> --format json`,
|
||||
},
|
||||
"chat group members remove": {
|
||||
"移除群成员使用 --id 和 --users,且不能移除群主。",
|
||||
"先确认成员和不可逆影响,检查群主身份后再执行。",
|
||||
`dws chat group members remove --id <openConversationId> --users <userId> --format json`,
|
||||
},
|
||||
"chat group members add-bot": {
|
||||
"添加机器人属于群成员管理,群参数沿用 --id,并需要 robot-code。",
|
||||
"确认机器人编码和目标群后执行。",
|
||||
`dws chat group members add-bot --id <openConversationId> --robot-code <robotCode> --format json`,
|
||||
},
|
||||
"chat group members remove-bot": {
|
||||
"移除机器人固定使用 --id 指定群、--bot-id 指定群内机器人。",
|
||||
"先列出群机器人取得 openBotId,再执行移除。",
|
||||
`dws chat group members remove-bot --id <openConversationId> --bot-id <openBotId> --format json`,
|
||||
},
|
||||
"chat group members list-by-ids": {
|
||||
"批量查询成员详情使用 --id + --users,users 为成员标识列表。",
|
||||
"确认目标群和成员 ID 后再查询。",
|
||||
`dws chat group members list-by-ids --id <openConversationId> --users <openDingTalkId1>,<openDingTalkId2> --format json`,
|
||||
},
|
||||
"chat group create": {
|
||||
"建群使用 --users;创建结果中的群 ID 可继续传给 members add 和 rename。",
|
||||
"先准备成员 userId,创建后保存返回的 openConversationId。",
|
||||
`dws chat group create --name "项目群" --users <userId1>,<userId2> --format json`,
|
||||
},
|
||||
"chat group rename": {
|
||||
"群改名只使用 --id + --name,不能使用 --group。",
|
||||
"先通过 chat search 获取 openConversationId。",
|
||||
`dws chat group rename --id <openConversationId> --name "新群名" --format json`,
|
||||
},
|
||||
"chat message list": {
|
||||
"message list 按会话和时间拉取消息,不执行服务端关键词搜索。",
|
||||
"按关键词查找时改用 message search;拉历史时提供会话和 time。",
|
||||
`dws chat message list --group <openConversationId> --time "2026-07-30 23:59:59" --direction older --format json`,
|
||||
},
|
||||
"chat message search": {
|
||||
"关键词审计应使用服务端搜索,并同时提供 query、start、end。",
|
||||
"不要用 message list 拉全量后人工筛选。",
|
||||
`dws chat message search --query "评审" --start "2026-07-01T00:00:00+08:00" --end "2026-07-31T23:59:59+08:00" --format json`,
|
||||
},
|
||||
"chat message search-advanced": {
|
||||
"简单关键词优先 message search;只有组合人员、@、会话等条件时才使用 search-advanced。",
|
||||
"至少提供一个真实搜索条件,分页参数不算搜索条件。",
|
||||
`dws chat message search-advanced --query "评审" --conversation-ids <openConversationId> --format json`,
|
||||
},
|
||||
"chat message list-all": {
|
||||
"list-all 按时间跨会话拉取消息,不执行关键词匹配。",
|
||||
"需要关键词时改用 message search,并始终限制时间范围。",
|
||||
`dws chat message list-all --start "2026-07-01T00:00:00+08:00" --end "2026-07-31T23:59:59+08:00" --format json`,
|
||||
},
|
||||
"chat message list-by-sender": {
|
||||
"list-by-sender 的核心条件是发送者;核心条件是关键词时应使用 message search。",
|
||||
"提供发送者 ID 和开始时间,按 nextCursor 翻页。",
|
||||
`dws chat message list-by-sender --sender-user-id <userId> --start "2026-07-01T00:00:00+08:00" --format json`,
|
||||
},
|
||||
}
|
||||
|
||||
func renderChatWorkbookHelpGuidance(cmd *cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
path := strings.TrimSpace(strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" "))
|
||||
guide, ok := chatWorkbookHelpGuidance[path]
|
||||
if !ok {
|
||||
meta, metaOK := cli.ResolveMeta(path)
|
||||
if !metaOK || meta.Identity.ProductID != "chat" {
|
||||
return
|
||||
}
|
||||
reason := meta.Selection.AgentSummary
|
||||
if reason == "" {
|
||||
reason = "执行前需要确认该 Chat 命令的适用场景、必填参数和安全边界。"
|
||||
}
|
||||
action := "根据帮助正文补齐必填参数,并在实际执行时增加 --format json。"
|
||||
if len(meta.Selection.UseWhen) > 0 {
|
||||
action = meta.Selection.UseWhen[0]
|
||||
}
|
||||
example := "dws " + path + " --format json"
|
||||
if len(meta.Selection.Examples) > 0 {
|
||||
example = meta.Selection.Examples[0]
|
||||
if !strings.Contains(example, "--format") {
|
||||
example += " --format json"
|
||||
}
|
||||
}
|
||||
guide = chatHelpGuidance{reason: reason, action: action, example: example}
|
||||
}
|
||||
w := cmd.ErrOrStderr()
|
||||
_, _ = fmt.Fprintln(w, "错误信息:当前为执行前 guidance,不是运行失败")
|
||||
_, _ = fmt.Fprintln(w, "原因:"+guide.reason)
|
||||
_, _ = fmt.Fprintln(w, "建议操作:")
|
||||
_, _ = fmt.Fprintln(w, "1. "+guide.action)
|
||||
_, _ = fmt.Fprintln(w, "示例:")
|
||||
_, _ = fmt.Fprintln(w, "1. "+guide.example)
|
||||
}
|
||||
|
||||
// renderChatAgentSelectionHint exposes the reviewed Chat selection contract in
|
||||
// command help without reintroducing a second product-local guidance map.
|
||||
// Selection prose remains authored in schema_hints/selection/chat.json and is
|
||||
// consumed through the repository-wide ResolveMeta API.
|
||||
func renderChatAgentSelectionHint(cmd *cobra.Command) {
|
||||
cliPath := strings.TrimSpace(strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" "))
|
||||
meta, ok := cli.ResolveMeta(cliPath)
|
||||
if !ok || meta.Identity.ProductID != "chat" {
|
||||
return
|
||||
}
|
||||
selection := meta.Selection
|
||||
|
||||
w := cmd.OutOrStdout()
|
||||
_, _ = fmt.Fprintln(w, "Agent guidance:")
|
||||
if selection.AgentSummary != "" {
|
||||
_, _ = fmt.Fprintf(w, " Outcome: %s\n", selection.AgentSummary)
|
||||
}
|
||||
for _, scenario := range selection.UseWhen {
|
||||
_, _ = fmt.Fprintf(w, " Use when: %s\n", scenario)
|
||||
}
|
||||
for _, scenario := range selection.AvoidWhen {
|
||||
_, _ = fmt.Fprintf(w, " Avoid when: %s\n", scenario)
|
||||
}
|
||||
for _, example := range selection.Examples {
|
||||
_, _ = fmt.Fprintf(w, " Example: %s\n", example)
|
||||
}
|
||||
_, _ = fmt.Fprintln(w, " Output: Agent execution should add --format json.")
|
||||
}
|
||||
|
||||
func renderRootHelp(root *cobra.Command) {
|
||||
services := visibleMCPRootCommands(root)
|
||||
utilities := visibleUtilityRootCommands(root)
|
||||
|
||||
@@ -69,6 +69,27 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatAgentGuidanceRendersOnlyOnStdout(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs([]string{"chat", "clear-messages", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat clear-messages --help: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
for _, want := range []string{"Agent guidance:", "Outcome:", "Use when:", "Avoid when:", "Example:", "Output:"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("chat help stdout missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
if got := strings.TrimSpace(stderr.String()); got != "" {
|
||||
t.Fatalf("chat help wrote guidance or warnings to stderr:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
|
||||
|
||||
+22
-15
@@ -742,7 +742,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("mcp_tool_error"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
|
||||
apperrors.WithHint(apperrors.SuggestBusinessHint(callResult.Content)),
|
||||
apperrors.WithServerDiag(diag),
|
||||
)
|
||||
// PAT scope error in business response: offer human-readable output and retry
|
||||
@@ -767,7 +767,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("business_error"),
|
||||
apperrors.WithServerKey(invocation.CanonicalProduct),
|
||||
apperrors.WithHint("The API returned a business-level error. Check required parameters and values."),
|
||||
apperrors.WithHint(apperrors.SuggestBusinessHint(callResult.Content)),
|
||||
apperrors.WithServerDiag(diag),
|
||||
)
|
||||
}
|
||||
@@ -1005,9 +1005,8 @@ 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 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
|
||||
// x-dingtalk-agent header. It does NOT influence the edition-fixed
|
||||
// claw-type or the host-owned PAT decision (driven solely by
|
||||
// DINGTALK_DWS_AGENTCODE).
|
||||
sessionID := os.Getenv(envDingtalkSessionID)
|
||||
if sessionID == "" {
|
||||
@@ -1068,22 +1067,30 @@ 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 headers == nil {
|
||||
headers = make(map[string]string)
|
||||
}
|
||||
|
||||
// claw-type is the edition-fixed routing/PAT identity. Agent Product is a
|
||||
// separate caller-declared observability and IM-display dimension.
|
||||
clawType := resolveEditionClawType(headers)
|
||||
headers["claw-type"] = clawType
|
||||
headers = applyAgentProductHeader(headers)
|
||||
agentProduct, hasAgentProduct := 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
|
||||
// Credential hooks cannot alter either identity dimension. Restore the
|
||||
// fixed claw-type and the validated Product Header (or its absence).
|
||||
headers["claw-type"] = clawType
|
||||
if hasAgentProduct {
|
||||
headers[agentproduct.HeaderName] = agentProduct
|
||||
} else {
|
||||
delete(headers, agentproduct.HeaderName)
|
||||
}
|
||||
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)), 124; got != want {
|
||||
if got, want := int(product["count"].(float64)), 159; 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
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:eb6e44775a6e85f92ca31c90c876f67543ab928d8a8bda04f07a33dc802f7028",
|
||||
"surface_hash": "sha256:29d4f5f43688cc01bc2277a4528b597de4fbab89a7b2f13fe2e56df85d1c66c4",
|
||||
"source_hash": "sha256:7111ced2e1cd0764338aacce792d95603734177a82deea4a4642626691940485",
|
||||
"surface_hash": "sha256:4897d582a472d9d3596f88fb4546e7795d6b9bc334d020712f5d895ce894f7a7",
|
||||
"coverage": {
|
||||
"surface_products": 26,
|
||||
"products_with_metadata": 26,
|
||||
"surface_tools": 840,
|
||||
"tools_with_metadata": 840,
|
||||
"tools_with_agent_summary": 840,
|
||||
"tools_with_use_when": 840,
|
||||
"tools_with_avoid_when": 840,
|
||||
"tools_with_examples": 840,
|
||||
"tools_with_interface_mode": 840,
|
||||
"unmatched_skill_tools": 122,
|
||||
"surface_tools": 875,
|
||||
"tools_with_metadata": 875,
|
||||
"tools_with_agent_summary": 875,
|
||||
"tools_with_use_when": 875,
|
||||
"tools_with_avoid_when": 875,
|
||||
"tools_with_examples": 875,
|
||||
"tools_with_interface_mode": 875,
|
||||
"unmatched_skill_tools": 96,
|
||||
"unreviewed_skill_tools": 11
|
||||
},
|
||||
"products": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+26586
-106
File diff suppressed because it is too large
Load Diff
@@ -79,36 +79,6 @@
|
||||
"calendar acl add",
|
||||
"calendar acl delete",
|
||||
"calendar book update",
|
||||
"chat category add-conv",
|
||||
"chat category create",
|
||||
"chat category delete",
|
||||
"chat category remove-conv",
|
||||
"chat category rename",
|
||||
"chat chmod",
|
||||
"chat clear-all-red-point",
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat data-auth cross-org",
|
||||
"chat group audit-join-validation",
|
||||
"chat group list-all",
|
||||
"chat group list-join-validations",
|
||||
"chat group members list-by-ids",
|
||||
"chat group notice create",
|
||||
"chat group notice edit",
|
||||
"chat group notice get",
|
||||
"chat group notice list",
|
||||
"chat group share-invite",
|
||||
"chat group update-alias",
|
||||
"chat hide",
|
||||
"chat list-all-conversations",
|
||||
"chat mark-read",
|
||||
"chat mark-unread",
|
||||
"chat message list-emotion-replies",
|
||||
"chat message set-top-msg",
|
||||
"chat message unset-top-msg",
|
||||
"chat mute-at-all",
|
||||
"chat mute-red-envelope",
|
||||
"chat text translate",
|
||||
"contact label get",
|
||||
"contact label list",
|
||||
"contact label list-members",
|
||||
@@ -168,7 +138,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 +174,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,378 @@
|
||||
{
|
||||
"id": "chat",
|
||||
"tools": [
|
||||
{
|
||||
"canonical_path": "chat.add_conv_to_categories",
|
||||
"cli_path": "chat category add-conv"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.add_custom_group_role",
|
||||
"cli_path": "chat group-role add"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.add_emoji_reaction",
|
||||
"cli_path": "chat message add-emoji"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.add_group_member",
|
||||
"cli_path": "chat group members add"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.add_message_favorite",
|
||||
"cli_path": "chat message add-favorite"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.add_robot_to_group",
|
||||
"cli_path": "chat group members add-bot"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.add_text_emotion",
|
||||
"cli_path": "chat message add-text-emotion"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.audit_join_group",
|
||||
"cli_path": "chat group audit-join-validation"
|
||||
},
|
||||
{
|
||||
"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.clear_all_red_point",
|
||||
"cli_path": "chat clear-all-red-point"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.clear_conversation_messages",
|
||||
"cli_path": "chat clear-messages"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.clear_conversation_red_point",
|
||||
"cli_path": "chat clear-red-point"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.combine_forward_messages",
|
||||
"cli_path": "chat message combine-forward"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.create_and_send_card",
|
||||
"cli_path": "chat message send-card"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.create_conv_category",
|
||||
"cli_path": "chat category create"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.create_group_conversation",
|
||||
"cli_path": "chat group create"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.create_group_notice",
|
||||
"cli_path": "chat group notice create"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.create_smart_conv_category",
|
||||
"cli_path": "chat category create-smart"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.create_text_emotion",
|
||||
"cli_path": "chat message create-text-emotion"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.delete_conv_category",
|
||||
"cli_path": "chat category delete"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.dismiss_group",
|
||||
"cli_path": "chat group dismiss"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.download_media",
|
||||
"cli_path": "chat message download-media"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.edit_group_notice",
|
||||
"cli_path": "chat group notice edit"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.edit_message",
|
||||
"cli_path": "chat message edit"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.forward_message",
|
||||
"cli_path": "chat message forward"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.forward_topic",
|
||||
"cli_path": "chat message forward-topic"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.get_conv_categories_info",
|
||||
"cli_path": "chat category batch-info"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.get_conv_info_by_group_id",
|
||||
"cli_path": "chat group get-by-group-id"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.get_conversation_info",
|
||||
"cli_path": "chat conversation-info"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.get_group_invite_url",
|
||||
"cli_path": "chat group invite-url"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.get_group_mute_config",
|
||||
"cli_path": "chat group get-mute-config"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.get_group_notice",
|
||||
"cli_path": "chat group notice get"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.grant_cross_org_data_access",
|
||||
"cli_path": "chat data-auth cross-org"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.grant_permission",
|
||||
"cli_path": "chat chmod"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.hide_conversation",
|
||||
"cli_path": "chat hide"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_all_conversations",
|
||||
"cli_path": "chat list-all-conversations"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_apply_join_group_records",
|
||||
"cli_path": "chat group list-join-validations"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_conv_categories_by_conv",
|
||||
"cli_path": "chat category list-by-conv"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_conversation_message_v2",
|
||||
"cli_path": "chat message list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_conversations_by_category",
|
||||
"cli_path": "chat category list-conversations"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_custom_group_roles",
|
||||
"cli_path": "chat group-role list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_group_bots",
|
||||
"cli_path": "chat group bots"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_group_member_by_ids",
|
||||
"cli_path": "chat group members list-by-ids"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_group_notices",
|
||||
"cli_path": "chat group notice list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_individual_chat_message",
|
||||
"cli_path": "chat message list-direct"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_message_emotion_replies",
|
||||
"cli_path": "chat message list-emotion-replies"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_message_favorites",
|
||||
"cli_path": "chat message list-favorites"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_messages_by_ids",
|
||||
"cli_path": "chat message list-by-ids"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_my_groups_pagination",
|
||||
"cli_path": "chat group list-all"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_owned_or_admin_groups",
|
||||
"cli_path": "chat group list-my-groups"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_pin_messages",
|
||||
"cli_path": "chat message list-pin-msg"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_special_focus_messages",
|
||||
"cli_path": "chat message list-focused"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_top_conversations",
|
||||
"cli_path": "chat list-top-conversations"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_topic_replies",
|
||||
"cli_path": "chat message list-topic-replies"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_user_define_conv_categories",
|
||||
"cli_path": "chat category list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.mark_conversation_unread",
|
||||
"cli_path": "chat mark-unread"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.mark_message_read",
|
||||
"cli_path": "chat mark-read"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.query_custom_user_roles",
|
||||
"cli_path": "chat group-role query-user"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.query_message_send_status",
|
||||
"cli_path": "chat message query-send-status"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.query_msg_read_status",
|
||||
"cli_path": "chat message read-status"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.quit_group",
|
||||
"cli_path": "chat group quit"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.recall_message",
|
||||
"cli_path": "chat message recall"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.recall_robot_message",
|
||||
"cli_path": "chat message recall-by-bot"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.remove_conv_from_categories",
|
||||
"cli_path": "chat category remove-conv"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.remove_custom_group_role",
|
||||
"cli_path": "chat group-role remove"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.remove_custom_user_roles",
|
||||
"cli_path": "chat group-role remove-user"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.remove_emoji_reaction",
|
||||
"cli_path": "chat message remove-emoji"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.remove_group_member",
|
||||
"cli_path": "chat group members remove"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.remove_message_favorite",
|
||||
"cli_path": "chat message remove-favorite"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.remove_robot_in_group",
|
||||
"cli_path": "chat group members remove-bot"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.remove_text_emotion",
|
||||
"cli_path": "chat message remove-text-emotion"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.rename_conv_category",
|
||||
"cli_path": "chat category rename"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.reply_personal_message",
|
||||
"cli_path": "chat message reply"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_at_me_message",
|
||||
"cli_path": "chat message list-mentions"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_bots",
|
||||
"cli_path": "chat bot find"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_common_groups",
|
||||
"cli_path": "chat search-common"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_groups",
|
||||
"cli_path": "chat search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_messages",
|
||||
"cli_path": "chat message search-advanced"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_messages_by_keyword",
|
||||
"cli_path": "chat message search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_messages_by_sender",
|
||||
"cli_path": "chat message list-by-sender"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_messages_by_time_range",
|
||||
"cli_path": "chat message list-all"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_my_robots",
|
||||
"cli_path": "chat bot search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.send_message_by_custom_robot",
|
||||
"cli_path": "chat message send-by-webhook"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.send_personal_message",
|
||||
"cli_path": "chat message send"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.send_robot_message",
|
||||
"cli_path": "chat message send-by-bot"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.set_custom_user_roles",
|
||||
"cli_path": "chat group-role set-user"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.set_group_member_mute_list",
|
||||
"cli_path": "chat group-mute-member"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.set_group_mute",
|
||||
"cli_path": "chat group-mute"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.set_pin_message",
|
||||
"cli_path": "chat message set-pin-msg"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.set_top_conversation",
|
||||
"cli_path": "chat set-top"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.set_top_message",
|
||||
"cli_path": "chat message set-top-msg"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.share_group_invite_url",
|
||||
"cli_path": "chat group share-invite"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_at_me",
|
||||
"cli_path": "chat +at-me"
|
||||
@@ -57,6 +429,10 @@
|
||||
"canonical_path": "chat.shortcut_chat_list_mine",
|
||||
"cli_path": "chat +chat-list-mine"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_messages",
|
||||
"cli_path": "chat +chat-messages"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_chat_mute",
|
||||
"cli_path": "chat +chat-mute"
|
||||
@@ -149,10 +525,18 @@
|
||||
"canonical_path": "chat.shortcut_messages_read_status",
|
||||
"cli_path": "chat +messages-read-status"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_send",
|
||||
"cli_path": "chat +messages-send"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_send_by_webhook",
|
||||
"cli_path": "chat +messages-send-by-webhook"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_send_card",
|
||||
"cli_path": "chat +messages-send-card"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_messages_update_card",
|
||||
"cli_path": "chat +messages-update-card"
|
||||
@@ -161,114 +545,62 @@
|
||||
"canonical_path": "chat.shortcut_my_groups",
|
||||
"cli_path": "chat +my-groups"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_search_msg",
|
||||
"cli_path": "chat +search-msg"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_send_to_group",
|
||||
"cli_path": "chat +send-to-group"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_thread_replies",
|
||||
"cli_path": "chat +thread-replies"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.shortcut_unread_chats",
|
||||
"cli_path": "chat +unread-chats"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_bots",
|
||||
"cli_path": "chat bot find"
|
||||
"canonical_path": "chat.transfer_group_owner",
|
||||
"cli_path": "chat group transfer-owner"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_my_robots",
|
||||
"cli_path": "chat bot search"
|
||||
"canonical_path": "chat.translate",
|
||||
"cli_path": "chat text translate"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.get_conv_categories_info",
|
||||
"cli_path": "chat category batch-info"
|
||||
"canonical_path": "chat.unread_message_conversation_list",
|
||||
"cli_path": "chat message list-unread-conversations"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.create_smart_conv_category",
|
||||
"cli_path": "chat category create-smart"
|
||||
"canonical_path": "chat.unset_pin_message",
|
||||
"cli_path": "chat message unset-pin-msg"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_user_define_conv_categories",
|
||||
"cli_path": "chat category list"
|
||||
"canonical_path": "chat.unset_top_message",
|
||||
"cli_path": "chat message unset-top-msg"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.get_conversation_info",
|
||||
"cli_path": "chat conversation-info"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_group_bots",
|
||||
"cli_path": "chat group bots"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.create_group_conversation",
|
||||
"cli_path": "chat group create"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.dismiss_group",
|
||||
"cli_path": "chat group dismiss"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_owned_or_admin_groups",
|
||||
"cli_path": "chat group list-my-groups"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.add_group_member",
|
||||
"cli_path": "chat group members add"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.add_robot_to_group",
|
||||
"cli_path": "chat group members add-bot"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.remove_group_member",
|
||||
"cli_path": "chat group members remove"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.remove_robot_in_group",
|
||||
"cli_path": "chat group members remove-bot"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.quit_group",
|
||||
"cli_path": "chat group quit"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.update_group_name",
|
||||
"cli_path": "chat group rename"
|
||||
"canonical_path": "chat.update_at_all_notification_off",
|
||||
"cli_path": "chat mute-at-all"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.update_conv_member_roles",
|
||||
"cli_path": "chat group set-admin"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.update_show_history_msg_option",
|
||||
"cli_path": "chat group set-history"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.transfer_group_owner",
|
||||
"cli_path": "chat group transfer-owner"
|
||||
"canonical_path": "chat.update_custom_group_role",
|
||||
"cli_path": "chat group-role update"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.update_group_icon",
|
||||
"cli_path": "chat group update-icon"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.update_group_name",
|
||||
"cli_path": "chat group rename"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.update_group_nick",
|
||||
"cli_path": "chat group update-nick"
|
||||
@@ -278,200 +610,16 @@
|
||||
"cli_path": "chat group update-settings"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.upgrade_group_to_external",
|
||||
"cli_path": "chat group upgrade-to-external"
|
||||
"canonical_path": "chat.update_notification_off",
|
||||
"cli_path": "chat mute"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.batch_query_group_chat_settings",
|
||||
"cli_path": "chat group user-settings query"
|
||||
"canonical_path": "chat.update_red_env_notification_off",
|
||||
"cli_path": "chat mute-red-envelope"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.set_group_member_mute_list",
|
||||
"cli_path": "chat group-mute-member"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.add_custom_group_role",
|
||||
"cli_path": "chat group-role add"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_custom_group_roles",
|
||||
"cli_path": "chat group-role list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.query_custom_user_roles",
|
||||
"cli_path": "chat group-role query-user"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.remove_custom_group_role",
|
||||
"cli_path": "chat group-role remove"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.remove_custom_user_roles",
|
||||
"cli_path": "chat group-role remove-user"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.set_custom_user_roles",
|
||||
"cli_path": "chat group-role set-user"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.update_custom_group_role",
|
||||
"cli_path": "chat group-role update"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_top_conversations",
|
||||
"cli_path": "chat list-top-conversations"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.add_emoji_reaction",
|
||||
"cli_path": "chat message add-emoji"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.add_message_favorite",
|
||||
"cli_path": "chat message add-favorite"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.add_text_emotion",
|
||||
"cli_path": "chat message add-text-emotion"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.combine_forward_messages",
|
||||
"cli_path": "chat message combine-forward"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.create_text_emotion",
|
||||
"cli_path": "chat message create-text-emotion"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.forward_topic",
|
||||
"cli_path": "chat message forward-topic"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_conversation_message_v2",
|
||||
"cli_path": "chat message list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_messages_by_time_range",
|
||||
"cli_path": "chat message list-all"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_messages_by_ids",
|
||||
"cli_path": "chat message list-by-ids"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_messages_by_sender",
|
||||
"cli_path": "chat message list-by-sender"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_individual_chat_message",
|
||||
"cli_path": "chat message list-direct"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_message_favorites",
|
||||
"cli_path": "chat message list-favorites"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_special_focus_messages",
|
||||
"cli_path": "chat message list-focused"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_at_me_message",
|
||||
"cli_path": "chat message list-mentions"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_pin_messages",
|
||||
"cli_path": "chat message list-pin-msg"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.list_topic_replies",
|
||||
"cli_path": "chat message list-topic-replies"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.unread_message_conversation_list",
|
||||
"cli_path": "chat message list-unread-conversations"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.query_message_send_status",
|
||||
"cli_path": "chat message query-send-status"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.query_msg_read_status",
|
||||
"cli_path": "chat message read-status"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.recall_message",
|
||||
"cli_path": "chat message recall"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.recall_robot_message",
|
||||
"cli_path": "chat message recall-by-bot"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.remove_emoji_reaction",
|
||||
"cli_path": "chat message remove-emoji"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.remove_message_favorite",
|
||||
"cli_path": "chat message remove-favorite"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.remove_text_emotion",
|
||||
"cli_path": "chat message remove-text-emotion"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.reply_personal_message",
|
||||
"cli_path": "chat message reply"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_messages_by_keyword",
|
||||
"cli_path": "chat message search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_messages",
|
||||
"cli_path": "chat message search-advanced"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.send_personal_message",
|
||||
"cli_path": "chat message send"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.send_robot_message",
|
||||
"cli_path": "chat message send-by-bot"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.send_message_by_custom_robot",
|
||||
"cli_path": "chat message send-by-webhook"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.create_and_send_card",
|
||||
"cli_path": "chat message send-card"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.set_pin_message",
|
||||
"cli_path": "chat message set-pin-msg"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.unset_pin_message",
|
||||
"cli_path": "chat message unset-pin-msg"
|
||||
"canonical_path": "chat.update_show_history_msg_option",
|
||||
"cli_path": "chat group set-history"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.update_streaming_card",
|
||||
@@ -482,20 +630,12 @@
|
||||
"cli_path": "chat message update-text-emotion"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.update_notification_off",
|
||||
"cli_path": "chat mute"
|
||||
"canonical_path": "chat.update_user_group_alias",
|
||||
"cli_path": "chat group update-alias"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_groups",
|
||||
"cli_path": "chat search"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.search_common_groups",
|
||||
"cli_path": "chat search-common"
|
||||
},
|
||||
{
|
||||
"canonical_path": "chat.set_top_conversation",
|
||||
"cli_path": "chat set-top"
|
||||
"canonical_path": "chat.upgrade_group_to_external",
|
||||
"cli_path": "chat group upgrade-to-external"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -801,6 +801,396 @@
|
||||
"cli_path": "chat group upgrade-to-external",
|
||||
"runtime_gate": "confirm_dangerous"
|
||||
},
|
||||
"chat.add_conv_to_categories": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns validation and calls a remote helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native category command with its real Cobra contract; runtime has no confirmation gate.",
|
||||
"cli_path": "chat category add-conv",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.create_conv_category": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns title validation and calls a remote helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native category command and preserve the runtime 15-character title validation; runtime has no confirmation gate.",
|
||||
"cli_path": "chat category create",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.delete_conv_category": {
|
||||
"effect": "destructive",
|
||||
"risk": "high",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote category-delete helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native category deletion command with its explicit typed --yes confirmation gate.",
|
||||
"cli_path": "chat category delete",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.remove_conv_from_categories": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns validation and calls a remote helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native category membership command with its real Cobra contract; runtime has no confirmation gate.",
|
||||
"cli_path": "chat category remove-conv",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.rename_conv_category": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns title validation and calls a remote helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native category rename command and preserve the runtime 15-character title validation; runtime has no confirmation gate.",
|
||||
"cli_path": "chat category rename",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.grant_permission": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed permission adapter: the executable CLI normalizes grant scope and parameters before calling chat_permission_grant.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented chat permission grant surface without inventing a runtime confirmation gate.",
|
||||
"cli_path": "chat chmod",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.clear_all_red_point": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native red-point command; runtime has no confirmation gate.",
|
||||
"cli_path": "chat clear-all-red-point",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.clear_conversation_messages": {
|
||||
"effect": "destructive",
|
||||
"risk": "high",
|
||||
"confirmation": "user_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote conversation-clear helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native clear-messages command with its explicit typed --yes confirmation gate.",
|
||||
"cli_path": "chat clear-messages",
|
||||
"runtime_gate": "typed_yes"
|
||||
},
|
||||
"chat.clear_conversation_red_point": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native red-point command; runtime has no confirmation gate.",
|
||||
"cli_path": "chat clear-red-point",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.grant_cross_org_data_access": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed cross-organization permission adapter: the executable CLI assembles scoped chat_permission_grant arguments.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented cross-organization data authorization command without inventing a runtime confirmation gate.",
|
||||
"cli_path": "chat data-auth cross-org",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.audit_join_group": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI validates supported audit statuses before calling a remote helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented join-audit command and preserve the runtime AuditApprove/AuditDelete restriction.",
|
||||
"cli_path": "chat group audit-join-validation",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.list_my_groups_pagination": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native paginated group-list command.",
|
||||
"cli_path": "chat group list-all",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.list_apply_join_group_records": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native join-validation list command.",
|
||||
"cli_path": "chat group list-join-validations",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.list_group_member_by_ids": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote member lookup helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native batch group-member lookup command.",
|
||||
"cli_path": "chat group members list-by-ids",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.create_group_notice": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI assembles notice scheduling and notification arguments for a remote helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native group notice creation command; runtime has no confirmation gate.",
|
||||
"cli_path": "chat group notice create",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.edit_group_notice": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI assembles notice update arguments for a remote helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native group notice edit command; runtime has no confirmation gate.",
|
||||
"cli_path": "chat group notice edit",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.get_group_notice": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI validates notice IDs before calling a remote helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native group notice detail command.",
|
||||
"cli_path": "chat group notice get",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.list_group_notices": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native group notice list command.",
|
||||
"cli_path": "chat group notice list",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.share_group_invite_url": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI enforces target/receiver exclusivity before calling a remote helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native invite-sharing command; runtime has no confirmation gate.",
|
||||
"cli_path": "chat group share-invite",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.update_user_group_alias": {
|
||||
"effect": "write",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote personal-alias helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native personal group-alias command.",
|
||||
"cli_path": "chat group update-alias",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.hide_conversation": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote conversation visibility helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native hide-conversation command.",
|
||||
"cli_path": "chat hide",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.list_all_conversations": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native all-conversations list command.",
|
||||
"cli_path": "chat list-all-conversations",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.mark_message_read": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote read-state helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native mark-read command.",
|
||||
"cli_path": "chat mark-read",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.mark_conversation_unread": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote read-state helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native mark-unread command.",
|
||||
"cli_path": "chat mark-unread",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.list_message_emotion_replies": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote emotion-reply helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native batch emotion reply list command.",
|
||||
"cli_path": "chat message list-emotion-replies",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.set_top_message": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote message-top helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native set-top-message command.",
|
||||
"cli_path": "chat message set-top-msg",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.unset_top_message": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote message-top helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native unset-top-message command.",
|
||||
"cli_path": "chat message unset-top-msg",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.update_at_all_notification_off": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote notification preference helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native @all notification preference command.",
|
||||
"cli_path": "chat mute-at-all",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.update_red_env_notification_off": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote notification preference helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native red-envelope notification preference command.",
|
||||
"cli_path": "chat mute-red-envelope",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.translate": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote translation helper absent from the pinned MCP snapshot.",
|
||||
"reviewed": true,
|
||||
"review_reason": "Publish the documented native chat text translation command.",
|
||||
"cli_path": "chat text translate",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"chat.shortcut_bot_search": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
@@ -1152,6 +1542,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 +1581,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 +1635,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 +1700,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 +1756,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 +1782,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",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"channel": "open-source"
|
||||
},
|
||||
"coverage": {
|
||||
"source_tools": 840,
|
||||
"source_tools": 875,
|
||||
"matched_tools": 71
|
||||
},
|
||||
"tools": {
|
||||
|
||||
@@ -150,12 +150,12 @@
|
||||
]
|
||||
},
|
||||
"chat.create_and_send_card": {
|
||||
"agent_summary": "创建并向群聊或单聊发送互动卡片",
|
||||
"agent_summary": "创建并向群聊或单聊发送通用流式卡片",
|
||||
"use_when": [
|
||||
"需要卡片式交互且已准备接收会话或用户时"
|
||||
"需要通用流式卡片交互且已准备接收会话或用户时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"只发送普通文本时使用 send 或 send-by-bot"
|
||||
"只发送普通文本时使用 send 或 send-by-bot;转发现有审批、日历、待办等原生产品卡片时应先取得真实 openMessageId,再使用 message forward"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat message send-card --group <openConversationId>"
|
||||
@@ -263,7 +263,7 @@
|
||||
"已知消息、会话和资源 ID,需要保存媒体文件时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"只查看文本消息内容时使用对应消息查询命令"
|
||||
"只查看文本消息内容时使用对应消息查询命令;本命令不是发送附件的前置步骤,本地文件发送直接使用 message send --msg-type file --file-path"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ."
|
||||
@@ -280,10 +280,10 @@
|
||||
"chat.forward_message": {
|
||||
"agent_summary": "把一条已有消息转发到另一个会话",
|
||||
"use_when": [
|
||||
"已知源消息与源、目标会话 ID 时"
|
||||
"已知同一源会话中的真实 openMessageId 与源、目标会话 ID,需要保留原消息或原生产品卡片时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"合并转发多条消息时使用 chat message combine-forward"
|
||||
"合并转发多条消息时使用 chat message combine-forward;OA 实例 ID、日历事件 ID、待办任务 ID 等产品对象 ID 不能代替消息 ID"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat message forward --src-conversation-id <srcConversationId> --msg-id <openMessageId> --dest-conversation-id <destConversationId>"
|
||||
@@ -340,7 +340,7 @@
|
||||
"chat.get_conversation_info": {
|
||||
"agent_summary": "获取群聊或单聊会话的详细信息",
|
||||
"use_when": [
|
||||
"已知群 ID 或用户标识并需要解析会话详情时"
|
||||
"已知群 ID 或单聊用户标识并需要解析会话详情时;--group、--user、--open-dingtalk-id 只能选择一个"
|
||||
],
|
||||
"avoid_when": [
|
||||
"按群名查找会话时使用 chat search"
|
||||
@@ -383,7 +383,7 @@
|
||||
"用户明确指定某个会话,并要读取消息或追溯引用回复中的原消息上下文时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"跨全部会话按时间查询时使用 chat message list-all"
|
||||
"跨全部会话按时间查询时使用 chat message list-all;关键词搜索或审计应使用 message search,不要拉最近消息后在本地筛选"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50"
|
||||
@@ -663,7 +663,7 @@
|
||||
"发送命令返回 openTaskId 后需要确认投递结果时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"没有 openTaskId 或只需查消息内容时不要使用"
|
||||
"没有 message send 真实返回的 openTaskId 时不要使用;openMessageId 和会话 ID 都不能代替 openTaskId"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat message query-send-status --open-task-id <openTaskId>"
|
||||
@@ -905,7 +905,7 @@
|
||||
"chat.reply_personal_message": {
|
||||
"agent_summary": "引用指定消息发送个人回复",
|
||||
"use_when": [
|
||||
"用户要针对某条已有消息进行引用回复时"
|
||||
"用户要针对某条已有消息进行引用回复,且 conversationId、openMessageId、senderOpenDingTalkId 来自同一条消息时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"无需引用上下文的普通消息使用 chat message send"
|
||||
@@ -1138,10 +1138,10 @@
|
||||
"chat.send_personal_message": {
|
||||
"agent_summary": "以当前用户身份发送群聊或单聊消息",
|
||||
"use_when": [
|
||||
"用户明确要以个人身份发送文本或媒体消息时"
|
||||
"用户明确要以个人身份发送消息时;文本用 --text,图片仅在已有有效 mediaId 时用 --msg-type image --media-id,本地 PDF/DOCX/XLSX 等文件用 --msg-type file --file-path"
|
||||
],
|
||||
"avoid_when": [
|
||||
"机器人身份或 Webhook 发送应使用对应命令"
|
||||
"机器人身份或 Webhook 发送应使用对应命令;发送审批、日历或待办摘要不会创建对应产品对象"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat message send --group <openConversationId> \"项目已更新\""
|
||||
@@ -1398,7 +1398,7 @@
|
||||
"chat.update_group_name": {
|
||||
"agent_summary": "修改指定群聊的名称",
|
||||
"use_when": [
|
||||
"需要给已有群聊重命名时"
|
||||
"需要把已有群聊改成用户指定的准确名称时;名称不得擅自加前缀、截断或改写,复合任务中重命名后继续完成后续步骤"
|
||||
],
|
||||
"avoid_when": [
|
||||
"只修改个人可见备注时不要使用群名称更新"
|
||||
@@ -1478,10 +1478,10 @@
|
||||
"chat.update_streaming_card": {
|
||||
"agent_summary": "更新已发送流式卡片的内容和状态",
|
||||
"use_when": [
|
||||
"已有 bizId 并需要追加内容或结束流式输出时"
|
||||
"已有 message send-card 真实返回的 bizId,并需要追加内容或结束流式输出时;最后一次更新使用完成状态"
|
||||
],
|
||||
"avoid_when": [
|
||||
"创建新卡片时使用 chat message send-card"
|
||||
"创建新卡片时使用 chat message send-card;openMessageId、OA 实例 ID、日历事件 ID或待办任务 ID 不能代替 bizId"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat message update-card --biz-id <bizId> --content \"处理完成\" --flow-status 2"
|
||||
@@ -1596,6 +1596,604 @@
|
||||
"live-dws-schema:chat.upgrade_group_to_external#FAILED"
|
||||
]
|
||||
},
|
||||
"chat.add_conv_to_categories": {
|
||||
"agent_summary": "把一个会话加入一个或多个现有自定义分组",
|
||||
"use_when": [
|
||||
"已从 category list 取得真实 categoryId,并要把当前账号可访问的会话归入这些分组时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"创建新分组使用 category create;移出分组使用 category remove-conv"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat category add-conv --group <openConversationId> --category-ids 123,456"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据 nanrun 树形路由与当前 Cobra/多维表参考复核会话分组写入路径;要求使用真实 ID 并在复合任务中继续完成后续步骤。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.add_conv_to_categories",
|
||||
"cobra-help:dws chat category add-conv",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.create_conv_category": {
|
||||
"agent_summary": "创建用户自定义会话分组",
|
||||
"use_when": [
|
||||
"用户明确要新建手工管理的会话分组,且名称不超过 15 个字符时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"按关键词或成员自动归类应使用 category create-smart;不得静默截断、缩写或改写用户名称"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat category create --title \"项目群\""
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "迁移 nanrun 的分组标题和复合任务 guidance 到 reviewed selection;保持当前 runtime 的 15 字符校验。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.create_conv_category",
|
||||
"cobra-help:dws chat category create",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.delete_conv_category": {
|
||||
"agent_summary": "删除指定的用户自定义会话分组",
|
||||
"use_when": [
|
||||
"用户明确要删除已知 categoryId 的自定义分组时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"只想把会话移出分组时使用 category remove-conv;目标不明确时不要删除"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat category delete --category-id 123"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前原生命令与 nanrun 树形 category 分支补齐删除能力的选择边界。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.delete_conv_category",
|
||||
"cobra-help:dws chat category delete",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.remove_conv_from_categories": {
|
||||
"agent_summary": "把一个会话从一个或多个自定义分组移出",
|
||||
"use_when": [
|
||||
"已知会话和真实 categoryId,需要解除现有分组归属时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"删除整个分组使用 category delete;加入分组使用 category add-conv"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat category remove-conv --group <openConversationId> --category-ids 123,456"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前原生命令与 nanrun 树形 category 分支补齐会话移出分组的选择边界。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.remove_conv_from_categories",
|
||||
"cobra-help:dws chat category remove-conv",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.rename_conv_category": {
|
||||
"agent_summary": "重命名用户自定义会话分组",
|
||||
"use_when": [
|
||||
"已知 categoryId,且用户给出了不超过 15 个字符的新名称时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"不得为绕过长度限制而截断、缩写或改写;创建新分组使用 category create"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat category rename --category-id 123 --title \"新名称\""
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "迁移 nanrun 的分组标题和复合任务 guidance 到 reviewed selection;保持当前 runtime 的 15 字符校验。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.rename_conv_category",
|
||||
"cobra-help:dws chat category rename",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.grant_permission": {
|
||||
"agent_summary": "为指定 chat scope 和业务参数发起高风险操作授权",
|
||||
"use_when": [
|
||||
"目标 chat 操作因行为授权缺失而需要按 scope、目标和时效发起授权时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"不要把它当作实际发送、撤回或群管理命令;跨组织数据读取授权使用 data-auth cross-org"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat chmod chat.message:send --grant-type timed --ttl 24h --permParam openCid=<openConversationId>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前授权 Cobra、dws-shared 安全规则和树形 data-auth/chmod 分支补齐 Agent 选路。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.grant_permission",
|
||||
"cobra-help:dws chat chmod",
|
||||
"skills/multi/dingtalk-chat/SKILL.md"
|
||||
]
|
||||
},
|
||||
"chat.clear_all_red_point": {
|
||||
"agent_summary": "清除当前用户所有会话的未读红点",
|
||||
"use_when": [
|
||||
"用户明确要求全部会话一键已读或清除所有红点时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"只处理一个会话时使用 clear-red-point;本命令不会删除聊天记录"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat clear-all-red-point"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 和 nanrun 会话状态树补齐全局红点清零能力。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.clear_all_red_point",
|
||||
"cobra-help:dws chat clear-all-red-point",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.clear_conversation_messages": {
|
||||
"agent_summary": "清空当前用户在指定会话中的聊天记录",
|
||||
"use_when": [
|
||||
"用户明确要求清空某个已知会话的本地聊天记录时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"仅清除未读红点使用 clear-red-point;未确认真实 openConversationId 时不要执行"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat clear-messages --conversation-id <openConversationId>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 和 nanrun 会话状态树补齐清空会话消息的严格目标边界。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.clear_conversation_messages",
|
||||
"cobra-help:dws chat clear-messages",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.clear_conversation_red_point": {
|
||||
"agent_summary": "清除指定会话的未读红点",
|
||||
"use_when": [
|
||||
"用户只要求把某个已知会话标为已读或清除红点时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"全部会话一键已读使用 clear-all-red-point;本命令不会删除消息"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat clear-red-point --conversation-id <openConversationId>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 和 nanrun 会话状态树补齐单会话红点清理能力。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.clear_conversation_red_point",
|
||||
"cobra-help:dws chat clear-red-point",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.grant_cross_org_data_access": {
|
||||
"agent_summary": "发起 chat 跨组织数据读取授权",
|
||||
"use_when": [
|
||||
"跨组织拉取聊天数据因 data scope 缺失,需要为目标组织或全部组织授权时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"发送、撤回和群管理授权使用 chat chmod;普通同组织查询不要预先授权"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat data-auth cross-org --target-org-id 439446171"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前授权 Cobra、dws-shared 跨组织规则和 nanrun data-auth 分支补齐 Agent 选路。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.grant_cross_org_data_access",
|
||||
"cobra-help:dws chat data-auth cross-org",
|
||||
"skills/multi/dingtalk-chat/SKILL.md"
|
||||
]
|
||||
},
|
||||
"chat.audit_join_group": {
|
||||
"agent_summary": "审批一条群聊入群验证记录",
|
||||
"use_when": [
|
||||
"已从 list-join-validations 取得真实记录、申请人和邀请人 ID,需要执行 AuditApprove 或 AuditDelete 时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"列出申请使用 list-join-validations;服务端不支持 AuditIgnore、AuditRefuse 或 AuditBlock"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <userId> --inviter <userId> --status AuditApprove"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据 nanrun 的状态 hint 和当前 runtime 支持范围补齐入群审核选择语义。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.audit_join_group",
|
||||
"cobra-help:dws chat group audit-join-validation",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.list_my_groups_pagination": {
|
||||
"agent_summary": "分页拉取当前用户加入的全部群聊",
|
||||
"use_when": [
|
||||
"需要完整分页列出我加入的所有群,并沿用 nextCursor 继续读取时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"只查我创建或管理的群使用 group list-my-groups;按关键词找群使用 chat search"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat group list-all --limit 100"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 与 nanrun group 树补齐分页群列表能力。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.list_my_groups_pagination",
|
||||
"cobra-help:dws chat group list-all",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.list_apply_join_group_records": {
|
||||
"agent_summary": "分页拉取当前用户相关的入群验证记录",
|
||||
"use_when": [
|
||||
"需要查看待处理或历史入群申请,并取得后续审核所需记录 ID 时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"执行审批使用 audit-join-validation;普通群成员列表使用 group members"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat group list-join-validations --limit 20"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 与 nanrun group 树补齐入群验证列表能力。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.list_apply_join_group_records",
|
||||
"cobra-help:dws chat group list-join-validations",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.list_group_member_by_ids": {
|
||||
"agent_summary": "按成员 openDingTalkId 批量查询群成员详情",
|
||||
"use_when": [
|
||||
"已知群 openConversationId 和一组成员 openDingTalkId,需要批量取成员详情时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"列出整个群成员直接使用 group members --id;不要臆造 members list 子命令"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat group members list-by-ids --id <openConversationId> --users openDingTalkId1,openDingTalkId2"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "迁移 nanrun 的群成员 flag guidance 到 reviewed selection,并固定 --id/--users 参数边界。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.list_group_member_by_ids",
|
||||
"cobra-help:dws chat group members list-by-ids",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.create_group_notice": {
|
||||
"agent_summary": "在指定群聊发布即时或定时群公告",
|
||||
"use_when": [
|
||||
"用户明确要发布群公告,并已给出群和 Markdown 正文时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"普通聊天消息使用 message send;修改已有公告使用 group notice edit"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat group notice create --group <openConversationId> --content \"今晚 22 点系统维护\""
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 与 nanrun notice 子树补齐群公告创建能力。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.create_group_notice",
|
||||
"cobra-help:dws chat group notice create",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.edit_group_notice": {
|
||||
"agent_summary": "整体替换指定群公告的正文与可选状态",
|
||||
"use_when": [
|
||||
"已从公告列表取得真实 dataId,需要修改现有群公告时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"发布新公告使用 group notice create;只查看内容使用 group notice get"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat group notice edit --group <openConversationId> --notice-id <dataId> --content \"更新后的公告\""
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 与 nanrun notice 子树补齐群公告编辑能力。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.edit_group_notice",
|
||||
"cobra-help:dws chat group notice edit",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.get_group_notice": {
|
||||
"agent_summary": "读取指定群公告的详情",
|
||||
"use_when": [
|
||||
"已知群和真实公告 dataId,需要查看正文、发布者或统计信息时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"不知道 dataId 时先用 group notice list;不要使用占位符公告 ID"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat group notice get --group <openConversationId> --notice-id <dataId>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据 nanrun 的真实 noticeId hint 和当前 Cobra 补齐公告详情选择语义。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.get_group_notice",
|
||||
"cobra-help:dws chat group notice get",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.list_group_notices": {
|
||||
"agent_summary": "分页读取指定群的已发布或定时公告",
|
||||
"use_when": [
|
||||
"需要列出群公告并取得后续详情或编辑使用的 dataId 时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"读取单条已知公告使用 group notice get;普通群消息使用 message list"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat group notice list --group <openConversationId> --limit 20"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 与 nanrun notice 子树补齐群公告列表能力。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.list_group_notices",
|
||||
"cobra-help:dws chat group notice list",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.share_group_invite_url": {
|
||||
"agent_summary": "把一个群的邀请链接分享到目标会话或单聊用户",
|
||||
"use_when": [
|
||||
"当前用户已加入源群,且要把邀请链接发给一个目标会话或一个接收人时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"只获取邀请链接使用 group invite-url;--target 与 --receiver 只能选择一个"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat group share-invite --source <sourceConversationId> --target <targetConversationId>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "迁移 nanrun 的 target/receiver 互斥 hint 与源群边界到 reviewed selection。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.share_group_invite_url",
|
||||
"cobra-help:dws chat group share-invite",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.update_user_group_alias": {
|
||||
"agent_summary": "设置仅当前用户可见的群备注",
|
||||
"use_when": [
|
||||
"用户要修改自己看到的群备注,而不是修改群的公开名称时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"修改所有成员看到的群名使用 group rename;修改自己群昵称使用 group update-nick"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat group update-alias --group <openConversationId> --alias-title \"客户项目群\""
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 与 nanrun group 树补齐群备注和群名/群昵称消歧。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.update_user_group_alias",
|
||||
"cobra-help:dws chat group update-alias",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.hide_conversation": {
|
||||
"agent_summary": "从当前用户的会话列表隐藏指定会话",
|
||||
"use_when": [
|
||||
"用户明确要隐藏一个已知会话,接受收到新消息后可能再次出现时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"免打扰使用 chat mute;退出群聊使用 group quit;本命令不会删除消息"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat hide --conversation-id <openConversationId>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 与 nanrun 会话状态树补齐隐藏会话能力。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.hide_conversation",
|
||||
"cobra-help:dws chat hide",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.list_all_conversations": {
|
||||
"agent_summary": "分页获取当前用户的全部单聊和群聊会话",
|
||||
"use_when": [
|
||||
"需要完整枚举会话并沿用 nextCursor 翻页时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"只看置顶会话使用 list-top-conversations;只看群聊使用 group list-all"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat list-all-conversations --limit 50"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 与 nanrun 会话树补齐全部会话列表能力。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.list_all_conversations",
|
||||
"cobra-help:dws chat list-all-conversations",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.mark_message_read": {
|
||||
"agent_summary": "把指定消息及之前的消息标记为已读",
|
||||
"use_when": [
|
||||
"已知同一会话中的真实 openMessageId,需要推进已读位置时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"只清除会话红点使用 clear-red-point;查询他人是否已读使用 message read-status"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat mark-read --conversation-id <openConversationId> --message-id <openMessageId>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 与 nanrun 会话状态树补齐消息已读写入能力。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.mark_message_read",
|
||||
"cobra-help:dws chat mark-read",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.mark_conversation_unread": {
|
||||
"agent_summary": "把指定会话标记为未读",
|
||||
"use_when": [
|
||||
"用户要稍后处理某个已知会话并将其重新标为未读时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"清除红点使用 clear-red-point;查询未读会话使用 message list-unread-conversations"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat mark-unread --conversation-id <openConversationId>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 与 nanrun 会话状态树补齐标记未读能力。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.mark_conversation_unread",
|
||||
"cobra-help:dws chat mark-unread",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.list_message_emotion_replies": {
|
||||
"agent_summary": "批量读取多条消息的 emoji 与文字表情回应",
|
||||
"use_when": [
|
||||
"已从消息查询取得一组真实 openMessageId,需要汇总回应信息时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"添加或移除回应使用 add/remove-emoji 或 add/remove-text-emotion;不要传占位符 ID"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat message list-emotion-replies --msg-ids <openMessageId1>,<openMessageId2>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 与 nanrun message 树补齐批量消息回应读取能力。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.list_message_emotion_replies",
|
||||
"cobra-help:dws chat message list-emotion-replies",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.set_top_message": {
|
||||
"agent_summary": "把指定消息置顶到会话顶部",
|
||||
"use_when": [
|
||||
"已从目标会话取得真实且同源的 openMessageId,需要设置消息置顶时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"置顶整个会话使用 chat set-top;钉住消息使用 message set-pin-msg"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat message set-top-msg --open-conversation-id <openConversationId> --msg-id <openMessageId>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "迁移 nanrun 的真实消息 ID guidance 到 reviewed selection,并与会话置顶和 Pin 消歧。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.set_top_message",
|
||||
"cobra-help:dws chat message set-top-msg",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.unset_top_message": {
|
||||
"agent_summary": "取消指定会话中的消息置顶",
|
||||
"use_when": [
|
||||
"已知同源的会话和真实 openMessageId,需要取消消息置顶时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"取消会话置顶使用 chat set-top --off;取消 Pin 使用 message unset-pin-msg"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat message unset-top-msg --open-conversation-id <openConversationId> --msg-id <openMessageId>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 与 nanrun message 树补齐取消消息置顶的选择边界。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.unset_top_message",
|
||||
"cobra-help:dws chat message unset-top-msg",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.update_at_all_notification_off": {
|
||||
"agent_summary": "关闭或恢复指定会话的 @所有人通知",
|
||||
"use_when": [
|
||||
"用户只想调整某个会话的 @所有人提醒偏好时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"关闭全部会话通知使用 chat mute;红包通知偏好使用 mute-red-envelope"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat mute-at-all --conversation-id <openConversationId>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 与 nanrun 会话通知树补齐 @all 通知偏好能力。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.update_at_all_notification_off",
|
||||
"cobra-help:dws chat mute-at-all",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.update_red_env_notification_off": {
|
||||
"agent_summary": "关闭或恢复指定会话的红包通知",
|
||||
"use_when": [
|
||||
"用户只想调整某个会话的红包消息提醒偏好时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"关闭全部会话通知使用 chat mute;@所有人通知偏好使用 mute-at-all"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat mute-red-envelope --conversation-id <openConversationId>"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 与 nanrun 会话通知树补齐红包通知偏好能力。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.update_red_env_notification_off",
|
||||
"cobra-help:dws chat mute-red-envelope",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.translate": {
|
||||
"agent_summary": "把指定聊天文本翻译成目标语言",
|
||||
"use_when": [
|
||||
"用户给出文本并明确要求翻译为支持的语言代码时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"翻译文档或文件内容应使用对应产品能力;本命令只处理传入文本"
|
||||
],
|
||||
"examples": [
|
||||
"dws chat text translate --query \"你好世界\" --to en_US"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra 与 nanrun text 分支补齐聊天文本翻译能力。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry/products/chat.json#chat.translate",
|
||||
"cobra-help:dws chat text translate",
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat.md"
|
||||
]
|
||||
},
|
||||
"chat.shortcut_bot_search": {
|
||||
"agent_summary": "搜索当前用户自己创建的机器人",
|
||||
"use_when": [
|
||||
@@ -2110,6 +2708,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 +2767,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 +2828,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 +2923,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 +3017,27 @@
|
||||
"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",
|
||||
"按群名找群或解析群 openConversationId 时不要使用;改用 +chat-search"
|
||||
],
|
||||
"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 +3057,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": [
|
||||
|
||||
@@ -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() {
|
||||
@@ -207,6 +207,270 @@ func TestCrossPlatformCoveragePATURLAndMutationCoverageEdges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATClassification(t *testing.T) {
|
||||
patErr := &PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`}
|
||||
if patErr.Error() != patErr.RawJSON || patErr.RawStderr() != patErr.RawJSON || patErr.ExitCode() != ExitCodePermission {
|
||||
t.Fatalf("PATError contract changed: %#v", patErr)
|
||||
}
|
||||
if !IsPATError(patErr) || IsPATError(stderrors.New("plain")) {
|
||||
t.Fatal("PAT error classification changed")
|
||||
}
|
||||
if !IsPATNoPermissionCode("PAT_NO_PERMISSION") || IsPATNoPermissionCode("UNKNOWN") {
|
||||
t.Fatal("PAT permission code classification changed")
|
||||
}
|
||||
|
||||
if code, ok := lookupCodeIn(map[string]any{
|
||||
"code": 1,
|
||||
"errorCode": "PAT_NO_PERMISSION",
|
||||
}, patNoPermissionCodes); !ok || code != "PAT_NO_PERMISSION" {
|
||||
t.Fatalf("lookupCodeIn fallback = %q, %v", code, ok)
|
||||
}
|
||||
if code, ok := lookupCodeIn(map[string]any{"code": "UNKNOWN"}, patNoPermissionCodes); ok || code != "" {
|
||||
t.Fatalf("lookupCodeIn unknown = %q, %v", code, ok)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
body map[string]any
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{body: map[string]any{"code": "PAT_NO_PERMISSION"}, want: "PAT_NO_PERMISSION", ok: true},
|
||||
{body: map[string]any{"error_code": "PAT_SCOPE_AUTH_REQUIRED"}, want: "PAT_SCOPE_AUTH_REQUIRED", ok: true},
|
||||
{body: map[string]any{"code": "UNKNOWN"}},
|
||||
} {
|
||||
code, ok := getPATErrorCode(tc.body)
|
||||
if code != tc.want || ok != tc.ok {
|
||||
t.Fatalf("getPATErrorCode(%v) = %q, %v", tc.body, code, ok)
|
||||
}
|
||||
}
|
||||
if code, ok := getDWSGatewayErrorCode(map[string]any{"errorCode": "DWS_AUTH_SERVICE_FAILED"}); !ok || code != "DWS_AUTH_SERVICE_FAILED" {
|
||||
t.Fatalf("gateway code = %q, %v", code, ok)
|
||||
}
|
||||
|
||||
if !isNotLoggedInError(map[string]any{
|
||||
"error": 1,
|
||||
"message": "Missing service_id or access_key",
|
||||
}) {
|
||||
t.Fatal("missing-login response was not recognized")
|
||||
}
|
||||
if isNotLoggedInError(map[string]any{"message": "other"}) {
|
||||
t.Fatal("ordinary response was recognized as missing login")
|
||||
}
|
||||
for _, body := range []map[string]any{
|
||||
{"error": "failure"},
|
||||
{"success": false},
|
||||
{"success": "FALSE"},
|
||||
} {
|
||||
if !isBusinessError(body) {
|
||||
t.Fatalf("business error was not recognized: %#v", body)
|
||||
}
|
||||
}
|
||||
for _, body := range []map[string]any{
|
||||
{},
|
||||
{"success": true},
|
||||
{"success": "true"},
|
||||
} {
|
||||
if isBusinessError(body) {
|
||||
t.Fatalf("successful response was classified as a business error: %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
if err := ClassifyToolResultContent(map[string]any{"code": "DWS_SERVICE_UNAUTHORIZED"}); err == nil {
|
||||
t.Fatal("gateway tool result was not classified")
|
||||
}
|
||||
if err := ClassifyToolResultContent(map[string]any{"code": "PAT_BATCH_AUTH_PENDING"}); !IsPATError(err) {
|
||||
t.Fatalf("PAT tool result = %T %v", err, err)
|
||||
}
|
||||
if err := ClassifyToolResultContent(map[string]any{"success": true}); err != nil {
|
||||
t.Fatalf("successful tool result = %v", err)
|
||||
}
|
||||
|
||||
responseCases := []struct {
|
||||
name string
|
||||
text string
|
||||
kind string
|
||||
}{
|
||||
{name: "invalid json", text: "not-json", kind: "nil"},
|
||||
{name: "gateway", text: `{"code":"DWS_AUTH_SERVICE_FAILED"}`, kind: "error"},
|
||||
{name: "not logged in", text: `{"message":"Missing service_id or access_key"}`, kind: "error"},
|
||||
{name: "pat", text: `{"code":"PAT_NO_PERMISSION"}`, kind: "pat"},
|
||||
{name: "business", text: `{"success":false,"message":"参数错误"}`, kind: "error"},
|
||||
{name: "success", text: `{"success":true}`, kind: "nil"},
|
||||
}
|
||||
for _, tc := range responseCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := ClassifyMCPResponseText(tc.text)
|
||||
switch tc.kind {
|
||||
case "nil":
|
||||
if err != nil {
|
||||
t.Fatalf("ClassifyMCPResponseText() = %v", err)
|
||||
}
|
||||
case "pat":
|
||||
if !IsPATError(err) {
|
||||
t.Fatalf("ClassifyMCPResponseText() = %T %v", err, err)
|
||||
}
|
||||
default:
|
||||
if err == nil {
|
||||
t.Fatal("ClassifyMCPResponseText() returned nil")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
if !strings.Contains(authExpiredHint(), "auth login") || !strings.Contains(notLoggedInHint(), "auth login") {
|
||||
t.Fatal("authentication recovery hints lost the login command")
|
||||
}
|
||||
|
||||
if got := ClassifyPatAuthCheck(map[string]any{"code": "PAT_LOW_RISK_NO_PERMISSION"}); got == nil {
|
||||
t.Fatal("ClassifyPatAuthCheck() returned nil")
|
||||
}
|
||||
if got := ClassifyPatAuthCheck(map[string]any{"code": "UNKNOWN"}); got != nil {
|
||||
t.Fatalf("ClassifyPatAuthCheck() = %#v", got)
|
||||
}
|
||||
wrapped := stderrors.Join(stderrors.New("outer"), patErr)
|
||||
if got := AsPatAuthCheckError(wrapped); got != patErr {
|
||||
t.Fatalf("AsPatAuthCheckError() = %#v", got)
|
||||
}
|
||||
if got := AsPatAuthCheckError(stderrors.New("plain")); got != nil {
|
||||
t.Fatalf("AsPatAuthCheckError() = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestBusinessHintChatRecovery(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing group role context",
|
||||
body: map[string]any{
|
||||
"error": map[string]any{"code": "IM_ERROR", "message": "listRoles null"},
|
||||
"summary": "context",
|
||||
"code": "TOP_LEVEL",
|
||||
},
|
||||
want: "list-my-groups",
|
||||
},
|
||||
{name: "legacy open id spelling", body: map[string]any{"message": "OpendId is not in conversation"}, want: "实际加入"},
|
||||
{name: "open id outside conversation", body: map[string]any{"message": "OpenId is not in conversation"}, want: "实际加入"},
|
||||
{name: "operator outside source group", body: map[string]any{"message": "The operator is not in this group chat"}, want: "源群"},
|
||||
{name: "missing invitation receiver", body: map[string]any{"message": "targetOpenConversationId和receiverUid不能同时为空"}, want: "--receiver"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := SuggestBusinessHint(tc.body); !strings.Contains(got, tc.want) {
|
||||
t.Errorf("SuggestBusinessHint(%v) = %q, want containing %q", tc.body, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATSerializationAndPolicyEdges(t *testing.T) {
|
||||
oldHost := hostControlProvider
|
||||
oldBrowser := patBrowserProvider
|
||||
t.Cleanup(func() {
|
||||
SetHostControlProvider(oldHost)
|
||||
SetPATOpenBrowserProvider(oldBrowser)
|
||||
})
|
||||
|
||||
SetHostControlProvider(func() string { return "" })
|
||||
if block := HostControlBlock(); block != nil {
|
||||
t.Fatalf("empty host provider returned %#v", block)
|
||||
}
|
||||
SetHostControlProvider(func() string { return "codex" })
|
||||
SetPATOpenBrowserProvider(func() bool { return false })
|
||||
|
||||
out := map[string]any{
|
||||
"data": map[string]any{
|
||||
"authUrl": " https://example.test/fe/old#%2FpersonalAuthorization%3FflowId%3Df%26userCode%3Du ",
|
||||
"callbacks": map[string]any{"owner": "cli"},
|
||||
},
|
||||
}
|
||||
ApplyHostMutations(out)
|
||||
data := out["data"].(map[string]any)
|
||||
if data["openBrowser"] != false || data["hostControl"] == nil || data["uri"] == "" {
|
||||
t.Fatalf("host mutations = %#v", data)
|
||||
}
|
||||
if _, ok := data["authUrl"]; ok {
|
||||
t.Fatalf("authUrl alias was not removed: %#v", data)
|
||||
}
|
||||
if _, ok := data["callbacks"]; ok {
|
||||
t.Fatalf("legacy callbacks were not removed: %#v", data)
|
||||
}
|
||||
|
||||
rawPolicy := cleanPATJSON(map[string]any{
|
||||
"message": "organization denied",
|
||||
"scope": "chat.read",
|
||||
}, "PAT_ORG_POLICY_DENIED")
|
||||
var policyPayload map[string]any
|
||||
if err := json.Unmarshal([]byte(rawPolicy), &policyPayload); err != nil {
|
||||
t.Fatalf("decode policy PAT JSON: %v", err)
|
||||
}
|
||||
policyData := policyPayload["data"].(map[string]any)
|
||||
for key, want := range map[string]any{
|
||||
"policy": "OPEN_SOURCE_ORG_SCOPE_FORBIDDEN",
|
||||
"message": "organization denied",
|
||||
"action": "contact_org_admin",
|
||||
"openBrowser": false,
|
||||
"retryable": false,
|
||||
} {
|
||||
if got := policyData[key]; got != want {
|
||||
t.Fatalf("policy data %s = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(policyData["hint"].(string), "organization denied") {
|
||||
t.Fatalf("policy hint = %#v", policyData["hint"])
|
||||
}
|
||||
|
||||
rawDefault := cleanPATJSON(map[string]any{}, "PAT_ORG_POLICY_DENIED")
|
||||
var defaultPayload map[string]any
|
||||
if err := json.Unmarshal([]byte(rawDefault), &defaultPayload); err != nil {
|
||||
t.Fatalf("decode default policy PAT JSON: %v", err)
|
||||
}
|
||||
defaultData := defaultPayload["data"].(map[string]any)
|
||||
if !strings.Contains(defaultData["hint"].(string), "组织策略") {
|
||||
t.Fatalf("default policy hint = %#v", defaultData["hint"])
|
||||
}
|
||||
|
||||
prepopulated := map[string]any{"data": map[string]any{
|
||||
"policy": "CUSTOM",
|
||||
"message": "existing",
|
||||
"hint": "existing hint",
|
||||
}}
|
||||
applyOrgPolicyDeniedHint(prepopulated, map[string]any{"message": "ignored"})
|
||||
prepopulatedData := prepopulated["data"].(map[string]any)
|
||||
if prepopulatedData["policy"] != "CUSTOM" ||
|
||||
prepopulatedData["message"] != "existing" ||
|
||||
prepopulatedData["hint"] != "existing hint" {
|
||||
t.Fatalf("prepopulated policy fields changed: %#v", prepopulatedData)
|
||||
}
|
||||
|
||||
if got := stringValue(map[string]any{
|
||||
"number": 1,
|
||||
"blank": " ",
|
||||
"value": " kept ",
|
||||
}, "number", "blank", "value"); got != "kept" {
|
||||
t.Fatalf("stringValue fallback = %q", got)
|
||||
}
|
||||
if got := stringValue(map[string]any{"blank": " "}, "blank", "missing"); got != "" {
|
||||
t.Fatalf("stringValue empty = %q", got)
|
||||
}
|
||||
|
||||
cleaned := stripClassFields(map[string]any{
|
||||
"class": "top",
|
||||
"items": []any{
|
||||
map[string]any{"class": "nested", "keep": "yes"},
|
||||
"scalar",
|
||||
},
|
||||
}).(map[string]any)
|
||||
if _, ok := cleaned["class"]; ok {
|
||||
t.Fatalf("top-level class was retained: %#v", cleaned)
|
||||
}
|
||||
items := cleaned["items"].([]any)
|
||||
nested := items[0].(map[string]any)
|
||||
if _, ok := nested["class"]; ok || nested["keep"] != "yes" || items[1] != "scalar" {
|
||||
t.Fatalf("nested class cleanup = %#v", cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseURLForTest(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(raw)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -46,9 +47,13 @@ type Error struct {
|
||||
Operation string
|
||||
ServerKey string
|
||||
Retryable bool
|
||||
RetryableSet bool
|
||||
RetryAfterSeconds *int64
|
||||
NextRetryAt *time.Time
|
||||
Reason string
|
||||
Hint string
|
||||
Actions []string
|
||||
Examples []string
|
||||
AvailableFlags []string
|
||||
Snapshot string
|
||||
RPCCode int `json:"rpc_code,omitempty"`
|
||||
@@ -107,6 +112,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +170,22 @@ func WithActions(actions ...string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithExamples records copyable command examples for recovery.
|
||||
func WithExamples(examples ...string) Option {
|
||||
return func(err *Error) {
|
||||
out := make([]string, 0, len(examples))
|
||||
for _, example := range examples {
|
||||
if strings.TrimSpace(example) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, example)
|
||||
}
|
||||
if len(out) > 0 {
|
||||
err.Examples = out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithAvailableFlags records visible local flag names for agent recovery.
|
||||
func WithAvailableFlags(names ...string) Option {
|
||||
return func(err *Error) {
|
||||
@@ -266,15 +312,26 @@ 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
|
||||
}
|
||||
if len(typed.Actions) > 0 {
|
||||
errorPayload["actions"] = typed.Actions
|
||||
errorPayload["suggested_actions"] = typed.Actions
|
||||
}
|
||||
if len(typed.Examples) > 0 {
|
||||
errorPayload["examples"] = typed.Examples
|
||||
}
|
||||
errorPayload["error_message"] = typed.Message
|
||||
if len(typed.AvailableFlags) > 0 {
|
||||
errorPayload["available_flags"] = typed.AvailableFlags
|
||||
}
|
||||
@@ -353,6 +410,23 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
return writeErr
|
||||
}
|
||||
|
||||
if len(typed.Examples) > 0 {
|
||||
lines := []string{
|
||||
"错误信息:" + typed.Message,
|
||||
"原因:" + typed.Reason,
|
||||
"建议操作:",
|
||||
}
|
||||
for i, action := range typed.Actions {
|
||||
lines = append(lines, fmt.Sprintf("%d. %s", i+1, action))
|
||||
}
|
||||
lines = append(lines, "示例:")
|
||||
for i, example := range typed.Examples {
|
||||
lines = append(lines, fmt.Sprintf("%d. %s", i+1, example))
|
||||
}
|
||||
_, writeErr := fmt.Fprintln(w, strings.Join(lines, "\n"))
|
||||
return writeErr
|
||||
}
|
||||
|
||||
// Line 1: Error summary
|
||||
lines := []string{
|
||||
fmt.Sprintf("%s %s", tui.StateMark("error"), tui.Danger(fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message))),
|
||||
@@ -383,8 +457,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()
|
||||
|
||||
@@ -98,6 +192,46 @@ func TestPrintJSON_AvailableFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJSON_FourPartGuidance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintJSON(&b, NewValidation(
|
||||
"缺少参数",
|
||||
WithReason("必须提供时间范围"),
|
||||
WithActions("补充开始时间", "补充结束时间"),
|
||||
WithExamples(`dws chat message list-all --start "..." --end "..." --format json`),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintJSON() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
for _, want := range []string{`"error_message"`, `"reason"`, `"suggested_actions"`, `"examples"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("four-part JSON output missing %s: %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHuman_FourPartGuidance(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var b strings.Builder
|
||||
if err := PrintHuman(&b, NewValidation(
|
||||
"缺少参数",
|
||||
WithReason("必须提供时间范围"),
|
||||
WithActions("补充开始和结束时间"),
|
||||
WithExamples(`dws chat message list-all --start "..." --end "..."`),
|
||||
)); err != nil {
|
||||
t.Fatalf("PrintHuman() error = %v", err)
|
||||
}
|
||||
got := b.String()
|
||||
for _, want := range []string{"错误信息:", "原因:", "建议操作:", "示例:"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("four-part human output missing %q: %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintHuman(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -308,6 +308,13 @@ func notLoggedInHint() string {
|
||||
return "请先登录:dws auth login"
|
||||
}
|
||||
|
||||
// SuggestBusinessHint returns an actionable recovery hint for a parsed MCP
|
||||
// business-error payload. Runtime callers share this entry point so product
|
||||
// helpers and the generic runner do not drift.
|
||||
func SuggestBusinessHint(body map[string]any) string {
|
||||
return suggestForBusinessErrorText(body)
|
||||
}
|
||||
|
||||
func suggestForBusinessErrorText(body map[string]any) string {
|
||||
msg := ""
|
||||
if v, ok := body["errorMsg"].(string); ok {
|
||||
@@ -316,6 +323,19 @@ func suggestForBusinessErrorText(body map[string]any) string {
|
||||
msg = v
|
||||
} else if v, ok := body["error"].(string); ok {
|
||||
msg = v
|
||||
} else if nested, ok := body["error"].(map[string]any); ok {
|
||||
if code, ok := nested["code"].(string); ok {
|
||||
msg = code
|
||||
}
|
||||
if message, ok := nested["message"].(string); ok {
|
||||
msg = strings.TrimSpace(msg + " " + message)
|
||||
}
|
||||
}
|
||||
if summary, ok := body["summary"].(string); ok {
|
||||
msg = strings.TrimSpace(msg + " " + summary)
|
||||
}
|
||||
if code, ok := body["code"].(string); ok {
|
||||
msg = strings.TrimSpace(msg + " " + code)
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(msg, "搜索内容不能为空"):
|
||||
@@ -326,6 +346,14 @@ func suggestForBusinessErrorText(body map[string]any) string {
|
||||
return "API rate limit exceeded, wait a moment and retry"
|
||||
case strings.Contains(msg, "参数错误") || strings.Contains(msg, "param error"):
|
||||
return "Check input parameters. Use --help for available flags"
|
||||
case strings.Contains(msg, "listRoles null"):
|
||||
return "当前群的群身份或权限上下文不可用。请先用 dws chat group list-my-groups --format json 选择当前账号实际加入或管理的群,再核对群成员与权限。"
|
||||
case strings.Contains(msg, "OpendId is not in conversation") || strings.Contains(msg, "OpenId is not in conversation"):
|
||||
return "当前账号不在该会话中。请先用 dws chat group list-my-groups --format json 选择实际加入的群,并重新获取该会话中的真实 OpendId。"
|
||||
case strings.Contains(msg, "The operator is not in this group chat"):
|
||||
return "当前操作者不在源群中。请重新选择当前账号已加入的群,或先完成入群;不要只替换接收方后重复原命令。"
|
||||
case strings.Contains(msg, "targetOpenConversationId和receiverUid不能同时为空"):
|
||||
return "分享群邀请链接必须提供接收目标:群到群使用 --target,群到人使用 --receiver;同时确认 --source 是当前操作者已加入的源群。"
|
||||
default:
|
||||
return "MCP tool returned a business error; check parameters and refer to skill documentation."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+291
-13
@@ -13,6 +13,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
@@ -55,11 +56,20 @@ const maxConversationCategoryTitleRunes = 15
|
||||
func validatedConversationCategoryTitle(raw string) (string, error) {
|
||||
title := strings.TrimSpace(raw)
|
||||
if title == "" {
|
||||
return "", apperrors.NewValidation("--title 不能为空")
|
||||
return "", apperrors.NewValidation(
|
||||
"--title 不能为空",
|
||||
apperrors.WithReason("invalid_category_title"),
|
||||
apperrors.WithHint("请提供 1 到 15 个字符的分组标题,并保持用户指定原文。"),
|
||||
apperrors.WithActions("补充非空 --title", "运行当前命令 --help 查看示例"),
|
||||
)
|
||||
}
|
||||
if utf8.RuneCountInString(title) > maxConversationCategoryTitleRunes {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf(
|
||||
"--title 最多 %d 个字符", maxConversationCategoryTitleRunes))
|
||||
if count := utf8.RuneCountInString(title); count > maxConversationCategoryTitleRunes {
|
||||
return "", apperrors.NewValidation(
|
||||
fmt.Sprintf("--title 当前 %d 个字符,最多 %d 个字符", count, maxConversationCategoryTitleRunes),
|
||||
apperrors.WithReason("category_title_too_long"),
|
||||
apperrors.WithHint("不得静默截断、缩写或改写用户指定名称;请让用户提供合法标题后重试。"),
|
||||
apperrors.WithActions("请用户将标题缩短到 15 个字符以内", "使用用户确认后的标题原文重试"),
|
||||
)
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
@@ -289,6 +299,66 @@ 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 chatGuidanceError(message, reason string, actions, examples []string) error {
|
||||
return apperrors.NewValidation(
|
||||
message,
|
||||
apperrors.WithReason(reason),
|
||||
apperrors.WithActions(actions...),
|
||||
apperrors.WithExamples(examples...),
|
||||
)
|
||||
}
|
||||
|
||||
func isLikelyPlaceholderID(value string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
return normalized == "" ||
|
||||
normalized == "0" ||
|
||||
strings.Contains(normalized, "placeholder") ||
|
||||
strings.HasPrefix(normalized, "test_") ||
|
||||
strings.HasPrefix(normalized, "test-") ||
|
||||
strings.HasPrefix(normalized, "<")
|
||||
}
|
||||
|
||||
func resolveOpenDingTalkID(ctx context.Context, value string) (string, error) {
|
||||
ids, err := resolveOpenDingTalkIDs(ctx, []string{value})
|
||||
if err != nil {
|
||||
@@ -1639,7 +1709,27 @@ func newChatCommand() *cobra.Command {
|
||||
if atAll && !strings.Contains(text, "<@all>") {
|
||||
text = "<@all> " + text
|
||||
}
|
||||
// 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 不变
|
||||
var addedMentions []string
|
||||
for _, rawID := range atOpenIds {
|
||||
id := strings.TrimSpace(rawID)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
wrapped := "<@" + id + ">"
|
||||
if !containsMessageMention(text, wrapped) && !containsMessageMention(text, "@"+id) {
|
||||
addedMentions = append(addedMentions, wrapped)
|
||||
}
|
||||
}
|
||||
if len(addedMentions) > 0 {
|
||||
text = strings.Join(addedMentions, " ") + " " + text
|
||||
fmt.Fprintf(
|
||||
os.Stderr,
|
||||
"错误信息:检测到 --at-open-dingtalk-ids,但正文缺少对应 @ 占位符;CLI 已自动补齐\n原因:钉钉群消息只有正文包含 <@openDingTalkId> 时才会真正展示 @ 提醒\n建议操作:\n1. 后续命令请在 --text 中显式写入对应占位符\n示例:\n1. dws chat message send --group <openConversationId> --at-open-dingtalk-ids %s --text %q --format json\n",
|
||||
atOpenIdsStr,
|
||||
text,
|
||||
)
|
||||
}
|
||||
// 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 保持不变
|
||||
text = normalizeAtPlaceholders(text, atOpenIds, true)
|
||||
// 群聊统一走 openDingTalkId @ 人接口。
|
||||
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
|
||||
@@ -1949,7 +2039,12 @@ func newChatCommand() *cobra.Command {
|
||||
return fmt.Errorf("--sender-user-id and --sender-open-dingtalk-id are mutually exclusive, specify exactly one")
|
||||
}
|
||||
if senderUserID == "" && senderOpenDingTalkID == "" {
|
||||
return fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required")
|
||||
return chatGuidanceError(
|
||||
"缺少消息发送者标识",
|
||||
"list-by-sender 查询的是指定对方发送的消息,必须提供对方的 userId 或 openDingTalkId",
|
||||
[]string{"使用 --sender-user-id 传入对方 userId", "或使用 --sender-open-dingtalk-id 传入对方 openDingTalkId"},
|
||||
[]string{`dws chat message list-by-sender --sender-user-id <对方userId> --start "2026-07-14T00:00:00+08:00" --format json`},
|
||||
)
|
||||
}
|
||||
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
|
||||
if err != nil {
|
||||
@@ -2140,6 +2235,25 @@ func newChatCommand() *cobra.Command {
|
||||
# 查询单聊会话 ID: dws chat conversation-info --user <userId>
|
||||
# 查询人员: dws contact user search --keyword "姓名" --format json`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
hasCondition := false
|
||||
for _, name := range []string{
|
||||
"query", "keyword", "user", "users", "userId", "sender-ids", "senders", "sender",
|
||||
"at-ids", "conversation-ids", "groups", "group", "message-type",
|
||||
"conversation-type", "search-conv-type", "start", "end",
|
||||
} {
|
||||
if value, _ := cmd.Flags().GetString(name); strings.TrimSpace(value) != "" {
|
||||
hasCondition = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasCondition {
|
||||
atMe, _ := cmd.Flags().GetBool("at-me")
|
||||
hasCondition = atMe || cmd.Flags().Changed("only-robot") || cmd.Flags().Changed("only-robot-messages")
|
||||
}
|
||||
if !hasCondition {
|
||||
return apperrors.NewValidation("at least one search condition is required")
|
||||
}
|
||||
|
||||
toolArgs := map[string]any{}
|
||||
|
||||
// The CLI primary is --query; the IM MCP field is still named "keyword".
|
||||
@@ -2934,6 +3048,7 @@ func newChatCommand() *cobra.Command {
|
||||
chatCategoryDeleteCmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "删除用户自定义会话分组",
|
||||
Long: "删除用户自定义会话分组。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。",
|
||||
Example: ` dws chat category delete --category-id <分组ID>
|
||||
# 分组ID 可通过 dws chat category list 获取`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -2941,6 +3056,14 @@ func newChatCommand() *cobra.Command {
|
||||
if categoryId == 0 {
|
||||
return fmt.Errorf("flag --category-id is required")
|
||||
}
|
||||
if !commandBoolFlag(cmd, "yes") {
|
||||
return apperrors.NewValidation(
|
||||
"删除会话分组不可逆;获得用户确认后加 --yes 执行",
|
||||
apperrors.WithReason("confirmation_required"),
|
||||
apperrors.WithHint("先确认目标分组及影响范围;用户明确同意后以相同参数追加 --yes"),
|
||||
apperrors.WithActions("确认目标会话分组", "获得用户确认后使用 --yes 执行"),
|
||||
)
|
||||
}
|
||||
return callMCPToolOnServer("im", "delete_conv_category", map[string]any{
|
||||
"categoryId": categoryId,
|
||||
})
|
||||
@@ -3016,6 +3139,16 @@ func newChatCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return fmt.Errorf("--category-ids: %w", err)
|
||||
}
|
||||
for _, categoryID := range categoryIds {
|
||||
if categoryID > 0 && categoryID < 1000 {
|
||||
return chatGuidanceError(
|
||||
"会话分组 ID 看起来仍是示例占位值",
|
||||
"--category-ids 必须来自 chat category list 返回的真实分组 ID;123、456 等短示例值不能直接用于移出操作",
|
||||
[]string{"先查询当前用户的会话分组", "从结果读取真实 categoryId 后再执行移出"},
|
||||
[]string{`dws chat category list --format json`, `dws chat category remove-conv --group <openConversationId> --category-ids <categoryId> --format json`},
|
||||
)
|
||||
}
|
||||
}
|
||||
return callMCPToolOnServer("im", "remove_conv_from_categories", map[string]any{
|
||||
"openConversationId": groupID,
|
||||
"categoryIds": categoryIds,
|
||||
@@ -3087,6 +3220,16 @@ func newChatCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
msgIds := parseCSVValues(mustGetFlag(cmd, "msg-ids"))
|
||||
for _, msgID := range msgIds {
|
||||
if isLikelyPlaceholderID(msgID) {
|
||||
return chatGuidanceError(
|
||||
"消息 ID 仍是占位符,无法查询真实消息",
|
||||
"--msg-ids 必须来自消息列表返回的真实 openMsgId,test_msg_id_placeholder 等示例值不会命中消息",
|
||||
[]string{"先执行 chat message list 获取目标消息", "从结果读取 openMsgId 后替换占位符"},
|
||||
[]string{`dws chat message list-by-ids --msg-ids <openMsgId> --format json`},
|
||||
)
|
||||
}
|
||||
}
|
||||
if len(msgIds) > 50 {
|
||||
return fmt.Errorf("--msg-ids 最多支持 50 条,当前 %d 条", len(msgIds))
|
||||
}
|
||||
@@ -3110,6 +3253,14 @@ func newChatCommand() *cobra.Command {
|
||||
if err := validateRequiredFlags(cmd, "msg-id", "emoji"); err != nil {
|
||||
return err
|
||||
}
|
||||
if msgID := mustGetFlag(cmd, "msg-id"); isLikelyPlaceholderID(msgID) {
|
||||
return chatGuidanceError(
|
||||
"消息 ID 仍是占位符,无法添加表情回应",
|
||||
"--msg-id 必须是目标消息真实的 openMsgId,不能使用测试占位符",
|
||||
[]string{"先拉取目标会话消息", "读取目标消息的 openMsgId 后重试"},
|
||||
[]string{`dws chat message add-emoji --conversation-id <openConversationId> --msg-id <openMsgId> --emoji "赞" --format json`},
|
||||
)
|
||||
}
|
||||
return callMCPToolOnServer("im", "add_emoji_reaction", map[string]any{
|
||||
"openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"),
|
||||
"openMsgId": mustGetFlag(cmd, "msg-id"),
|
||||
@@ -3392,6 +3543,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
conversationID := mustGetFlag(cmd, "open-conversation-id")
|
||||
messageID := mustGetFlag(cmd, "message-id")
|
||||
outputPath := mustGetFlag(cmd, "output")
|
||||
if isLikelyPlaceholderID(resourceID) || isLikelyPlaceholderID(messageID) {
|
||||
return chatGuidanceError(
|
||||
"媒体资源参数仍包含占位符",
|
||||
"--resource-id 和 --message-id 必须来自同一条真实消息,不能使用 test-media 或 test_msg_id_placeholder",
|
||||
[]string{"先拉取包含媒体的目标消息", "从同一条消息读取 mediaId、openMessageId 和 openConversationId"},
|
||||
[]string{`dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/ --format json`},
|
||||
)
|
||||
}
|
||||
|
||||
switch resourceType {
|
||||
case "mediaId":
|
||||
@@ -3504,7 +3663,12 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
newOwner = newOwnerUserID
|
||||
}
|
||||
if newOwner == "" {
|
||||
return fmt.Errorf("flag --new-owner or --user is required")
|
||||
return chatGuidanceError(
|
||||
"缺少新群主标识",
|
||||
"--new-owner 接收新群主 openDingTalkId,--user 接收新群主 userId,二者必须选择一个",
|
||||
[]string{"先查询新群主的人员标识", "使用 --new-owner 或 --user 之一"},
|
||||
[]string{`dws chat group transfer-owner --group <openConversationId> --new-owner <openDingTalkId> --format json`},
|
||||
)
|
||||
}
|
||||
if !isOpenDingTalkID(newOwner) {
|
||||
return callMCPToolOnServer("im", "transfer_group_owner", map[string]any{
|
||||
@@ -3654,9 +3818,36 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
return fmt.Errorf("flag --status is required (0=关闭, 1=开启)")
|
||||
}
|
||||
status, _ := cmd.Flags().GetInt("status")
|
||||
settingKey := mustGetFlag(cmd, "setting-key")
|
||||
validSettingKeys := map[string]bool{
|
||||
"authority": true, "joinValidation": true, "onlyAdminCanAtAll": true,
|
||||
"searchable": true, "addFriendForbidden": true, "toolbarStatus": true,
|
||||
"pluginCustomizeVerify": true, "onlyAdminCanDING": true,
|
||||
"allMembersCanCreateMcsConf": true, "onlyAdminCanSetMsgTop": true,
|
||||
"onlyAdminCanPinMsg": true, "onlyAdminCanSendFile": true,
|
||||
"allMembersCanCreateCalendar": true, "groupEmailDisabled": true,
|
||||
"groupRedEnvelopeSwitch": true, "groupLiveAuthority": true,
|
||||
"groupBillAuthority": true,
|
||||
}
|
||||
if !validSettingKeys[settingKey] {
|
||||
return chatGuidanceError(
|
||||
"不支持的群设置项:"+settingKey,
|
||||
"--setting-key 必须使用当前接口支持的精确枚举值,on 不是设置项名称",
|
||||
[]string{"从 --help 列表选择合法 setting-key", "开启或关闭通过 --status 1/0 表达"},
|
||||
[]string{`dws chat group update-settings --group <openConversationId> --setting-key searchable --status 1 --format json`},
|
||||
)
|
||||
}
|
||||
if status != 0 && status != 1 {
|
||||
return chatGuidanceError(
|
||||
"群设置值只能是 0 或 1",
|
||||
"--status 表示开关状态:0=关闭,1=开启;其他整数不会被服务端接受",
|
||||
[]string{"关闭设置时传 --status 0", "开启设置时传 --status 1"},
|
||||
[]string{`dws chat group update-settings --group <openConversationId> --setting-key searchable --status 1 --format json`},
|
||||
)
|
||||
}
|
||||
return callMCPToolOnServer("im", "update_group_settings", map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"settingKey": mustGetFlag(cmd, "setting-key"),
|
||||
"settingKey": settingKey,
|
||||
"status": status,
|
||||
})
|
||||
},
|
||||
@@ -3745,6 +3936,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
if err := validateRequiredFlags(cmd, "src-conversation-id", "msg-id", "dest-conversation-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if msgID := mustGetFlag(cmd, "msg-id"); isLikelyPlaceholderID(msgID) {
|
||||
return chatGuidanceError(
|
||||
"转发消息 ID 仍是占位符",
|
||||
"--msg-id 必须是源会话中真实消息的 openMessageId",
|
||||
[]string{"先拉取源会话消息", "确认消息属于 --src-conversation-id 后读取 openMessageId"},
|
||||
[]string{`dws chat message forward --src-conversation-id <源会话ID> --msg-id <openMessageId> --dest-conversation-id <目标会话ID> --format json`},
|
||||
)
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"srcOpenCid": mustGetFlag(cmd, "src-conversation-id"),
|
||||
"srcOpenMessageId": mustGetFlag(cmd, "msg-id"),
|
||||
@@ -3889,7 +4088,21 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
if !off {
|
||||
muteTime, _ := cmd.Flags().GetInt64("mute-time")
|
||||
if muteTime <= 0 {
|
||||
return fmt.Errorf("--mute-time is required when muting (supported: 300000/3600000/86400000/604800000/2592000000)")
|
||||
return chatGuidanceError(
|
||||
"禁言时必须提供 --mute-time",
|
||||
"--mute-time 的单位是毫秒,仅支持 5 分钟、1 小时、1 天、7 天或 30 天对应的固定值",
|
||||
[]string{"选择支持的毫秒值之一", "取消禁言时改用 --off"},
|
||||
[]string{`dws chat group-mute-member --group <openConversationId> --user <userId> --mute-time 300000 --format json`},
|
||||
)
|
||||
}
|
||||
validMuteTimes := map[int64]bool{300000: true, 3600000: true, 86400000: true, 604800000: true, 2592000000: true}
|
||||
if !validMuteTimes[muteTime] {
|
||||
return chatGuidanceError(
|
||||
"不支持的禁言时长",
|
||||
"--mute-time 使用毫秒且只支持 300000、3600000、86400000、604800000、2592000000;300 表示的时间不在支持范围内",
|
||||
[]string{"5 分钟使用 300000", "从支持的五档时长中选择"},
|
||||
[]string{`dws chat group-mute-member --group <openConversationId> --user <userId> --mute-time 300000 --format json`},
|
||||
)
|
||||
}
|
||||
toolArgs["muteTime"] = muteTime
|
||||
}
|
||||
@@ -4056,6 +4269,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
if err := validateRequiredFlags(cmd, "group", "role-id", "name"); err != nil {
|
||||
return err
|
||||
}
|
||||
if roleID := mustGetFlag(cmd, "role-id"); isLikelyPlaceholderID(roleID) {
|
||||
return chatGuidanceError(
|
||||
"群身份 ID 仍是占位符",
|
||||
"--role-id 必须来自 chat group-role list 返回的真实 openRoleId,0 不是有效群身份 ID",
|
||||
[]string{"先列出目标群的群身份", "从结果读取 openRoleId 后重试"},
|
||||
[]string{`dws chat group-role list --group <openConversationId> --format json`, `dws chat group-role update --group <openConversationId> --role-id <openRoleId> --name "新名称" --format json`},
|
||||
)
|
||||
}
|
||||
return callMCPToolOnServer("im", "update_custom_group_role", map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"openRoleId": mustGetFlag(cmd, "role-id"),
|
||||
@@ -4350,9 +4571,28 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
if err := validateRequiredFlags(cmd, "src-conversation-id", "msg-ids", "dest-conversation-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
msgIDs := parseCSVValues(mustGetFlag(cmd, "msg-ids"))
|
||||
if len(msgIDs) < 2 {
|
||||
return chatGuidanceError(
|
||||
"合并转发至少需要两条消息",
|
||||
"--msg-ids 只有一条消息时不构成合并转发;单条消息应使用 message forward",
|
||||
[]string{"提供至少两个来自同一源会话的 openMessageId", "只有一条时改用 message forward"},
|
||||
[]string{`dws chat message combine-forward --src-conversation-id <源会话ID> --msg-ids <id1>,<id2> --dest-conversation-id <目标会话ID> --format json`},
|
||||
)
|
||||
}
|
||||
for _, msgID := range msgIDs {
|
||||
if isLikelyPlaceholderID(msgID) {
|
||||
return chatGuidanceError(
|
||||
"合并转发消息列表包含占位符",
|
||||
"--msg-ids 必须全部是源会话中的真实 openMessageId",
|
||||
[]string{"先拉取源会话消息", "选择至少两个真实消息 ID"},
|
||||
[]string{`dws chat message combine-forward --src-conversation-id <源会话ID> --msg-ids <id1>,<id2> --dest-conversation-id <目标会话ID> --format json`},
|
||||
)
|
||||
}
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"srcOpenCid": mustGetFlag(cmd, "src-conversation-id"),
|
||||
"srcOpenMessageIds": parseCSVValues(mustGetFlag(cmd, "msg-ids")),
|
||||
"srcOpenMessageIds": msgIDs,
|
||||
"destOpenCid": mustGetFlag(cmd, "dest-conversation-id"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||
@@ -4388,6 +4628,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
if err := validateRequiredFlags(cmd, "src-msg-id", "src-conversation-id", "src-thread-id", "dest-conversation-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if msgID := mustGetFlag(cmd, "src-msg-id"); isLikelyPlaceholderID(msgID) {
|
||||
return chatGuidanceError(
|
||||
"话题转发的源消息 ID 仍是占位符",
|
||||
"--src-msg-id 必须来自源话题中的真实 openMessageId,并与源会话和 thread-id 对应",
|
||||
[]string{"先拉取源会话的话题消息", "从同一条消息读取 openMessageId 和 openConvThreadId"},
|
||||
[]string{`dws chat message forward-topic --src-msg-id <openMessageId> --src-conversation-id <源会话ID> --src-thread-id <openConvThreadId> --dest-conversation-id <目标会话ID> --format json`},
|
||||
)
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"srcOpenMessageId": mustGetFlag(cmd, "src-msg-id"),
|
||||
"srcOpenConversationId": mustGetFlag(cmd, "src-conversation-id"),
|
||||
@@ -4666,12 +4914,21 @@ status 可选值:
|
||||
if err != nil {
|
||||
return fmt.Errorf("--record-id must be a valid integer: %w", err)
|
||||
}
|
||||
status := mustGetFlag(cmd, "status")
|
||||
if status != "AuditApprove" && status != "AuditDelete" {
|
||||
return chatGuidanceError(
|
||||
"不支持的入群审批状态:"+status,
|
||||
"当前服务端仅接受大小写完全一致的 AuditApprove 或 AuditDelete;AuditRefuse 和 approve 均不可用",
|
||||
[]string{"通过申请使用 AuditApprove", "拒绝或删除申请使用 AuditDelete,并可补充 --description"},
|
||||
[]string{`dws chat group audit-join-validation --group <openConversationId> --record-id <recordId> --applicant <userId> --inviter <userId> --status AuditApprove --format json`},
|
||||
)
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"applyRecordId": recordID,
|
||||
"applicantUid": mustGetFlag(cmd, "applicant"),
|
||||
"inviterUid": mustGetFlag(cmd, "inviter"),
|
||||
"status": mustGetFlag(cmd, "status"),
|
||||
"status": status,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("description"); v != "" {
|
||||
toolArgs["auditDescription"] = v
|
||||
@@ -4797,7 +5054,7 @@ status 可选值:
|
||||
chatClearMessagesCmd := &cobra.Command{
|
||||
Use: "clear-messages",
|
||||
Short: "清空当前用户指定会话的聊天记录",
|
||||
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。
|
||||
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。
|
||||
|
||||
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
|
||||
- 群聊:dws chat search --query "群名"
|
||||
@@ -4809,6 +5066,14 @@ status 可选值:
|
||||
if convID == "" {
|
||||
return fmt.Errorf("flag --conversation-id is required\n hint: dws chat clear-messages --conversation-id <openConversationId>")
|
||||
}
|
||||
if !commandBoolFlag(cmd, "yes") {
|
||||
return apperrors.NewValidation(
|
||||
"清空会话聊天记录不可逆;获得用户确认后加 --yes 执行",
|
||||
apperrors.WithReason("confirmation_required"),
|
||||
apperrors.WithHint("先确认目标会话及影响范围;用户明确同意后以相同参数追加 --yes"),
|
||||
apperrors.WithActions("确认目标会话", "获得用户确认后使用 --yes 执行"),
|
||||
)
|
||||
}
|
||||
return callMCPToolOnServer("im", "clear_conversation_messages", map[string]any{
|
||||
"openConversationId": convID,
|
||||
})
|
||||
@@ -5151,6 +5416,14 @@ status 可选值:
|
||||
if err := validateRequiredFlags(cmd, "group", "notice-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if noticeID := mustGetFlag(cmd, "notice-id"); isLikelyPlaceholderID(noticeID) {
|
||||
return chatGuidanceError(
|
||||
"群公告 ID 仍是占位符",
|
||||
"--notice-id 必须来自 chat group notice list 返回的真实 dataId,0 不是有效公告 ID",
|
||||
[]string{"先查询目标群公告列表", "从结果读取 dataId 后重试;查询操作不需要 --dry-run"},
|
||||
[]string{`dws chat group notice list --group <openConversationId> --format json`, `dws chat group notice get --group <openConversationId> --notice-id <dataId> --format json`},
|
||||
)
|
||||
}
|
||||
return callMCPToolOnServer("im", "get_group_notice", map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"dataId": mustGetFlag(cmd, "notice-id"),
|
||||
@@ -5212,7 +5485,12 @@ status 可选值:
|
||||
target, _ := cmd.Flags().GetString("target")
|
||||
receiver, _ := cmd.Flags().GetString("receiver")
|
||||
if target == "" && receiver == "" {
|
||||
return fmt.Errorf("--target or --receiver is required")
|
||||
return chatGuidanceError(
|
||||
"缺少群邀请链接的接收目标",
|
||||
"--target 表示接收分享的目标会话,--receiver 表示接收分享的单聊用户,二者必须选择一个",
|
||||
[]string{"分享到群或会话时使用 --target", "分享到个人时使用 --receiver openDingTalkId"},
|
||||
[]string{`dws chat group share-invite --source <源群ID> --target <目标会话ID> --format json`},
|
||||
)
|
||||
}
|
||||
if target != "" && receiver != "" {
|
||||
return fmt.Errorf("--target and --receiver are mutually exclusive")
|
||||
|
||||
@@ -114,6 +114,32 @@ func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) {
|
||||
for _, wrap := range []bool{true, false} {
|
||||
_ = normalizeAtPlaceholders("hello @u1 <@u2>", []string{"", "u1", "u2"}, wrap)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{value: "0", want: true},
|
||||
{value: "test_msg_id_placeholder", want: true},
|
||||
{value: "test-media", want: true},
|
||||
{value: "<openMsgId>", want: true},
|
||||
{value: "real-message-id", want: false},
|
||||
} {
|
||||
if got := isLikelyPlaceholderID(tc.value); got != tc.want {
|
||||
t.Fatalf("isLikelyPlaceholderID(%q) = %v, want %v", tc.value, got, tc.want)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -63,8 +63,14 @@ func newChatMediaUploadCommand() *cobra.Command {
|
||||
|
||||
func chatMediaUploadDownlineError() error {
|
||||
return apperrors.NewValidation(
|
||||
"chat media upload 已下线,当前 CLI 不提供本地文件到 mediaId 的上传能力。" +
|
||||
" 本地图片或文件请改用: " + chatMediaUploadReplacement +
|
||||
"chat media upload 已下线,当前 CLI 不提供本地文件到 mediaId 的上传能力。"+
|
||||
" 本地图片或文件请改用: "+chatMediaUploadReplacement+
|
||||
";已有 mediaId 时可使用 dws chat message send --msg-type image --media-id <mediaId>。",
|
||||
apperrors.WithReason("chat_media_upload_retired"),
|
||||
apperrors.WithHint("本地图片、PDF、DOCX、XLSX 等统一通过 message send --msg-type file --file-path 发送;不要改用 drive upload。"),
|
||||
apperrors.WithActions(
|
||||
"本地文件:dws chat message send --msg-type file --file-path <本地路径>",
|
||||
"已有图片 mediaId:dws chat message send --msg-type image --media-id <mediaId>",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -217,6 +218,73 @@ func TestCrossPlatformCoverageChatSendResolvesUserBeforeDispatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSendAndReplyDefaultToAgentProductForIMClawType(t *testing.T) {
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "send",
|
||||
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello"},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
if got := caller.calls[0].args["clawType"]; got != "qwenwork" {
|
||||
t.Fatalf("clawType = %#v, want qwenwork", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "send",
|
||||
args: []string{"message", "send", "--open-dingtalk-id", "D1", "--text", "hello", "--ai-tag=false"},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
args: []string{"message", "reply", "--conversation-id", "cid", "--ref-msg-id", "mid", "--ref-sender", "D1", "--text", "hello", "--ai-tag=false"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
got, present := caller.calls[0].args["clawType"]
|
||||
if !present || got != "" {
|
||||
t.Fatalf("clawType = %#v, present = %v; want present empty string", got, present)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendFailsClosedWhenUserCannotResolve(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
|
||||
|
||||
@@ -16,6 +16,40 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestChatConversationDestructiveCommandsRequireConfirmation(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"category", "delete", "--category-id", "42"},
|
||||
{"clear-messages", "--conversation-id", "cid-1"},
|
||||
} {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand, args...)
|
||||
if err == nil || !strings.Contains(err.Error(), "确认") {
|
||||
t.Fatalf("chat %v error = %v, want confirmation error", args, err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("chat %v made tool calls before confirmation: %#v", args, caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatConversationDestructiveCommandsRunAfterConfirmation(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
toolName string
|
||||
}{
|
||||
{[]string{"category", "delete", "--category-id", "42", "--yes"}, "delete_conv_category"},
|
||||
{[]string{"clear-messages", "--conversation-id", "cid-1", "--yes"}, "clear_conversation_messages"},
|
||||
} {
|
||||
caller := &guardedMutationCaller{}
|
||||
if err := executeGuardedMutationCommand(t, caller, newChatCommand, tc.args...); err != nil {
|
||||
t.Fatalf("chat %v error = %v", tc.args, err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != tc.toolName {
|
||||
t.Fatalf("chat %v calls = %#v, want one %s call", tc.args, caller.calls, tc.toolName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type contractDefectCaller struct {
|
||||
dryRun bool
|
||||
calls []guardedMutationCall
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageConversationFileShortcutWrappersReuseNativeFlow(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "report.txt")
|
||||
if err := os.WriteFile(file, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta, err := BuildConversationLocalFileMeta(file, "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta.FileName != "report.txt" || meta.FileSize != 7 {
|
||||
t.Fatalf("meta = %#v", meta)
|
||||
}
|
||||
content, err := BuildConversationFileContent(11, 22, meta)
|
||||
if err != nil || !strings.Contains(content, `"dentryId":11`) {
|
||||
t.Fatalf("content = %q, %v", content, err)
|
||||
}
|
||||
dentryID, spaceID, err := ParseConversationFileSendIDs(`{"result":{"dentryId":11,"spaceId":22}}`)
|
||||
if err != nil || dentryID != 11 || spaceID != 22 {
|
||||
t.Fatalf("ids = %d/%d, %v", dentryID, spaceID, err)
|
||||
}
|
||||
|
||||
oldPut := httpPutFile
|
||||
t.Cleanup(func() { httpPutFile = oldPut })
|
||||
var putPath string
|
||||
httpPutFile = func(_ context.Context, _ string, _ map[string]string, localPath string, size int64) error {
|
||||
putPath = localPath
|
||||
if size != 7 {
|
||||
t.Errorf("upload size = %d", size)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example.test/file","uploadKey":"key"}`},
|
||||
{text: `{"result":{"dentryId":11,"spaceId":22}}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
commit, err := UploadConversationLocalFile(
|
||||
context.Background(),
|
||||
map[string]any{"openConversationId": "cid"},
|
||||
meta,
|
||||
"uuid",
|
||||
)
|
||||
if err != nil || !strings.Contains(commit, `"dentryId":11`) || putPath != file || caller.calls != 2 {
|
||||
t.Fatalf("upload = %q, path=%q calls=%d err=%v", commit, putPath, caller.calls, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadToolNameContractAndHelperBoundary(t *testing.T) {
|
||||
for tool, want := range map[string]bool{
|
||||
" get_conversation ": true,
|
||||
"LIST_MESSAGES": true,
|
||||
"query_send_status": true,
|
||||
"search_messages": true,
|
||||
"unread_message_conversation_list": true,
|
||||
"send_personal_message": false,
|
||||
"": false,
|
||||
} {
|
||||
if got := IsReadToolName(tool); got != want {
|
||||
t.Errorf("IsReadToolName(%q) = %v, want %v", tool, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
caller := &helpersReadCaller{
|
||||
helpersCoreCaller: &helpersCoreCaller{format: "json", dry: true},
|
||||
readResult: textToolResult(`{"success":true}`),
|
||||
}
|
||||
installHelpersCoreDeps(t, caller)
|
||||
if _, err := CallMCPReadToolTextOnServer("chat", "send_personal_message", nil); err == nil {
|
||||
t.Fatal("write tool was accepted by the read helper boundary")
|
||||
}
|
||||
if caller.readCalls != 0 || caller.calls != 0 {
|
||||
t.Fatalf("rejected write reached caller: read=%d regular=%d", caller.readCalls, caller.calls)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
@@ -156,6 +157,12 @@ func callMCPToolReturnTextOnServer(ctx context.Context, serverID, toolName strin
|
||||
// returning a synthetic dry-run envelope that looks like business data.
|
||||
func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) {
|
||||
ctx := context.Background()
|
||||
if !IsReadToolName(toolName) {
|
||||
return "", &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: fmt.Sprintf("tool %q is not allowed on the dry-run read channel", toolName),
|
||||
}
|
||||
}
|
||||
if deps == nil || deps.Caller == nil {
|
||||
return "", &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
@@ -176,6 +183,22 @@ func CallMCPReadToolTextOnServer(serverID, toolName string, args map[string]any)
|
||||
return parseMCPToolTextResult(serverID, toolName, result, err)
|
||||
}
|
||||
|
||||
// IsReadToolName is the fail-closed naming contract for the dry-run read
|
||||
// channel. Both the Shortcut runtime and the helper boundary enforce it so a
|
||||
// future direct helper caller cannot accidentally route a write tool through
|
||||
// ReadToolCaller.
|
||||
func IsReadToolName(toolName string) bool {
|
||||
toolName = strings.TrimSpace(strings.ToLower(toolName))
|
||||
for _, prefix := range []string{
|
||||
"get_", "list_", "query_", "search_", "unread_",
|
||||
} {
|
||||
if strings.HasPrefix(toolName, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResult, err error) (string, error) {
|
||||
if err != nil {
|
||||
if patErr := reclassifyPATFromError(err); patErr != nil {
|
||||
@@ -647,15 +670,7 @@ func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
|
||||
// suggestForBusinessError returns a user-facing suggestion for known business
|
||||
// error patterns in a parsed JSON body, or "" if no specific suggestion applies.
|
||||
func suggestForBusinessError(body map[string]any) string {
|
||||
msg := ""
|
||||
if v, ok := body["errorMsg"].(string); ok {
|
||||
msg = v
|
||||
} else if v, ok := body["message"].(string); ok {
|
||||
msg = v
|
||||
} else if v, ok := body["error"].(string); ok {
|
||||
msg = v
|
||||
}
|
||||
return suggestForBusinessErrorText(msg)
|
||||
return apperrors.SuggestBusinessHint(body)
|
||||
}
|
||||
|
||||
// confirmDelete is a convenience wrapper around cmdutil.ConfirmDelete that
|
||||
|
||||
+9
-6
@@ -55,12 +55,15 @@ Host-owned PAT 开关:
|
||||
由宿主处理全部 UI / 交互 / 回调节奏 / 重试逻辑,
|
||||
CLI 侧不再拉起任何本地浏览器 / 轮询。
|
||||
|
||||
服务端 Agent 产品标签 claw-type:
|
||||
开源构建默认在出站 MCP 请求中注入 claw-type: openClaw。
|
||||
如设置 DWS_AGENT_PRODUCT,则使用经校验的环境变量值覆盖该默认值。
|
||||
hostControl.clawType 会回填请求实际使用的值,避免 PAT 与请求标识漂移。
|
||||
该值由调用方声明,不是认证凭据;服务端不得仅凭它放权或跳过授权。
|
||||
它也不会修改 IM 消息展示使用的 clawType 参数或 --ai-tag 行为。
|
||||
服务端 PAT / 路由标签 claw-type:
|
||||
开源构建固定在出站 MCP 请求中注入 claw-type: openClaw,
|
||||
hostControl.clawType 会回填相同值。DWS_AGENT_PRODUCT 不会修改它。
|
||||
|
||||
Agent 产品标识 DWS_AGENT_PRODUCT:
|
||||
合法非空值作为 x-dws-agent-product 请求头发送,供下游日志 / BI 使用;
|
||||
本客户端不使用该值派生或改变 PAT、鉴权或路由,下游使用契约由对应
|
||||
服务自行定义。同时该值作为启用 --ai-tag 时 IM 消息小尾巴的 clawType
|
||||
参数。未设置或为空时请求头省略,小尾巴回退发行版默认值。
|
||||
|
||||
DINGTALK_AGENT(可选,仅供 x-dingtalk-agent 使用):
|
||||
如设置,将原样注入 HTTP 请求头 x-dingtalk-agent,
|
||||
|
||||
+245
-10
@@ -14,9 +14,14 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
@@ -30,36 +35,61 @@ import (
|
||||
// If the target command cannot be resolved (e.g. the user typed a
|
||||
// non-existent command), PreParse is skipped silently and Cobra will
|
||||
// handle the error.
|
||||
func RunPreParse(root *cobra.Command, engine *Engine) {
|
||||
func RunPreParse(root *cobra.Command, engine *Engine) error {
|
||||
_, err := RunPreParseArgs(root, engine, os.Args[1:])
|
||||
return err
|
||||
}
|
||||
|
||||
// RunPreParseArgs is the testable form of RunPreParse. Production passes
|
||||
// os.Args[1:]; end-to-end tests can pass an isolated argv while exercising the
|
||||
// exact same command traversal, FlagInfo extraction, handler chain, and
|
||||
// root.SetArgs delivery path.
|
||||
func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Context, error) {
|
||||
if engine == nil || !engine.HasHandlers(PreParse) {
|
||||
return
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rawArgs := os.Args[1:]
|
||||
if len(rawArgs) == 0 {
|
||||
return
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Traverse the command tree to find the target command.
|
||||
target, _, err := root.Traverse(rawArgs)
|
||||
// Cobra's Traverse does not merge root persistent flags before deciding
|
||||
// whether a leading flag consumes the next token. For example, it can treat
|
||||
// the command name in `--dry-run calendar event list` as a value and resolve
|
||||
// the unrelated root command `event list`. Remove only known root-persistent
|
||||
// flags from the traversal copy; the original argv remains intact for the
|
||||
// handlers and Cobra's real parse.
|
||||
target, _, err := root.Traverse(argsForCommandTraversal(root, rawArgs))
|
||||
if err != nil {
|
||||
return
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Build FlagInfo from the target command's registered flags.
|
||||
flagInfos := FlagInfoFromCommand(target)
|
||||
if len(flagInfos) == 0 {
|
||||
return
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := &Context{
|
||||
Args: append([]string{}, rawArgs...),
|
||||
Args: append([]string{}, rawArgs...),
|
||||
// target.CommandPath() still carries the "dws" prefix; PreParse
|
||||
// handlers that key off a command (e.g. the semantic-alias table)
|
||||
// normalize it themselves, so the runtime key matches the build key.
|
||||
Command: target.CommandPath(),
|
||||
FlagSpecs: flagInfos,
|
||||
}
|
||||
|
||||
if err := engine.RunPhase(PreParse, ctx); err != nil {
|
||||
// Cobra has not parsed persistent flags yet, but this error is rendered
|
||||
// immediately by app.Execute. Prime only the presentation controls so
|
||||
// --format/--debug/--verbose affect this early error exactly as they do
|
||||
// errors returned after Cobra parsing. No credentials, profiles, output
|
||||
// paths, or execution controls are applied here.
|
||||
if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil {
|
||||
slog.Debug("pipeline pre-parse presentation flags", "error", presentationErr)
|
||||
}
|
||||
slog.Debug("pipeline pre-parse", "error", err)
|
||||
return
|
||||
return ctx, err
|
||||
}
|
||||
|
||||
// Only set corrected args if PreParse actually changed something.
|
||||
@@ -75,6 +105,210 @@ func RunPreParse(root *cobra.Command, engine *Engine) {
|
||||
)
|
||||
}
|
||||
}
|
||||
return ctx, nil
|
||||
}
|
||||
|
||||
func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string {
|
||||
if root == nil || len(rawArgs) == 0 {
|
||||
return rawArgs
|
||||
}
|
||||
flags := root.PersistentFlags()
|
||||
if flags == nil || !flags.HasFlags() {
|
||||
return rawArgs
|
||||
}
|
||||
|
||||
matcher := newFlagTokenMatcher(flags)
|
||||
out := make([]string, 0, len(rawArgs))
|
||||
for index := 0; index < len(rawArgs); index++ {
|
||||
argument := rawArgs[index]
|
||||
if argument == "--" {
|
||||
out = append(out, rawArgs[index:]...)
|
||||
break
|
||||
}
|
||||
flag, inlineValue, matched := matcher.matchTraversalToken(argument)
|
||||
if !matched {
|
||||
out = append(out, argument)
|
||||
continue
|
||||
}
|
||||
if index+1 < len(rawArgs) {
|
||||
if _, ok := separatedBoolValue(argument, rawArgs[index+1], flag, inlineValue); ok {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
}
|
||||
if !inlineValue && flag.NoOptDefVal == "" && index+1 < len(rawArgs) {
|
||||
index++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// separatedBoolValue recognises model-friendly `--flag false` and exact
|
||||
// shorthand `-f false` spellings without mistaking an already attached value
|
||||
// or a shorthand cluster for a detached value. pflag otherwise treats a bare
|
||||
// bool flag as true and leaves the following token positional.
|
||||
func separatedBoolValue(argument, following string, flag *pflag.Flag, inlineValue bool) (string, bool) {
|
||||
if flag == nil || (flag.Value.Type() != "bool" && flag.Value.Type() != "boolean") {
|
||||
return "", false
|
||||
}
|
||||
if strings.HasPrefix(argument, "--") {
|
||||
if inlineValue || strings.Contains(argument, "=") {
|
||||
return "", false
|
||||
}
|
||||
} else if flag.Shorthand == "" || argument != "-"+flag.Shorthand {
|
||||
return "", false
|
||||
}
|
||||
return cmdutil.NormalizeBoolLiteral(following)
|
||||
}
|
||||
|
||||
type longFlagMatch struct {
|
||||
flag *pflag.Flag
|
||||
value string
|
||||
hasValue bool
|
||||
recognized bool
|
||||
}
|
||||
|
||||
type flagTokenMatcher struct {
|
||||
byName map[string]*pflag.Flag
|
||||
byShorthand map[string]*pflag.Flag
|
||||
known map[string]bool
|
||||
candidates []string
|
||||
specByName map[string]FlagInfo
|
||||
}
|
||||
|
||||
func newFlagTokenMatcher(flagSets ...*pflag.FlagSet) *flagTokenMatcher {
|
||||
matcher := &flagTokenMatcher{
|
||||
byName: make(map[string]*pflag.Flag),
|
||||
byShorthand: make(map[string]*pflag.Flag),
|
||||
known: make(map[string]bool),
|
||||
specByName: make(map[string]FlagInfo),
|
||||
}
|
||||
for _, flags := range flagSets {
|
||||
if flags == nil {
|
||||
continue
|
||||
}
|
||||
flags.VisitAll(func(flag *pflag.Flag) {
|
||||
if _, exists := matcher.byName[flag.Name]; exists {
|
||||
return
|
||||
}
|
||||
matcher.byName[flag.Name] = flag
|
||||
matcher.known[flag.Name] = true
|
||||
matcher.candidates = append(matcher.candidates, flag.Name)
|
||||
matcher.specByName[flag.Name] = flagInfoFromPflag(flag)
|
||||
if flag.Shorthand != "" {
|
||||
matcher.byShorthand[flag.Shorthand] = flag
|
||||
}
|
||||
})
|
||||
}
|
||||
return matcher
|
||||
}
|
||||
|
||||
func (m *flagTokenMatcher) matchLongToken(argument string) longFlagMatch {
|
||||
if m == nil || !strings.HasPrefix(argument, "--") || argument == "--" {
|
||||
return longFlagMatch{}
|
||||
}
|
||||
|
||||
canonical := argument
|
||||
body := strings.TrimPrefix(canonical, "--")
|
||||
name, value, hasValue := strings.Cut(body, "=")
|
||||
if flag := m.byName[name]; flag != nil {
|
||||
return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: true}
|
||||
}
|
||||
|
||||
if normalized, ok := NormalizeFlagToken(argument, m.known); ok {
|
||||
canonical = normalized
|
||||
} else if split, ok := SplitStickyFlag(argument, m.specByName); ok {
|
||||
name = strings.TrimPrefix(split.Flag, "--")
|
||||
return longFlagMatch{flag: m.byName[name], value: split.Value, hasValue: true, recognized: true}
|
||||
} else if fuzzy, ok := FuzzyMatchFlag(argument, m.known, m.candidates); ok {
|
||||
canonical = fuzzy
|
||||
} else {
|
||||
return longFlagMatch{}
|
||||
}
|
||||
|
||||
body = strings.TrimPrefix(canonical, "--")
|
||||
name, value, hasValue = strings.Cut(body, "=")
|
||||
flag := m.byName[name]
|
||||
return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: flag != nil}
|
||||
}
|
||||
|
||||
func (m *flagTokenMatcher) matchTraversalToken(argument string) (*pflag.Flag, bool, bool) {
|
||||
if m == nil || argument == "" || argument == "-" || argument == "--" {
|
||||
return nil, false, false
|
||||
}
|
||||
if strings.HasPrefix(argument, "--") {
|
||||
match := m.matchLongToken(argument)
|
||||
return match.flag, match.hasValue, match.recognized
|
||||
}
|
||||
if !strings.HasPrefix(argument, "-") {
|
||||
return nil, false, false
|
||||
}
|
||||
|
||||
body := strings.TrimPrefix(argument, "-")
|
||||
shorthands := []rune(body)
|
||||
for index, shorthand := range shorthands {
|
||||
flag := m.byShorthand[string(shorthand)]
|
||||
if flag == nil {
|
||||
return nil, false, false
|
||||
}
|
||||
if flag.NoOptDefVal == "" {
|
||||
// A value-taking shorthand consumes the next token only when it is
|
||||
// last; otherwise the remainder is its attached value (`-vfjson`).
|
||||
return flag, index < len(shorthands)-1, true
|
||||
}
|
||||
}
|
||||
return m.byShorthand[string(shorthands[0])], true, true
|
||||
}
|
||||
|
||||
func primeEarlyErrorPresentation(root, target *cobra.Command, rawArgs []string) error {
|
||||
if root == nil || target == nil || len(rawArgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
rootFlags := root.PersistentFlags()
|
||||
presentationFlags := pflag.NewFlagSet("early-error-presentation", pflag.ContinueOnError)
|
||||
presentationFlags.SetOutput(io.Discard)
|
||||
presentationFlags.ParseErrorsWhitelist.UnknownFlags = true
|
||||
presentationFlags.SetNormalizeFunc(func(_ *pflag.FlagSet, name string) pflag.NormalizedName {
|
||||
return pflag.NormalizedName(cmdutil.Morph(name))
|
||||
})
|
||||
|
||||
names := make([]string, 0, 3)
|
||||
if source := rootFlags.Lookup("format"); source != nil {
|
||||
value, err := rootFlags.GetString("format")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read presentation flag --format: %w", err)
|
||||
}
|
||||
presentationFlags.StringP("format", source.Shorthand, value, source.Usage)
|
||||
names = append(names, "format")
|
||||
}
|
||||
for _, name := range []string{"debug", "verbose"} {
|
||||
source := rootFlags.Lookup(name)
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
value, err := rootFlags.GetBool(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read presentation flag --%s: %w", name, err)
|
||||
}
|
||||
presentationFlags.BoolP(name, source.Shorthand, value, source.Usage)
|
||||
names = append(names, name)
|
||||
}
|
||||
// Keep the existing contract in which a PreParse conflict wins over help;
|
||||
// registering help prevents pflag's special unknown-help early return.
|
||||
presentationFlags.BoolP("help", "h", false, "")
|
||||
|
||||
parseErr := presentationFlags.Parse(rawArgs)
|
||||
errs := []error{parseErr}
|
||||
for _, name := range names {
|
||||
if !presentationFlags.Changed(name) {
|
||||
continue
|
||||
}
|
||||
value := presentationFlags.Lookup(name).Value.String()
|
||||
if err := rootFlags.Set(name, value); err != nil {
|
||||
errs = append(errs, fmt.Errorf("apply presentation flag --%s: %w", name, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// FlagInfoFromCommand extracts FlagInfo entries from a Cobra
|
||||
@@ -117,6 +351,7 @@ func appendFlagInfo(infos *[]FlagInfo, seen map[string]bool, flag *pflag.Flag) {
|
||||
func flagInfoFromPflag(f *pflag.Flag) FlagInfo {
|
||||
fi := FlagInfo{
|
||||
Name: f.Name,
|
||||
Shorthand: f.Shorthand,
|
||||
PropertyName: f.Name,
|
||||
Type: f.Value.Type(),
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageFlagInfoFromCommandIncludesLocalInheritedAndAnnotations(t *testing.T) {
|
||||
@@ -16,7 +18,7 @@ func TestCrossPlatformCoverageFlagInfoFromCommandIncludesLocalInheritedAndAnnota
|
||||
root := &cobra.Command{Use: "root"}
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
child := &cobra.Command{Use: "child"}
|
||||
child.Flags().String("start-time", "", "")
|
||||
child.Flags().StringP("start-time", "s", "", "")
|
||||
child.Flags().Lookup("start-time").Annotations = map[string][]string{
|
||||
"x-cli-format": {"date-time"},
|
||||
"x-cli-enum": {"one", "two"},
|
||||
@@ -31,7 +33,7 @@ func TestCrossPlatformCoverageFlagInfoFromCommandIncludesLocalInheritedAndAnnota
|
||||
for _, info := range infos {
|
||||
byName[info.Name] = info
|
||||
}
|
||||
if byName["profile"].Type != "string" || byName["start-time"].Format != "date-time" ||
|
||||
if byName["profile"].Type != "string" || byName["start-time"].Shorthand != "s" || byName["start-time"].Format != "date-time" ||
|
||||
!reflect.DeepEqual(byName["start-time"].Enum, []string{"one", "two"}) {
|
||||
t.Fatalf("flag infos = %#v", infos)
|
||||
}
|
||||
@@ -106,8 +108,348 @@ func TestCrossPlatformCoverageRunPreParseAppliesCorrectionsOnlyOnSuccess(t *test
|
||||
failing := NewEngine()
|
||||
failing.Register(newStub("fail", PreParse, func(*Context) error { return errors.New("boom") }))
|
||||
os.Args = []string{"root", "child", "--name", "original"}
|
||||
RunPreParse(root, failing)
|
||||
if err := RunPreParse(root, failing); err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("failed preparse error = %v, want boom", err)
|
||||
}
|
||||
if err := root.Execute(); err != nil || *value != "original" {
|
||||
t.Fatalf("failed preparse execute = %q, %v", *value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPreParseResolvesCommandPastLeadingPersistentFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
executable bool
|
||||
}{
|
||||
{name: "boolean long flag", args: []string{"--dry-run", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true},
|
||||
{name: "boolean long flag with detached false", args: []string{"--dry-run", "false", "calendar", "event", "list", "--date", "2026-03-10"}},
|
||||
{name: "camel-case boolean long flag", args: []string{"--dryRun", "calendar", "event", "list", "--date", "2026-03-10"}},
|
||||
{name: "camel-case boolean with detached false", args: []string{"--dryRun", "false", "calendar", "event", "list", "--date", "2026-03-10"}},
|
||||
{name: "fuzzy boolean long flag", args: []string{"--dry-rnu", "calendar", "event", "list", "--date", "2026-03-10"}},
|
||||
{name: "fuzzy boolean with detached false", args: []string{"--dry-rnu", "false", "calendar", "event", "list", "--date", "2026-03-10"}},
|
||||
{name: "valued long flag", args: []string{"--profile", "corp:user", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true},
|
||||
{name: "fuzzy valued long flag", args: []string{"--profle", "corp:user", "calendar", "event", "list", "--date", "2026-03-10"}},
|
||||
{name: "sticky valued long flag", args: []string{"--timeout30", "calendar", "event", "list", "--date", "2026-03-10"}},
|
||||
{name: "valued shorthand", args: []string{"-f", "json", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true},
|
||||
{name: "attached shorthand", args: []string{"-fjson", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true},
|
||||
{name: "clustered attached shorthand", args: []string{"-vfjson", "calendar", "event", "list", "--date", "2026-03-10"}, executable: true},
|
||||
{name: "boolean shorthand with detached false", args: []string{"-v", "false", "calendar", "event", "list", "--date", "2026-03-10"}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
root.PersistentFlags().Int("timeout", 0, "")
|
||||
root.PersistentFlags().StringP("format", "f", "json", "")
|
||||
root.PersistentFlags().BoolP("verbose", "v", false, "")
|
||||
|
||||
// This similarly named root path makes the old traversal failure
|
||||
// deterministic: `--dry-run` consumed "calendar" as a value and
|
||||
// incorrectly selected `dws event list`.
|
||||
misleadingEvent := &cobra.Command{Use: "event"}
|
||||
misleadingEvent.AddCommand(&cobra.Command{Use: "list"})
|
||||
root.AddCommand(misleadingEvent)
|
||||
|
||||
calendar := &cobra.Command{Use: "calendar"}
|
||||
event := &cobra.Command{Use: "event"}
|
||||
value := ""
|
||||
list := &cobra.Command{Use: "list"}
|
||||
list.Flags().StringVar(&value, "start", "", "")
|
||||
event.AddCommand(list)
|
||||
calendar.AddCommand(event)
|
||||
root.AddCommand(calendar)
|
||||
|
||||
engine := NewEngine()
|
||||
engine.Register(newStub("calendar-date-alias", PreParse, func(ctx *Context) error {
|
||||
if ctx.Command != "dws calendar event list" {
|
||||
t.Fatalf("resolved command = %q, want dws calendar event list", ctx.Command)
|
||||
}
|
||||
for index, argument := range ctx.Args {
|
||||
if argument == "--date" {
|
||||
ctx.Args[index] = "--start"
|
||||
ctx.AddCorrection("calendar-date-alias", PreParse, "start", "--date", "--start", "test")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
|
||||
root.SetArgs(test.args)
|
||||
ctx, err := RunPreParseArgs(root, engine, test.args)
|
||||
if err != nil {
|
||||
t.Fatalf("RunPreParseArgs() error = %v", err)
|
||||
}
|
||||
if ctx == nil || len(ctx.Corrections) != 1 {
|
||||
t.Fatalf("RunPreParseArgs() context = %#v", ctx)
|
||||
}
|
||||
if test.executable {
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("corrected command failed: %v", err)
|
||||
}
|
||||
if value != "2026-03-10" {
|
||||
t.Fatalf("canonical --start value = %q", value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPreParsePrimesPresentationFlagsForEarlyErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantFormat string
|
||||
wantDebug bool
|
||||
wantVerbose bool
|
||||
}{
|
||||
{
|
||||
name: "canonical flags after command",
|
||||
args: []string{"child", "--name", "demo", "--format", "table", "--debug"},
|
||||
wantFormat: "table",
|
||||
wantDebug: true,
|
||||
},
|
||||
{
|
||||
name: "normalized presentation names",
|
||||
args: []string{"--dryRun", "--FORMAT=pretty", "--Verbose", "child", "--name", "demo"},
|
||||
wantFormat: "pretty",
|
||||
wantVerbose: true,
|
||||
},
|
||||
{
|
||||
name: "clustered shorthands",
|
||||
args: []string{"-vfraw", "child", "--name", "demo"},
|
||||
wantFormat: "raw",
|
||||
wantVerbose: true,
|
||||
},
|
||||
{
|
||||
name: "explicit boolean presentation values",
|
||||
args: []string{"--debug=true", "-v=false", "child", "--name", "demo"},
|
||||
wantFormat: "json",
|
||||
wantDebug: true,
|
||||
wantVerbose: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().StringP("format", "f", "json", "")
|
||||
root.PersistentFlags().Bool("debug", false, "")
|
||||
root.PersistentFlags().BoolP("verbose", "v", false, "")
|
||||
child := &cobra.Command{Use: "child"}
|
||||
child.Flags().String("name", "", "")
|
||||
root.AddCommand(child)
|
||||
|
||||
engine := NewEngine()
|
||||
engine.Register(newStub("fail", PreParse, func(*Context) error { return errors.New("early") }))
|
||||
ctx, err := RunPreParseArgs(root, engine, test.args)
|
||||
if err == nil || ctx == nil {
|
||||
t.Fatalf("RunPreParseArgs() = %#v, %v; want early error with context", ctx, err)
|
||||
}
|
||||
format, _ := root.PersistentFlags().GetString("format")
|
||||
debug, _ := root.PersistentFlags().GetBool("debug")
|
||||
verbose, _ := root.PersistentFlags().GetBool("verbose")
|
||||
if format != test.wantFormat || debug != test.wantDebug || verbose != test.wantVerbose {
|
||||
t.Fatalf("presentation flags = format:%q debug:%v verbose:%v; want %q/%v/%v", format, debug, verbose, test.wantFormat, test.wantDebug, test.wantVerbose)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPreParseKeepsPrimaryErrorWhenPresentationParsingFails(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().StringP("format", "f", "json", "")
|
||||
root.PersistentFlags().Bool("debug", false, "")
|
||||
root.PersistentFlags().BoolP("verbose", "v", false, "")
|
||||
child := &cobra.Command{Use: "child"}
|
||||
child.Flags().String("name", "", "")
|
||||
root.AddCommand(child)
|
||||
|
||||
primaryErr := errors.New("primary pre-parse failure")
|
||||
engine := NewEngine()
|
||||
engine.Register(newStub("fail", PreParse, func(*Context) error { return primaryErr }))
|
||||
|
||||
ctx, err := RunPreParseArgs(root, engine, []string{
|
||||
"child", "--name", "demo", "--format", "table", "--debug=maybe",
|
||||
})
|
||||
if ctx == nil || !errors.Is(err, primaryErr) {
|
||||
t.Fatalf("RunPreParseArgs() = %#v, %v; want primary error", ctx, err)
|
||||
}
|
||||
format, _ := root.PersistentFlags().GetString("format")
|
||||
debug, _ := root.PersistentFlags().GetBool("debug")
|
||||
if format != "table" || debug {
|
||||
t.Fatalf("partially valid presentation values = format:%q debug:%v", format, debug)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandTraversalFlagTokenEdges(t *testing.T) {
|
||||
raw := []string{"child"}
|
||||
if got := argsForCommandTraversal(nil, raw); !reflect.DeepEqual(got, raw) {
|
||||
t.Fatalf("nil-root traversal args = %v", got)
|
||||
}
|
||||
root := &cobra.Command{Use: "root"}
|
||||
if got := argsForCommandTraversal(root, nil); got != nil {
|
||||
t.Fatalf("empty traversal args = %v", got)
|
||||
}
|
||||
root.PersistentFlags().BoolP("verbose", "v", false, "")
|
||||
root.PersistentFlags().StringP("format", "f", "", "")
|
||||
if got := argsForCommandTraversal(root, []string{"--", "--verbose", "child"}); !reflect.DeepEqual(got, []string{"--", "--verbose", "child"}) {
|
||||
t.Fatalf("double-dash traversal args = %v", got)
|
||||
}
|
||||
|
||||
if flag, inline, matched := newFlagTokenMatcher(nil).matchTraversalToken("--verbose"); flag != nil || inline || matched {
|
||||
t.Fatalf("nil flag set matched: %#v, %v, %v", flag, inline, matched)
|
||||
}
|
||||
if flag, inline, matched := (*flagTokenMatcher)(nil).matchTraversalToken(""); flag != nil || inline || matched {
|
||||
t.Fatalf("nil matcher matched: %#v, %v, %v", flag, inline, matched)
|
||||
}
|
||||
if match := (*flagTokenMatcher)(nil).matchLongToken("--verbose"); match.recognized {
|
||||
t.Fatalf("nil long matcher matched: %#v", match)
|
||||
}
|
||||
if flag, inline, matched := newFlagTokenMatcher(root.PersistentFlags()).matchTraversalToken("-x"); flag != nil || inline || matched {
|
||||
t.Fatalf("unknown shorthand matched: %#v, %v, %v", flag, inline, matched)
|
||||
}
|
||||
flag, inline, matched := newFlagTokenMatcher(root.PersistentFlags()).matchTraversalToken("-vv")
|
||||
if !matched || !inline || flag == nil || flag.Name != "verbose" {
|
||||
t.Fatalf("boolean shorthand cluster = %#v, %v, %v", flag, inline, matched)
|
||||
}
|
||||
|
||||
duplicate := pflag.NewFlagSet("duplicate", pflag.ContinueOnError)
|
||||
duplicate.Bool("verbose", false, "")
|
||||
matcher := newFlagTokenMatcher(root.PersistentFlags(), duplicate)
|
||||
if len(matcher.byName) != 2 || matcher.byName["verbose"] != root.PersistentFlags().Lookup("verbose") {
|
||||
t.Fatalf("duplicate flag precedence = %#v", matcher.byName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeparatedBoolValueRecognition(t *testing.T) {
|
||||
flags := pflag.NewFlagSet("test", pflag.ContinueOnError)
|
||||
flags.BoolP("verbose", "v", false, "")
|
||||
flags.String("format", "", "")
|
||||
verbose := flags.Lookup("verbose")
|
||||
format := flags.Lookup("format")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
argument string
|
||||
following string
|
||||
flag *pflag.Flag
|
||||
inline bool
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{name: "nil flag", argument: "--verbose", following: "false"},
|
||||
{name: "non bool", argument: "--format", following: "false", flag: format},
|
||||
{name: "long false", argument: "--verbose", following: "false", flag: verbose, want: "false", ok: true},
|
||||
{name: "long synonym", argument: "--verbose", following: "on", flag: verbose, want: "true", ok: true},
|
||||
{name: "inline long", argument: "--verbosefalse", following: "false", flag: verbose, inline: true},
|
||||
{name: "equals long", argument: "--verbose=false", following: "true", flag: verbose},
|
||||
{name: "exact shorthand", argument: "-v", following: "0", flag: verbose, want: "false", ok: true},
|
||||
{name: "shorthand cluster", argument: "-vv", following: "false", flag: verbose},
|
||||
{name: "invalid literal", argument: "--verbose", following: "maybe", flag: verbose},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, ok := separatedBoolValue(test.argument, test.following, test.flag, test.inline)
|
||||
if got != test.want || ok != test.ok {
|
||||
t.Fatalf("separatedBoolValue() = %q, %v; want %q, %v", got, ok, test.want, test.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimeEarlyErrorPresentationEdges(t *testing.T) {
|
||||
if err := primeEarlyErrorPresentation(nil, nil, nil); err != nil {
|
||||
t.Fatalf("nil presentation priming error = %v", err)
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "root"}
|
||||
root.PersistentFlags().StringP("format", "f", "json", "")
|
||||
root.PersistentFlags().Bool("debug", false, "")
|
||||
root.PersistentFlags().BoolP("verbose", "v", false, "")
|
||||
child := &cobra.Command{Use: "child"}
|
||||
child.Flags().String("name", "", "")
|
||||
root.AddCommand(child)
|
||||
|
||||
if err := primeEarlyErrorPresentation(root, child, []string{
|
||||
"child", "--unknown", "value", "-x", "--name", "demo",
|
||||
"-f", "table", "-v", "maybe", "--", "--debug",
|
||||
}); err != nil {
|
||||
t.Fatalf("presentation priming error = %v", err)
|
||||
}
|
||||
format, _ := root.PersistentFlags().GetString("format")
|
||||
debug, _ := root.PersistentFlags().GetBool("debug")
|
||||
verbose, _ := root.PersistentFlags().GetBool("verbose")
|
||||
if format != "table" || debug || !verbose {
|
||||
t.Fatalf("presentation after edge argv = format:%q debug:%v verbose:%v", format, debug, verbose)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimeEarlyErrorPresentationReportsParseAndContractErrors(t *testing.T) {
|
||||
t.Run("invalid presentation value is reported after applying valid values", func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
root.PersistentFlags().StringP("format", "f", "json", "")
|
||||
root.PersistentFlags().Bool("debug", false, "")
|
||||
root.PersistentFlags().BoolP("verbose", "v", false, "")
|
||||
child := &cobra.Command{Use: "child"}
|
||||
root.AddCommand(child)
|
||||
|
||||
err := primeEarlyErrorPresentation(root, child, []string{"child", "--format", "table", "--debug=maybe"})
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid argument") {
|
||||
t.Fatalf("invalid presentation error = %v", err)
|
||||
}
|
||||
format, _ := root.PersistentFlags().GetString("format")
|
||||
debug, _ := root.PersistentFlags().GetBool("debug")
|
||||
if format != "table" || debug {
|
||||
t.Fatalf("partially valid presentation values = format:%q debug:%v", format, debug)
|
||||
}
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
add func(*pflag.FlagSet)
|
||||
want string
|
||||
}{
|
||||
{name: "format type drift", add: func(flags *pflag.FlagSet) { flags.Bool("format", false, "") }, want: "read presentation flag --format"},
|
||||
{name: "debug type drift", add: func(flags *pflag.FlagSet) {
|
||||
flags.String("format", "json", "")
|
||||
flags.String("debug", "", "")
|
||||
}, want: "read presentation flag --debug"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
test.add(root.PersistentFlags())
|
||||
err := primeEarlyErrorPresentation(root, root, []string{"--format", "table"})
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("presentation contract error = %v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("apply error is returned", func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
value := &rejectingPresentationString{value: "json"}
|
||||
root.PersistentFlags().VarP(value, "format", "f", "")
|
||||
err := primeEarlyErrorPresentation(root, root, []string{"--format", "table"})
|
||||
if err == nil || !strings.Contains(err.Error(), "apply presentation flag --format") {
|
||||
t.Fatalf("presentation apply error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing presentation flags are optional", func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
if err := primeEarlyErrorPresentation(root, root, []string{"--unknown", "value"}); err != nil {
|
||||
t.Fatalf("optional presentation flags error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type rejectingPresentationString struct {
|
||||
value string
|
||||
}
|
||||
|
||||
func (v *rejectingPresentationString) Set(string) error { return errors.New("rejected") }
|
||||
func (v *rejectingPresentationString) String() string { return v.value }
|
||||
func (*rejectingPresentationString) Type() string { return "string" }
|
||||
|
||||
@@ -24,6 +24,23 @@ type Engine struct {
|
||||
handlers map[Phase][]Handler
|
||||
}
|
||||
|
||||
// HandlerError preserves the pipeline location of a handler failure for logs
|
||||
// and diagnostics while keeping the underlying domain error available to
|
||||
// user-facing adapters through Unwrap.
|
||||
type HandlerError struct {
|
||||
Phase Phase
|
||||
Handler string
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (e *HandlerError) Error() string {
|
||||
return fmt.Sprintf("pipeline %s handler %q: %v", e.Phase, e.Handler, e.Cause)
|
||||
}
|
||||
|
||||
func (e *HandlerError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// NewEngine creates a pipeline engine with no registered handlers.
|
||||
func NewEngine() *Engine {
|
||||
return &Engine{
|
||||
@@ -64,7 +81,7 @@ func (e *Engine) HasHandlers(phase Phase) bool {
|
||||
func (e *Engine) RunPhase(phase Phase, ctx *Context) error {
|
||||
for _, h := range e.handlers[phase] {
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
return fmt.Errorf("pipeline %s handler %q: %w", phase, h.Name(), err)
|
||||
return &HandlerError{Phase: phase, Handler: h.Name(), Cause: err}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -168,6 +168,10 @@ func TestRunPhaseErrorAbortsChain(t *testing.T) {
|
||||
if !strings.Contains(err.Error(), "fail") {
|
||||
t.Errorf("error should contain handler name, got %q", err.Error())
|
||||
}
|
||||
var handlerErr *HandlerError
|
||||
if !errors.As(err, &handlerErr) || handlerErr.Phase != PreParse || handlerErr.Handler != "fail" || handlerErr.Unwrap() != boom {
|
||||
t.Fatalf("handler error = %#v, want pre-parse/fail wrapping boom", handlerErr)
|
||||
}
|
||||
if !h1.called {
|
||||
t.Error("h1 should have been called")
|
||||
}
|
||||
@@ -240,6 +244,42 @@ func TestContextAddCorrection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextFlagProtectionAndConflictError(t *testing.T) {
|
||||
var nilContext *Context
|
||||
nilContext.ProtectFlag("uid", FlagProtectionBlocked)
|
||||
if nilContext.IsFlagProtected("uid") {
|
||||
t.Fatal("nil context reported a protected flag")
|
||||
}
|
||||
|
||||
ctx := &Context{}
|
||||
ctx.ProtectFlag("", FlagProtectionBlocked)
|
||||
if ctx.ProtectedFlags != nil {
|
||||
t.Fatalf("empty flag initialized protection map: %#v", ctx.ProtectedFlags)
|
||||
}
|
||||
ctx.ProtectFlag("uid", FlagProtectionAmbiguous)
|
||||
if !ctx.IsFlagProtected("uid") || ctx.IsFlagProtected("missing") {
|
||||
t.Fatalf("protection lookup mismatch: %#v", ctx.ProtectedFlags)
|
||||
}
|
||||
|
||||
err := (&FlagConflictError{
|
||||
Command: "dws demo run",
|
||||
Canonical: "user",
|
||||
Spellings: []string{"--user-id", "uid"},
|
||||
}).Error()
|
||||
if !strings.Contains(err, `for --user on "dws demo run": --user-id, --uid`) {
|
||||
t.Fatalf("FlagConflictError.Error() = %q", err)
|
||||
}
|
||||
|
||||
boolErr := (&BoolValueConflictError{
|
||||
Command: "dws demo run",
|
||||
Flag: "--yes",
|
||||
Values: []string{"true", "false"},
|
||||
}).Error()
|
||||
if !strings.Contains(boolErr, `for --yes on "dws demo run": false, true`) {
|
||||
t.Fatalf("BoolValueConflictError.Error() = %q", boolErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPhaseString(t *testing.T) {
|
||||
tests := []struct {
|
||||
phase Phase
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
// 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 pipeline
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
const maxFlagEditDistance = 2
|
||||
|
||||
// NormalizeFlagToken folds a long flag's morphological spelling and returns a
|
||||
// canonical token only when the folded name is a real flag. Both the normal
|
||||
// PreParse handler chain and Cobra command traversal use this primitive so a
|
||||
// spelling accepted after a command is also recognised before it.
|
||||
func NormalizeFlagToken(argument string, known map[string]bool) (string, bool) {
|
||||
if !strings.HasPrefix(argument, "--") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
bare := argument[2:]
|
||||
if bare == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var suffix string
|
||||
if index := strings.IndexByte(bare, '='); index >= 0 {
|
||||
suffix = bare[index:]
|
||||
bare = bare[:index]
|
||||
}
|
||||
if known[bare] {
|
||||
return "", false
|
||||
}
|
||||
|
||||
normalized := cmdutil.Morph(bare)
|
||||
if normalized == bare || !known[normalized] {
|
||||
return "", false
|
||||
}
|
||||
return "--" + normalized + suffix, true
|
||||
}
|
||||
|
||||
// StickyFlagPair is the canonical flag and value resolved from one glued
|
||||
// long-flag token. Inline is required for boolean flags because pflag treats a
|
||||
// bare boolean as true without consuming the following argv token.
|
||||
type StickyFlagPair struct {
|
||||
Flag string
|
||||
Value string
|
||||
Inline bool
|
||||
}
|
||||
|
||||
// SplitStickyFlag splits a safely recognisable glued flag/value token. The
|
||||
// suffix must satisfy the real flag's type/format/enum contract, preventing a
|
||||
// typo from being reinterpreted as data.
|
||||
func SplitStickyFlag(argument string, specByName map[string]FlagInfo) (StickyFlagPair, bool) {
|
||||
if !strings.HasPrefix(argument, "--") || strings.Contains(argument, "=") {
|
||||
return StickyFlagPair{}, false
|
||||
}
|
||||
|
||||
bare := argument[2:]
|
||||
if bare == "" {
|
||||
return StickyFlagPair{}, false
|
||||
}
|
||||
if _, ok := specByName[bare]; ok {
|
||||
return StickyFlagPair{}, false
|
||||
}
|
||||
if _, ok := specByName[cmdutil.Morph(bare)]; ok {
|
||||
return StickyFlagPair{}, false
|
||||
}
|
||||
|
||||
for index := len(bare) - 1; index >= 1; index-- {
|
||||
prefix := bare[:index]
|
||||
matchedFlag := ""
|
||||
if _, ok := specByName[prefix]; ok {
|
||||
matchedFlag = prefix
|
||||
} else if normalized := cmdutil.Morph(prefix); normalized != "" {
|
||||
if _, ok := specByName[normalized]; ok {
|
||||
matchedFlag = normalized
|
||||
}
|
||||
}
|
||||
if matchedFlag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
suffix := bare[index:]
|
||||
spec := specByName[matchedFlag]
|
||||
if !cmdutil.SuffixLooksLikeValue(suffix, spec.Type, spec.Format, spec.Enum) {
|
||||
return StickyFlagPair{}, false
|
||||
}
|
||||
inline := false
|
||||
if spec.Type == "bool" || spec.Type == "boolean" {
|
||||
suffix, _ = cmdutil.NormalizeBoolLiteral(suffix)
|
||||
inline = true
|
||||
}
|
||||
return StickyFlagPair{Flag: "--" + matchedFlag, Value: suffix, Inline: inline}, true
|
||||
}
|
||||
return StickyFlagPair{}, false
|
||||
}
|
||||
|
||||
// FuzzyMatchFlag returns the unique closest real long flag within the
|
||||
// conservative edit-distance threshold used by ParamNameHandler.
|
||||
func FuzzyMatchFlag(argument string, known map[string]bool, candidates []string) (string, bool) {
|
||||
if !strings.HasPrefix(argument, "--") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
bare := argument[2:]
|
||||
if bare == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var suffix string
|
||||
if index := strings.IndexByte(bare, '='); index >= 0 {
|
||||
suffix = bare[index:]
|
||||
bare = bare[:index]
|
||||
}
|
||||
if known[bare] {
|
||||
return "", false
|
||||
}
|
||||
|
||||
threshold := maxFlagEditDistance
|
||||
if len(bare) <= 3 {
|
||||
threshold = 1
|
||||
}
|
||||
bestDistance := threshold + 1
|
||||
bestMatch := ""
|
||||
ambiguous := false
|
||||
for _, candidate := range candidates {
|
||||
distance := cmdutil.LevenshteinDist(bare, candidate)
|
||||
if distance < bestDistance {
|
||||
bestDistance = distance
|
||||
bestMatch = candidate
|
||||
ambiguous = false
|
||||
} else if distance == bestDistance && candidate != bestMatch {
|
||||
ambiguous = true
|
||||
}
|
||||
}
|
||||
if bestDistance > threshold || ambiguous || bestMatch == "" {
|
||||
return "", false
|
||||
}
|
||||
return "--" + bestMatch + suffix, true
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// 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 pipeline
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeFlagTokenDirectContract(t *testing.T) {
|
||||
known := map[string]bool{"user-id": true}
|
||||
tests := []struct {
|
||||
argument string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{argument: "-u"},
|
||||
{argument: "--"},
|
||||
{argument: "--user-id"},
|
||||
{argument: "--unknown"},
|
||||
{argument: "--unknownFlag"},
|
||||
{argument: "--userId=42", want: "--user-id=42", ok: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got, ok := NormalizeFlagToken(test.argument, known)
|
||||
if got != test.want || ok != test.ok {
|
||||
t.Errorf("NormalizeFlagToken(%q) = %q, %v; want %q, %v", test.argument, got, ok, test.want, test.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitStickyFlagDirectContract(t *testing.T) {
|
||||
specs := map[string]FlagInfo{
|
||||
"limit": {Name: "limit", Type: "int"},
|
||||
"page-size": {Name: "page-size", Type: "int"},
|
||||
"yes": {Name: "yes", Type: "bool"},
|
||||
}
|
||||
tests := []struct {
|
||||
argument string
|
||||
want StickyFlagPair
|
||||
ok bool
|
||||
}{
|
||||
{argument: "-limit100"},
|
||||
{argument: "--limit=100"},
|
||||
{argument: "--"},
|
||||
{argument: "--limit"},
|
||||
{argument: "--pageSize"},
|
||||
{argument: "--unknown100"},
|
||||
{argument: "--limitabc"},
|
||||
{argument: "--yesfalse", want: StickyFlagPair{Flag: "--yes", Value: "false", Inline: true}, ok: true},
|
||||
{argument: "--yestrue", want: StickyFlagPair{Flag: "--yes", Value: "true", Inline: true}, ok: true},
|
||||
{argument: "--yesno", want: StickyFlagPair{Flag: "--yes", Value: "false", Inline: true}, ok: true},
|
||||
{argument: "--yesmaybe"},
|
||||
{argument: "--limit100", want: StickyFlagPair{Flag: "--limit", Value: "100"}, ok: true},
|
||||
{argument: "--pageSize50", want: StickyFlagPair{Flag: "--page-size", Value: "50"}, ok: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
got, ok := SplitStickyFlag(test.argument, specs)
|
||||
if got != test.want || ok != test.ok {
|
||||
t.Errorf("SplitStickyFlag(%q) = %#v, %v; want %#v, %v", test.argument, got, ok, test.want, test.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFuzzyMatchFlagDirectContract(t *testing.T) {
|
||||
known := map[string]bool{"limit": true, "name": true, "nave": true, "id": true}
|
||||
candidates := []string{"limit", "name", "nave", "id"}
|
||||
tests := []struct {
|
||||
argument string
|
||||
candidates []string
|
||||
useCandidates bool
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{argument: "-limt"},
|
||||
{argument: "--"},
|
||||
{argument: "--limit"},
|
||||
{argument: "--xy"},
|
||||
{argument: "--nae"},
|
||||
{argument: "--nothing", useCandidates: true},
|
||||
{argument: "--limt=10", want: "--limit=10", ok: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
caseCandidates := candidates
|
||||
if test.useCandidates {
|
||||
caseCandidates = test.candidates
|
||||
}
|
||||
got, ok := FuzzyMatchFlag(test.argument, known, caseCandidates)
|
||||
if got != test.want || ok != test.ok {
|
||||
t.Errorf("FuzzyMatchFlag(%q) = %q, %v; want %q, %v", test.argument, got, ok, test.want, test.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,8 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
// AliasHandler normalises flag names in raw argv so that common
|
||||
@@ -46,6 +44,10 @@ func (AliasHandler) Handle(ctx *pipeline.Context) error {
|
||||
result := make([]string, 0, len(ctx.Args))
|
||||
|
||||
for i, arg := range ctx.Args {
|
||||
if arg == "--" {
|
||||
result = append(result, ctx.Args[i:]...)
|
||||
break
|
||||
}
|
||||
rewritten, ok := tryNormaliseFlag(arg, known)
|
||||
if ok {
|
||||
ctx.AddCorrection("alias", pipeline.PreParse, rewritten, arg, rewritten, "alias")
|
||||
@@ -63,40 +65,14 @@ func (AliasHandler) Handle(ctx *pipeline.Context) error {
|
||||
// normalised to a known flag name. It handles both bare flags and
|
||||
// "--flag=value" syntax.
|
||||
func tryNormaliseFlag(arg string, known map[string]bool) (string, bool) {
|
||||
if !strings.HasPrefix(arg, "--") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
bare := arg[2:]
|
||||
if bare == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Handle --flag=value syntax: split, normalise the key, reassemble.
|
||||
var suffix string
|
||||
if idx := strings.IndexByte(bare, '='); idx >= 0 {
|
||||
suffix = bare[idx:] // includes "="
|
||||
bare = bare[:idx]
|
||||
}
|
||||
|
||||
// Already a known flag in its current form — no change needed.
|
||||
if known[bare] {
|
||||
return "", false
|
||||
}
|
||||
|
||||
normalised := toKebabCase(bare)
|
||||
if normalised == bare {
|
||||
return "", false
|
||||
}
|
||||
if !known[normalised] {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return "--" + normalised + suffix, true
|
||||
return pipeline.NormalizeFlagToken(arg, known)
|
||||
}
|
||||
|
||||
// toKebabCase converts a string from camelCase, PascalCase, or
|
||||
// snake_case to kebab-case. Examples:
|
||||
// toKebabCase converts a string from camelCase, PascalCase, or snake_case to
|
||||
// kebab-case. It is a thin compatibility shim over the single shared
|
||||
// normaliser cmdutil.Morph so the pipeline handlers and the build-time
|
||||
// parameter-alias generator can never diverge on how a flag spelling is
|
||||
// folded. Examples:
|
||||
//
|
||||
// "userId" → "user-id"
|
||||
// "UserName" → "user-name"
|
||||
@@ -104,43 +80,5 @@ func tryNormaliseFlag(arg string, known map[string]bool) (string, bool) {
|
||||
// "USER_ID" → "user-id"
|
||||
// "pageSize" → "page-size"
|
||||
func toKebabCase(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(s) + 4) // small extra for hyphens
|
||||
|
||||
runes := []rune(s)
|
||||
for i, r := range runes {
|
||||
if r == '_' || r == ' ' {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if unicode.IsUpper(r) {
|
||||
// Insert hyphen before an uppercase letter when:
|
||||
// 1. Not at start, AND
|
||||
// 2. Previous char was lowercase, OR
|
||||
// 3. Next char is lowercase (handles "userID" → "user-id"
|
||||
// at the boundary between "I" and "D" in "ID" we don't
|
||||
// split, but "IDs" → we split before "s" which is
|
||||
// handled by the lowercase check at the next iteration).
|
||||
if i > 0 {
|
||||
prev := runes[i-1]
|
||||
if unicode.IsLower(prev) {
|
||||
b.WriteByte('-')
|
||||
} else if unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
} else {
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Trim(b.String(), "-")
|
||||
return cmdutil.Morph(s)
|
||||
}
|
||||
|
||||
@@ -184,3 +184,9 @@ func TestAliasHandlerNameAndPhase(t *testing.T) {
|
||||
t.Errorf("Phase() = %v, want PreParse", h.Phase())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTryNormaliseFlagRejectsBareDoubleDash(t *testing.T) {
|
||||
if got, ok := tryNormaliseFlag("--", map[string]bool{"limit": true}); ok || got != "" {
|
||||
t.Fatalf("tryNormaliseFlag(--) = %q, %v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// 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 handlers
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
// BoolValueHandler gives every real Cobra boolean flag one consistent input
|
||||
// grammar before pflag parsing. pflag interprets `--yes false` as a bare
|
||||
// `--yes` (true) plus a positional `false`; rewriting the pair to
|
||||
// `--yes=false` preserves the value the caller actually supplied. The same
|
||||
// rule applies to default-true, required, local, and inherited boolean flags:
|
||||
// the handler preserves an explicit value and never substitutes a default.
|
||||
//
|
||||
// This handler runs after all flag-name handlers so camelCase, semantic
|
||||
// aliases, and conservative fuzzy corrections have already reached their
|
||||
// canonical names. It consumes only recognised boolean literals, stops at
|
||||
// `--`, and rejects contradictory values for the same canonical flag so the
|
||||
// result cannot depend on argv order.
|
||||
type BoolValueHandler struct{}
|
||||
|
||||
func (BoolValueHandler) Name() string { return "boolvalue" }
|
||||
func (BoolValueHandler) Phase() pipeline.Phase { return pipeline.PreParse }
|
||||
|
||||
func (BoolValueHandler) Handle(ctx *pipeline.Context) error {
|
||||
if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
longNames := make(map[string]pipeline.FlagInfo)
|
||||
shortNames := make(map[string]pipeline.FlagInfo)
|
||||
for _, spec := range ctx.FlagSpecs {
|
||||
if spec.Name == "" || (spec.Type != "bool" && spec.Type != "boolean") {
|
||||
continue
|
||||
}
|
||||
longNames[spec.Name] = spec
|
||||
if spec.Shorthand != "" {
|
||||
shortNames[spec.Shorthand] = spec
|
||||
}
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(ctx.Args))
|
||||
valuesByFlag := make(map[string]map[string]bool)
|
||||
for index := 0; index < len(ctx.Args); index++ {
|
||||
argument := ctx.Args[index]
|
||||
if argument == "--" {
|
||||
result = append(result, ctx.Args[index:]...)
|
||||
break
|
||||
}
|
||||
|
||||
spec, inlineValue, hasInlineValue, matched := matchBooleanFlagToken(argument, longNames, shortNames)
|
||||
if !matched || ctx.IsFlagProtected(cmdutil.Morph(spec.Name)) {
|
||||
result = append(result, argument)
|
||||
continue
|
||||
}
|
||||
|
||||
normalized := "true"
|
||||
corrected := argument
|
||||
original := argument
|
||||
changed := false
|
||||
if hasInlineValue {
|
||||
var ok bool
|
||||
normalized, ok = cmdutil.NormalizeBoolLiteral(inlineValue)
|
||||
if !ok {
|
||||
result = append(result, argument)
|
||||
continue
|
||||
}
|
||||
corrected = "--" + spec.Name + "=" + normalized
|
||||
changed = corrected != argument
|
||||
} else if index+1 < len(ctx.Args) {
|
||||
if value, ok := cmdutil.NormalizeBoolLiteral(ctx.Args[index+1]); ok {
|
||||
normalized = value
|
||||
corrected = "--" + spec.Name + "=" + normalized
|
||||
original = strings.Join(ctx.Args[index:index+2], " ")
|
||||
changed = true
|
||||
index++
|
||||
}
|
||||
}
|
||||
|
||||
if valuesByFlag[spec.Name] == nil {
|
||||
valuesByFlag[spec.Name] = make(map[string]bool)
|
||||
}
|
||||
valuesByFlag[spec.Name][normalized] = true
|
||||
if changed {
|
||||
ctx.AddCorrection("boolvalue", pipeline.PreParse, "--"+spec.Name, original, corrected, "explicit-bool")
|
||||
}
|
||||
result = append(result, corrected)
|
||||
}
|
||||
|
||||
ctx.Args = result
|
||||
names := make([]string, 0, len(valuesByFlag))
|
||||
for name := range valuesByFlag {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
values := valuesByFlag[name]
|
||||
if len(values) < 2 {
|
||||
continue
|
||||
}
|
||||
list := make([]string, 0, len(values))
|
||||
for value := range values {
|
||||
list = append(list, value)
|
||||
}
|
||||
sort.Strings(list)
|
||||
return &pipeline.BoolValueConflictError{
|
||||
Command: ctx.Command,
|
||||
Flag: name,
|
||||
Values: list,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// matchBooleanFlagToken recognises canonical long flags, exact shorthands,
|
||||
// and their explicit =value forms. Shorthand clusters remain native pflag
|
||||
// syntax and are intentionally not reinterpreted here.
|
||||
func matchBooleanFlagToken(argument string, longNames, shortNames map[string]pipeline.FlagInfo) (pipeline.FlagInfo, string, bool, bool) {
|
||||
if strings.HasPrefix(argument, "--") {
|
||||
bare, suffix, isFlag := splitFlagToken(argument)
|
||||
if !isFlag {
|
||||
return pipeline.FlagInfo{}, "", false, false
|
||||
}
|
||||
spec, ok := longNames[bare]
|
||||
if !ok {
|
||||
return pipeline.FlagInfo{}, "", false, false
|
||||
}
|
||||
if suffix == "" {
|
||||
return spec, "", false, true
|
||||
}
|
||||
return spec, strings.TrimPrefix(suffix, "="), true, true
|
||||
}
|
||||
if !strings.HasPrefix(argument, "-") || strings.HasPrefix(argument, "--") {
|
||||
return pipeline.FlagInfo{}, "", false, false
|
||||
}
|
||||
body := strings.TrimPrefix(argument, "-")
|
||||
name, value, hasValue := body, "", false
|
||||
if index := strings.IndexByte(body, '='); index >= 0 {
|
||||
name, value, hasValue = body[:index], body[index+1:], true
|
||||
}
|
||||
spec, ok := shortNames[name]
|
||||
if !ok {
|
||||
return pipeline.FlagInfo{}, "", false, false
|
||||
}
|
||||
return spec, value, hasValue, true
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// 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 handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
func TestBoolValueHandler(t *testing.T) {
|
||||
boolFlags := []pipeline.FlagInfo{{Name: "yes", Shorthand: "y", Type: "bool"}}
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
flags []pipeline.FlagInfo
|
||||
protected []string
|
||||
want []string
|
||||
corrections int
|
||||
conflict bool
|
||||
}{
|
||||
{name: "long false", args: []string{"--yes", "false"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1},
|
||||
{name: "long mixed-case false", args: []string{"--yes", "FaLsE"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1},
|
||||
{name: "long true synonym", args: []string{"--yes", "yes"}, flags: boolFlags, want: []string{"--yes=true"}, corrections: 1},
|
||||
{name: "shorthand zero", args: []string{"-y", "0"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1},
|
||||
{name: "shorthand equals synonym", args: []string{"-y=off"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1},
|
||||
{name: "preserves following flags", args: []string{"--yes", "off", "--format", "json"}, flags: boolFlags, want: []string{"--yes=false", "--format", "json"}, corrections: 1},
|
||||
{name: "explicit equals stays native", args: []string{"--yes=false"}, flags: boolFlags, want: []string{"--yes=false"}},
|
||||
{name: "explicit equals synonym normalizes", args: []string{"--yes=No"}, flags: boolFlags, want: []string{"--yes=false"}, corrections: 1},
|
||||
{name: "bare bool stays native", args: []string{"--yes"}, flags: boolFlags, want: []string{"--yes"}},
|
||||
{name: "invalid literal is positional", args: []string{"--yes", "maybe"}, flags: boolFlags, want: []string{"--yes", "maybe"}},
|
||||
{name: "invalid inline literal stays native", args: []string{"--yes=maybe"}, flags: boolFlags, want: []string{"--yes=maybe"}},
|
||||
{name: "non bool flag is unchanged", args: []string{"--name", "false"}, flags: []pipeline.FlagInfo{{Name: "name", Type: "string"}}, want: []string{"--name", "false"}},
|
||||
{name: "unknown flag is unchanged", args: []string{"--confirm", "false"}, flags: boolFlags, want: []string{"--confirm", "false"}},
|
||||
{name: "shorthand cluster is unchanged", args: []string{"-vy", "false"}, flags: boolFlags, want: []string{"-vy", "false"}},
|
||||
{name: "protected bool is unchanged", args: []string{"--yes", "false"}, flags: boolFlags, protected: []string{"yes"}, want: []string{"--yes", "false"}},
|
||||
{name: "protected noncanonical bool uses morphed key", args: []string{"--dry_run", "false"}, flags: []pipeline.FlagInfo{{Name: "dry_run", Type: "bool"}}, protected: []string{"dry-run"}, want: []string{"--dry_run", "false"}},
|
||||
{name: "stops at double dash", args: []string{"--", "--yes", "false"}, flags: boolFlags, want: []string{"--", "--yes", "false"}},
|
||||
{name: "no specs", args: []string{"--yes", "false"}, want: []string{"--yes", "false"}},
|
||||
{name: "identical repeated values remain valid", args: []string{"--yes", "--yes=true", "--yes", "yes"}, flags: boolFlags, want: []string{"--yes", "--yes=true", "--yes=true"}, corrections: 1},
|
||||
{name: "contradictory detached values fail", args: []string{"--yes", "true", "--yes", "false"}, flags: boolFlags, want: []string{"--yes=true", "--yes=false"}, corrections: 2, conflict: true},
|
||||
{name: "bare and explicit false fail", args: []string{"--yes", "--yes=false"}, flags: boolFlags, want: []string{"--yes", "--yes=false"}, conflict: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Args: append([]string(nil), test.args...),
|
||||
FlagSpecs: test.flags,
|
||||
}
|
||||
for _, protected := range test.protected {
|
||||
ctx.ProtectFlag(protected, pipeline.FlagProtectionBlocked)
|
||||
}
|
||||
handler := BoolValueHandler{}
|
||||
err := handler.Handle(ctx)
|
||||
var conflict *pipeline.BoolValueConflictError
|
||||
if test.conflict {
|
||||
if !errors.As(err, &conflict) {
|
||||
t.Fatalf("Handle() error = %v, want BoolValueConflictError", err)
|
||||
}
|
||||
if conflict.Flag != "yes" || !reflect.DeepEqual(conflict.Values, []string{"false", "true"}) {
|
||||
t.Fatalf("conflict = %#v", conflict)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("Handle() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(ctx.Args, test.want) {
|
||||
t.Fatalf("Args = %#v, want %#v", ctx.Args, test.want)
|
||||
}
|
||||
if len(ctx.Corrections) != test.corrections {
|
||||
t.Fatalf("Corrections = %#v, want %d", ctx.Corrections, test.corrections)
|
||||
}
|
||||
if test.corrections > 0 {
|
||||
correction := ctx.Corrections[0]
|
||||
if correction.Handler != "boolvalue" || correction.Kind != "explicit-bool" || correction.Field != "--yes" {
|
||||
t.Fatalf("correction metadata = %#v", correction)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchBooleanFlagToken(t *testing.T) {
|
||||
longNames := map[string]pipeline.FlagInfo{"yes": {Name: "yes", Shorthand: "y", Type: "bool"}}
|
||||
shortNames := map[string]pipeline.FlagInfo{"y": longNames["yes"]}
|
||||
tests := []struct {
|
||||
argument string
|
||||
value string
|
||||
hasValue bool
|
||||
matched bool
|
||||
}{
|
||||
{argument: "--yes", matched: true},
|
||||
{argument: "--yes=false", value: "false", hasValue: true, matched: true},
|
||||
{argument: "-y", matched: true},
|
||||
{argument: "-y=true", value: "true", hasValue: true, matched: true},
|
||||
{argument: "-vy"},
|
||||
{argument: "--unknown=false"},
|
||||
{argument: "yes"},
|
||||
{argument: "--"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.argument, func(t *testing.T) {
|
||||
spec, value, hasValue, matched := matchBooleanFlagToken(test.argument, longNames, shortNames)
|
||||
if value != test.value || hasValue != test.hasValue || matched != test.matched {
|
||||
t.Fatalf("matchBooleanFlagToken(%q) = %#v, %q, %v, %v", test.argument, spec, value, hasValue, matched)
|
||||
}
|
||||
if matched && spec.Name != "yes" {
|
||||
t.Fatalf("matched spec = %#v", spec)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoolValueHandlerMeta(t *testing.T) {
|
||||
handler := BoolValueHandler{}
|
||||
if handler.Name() != "boolvalue" || handler.Phase() != pipeline.PreParse {
|
||||
t.Fatalf("handler metadata = %q/%v", handler.Name(), handler.Phase())
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
)
|
||||
|
||||
// TestFullPreParsePipeline exercises the complete PreParse handler
|
||||
// chain: AliasHandler → StickyHandler → ParamNameHandler. It
|
||||
// chain: AliasHandler → StickyHandler → ParamNameHandler → BoolValueHandler. It
|
||||
// simulates a model-generated CLI invocation with multiple errors
|
||||
// and verifies the pipeline corrects all of them in one pass.
|
||||
func TestFullPreParsePipeline(t *testing.T) {
|
||||
@@ -30,6 +30,7 @@ func TestFullPreParsePipeline(t *testing.T) {
|
||||
AliasHandler{},
|
||||
StickyHandler{},
|
||||
ParamNameHandler{},
|
||||
BoolValueHandler{},
|
||||
)
|
||||
|
||||
// Numeric / boolean flag typing matters for the sticky guard. The
|
||||
@@ -106,6 +107,20 @@ func TestFullPreParsePipeline(t *testing.T) {
|
||||
want: "--limit-value 100",
|
||||
corrections: 1, // sticky handles both kebab-normalisation and split
|
||||
},
|
||||
{
|
||||
name: "camelCase bool with detached value",
|
||||
args: []string{"--dryRun", "false"},
|
||||
flags: []pipeline.FlagInfo{{Name: "dry-run", Type: "bool"}},
|
||||
want: "--dry-run=false",
|
||||
corrections: 2, // alias(dryRun) + boolvalue(false)
|
||||
},
|
||||
{
|
||||
name: "fuzzy bool with detached value",
|
||||
args: []string{"--yess", "no"},
|
||||
flags: []pipeline.FlagInfo{{Name: "yes", Type: "bool"}},
|
||||
want: "--yes=false",
|
||||
corrections: 2, // paramname(yess) + boolvalue(no)
|
||||
},
|
||||
|
||||
// Hardening: a mistyped flag whose name happens to start with
|
||||
// a real flag must NOT be split. The pipeline should leave the
|
||||
@@ -145,6 +160,56 @@ func TestFullPreParsePipeline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticProtectionSurvivesStickyAndParamName(t *testing.T) {
|
||||
engine := pipeline.NewEngine()
|
||||
engine.RegisterAll(
|
||||
SemanticAliasHandler{Lookup: fakeLookup(nil, []string{"limt", "limit100"}, nil)},
|
||||
StickyHandler{},
|
||||
ParamNameHandler{},
|
||||
)
|
||||
ctx := &pipeline.Context{
|
||||
Command: "dws demo cmd",
|
||||
Args: []string{"--limt", "10", "--limit100"},
|
||||
FlagSpecs: []pipeline.FlagInfo{
|
||||
{Name: "limit", Type: "int"},
|
||||
},
|
||||
}
|
||||
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
||||
t.Fatalf("RunPhase() error = %v", err)
|
||||
}
|
||||
if got, want := strings.Join(ctx.Args, " "), "--limt 10 --limit100"; got != want {
|
||||
t.Fatalf("protected args = %q, want %q", got, want)
|
||||
}
|
||||
if len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("protected args were corrected: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullPreParsePipelineStopsAtDoubleDash(t *testing.T) {
|
||||
engine := pipeline.NewEngine()
|
||||
engine.RegisterAll(
|
||||
AliasHandler{},
|
||||
SemanticAliasHandler{Lookup: fakeLookup(map[string]string{"keyword": "query"}, nil, nil)},
|
||||
StickyHandler{},
|
||||
ParamNameHandler{},
|
||||
BoolValueHandler{},
|
||||
)
|
||||
ctx := &pipeline.Context{
|
||||
Command: "dws demo cmd",
|
||||
Args: []string{"--query", "before", "--", "--keyword", "--limit100", "--limt"},
|
||||
FlagSpecs: []pipeline.FlagInfo{
|
||||
{Name: "query", Type: "string"},
|
||||
{Name: "limit", Type: "int"},
|
||||
},
|
||||
}
|
||||
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
||||
t.Fatalf("RunPhase() error = %v", err)
|
||||
}
|
||||
if got, want := strings.Join(ctx.Args, " "), "--query before -- --keyword --limit100 --limt"; got != want {
|
||||
t.Fatalf("args after -- = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFullPostParsePipeline exercises the PostParse handler chain
|
||||
// with the ParamValueHandler normalising multiple value types in a
|
||||
// single invocation.
|
||||
@@ -227,7 +292,7 @@ func TestFullPipelineEndToEnd(t *testing.T) {
|
||||
t.Fatalf("PreParse error: %v", err)
|
||||
}
|
||||
|
||||
want := "--user-id u001 --page-size 50 --verbose true"
|
||||
want := "--user-id u001 --page-size 50 --verbose=true"
|
||||
got := strings.Join(ctx.Args, " ")
|
||||
if got != want {
|
||||
t.Errorf("after PreParse: Args = %q, want %q", got, want)
|
||||
@@ -284,6 +349,7 @@ func TestFullFivePhasePipeline(t *testing.T) {
|
||||
AliasHandler{},
|
||||
StickyHandler{},
|
||||
ParamNameHandler{},
|
||||
BoolValueHandler{},
|
||||
ParamValueHandler{},
|
||||
PreRequestHandler{},
|
||||
PostResponseHandler{},
|
||||
@@ -326,7 +392,7 @@ func TestFullFivePhasePipeline(t *testing.T) {
|
||||
t.Fatalf("PreParse error: %v", err)
|
||||
}
|
||||
|
||||
want := "--user-id u001 --page-size 50 --verbose true"
|
||||
want := "--user-id u001 --page-size 50 --verbose=true"
|
||||
got := strings.Join(ctx.Args, " ")
|
||||
if got != want {
|
||||
t.Errorf("after PreParse: Args = %q, want %q", got, want)
|
||||
@@ -426,6 +492,7 @@ func TestFivePhasePipelineCorrectHandlerCounts(t *testing.T) {
|
||||
AliasHandler{},
|
||||
StickyHandler{},
|
||||
ParamNameHandler{},
|
||||
BoolValueHandler{},
|
||||
ParamValueHandler{},
|
||||
PreRequestHandler{},
|
||||
PostResponseHandler{},
|
||||
@@ -436,7 +503,7 @@ func TestFivePhasePipelineCorrectHandlerCounts(t *testing.T) {
|
||||
want int
|
||||
}{
|
||||
{pipeline.Register, 1},
|
||||
{pipeline.PreParse, 3},
|
||||
{pipeline.PreParse, 4},
|
||||
{pipeline.PostParse, 1},
|
||||
{pipeline.PreRequest, 1},
|
||||
{pipeline.PostResponse, 1},
|
||||
@@ -446,8 +513,8 @@ func TestFivePhasePipelineCorrectHandlerCounts(t *testing.T) {
|
||||
t.Errorf("Handlers(%v) = %d, want %d", tt.phase, got, tt.want)
|
||||
}
|
||||
}
|
||||
if got := engine.HandlerCount(); got != 7 {
|
||||
t.Errorf("HandlerCount = %d, want 7", got)
|
||||
if got := engine.HandlerCount(); got != 8 {
|
||||
t.Errorf("HandlerCount = %d, want 8", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,6 +540,7 @@ func TestPreParseDoesNotBreakValidArgs(t *testing.T) {
|
||||
AliasHandler{},
|
||||
StickyHandler{},
|
||||
ParamNameHandler{},
|
||||
BoolValueHandler{},
|
||||
)
|
||||
|
||||
original := []string{
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
@@ -35,8 +33,6 @@ import (
|
||||
// done and fuzzy matching only handles genuine near-misses.
|
||||
type ParamNameHandler struct{}
|
||||
|
||||
const maxEditDistance = 2
|
||||
|
||||
func (ParamNameHandler) Name() string { return "paramname" }
|
||||
func (ParamNameHandler) Phase() pipeline.Phase { return pipeline.PreParse }
|
||||
|
||||
@@ -54,7 +50,15 @@ func (ParamNameHandler) Handle(ctx *pipeline.Context) error {
|
||||
}
|
||||
|
||||
result := make([]string, 0, len(ctx.Args))
|
||||
for _, arg := range ctx.Args {
|
||||
for i, arg := range ctx.Args {
|
||||
if arg == "--" {
|
||||
result = append(result, ctx.Args[i:]...)
|
||||
break
|
||||
}
|
||||
if bare, _, isFlag := splitFlagToken(arg); isFlag && ctx.IsFlagProtected(cmdutil.Morph(bare)) {
|
||||
result = append(result, arg)
|
||||
continue
|
||||
}
|
||||
rewritten, ok := tryFuzzyMatch(arg, known, names)
|
||||
if ok {
|
||||
ctx.AddCorrection("paramname", pipeline.PreParse, rewritten, arg, rewritten, "fuzzy")
|
||||
@@ -71,50 +75,5 @@ func (ParamNameHandler) Handle(ctx *pipeline.Context) error {
|
||||
// tryFuzzyMatch attempts to correct an unrecognised "--flag" token by
|
||||
// finding the closest known flag name within the edit distance threshold.
|
||||
func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (string, bool) {
|
||||
if !strings.HasPrefix(arg, "--") {
|
||||
return "", false
|
||||
}
|
||||
|
||||
bare := arg[2:]
|
||||
if bare == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Handle --flag=value syntax.
|
||||
var suffix string
|
||||
if idx := strings.IndexByte(bare, '='); idx >= 0 {
|
||||
suffix = bare[idx:]
|
||||
bare = bare[:idx]
|
||||
}
|
||||
|
||||
// Already known — nothing to fix.
|
||||
if known[bare] {
|
||||
return "", false
|
||||
}
|
||||
|
||||
threshold := maxEditDistance
|
||||
if len(bare) <= 3 {
|
||||
threshold = 1
|
||||
}
|
||||
|
||||
bestDist := threshold + 1
|
||||
bestMatch := ""
|
||||
ambiguous := false
|
||||
|
||||
for _, candidate := range candidates {
|
||||
dist := cmdutil.LevenshteinDist(bare, candidate)
|
||||
if dist < bestDist {
|
||||
bestDist = dist
|
||||
bestMatch = candidate
|
||||
ambiguous = false
|
||||
} else if dist == bestDist && candidate != bestMatch {
|
||||
ambiguous = true
|
||||
}
|
||||
}
|
||||
|
||||
if bestDist > threshold || ambiguous || bestMatch == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return "--" + bestMatch + suffix, true
|
||||
return pipeline.FuzzyMatchFlag(arg, known, candidates)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// 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 handlers
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
// SemanticAliasHandler rewrites semantic parameter synonyms to a command's
|
||||
// canonical real flag using the build-time reduced alias table. Where
|
||||
// AliasHandler only folds a spelling that already matches a real flag
|
||||
// (--userId → --user-id), this handler resolves a different word to the real
|
||||
// flag the command actually accepts (--keyword → --query) based on the
|
||||
// reviewed concept dictionary.
|
||||
//
|
||||
// It runs in PreParse after AliasHandler (morphology first) and before sticky
|
||||
// and paramname. The alias table is injected as Lookup so this handler never
|
||||
// imports the cli package; root.go wires cli.LookupParamAlias in.
|
||||
type SemanticAliasHandler struct {
|
||||
// Lookup returns the aliases/blocked/ambiguous sets reduced for a raw
|
||||
// Cobra CommandPath, or ok=false when the command has no reduced entry.
|
||||
// Keys are already morphed (cmdutil.Morph), matching how the table is built.
|
||||
Lookup func(rawCommandPath string) (aliases map[string]string, blocked, ambiguous []string, ok bool)
|
||||
}
|
||||
|
||||
func (SemanticAliasHandler) Name() string { return "semantic-alias" }
|
||||
func (SemanticAliasHandler) Phase() pipeline.Phase { return pipeline.PreParse }
|
||||
|
||||
func (h SemanticAliasHandler) Handle(ctx *pipeline.Context) error {
|
||||
if h.Lookup == nil || ctx.Command == "" || len(ctx.Args) == 0 {
|
||||
return nil
|
||||
}
|
||||
aliases, blocked, ambiguous, ok := h.Lookup(ctx.Command)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, name := range blocked {
|
||||
ctx.ProtectFlag(name, pipeline.FlagProtectionBlocked)
|
||||
}
|
||||
for _, name := range ambiguous {
|
||||
ctx.ProtectFlag(name, pipeline.FlagProtectionAmbiguous)
|
||||
}
|
||||
|
||||
if err := rejectMixedAliasSpellings(ctx, aliases); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for i, arg := range ctx.Args {
|
||||
if arg == "--" {
|
||||
break
|
||||
}
|
||||
bare, suffix, isFlag := splitFlagToken(arg)
|
||||
if !isFlag {
|
||||
continue
|
||||
}
|
||||
morphed := cmdutil.Morph(bare)
|
||||
|
||||
// A blocked or intentionally ambiguous name must never be silently
|
||||
// rewritten: it is left untouched so the unknown-flag did-you-mean
|
||||
// path can surface the reviewed candidates instead of guessing.
|
||||
if ctx.IsFlagProtected(morphed) {
|
||||
continue
|
||||
}
|
||||
|
||||
canon, hit := aliases[morphed]
|
||||
if !hit || canon == bare {
|
||||
continue
|
||||
}
|
||||
|
||||
rewritten := "--" + canon + suffix
|
||||
ctx.Args[i] = rewritten
|
||||
ctx.AddCorrection("semantic-alias", pipeline.PreParse, canon, arg, rewritten, "semantic")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rejectMixedAliasSpellings(ctx *pipeline.Context, aliases map[string]string) error {
|
||||
if len(aliases) == 0 {
|
||||
return nil
|
||||
}
|
||||
targetByMorph := make(map[string]string, len(aliases))
|
||||
for _, canonical := range aliases {
|
||||
targetByMorph[cmdutil.Morph(canonical)] = canonical
|
||||
}
|
||||
spellingsByTarget := make(map[string]map[string]bool)
|
||||
hasAliasByTarget := make(map[string]bool)
|
||||
for _, arg := range ctx.Args {
|
||||
if arg == "--" {
|
||||
break
|
||||
}
|
||||
bare, _, isFlag := splitFlagToken(arg)
|
||||
if !isFlag {
|
||||
continue
|
||||
}
|
||||
morphed := cmdutil.Morph(bare)
|
||||
if ctx.IsFlagProtected(morphed) {
|
||||
continue
|
||||
}
|
||||
canonical, isAlias := aliases[morphed]
|
||||
if !isAlias {
|
||||
canonical = targetByMorph[morphed]
|
||||
}
|
||||
if canonical == "" {
|
||||
continue
|
||||
}
|
||||
if spellingsByTarget[canonical] == nil {
|
||||
spellingsByTarget[canonical] = make(map[string]bool)
|
||||
}
|
||||
spellingsByTarget[canonical][morphed] = true
|
||||
if isAlias {
|
||||
hasAliasByTarget[canonical] = true
|
||||
}
|
||||
}
|
||||
targets := make([]string, 0, len(spellingsByTarget))
|
||||
for canonical := range spellingsByTarget {
|
||||
targets = append(targets, canonical)
|
||||
}
|
||||
sort.Strings(targets)
|
||||
for _, canonical := range targets {
|
||||
spellings := spellingsByTarget[canonical]
|
||||
if !hasAliasByTarget[canonical] || len(spellings) < 2 {
|
||||
continue
|
||||
}
|
||||
list := make([]string, 0, len(spellings))
|
||||
for spelling := range spellings {
|
||||
list = append(list, spelling)
|
||||
}
|
||||
sort.Strings(list)
|
||||
return &pipeline.FlagConflictError{
|
||||
Command: ctx.Command,
|
||||
Canonical: canonical,
|
||||
Spellings: list,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// splitFlagToken splits a raw argv token into its bare flag name and any
|
||||
// "=value" suffix. isFlag is false for anything that is not a "--flag" token
|
||||
// (positional args, "-x" short flags, the bare "--" separator, or "--=v").
|
||||
func splitFlagToken(arg string) (bare, suffix string, isFlag bool) {
|
||||
if !strings.HasPrefix(arg, "--") {
|
||||
return "", "", false
|
||||
}
|
||||
body := arg[2:]
|
||||
if body == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if idx := strings.IndexByte(body, '='); idx >= 0 {
|
||||
if idx == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return body[:idx], body[idx:], true
|
||||
}
|
||||
return body, "", true
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// 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 handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
// fakeLookup returns a fixed table for the "dws demo cmd" command and ok=false
|
||||
// for anything else, standing in for cli.LookupParamAlias in tests.
|
||||
func fakeLookup(aliases map[string]string, blocked, ambiguous []string) func(string) (map[string]string, []string, []string, bool) {
|
||||
return func(raw string) (map[string]string, []string, []string, bool) {
|
||||
if raw != "dws demo cmd" {
|
||||
return nil, nil, nil, false
|
||||
}
|
||||
return aliases, blocked, ambiguous, true
|
||||
}
|
||||
}
|
||||
|
||||
func newSemanticHandler() SemanticAliasHandler {
|
||||
return SemanticAliasHandler{
|
||||
Lookup: fakeLookup(
|
||||
map[string]string{"keyword": "query", "page-size": "limit"},
|
||||
[]string{"count"},
|
||||
[]string{"user-id"},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticAliasHandlerRewritesSynonym(t *testing.T) {
|
||||
ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--keyword", "hello"}}
|
||||
if err := newSemanticHandler().Handle(ctx); err != nil {
|
||||
t.Fatalf("Handle() error = %v", err)
|
||||
}
|
||||
if want := []string{"--query", "hello"}; !reflect.DeepEqual(ctx.Args, want) {
|
||||
t.Fatalf("Args = %v, want %v", ctx.Args, want)
|
||||
}
|
||||
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Kind != "semantic" || ctx.Corrections[0].Corrected != "--query" {
|
||||
t.Fatalf("correction = %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticAliasHandlerNameAndPhase(t *testing.T) {
|
||||
h := SemanticAliasHandler{}
|
||||
if h.Name() != "semantic-alias" || h.Phase() != pipeline.PreParse {
|
||||
t.Fatalf("handler identity = %q / %s", h.Name(), h.Phase())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticAliasHandlerPreservesEqualsValueSyntax(t *testing.T) {
|
||||
ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--pageSize=50"}}
|
||||
if err := newSemanticHandler().Handle(ctx); err != nil {
|
||||
t.Fatalf("Handle() error = %v", err)
|
||||
}
|
||||
// --pageSize morphs to page-size, which the table aliases to limit.
|
||||
if want := []string{"--limit=50"}; !reflect.DeepEqual(ctx.Args, want) {
|
||||
t.Fatalf("Args = %v, want %v", ctx.Args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticAliasHandlerLeavesBlockedAndAmbiguous(t *testing.T) {
|
||||
ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--count", "10", "--user-id", "u1"}}
|
||||
if err := newSemanticHandler().Handle(ctx); err != nil {
|
||||
t.Fatalf("Handle() error = %v", err)
|
||||
}
|
||||
if want := []string{"--count", "10", "--user-id", "u1"}; !reflect.DeepEqual(ctx.Args, want) {
|
||||
t.Fatalf("blocked/ambiguous names must not be rewritten: Args = %v, want %v", ctx.Args, want)
|
||||
}
|
||||
if len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("no corrections expected, got %#v", ctx.Corrections)
|
||||
}
|
||||
if ctx.ProtectedFlags["count"] != pipeline.FlagProtectionBlocked || ctx.ProtectedFlags["user-id"] != pipeline.FlagProtectionAmbiguous {
|
||||
t.Fatalf("protections = %#v", ctx.ProtectedFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticAliasHandlerProtectsWithoutAliases(t *testing.T) {
|
||||
h := SemanticAliasHandler{Lookup: fakeLookup(nil, []string{"limt"}, nil)}
|
||||
ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--limt", "10"}}
|
||||
if err := h.Handle(ctx); err != nil {
|
||||
t.Fatalf("Handle() error = %v", err)
|
||||
}
|
||||
if !ctx.IsFlagProtected("limt") {
|
||||
t.Fatalf("blocked-only entry did not populate pipeline protection: %#v", ctx.ProtectedFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticAliasHandlerRejectsMixedAliasAndCanonical(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"--keyword", "one", "--query", "two"},
|
||||
{"--query=two", "--keyword=one"},
|
||||
} {
|
||||
ctx := &pipeline.Context{Command: "dws demo cmd", Args: args}
|
||||
err := newSemanticHandler().Handle(ctx)
|
||||
var conflict *pipeline.FlagConflictError
|
||||
if !errors.As(err, &conflict) {
|
||||
t.Fatalf("Handle(%v) error = %v, want FlagConflictError", args, err)
|
||||
}
|
||||
if conflict.Canonical != "query" || !reflect.DeepEqual(conflict.Spellings, []string{"keyword", "query"}) {
|
||||
t.Fatalf("conflict = %#v", conflict)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticAliasHandlerStopsAtDoubleDash(t *testing.T) {
|
||||
ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--query", "one", "--", "--keyword", "two"}}
|
||||
if err := newSemanticHandler().Handle(ctx); err != nil {
|
||||
t.Fatalf("Handle() error = %v", err)
|
||||
}
|
||||
if want := []string{"--query", "one", "--", "--keyword", "two"}; !reflect.DeepEqual(ctx.Args, want) {
|
||||
t.Fatalf("Args = %v, want %v", ctx.Args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSemanticAliasHandlerNoOpCases(t *testing.T) {
|
||||
h := newSemanticHandler()
|
||||
|
||||
// Unknown command → Lookup returns ok=false.
|
||||
ctx := &pipeline.Context{Command: "dws other", Args: []string{"--keyword", "x"}}
|
||||
_ = h.Handle(ctx)
|
||||
if !reflect.DeepEqual(ctx.Args, []string{"--keyword", "x"}) || len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("unknown command must be a no-op: %v / %#v", ctx.Args, ctx.Corrections)
|
||||
}
|
||||
|
||||
// Empty command path.
|
||||
ctx = &pipeline.Context{Command: "", Args: []string{"--keyword", "x"}}
|
||||
_ = h.Handle(ctx)
|
||||
if !reflect.DeepEqual(ctx.Args, []string{"--keyword", "x"}) {
|
||||
t.Fatalf("empty command must be a no-op: %v", ctx.Args)
|
||||
}
|
||||
|
||||
// Nil Lookup (handler not wired).
|
||||
ctx = &pipeline.Context{Command: "dws demo cmd", Args: []string{"--keyword", "x"}}
|
||||
_ = SemanticAliasHandler{}.Handle(ctx)
|
||||
if !reflect.DeepEqual(ctx.Args, []string{"--keyword", "x"}) {
|
||||
t.Fatalf("nil Lookup must be a no-op: %v", ctx.Args)
|
||||
}
|
||||
|
||||
// A real flag that also appears nowhere in the table is left alone.
|
||||
ctx = &pipeline.Context{Command: "dws demo cmd", Args: []string{"--query", "x", "--unknown", "y", "positional", "-n"}}
|
||||
_ = h.Handle(ctx)
|
||||
if !reflect.DeepEqual(ctx.Args, []string{"--query", "x", "--unknown", "y", "positional", "-n"}) {
|
||||
t.Fatalf("canonical/positional/short tokens must be untouched: %v", ctx.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitFlagToken(t *testing.T) {
|
||||
cases := []struct {
|
||||
arg string
|
||||
bare string
|
||||
suffix string
|
||||
isFlag bool
|
||||
}{
|
||||
{"--query", "query", "", true},
|
||||
{"--limit=50", "limit", "=50", true},
|
||||
{"--pageSize", "pageSize", "", true},
|
||||
{"positional", "", "", false},
|
||||
{"-n", "", "", false},
|
||||
{"--", "", "", false},
|
||||
{"--=v", "", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
bare, suffix, isFlag := splitFlagToken(c.arg)
|
||||
if bare != c.bare || suffix != c.suffix || isFlag != c.isFlag {
|
||||
t.Fatalf("splitFlagToken(%q) = (%q,%q,%v), want (%q,%q,%v)",
|
||||
c.arg, bare, suffix, isFlag, c.bare, c.suffix, c.isFlag)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,15 +14,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
// StickyHandler detects glued flag-value pairs in raw argv and splits
|
||||
// them into separate tokens. For example, "--limit100" becomes
|
||||
// "--limit", "100" when "limit" is a known flag name.
|
||||
// StickyHandler detects glued flag-value pairs in raw argv and normalizes
|
||||
// them. For example, "--limit100" becomes "--limit", "100" while a boolean
|
||||
// such as "--verbosefalse" becomes the single safe token "--verbose=false".
|
||||
//
|
||||
// The handler only operates on tokens that start with "--" and do not
|
||||
// contain "=". It tries to match the longest known flag name prefix
|
||||
@@ -45,11 +43,25 @@ func (StickyHandler) Handle(ctx *pipeline.Context) error {
|
||||
specByName := buildFlagSpecIndex(ctx.FlagSpecs)
|
||||
result := make([]string, 0, len(ctx.Args))
|
||||
|
||||
for _, arg := range ctx.Args {
|
||||
for i, arg := range ctx.Args {
|
||||
if arg == "--" {
|
||||
result = append(result, ctx.Args[i:]...)
|
||||
break
|
||||
}
|
||||
if bare, _, isFlag := splitFlagToken(arg); isFlag && ctx.IsFlagProtected(cmdutil.Morph(bare)) {
|
||||
result = append(result, arg)
|
||||
continue
|
||||
}
|
||||
split, ok := trySplitSticky(arg, specByName)
|
||||
if ok {
|
||||
ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, split.flag+" "+split.value, "sticky")
|
||||
result = append(result, split.flag, split.value)
|
||||
if split.inline {
|
||||
corrected := split.flag + "=" + split.value
|
||||
ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, corrected, "sticky")
|
||||
result = append(result, corrected)
|
||||
} else {
|
||||
ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, split.flag+" "+split.value, "sticky")
|
||||
result = append(result, split.flag, split.value)
|
||||
}
|
||||
} else {
|
||||
result = append(result, arg)
|
||||
}
|
||||
@@ -60,8 +72,9 @@ func (StickyHandler) Handle(ctx *pipeline.Context) error {
|
||||
}
|
||||
|
||||
type stickyPair struct {
|
||||
flag string
|
||||
value string
|
||||
flag string
|
||||
value string
|
||||
inline bool
|
||||
}
|
||||
|
||||
// trySplitSticky checks if arg looks like a glued flag-value (e.g.
|
||||
@@ -79,69 +92,8 @@ type stickyPair struct {
|
||||
// that camelCase+glued values like "--pageSize50" are correctly
|
||||
// split to "--page-size", "50".
|
||||
func trySplitSticky(arg string, specByName map[string]pipeline.FlagInfo) (stickyPair, bool) {
|
||||
if !strings.HasPrefix(arg, "--") || strings.Contains(arg, "=") {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
// Strip "--" prefix to work with the bare token.
|
||||
bare := arg[2:]
|
||||
if bare == "" {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
// If the whole token is a known flag, it is not sticky — it is
|
||||
// a normal flag expecting a separate value token.
|
||||
if _, ok := specByName[bare]; ok {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
if _, ok := specByName[toKebabCase(bare)]; ok {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
// Try longest-prefix match: walk from len-1 down to 1, looking
|
||||
// for the longest known flag that is a prefix of bare. For each
|
||||
// candidate prefix, try both the raw form and kebab-case form.
|
||||
bestLen := 0
|
||||
bestFlag := ""
|
||||
for i := len(bare) - 1; i >= 1; i-- {
|
||||
prefix := bare[:i]
|
||||
|
||||
matchedFlag := ""
|
||||
if _, ok := specByName[prefix]; ok {
|
||||
matchedFlag = prefix
|
||||
} else {
|
||||
kebab := toKebabCase(prefix)
|
||||
if kebab != "" {
|
||||
if _, ok := specByName[kebab]; ok {
|
||||
matchedFlag = kebab
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matchedFlag != "" && i > bestLen {
|
||||
bestLen = i
|
||||
bestFlag = matchedFlag
|
||||
break // longest first since we walk from the end
|
||||
}
|
||||
}
|
||||
if bestFlag == "" {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
suffix := bare[bestLen:]
|
||||
// Guard: only split if the suffix plausibly looks like a value
|
||||
// for this flag's declared type/format/enum. Otherwise leave the
|
||||
// token untouched so Cobra reports "unknown flag" instead of
|
||||
// silently corrupting the value.
|
||||
fi := specByName[bestFlag]
|
||||
if !cmdutil.SuffixLooksLikeValue(suffix, fi.Type, fi.Format, fi.Enum) {
|
||||
return stickyPair{}, false
|
||||
}
|
||||
|
||||
return stickyPair{
|
||||
flag: "--" + bestFlag,
|
||||
value: suffix,
|
||||
}, true
|
||||
pair, ok := pipeline.SplitStickyFlag(arg, specByName)
|
||||
return stickyPair{flag: pair.Flag, value: pair.Value, inline: pair.Inline}, ok
|
||||
}
|
||||
|
||||
// buildFlagSpecIndex creates an index of known flag names (without "--"
|
||||
|
||||
@@ -157,10 +157,24 @@ func TestStickyHandler(t *testing.T) {
|
||||
corrections: 0,
|
||||
},
|
||||
{
|
||||
name: "boolean-like value splits when type is bool",
|
||||
name: "boolean-like value normalizes inline when type is bool",
|
||||
args: []string{"--verbosetrue"},
|
||||
flags: specs(flagSpec{name: "verbose", typ: "bool"}),
|
||||
want: "--verbose true",
|
||||
want: "--verbose=true",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "confirmation false normalizes to an inline false value",
|
||||
args: []string{"--yesfalse"},
|
||||
flags: specs(flagSpec{name: "yes", typ: "bool"}),
|
||||
want: "--yes=false",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
name: "model-friendly boolean no normalizes to inline false",
|
||||
args: []string{"--yesno"},
|
||||
flags: specs(flagSpec{name: "yes", typ: "bool"}),
|
||||
want: "--yes=false",
|
||||
corrections: 1,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,6 +13,12 @@
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Phase represents a named stage in the CLI execution pipeline.
|
||||
// Handlers are grouped by phase and executed in chain order within
|
||||
// each phase. Phases themselves execute in a fixed order defined
|
||||
@@ -77,8 +83,11 @@ type Context struct {
|
||||
// PreParse handlers may rewrite this in place.
|
||||
Args []string
|
||||
|
||||
// Command is the resolved product.tool canonical path
|
||||
// (available from PostParse onward).
|
||||
// Command identifies the resolved command. RunPreParse fills it with
|
||||
// Cobra's raw CommandPath() (e.g. "dws chat message send-by-bot") so
|
||||
// PreParse handlers can key per-command tables; the PostParse pipeline
|
||||
// fills it with the resolved product.tool canonical path. The two phases
|
||||
// use independent Context instances, so the differing forms never mix.
|
||||
Command string
|
||||
|
||||
// Params holds structured key→value parameters after Cobra
|
||||
@@ -105,11 +114,82 @@ type Context struct {
|
||||
// handlers use this to match against raw argv tokens.
|
||||
FlagSpecs []FlagInfo
|
||||
|
||||
// ProtectedFlags carries reviewed semantic guard decisions across the
|
||||
// complete PreParse chain. Keys are morphed flag names. Sticky and fuzzy
|
||||
// handlers must not reinterpret a name classified as blocked or ambiguous
|
||||
// by the semantic alias table.
|
||||
ProtectedFlags map[string]FlagProtection
|
||||
|
||||
// Corrections records every correction applied by handlers,
|
||||
// enabling downstream logging and debugging.
|
||||
Corrections []Correction
|
||||
}
|
||||
|
||||
// FlagProtection identifies why an emitted flag name must not be automatically
|
||||
// rewritten.
|
||||
type FlagProtection string
|
||||
|
||||
const (
|
||||
FlagProtectionBlocked FlagProtection = "blocked"
|
||||
FlagProtectionAmbiguous FlagProtection = "ambiguous"
|
||||
)
|
||||
|
||||
// ProtectFlag records a reviewed no-touch decision for the remainder of the
|
||||
// current pipeline context.
|
||||
func (c *Context) ProtectFlag(morphed string, protection FlagProtection) {
|
||||
if c == nil || morphed == "" {
|
||||
return
|
||||
}
|
||||
if c.ProtectedFlags == nil {
|
||||
c.ProtectedFlags = make(map[string]FlagProtection)
|
||||
}
|
||||
c.ProtectedFlags[morphed] = protection
|
||||
}
|
||||
|
||||
// IsFlagProtected reports whether a morphed flag name is guarded from further
|
||||
// automatic interpretation.
|
||||
func (c *Context) IsFlagProtected(morphed string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := c.ProtectedFlags[morphed]
|
||||
return ok
|
||||
}
|
||||
|
||||
// FlagConflictError is returned when multiple distinct spellings that reduce
|
||||
// to one scalar canonical flag are present in the same argv. Rejecting the
|
||||
// command makes the outcome independent of argument order.
|
||||
type FlagConflictError struct {
|
||||
Command string
|
||||
Canonical string
|
||||
Spellings []string
|
||||
}
|
||||
|
||||
func (e *FlagConflictError) Error() string {
|
||||
spellings := append([]string(nil), e.Spellings...)
|
||||
sort.Strings(spellings)
|
||||
for i := range spellings {
|
||||
spellings[i] = "--" + strings.TrimPrefix(spellings[i], "--")
|
||||
}
|
||||
return fmt.Sprintf("conflicting parameter spellings for --%s on %q: %s; pass exactly one spelling", e.Canonical, e.Command, strings.Join(spellings, ", "))
|
||||
}
|
||||
|
||||
// BoolValueConflictError is returned when one canonical boolean flag receives
|
||||
// both true and false in the same argv. Rejecting contradictory values keeps
|
||||
// the outcome independent of argument order while allowing repeated identical
|
||||
// spellings to retain Cobra's native behaviour.
|
||||
type BoolValueConflictError struct {
|
||||
Command string
|
||||
Flag string
|
||||
Values []string
|
||||
}
|
||||
|
||||
func (e *BoolValueConflictError) Error() string {
|
||||
values := append([]string(nil), e.Values...)
|
||||
sort.Strings(values)
|
||||
return fmt.Sprintf("conflicting boolean values for --%s on %q: %s; pass exactly one value", strings.TrimPrefix(e.Flag, "--"), e.Command, strings.Join(values, ", "))
|
||||
}
|
||||
|
||||
// FlagInfo describes a single CLI flag derived from a tool's input
|
||||
// schema. PreParse handlers use this to recognise valid flag names
|
||||
// when performing fuzzy matching or alias resolution.
|
||||
@@ -117,6 +197,11 @@ type FlagInfo struct {
|
||||
// Name is the canonical kebab-case flag name (e.g. "user-id").
|
||||
Name string
|
||||
|
||||
// Shorthand is the optional single-character pflag shorthand (e.g. "y"
|
||||
// for --yes). PreParse uses exact shorthand tokens when normalising
|
||||
// explicit boolean values; shorthand clusters retain native pflag syntax.
|
||||
Shorthand string
|
||||
|
||||
// PropertyName is the original schema property key (e.g. "userId").
|
||||
PropertyName string
|
||||
|
||||
|
||||
@@ -62,13 +62,18 @@ var ConversationSetTop = shortcut.Shortcut{
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
|
||||
{Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId(最多 10 个)"},
|
||||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
|
||||
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
|
||||
{Name: "chat-ids", Type: shortcut.FlagStringSlice, Desc: "--conversation-ids 的兼容别名", Hidden: true},
|
||||
{Name: "off", Type: shortcut.FlagBool, Desc: "取消置顶(不传则设置置顶)"},
|
||||
{Name: "top", Type: shortcut.FlagBool, Default: "true", Desc: "置顶状态兼容参数", Hidden: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"conversation-id", "conversation-ids"}},
|
||||
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"conversation-id", "conversation-ids", "open-conversation-id", "chat-id", "chat-ids"}},
|
||||
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"off", "top"}},
|
||||
{
|
||||
Kind: shortcut.ConstraintCustom,
|
||||
Flags: []string{"conversation-id", "conversation-ids"},
|
||||
Flags: []string{"conversation-id", "conversation-ids", "open-conversation-id", "chat-id", "chat-ids"},
|
||||
Description: "会话 ID 去重后必须为 1-10 个",
|
||||
},
|
||||
},
|
||||
@@ -85,6 +90,10 @@ var ConversationSetTop = shortcut.Shortcut{
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
ids := conversationSetTopIDs(rt)
|
||||
top := !rt.Bool("off")
|
||||
if rt.Changed("top") {
|
||||
top = rt.Bool("top")
|
||||
}
|
||||
items := make([]shortcutBatchWrite, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
items = append(items, shortcutBatchWrite{
|
||||
@@ -92,7 +101,7 @@ var ConversationSetTop = shortcut.Shortcut{
|
||||
arguments: map[string]any{
|
||||
"openConversationId": id,
|
||||
"cid": id,
|
||||
"top": !rt.Bool("off"),
|
||||
"top": top,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -102,8 +111,11 @@ var ConversationSetTop = shortcut.Shortcut{
|
||||
|
||||
func conversationSetTopIDs(rt *shortcut.RuntimeContext) []string {
|
||||
values := append([]string{}, rt.StrSlice("conversation-ids")...)
|
||||
if value := rt.Str("conversation-id"); value != "" {
|
||||
values = append(values, value)
|
||||
values = append(values, rt.StrSlice("chat-ids")...)
|
||||
for _, name := range []string{"conversation-id", "open-conversation-id", "chat-id"} {
|
||||
if value := rt.Str(name); value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
return uniqueShortcutStrings(values)
|
||||
}
|
||||
|
||||
@@ -69,15 +69,25 @@ var ChatMembersGet = shortcut.Shortcut{
|
||||
Intent: "当你已有若干成员的 openDingTalkId、需要批量获取他们在该群内的详情(群昵称、角色等)时使用;只读,需传群 openConversationId 和成员 openDingTalkId 列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 openDingTalkId 列表", Required: true},
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
|
||||
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
|
||||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
|
||||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 openDingTalkId 列表(必填)"},
|
||||
{Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "--users 的兼容别名", Hidden: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "group", "chat-id", "conversation-id", "open-conversation-id"}},
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"users", "open-dingtalk-ids"}},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-members-get --id <openConversationId> --users odid1,odid2`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
conversationID := rt.StrFirst("id", "group", "chat-id", "conversation-id", "open-conversation-id")
|
||||
return rt.CallMCP("list_group_member_by_ids", map[string]any{
|
||||
"openConversationId": rt.Str("id"),
|
||||
"cid": rt.Str("id"),
|
||||
"memberOpenDingTalkIds": rt.StrSlice("users"),
|
||||
"openConversationId": conversationID,
|
||||
"cid": conversationID,
|
||||
"memberOpenDingTalkIds": rt.StrSliceFirst("users", "open-dingtalk-ids"),
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -122,14 +132,22 @@ var ChatInviteURL = shortcut.Shortcut{
|
||||
Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"},
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
|
||||
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
|
||||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
|
||||
{Name: "expires-seconds", Type: shortcut.FlagInt, Desc: "链接有效期(秒),0 表示永久"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "id", "chat-id", "conversation-id", "open-conversation-id"}},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-invite-url --group <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
conversationID := rt.StrFirst("group", "id", "chat-id", "conversation-id", "open-conversation-id")
|
||||
params := map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"cid": rt.Str("group"),
|
||||
"openConversationId": conversationID,
|
||||
"cid": conversationID,
|
||||
}
|
||||
if rt.Changed("expires-seconds") {
|
||||
params["expiresSeconds"] = rt.Int("expires-seconds")
|
||||
@@ -552,11 +570,20 @@ var ChatBots = shortcut.Shortcut{
|
||||
Intent: "当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"},
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
|
||||
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
|
||||
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "id", "chat-id", "conversation-id", "open-conversation-id"}},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-bots --group <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": rt.Str("group")})
|
||||
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{
|
||||
"openConversationId": rt.StrFirst("group", "id", "chat-id", "conversation-id", "open-conversation-id"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user