Compare commits

...
Author SHA1 Message Date
chichuan 3519965285 ci: increase integration test timeouts 2026-07-30 20:52:18 +08:00
chichuan a7074bf53f Merge pull request #838 from DingTalk-Real-AI/codex/fix-scoped-coverage-timeout
fix: align scoped coverage and test timeout
2026-07-30 20:20:01 +08:00
chichuan 21144af79b fix: align scoped coverage and test timeout 2026-07-30 20:06:24 +08:00
github-actions[bot] 3bdc30badb Merge pull request #834 from wxianfeng/fix/event-subscription-retry-storm
fix(event): prevent subscription retry storms
2026-07-30 17:18:27 +08:00
wxianfeng b82e975429 test(app): preserve audit sink ownership in coverage gate 2026-07-30 16:25:56 +08:00
wxianfeng 2808e71cb6 fix(event): address retry storm review 2026-07-30 16:08:19 +08:00
wxianfeng ec99654854 fix(event): prevent subscription retry storms 2026-07-30 13:49:08 +08:00
github-actions[bot] 9aa76ea748 Merge pull request #806 from DingTalk-Real-AI/fix/param-hallucination
feat(param): 参数概念归一化治理与 IM 场景完善
2026-07-30 04:00:22 +00:00
克谨 885c3fe021 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-30 11:46:37 +08:00
github-actions[bot] d0d56cbaf5 Merge pull request #817 from DingTalk-Real-AI/codex/im-shortcut-gap-fill
feat(im): close shortcut capability gaps
2026-07-30 11:42:23 +08:00
chichuan 9dbbd64f3c Merge branch 'main' into codex/im-shortcut-gap-fill 2026-07-30 11:31:19 +08:00
github-actions[bot] 7ba12a8e4c chore: update formula for v1.0.55 [skip ci] 2026-07-30 03:11:41 +00:00
克谨 dfba9546f4 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-30 11:06:02 +08:00
克谨 b3ba9fee97 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-30 10:43:51 +08:00
Dennis 41372b0597 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-30 10:32:58 +08:00
Dennis cffc48406c fix(im): resolve direct recipients via contact search 2026-07-30 10:29:39 +08:00
Dennis f9e3476d42 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-30 10:02:34 +08:00
克谨 1e04e301ea Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-30 09:44:52 +08:00
克谨 5f038d440b test: reduce parameter alias race runtime 2026-07-30 09:44:30 +08:00
Dennis 2b48f27a4b fix(im): harden shortcut review follow-ups 2026-07-29 23:35:52 +08:00
Dennis bf79a67efe fix(im): close shortcut review gaps 2026-07-29 21:25:24 +08:00
克谨 8936c20ef0 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-29 20:07:03 +08:00
Dennis 95d262bbb2 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-29 19:32:18 +08:00
Dennis d5c260c7c0 fix(im): address shortcut review regressions 2026-07-29 19:31:48 +08:00
Dennis 9c297d0520 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill 2026-07-29 18:02:49 +08:00
Dennis 37230d2d4d chore(schema): refresh shortcut skill source hashes 2026-07-29 18:01:54 +08:00
Dennis fde6b59074 Merge remote-tracking branch 'origin/main' into codex/im-shortcut-gap-fill
# Conflicts:
#	internal/app/schema_shortcut_contract_test.go
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
#	internal/cli/schema_command_registry/products/chat.json
#	internal/cli/schema_hints/runtime-surface-completeness.json
#	skills/multi/dingtalk-chat/SKILL.md
#	skills/multi/dingtalk-chat/references/chat.md
2026-07-29 17:51:27 +08:00
克谨 e2abc70e84 Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-29 17:35:07 +08:00
克谨 15a27a9f83 fix(cli): harden parameter preparse normalization 2026-07-29 17:33:44 +08:00
wxianfeng 4567dd1cd6 fix(im): gate optional resource downloads at runtime 2026-07-29 15:33:01 +08:00
Dennis 75bb01bb64 docs(skill): align IM shortcut routing 2026-07-29 14:45:28 +08:00
Dennis 11a7ab8b7c fix(im): harden shortcut downloads and message context 2026-07-29 14:20:39 +08:00
克谨 2e1cce8501 test(param): align category alias fixtures with title limits 2026-07-29 13:34:59 +08:00
Dennis 41e0fb381a feat(im): close shortcut capability gaps 2026-07-29 13:29:53 +08:00
克谨 870fba823b Merge remote-tracking branch 'origin/main' into fix/param-hallucination 2026-07-29 13:18:47 +08:00
克谨 d083de5f84 fix(cli): normalize explicit boolean flag values safely 2026-07-29 13:18:27 +08:00
克谨 1fb966dbff fix(cli): centralize parameter alias generation entrypoint 2026-07-29 13:17:55 +08:00
克谨 7987fb3a35 chore(ci): retrigger pull request checks 2026-07-29 10:02:28 +08:00
克谨 3d6a9c6232 test(pipeline): cover shared flag matchers 2026-07-29 10:02:28 +08:00
克谨 195569ddfa fix(cli): harden parameter preparse integration 2026-07-29 10:02:28 +08:00
克谨 7827856876 fix(param): align aliases and bound exhaustive tests 2026-07-29 10:02:28 +08:00
克谨 f79f41e930 chore(param): exclude normalization specs from review 2026-07-29 10:02:27 +08:00
克谨 bfd53df9b2 feat(param): expand reviewed IM parameter normalization 2026-07-29 10:02:27 +08:00
克谨 4db117893e fix(param): freeze reviewed normalization baseline
Restore calendar helper behavior to main, finalize reviewed alias/guard decisions, cover payload and dry-run paths, and record the local migration freeze checkpoint.
2026-07-29 10:02:27 +08:00
克谨 b314749ef7 test(param): cover final alias payloads and guard errors 2026-07-29 10:02:27 +08:00
克谨 5133103a54 fix(param): harden command-scoped normalization safety 2026-07-29 10:02:27 +08:00
克谨 9faa332306 chore: ignore stray compiled param-aliases generator binary 2026-07-29 10:02:27 +08:00
克谨 17fa1e1b34 refactor(calendar): read canonical flags in event list after normalization
Now that alias spellings are normalized to canonical flags in the PreParse
pipeline, drop the redundant flagOrFallback tails in the event-list handler and
read --start/--end/--calendar-id/--cursor/--limit directly (keeping --count as a
deliberately separate flag). Behaviour is unchanged; the pilot test guards it.
2026-07-29 10:01:53 +08:00
克谨 af1f8ccd05 test(param): fixture regression through delivery path + co-occurrence gate
Add the ⑥ regression gate that replays every reviewed validation_fixture bad case
through the real embedded PreParse pipeline and asserts the canonical outcome
(accepting either semantic rewrite or native real-flag acceptance, failing only
on a genuine unknown-flag hallucination). Add check-param-concepts.sh (dictionary
schema/loader invariants) and check-param-alias-cooccurrence.sh (full-tree
co-occurrence scan), and wire all three into make policy.
2026-07-29 10:01:53 +08:00
克谨 c26cbbbbb8 feat(param): wire semantic alias table into PreParse; pilot calendar event list
Unify runtime morphology on pkg/cmdutil.Morph (same function the generator uses),
add a SemanticAliasHandler that looks up the embedded generated table after
morphological normalization and rewrites synonyms to the command's canonical flag
(leaving blocked/ambiguous synonyms untouched for the did-you-mean path), and
thread the command CLIPath through the pipeline Context. Pilot the mechanism on
'calendar event list' by removing its hand-written hidden spelling variants; a
behaviour-preservation test locks the outcome.
2026-07-29 10:01:53 +08:00
克谨 2733f510af feat(param): generate per-command alias table from concepts
Add internal/generator/cmd_param_aliases: reads the reviewed dictionary plus the
live Cobra tree, reduces each concept against a command's real flags (>=2 visible
real flags without a reviewed ambiguous entry fails generation), and emits the
committed internal/cli/param_aliases_generated.go table with lookup helpers.
Extend generate-schema and check-generated-drift.sh to treat the dictionary as a
reviewed input and byte-guard the generated table.
2026-07-29 10:01:53 +08:00
克谨 abc62622fb feat(param): add reviewed param-concept dictionary, closed schema, and loader
Introduce internal/cli/param_concepts.json as the single reviewed source of
parameter-normalization concepts and per-command overrides, guarded by a closed
JSON schema and a go:embed loader with contract tests. Add the design spec.
2026-07-29 10:00:41 +08:00
126 changed files with 28725 additions and 1552 deletions
+9 -3
View File
@@ -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"
+1
View File
@@ -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
+3
View File
@@ -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
+6 -5
View File
@@ -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`,
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.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
+24 -1
View File
@@ -87,6 +87,9 @@ policy: test-auth-legacy-compat
@$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.sh
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
@$(POLICY_ENV) ./scripts/policy/check-param-alias-cooccurrence.sh
@$(POLICY_ENV) $(GO) test -count=1 ./internal/app -run '^(TestParamAlias(FixtureThroughEmbeddedDeliveryPath|ReadCommandFinalPayload|WriteCommandFinalPayload|CanonicalConflictFailsBeforeRunE|BlockedFlagReachesReviewedFinalError)|TestFlagConflictErrorFormattingIsDeterministic)$$'
@$(POLICY_ENV) ./scripts/policy/check-schema-catalog.sh
@$(POLICY_ENV) ./scripts/policy/check-schema-binary.sh
@$(POLICY_ENV) $(MAKE) test-schema-agent-examples
@@ -130,10 +133,14 @@ test-schema-agent-examples:
generate-schema:
@set -e; \
registry_guard=$$(mktemp -d); \
concepts_guard=$$(mktemp); \
concepts_schema_guard=$$(mktemp); \
metadata_guard=$$(mktemp -d); \
selection_guard=$$(mktemp -d); \
trap 'rm -rf "$$registry_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
trap 'rm -rf "$$registry_guard" "$$concepts_guard" "$$concepts_schema_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
cp -R internal/cli/schema_command_registry/ "$$registry_guard/"; \
cp internal/cli/param_concepts.json "$$concepts_guard"; \
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
cp -R internal/cli/schema_hints/metadata/. "$$metadata_guard/"; \
cp -R internal/cli/schema_hints/selection/. "$$selection_guard/"; \
$(GO) generate ./internal/cli; \
@@ -141,6 +148,22 @@ generate-schema:
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry/' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
exit 1; \
}; \
cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
exit 1; \
}; \
diff -qr internal/cli/schema_hints/metadata "$$metadata_guard" >/dev/null || { \
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/metadata' >&2; \
exit 1; \
+68 -1
View File
@@ -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
+8
View File
@@ -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()
})
+228
View File
@@ -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 := &paramAliasCaptureCaller{}
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
}
+4 -3
View File
@@ -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 {
+1 -1
View File
@@ -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{
+552
View File
@@ -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
+229 -113
View File
@@ -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 }
+224 -2
View File
@@ -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
}
+62
View File
@@ -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)
}
}
+826
View File
@@ -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 := &paramAliasDryRunRejectRunner{}
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 &paramAliasCaptureRunner{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 := &paramAliasCaptureCaller{}
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 := &paramAliasCaptureCaller{}
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 := &paramAliasCaptureCaller{}
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 := &paramAliasCaptureCaller{}
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 := &paramAliasCaptureCaller{}
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 := &paramAliasCaptureCaller{}
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 := &paramAliasCaptureCaller{}
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)
}
}
+220
View File
@@ -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 := &paramAliasCaptureCaller{}
_, 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 := &paramAliasCaptureCaller{}
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 := &paramAliasCaptureCaller{}
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 := &paramAliasCaptureCaller{}
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)
}
}
+100
View File
@@ -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)
}
}
+111 -6
View File
@@ -76,6 +76,7 @@ var (
rootPluginLoadHooks = (*plugin.Plugin).LoadHooks
rootPluginSyncSkills = plugin.SyncSkills
rootAuthLoadTokenData = authpkg.LoadTokenData
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
)
// Execute runs the root command and returns the process exit code.
@@ -114,7 +115,11 @@ func Execute() (exitCode int) {
// Run PreParse handlers on raw argv before Cobra parses flags.
// This corrects model-generated errors like --userId → --user-id
// and --limit100 → --limit 100.
rootRunPreParse(root, engine)
if err := rootRunPreParse(root, engine); err != nil {
err = newPreParseValidationError(err)
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
return apperrors.ExitCode(err)
}
executed, err := rootExecuteCommand(root)
if err != nil {
@@ -136,6 +141,22 @@ func Execute() (exitCode int) {
return 0
}
// newPreParseValidationError keeps pipeline handler identity in internal logs
// while exposing only the underlying parameter-domain error to CLI users.
func newPreParseValidationError(err error) error {
userErr := err
var handlerErr *pipeline.HandlerError
if stderrors.As(err, &handlerErr) && handlerErr.Unwrap() != nil {
userErr = handlerErr.Unwrap()
}
return apperrors.NewValidation(
userErr.Error(),
apperrors.WithReason("parameter_conflict"),
apperrors.WithHint("Remove the duplicate alias/canonical spelling and pass the parameter exactly once."),
apperrors.WithCause(userErr),
)
}
func isUnknownCommandError(err error) bool {
return err != nil && strings.Contains(err.Error(), "unknown command")
}
@@ -183,6 +204,22 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
// 无论哪种格式,子串 "--help' for usage." 都可被检索到。
tail := fmt.Sprintf("\nSee '%s --help' for usage.", cmd.CommandPath())
msgWithTail := errMsg + tail
if flag, protection, ok := reviewedFlagProtection(cmd, errMsg); ok {
hint := fmt.Sprintf("Parameter --%s is blocked from automatic normalization on %q; choose an explicit flag from --help.", flag, cmd.CommandPath())
reason := "blocked_flag"
if protection == pipeline.FlagProtectionAmbiguous {
hint = fmt.Sprintf("Parameter --%s is ambiguous on %q and cannot be normalized safely; choose the intended explicit flag from --help.", flag, cmd.CommandPath())
reason = "ambiguous_flag"
}
return apperrors.NewValidation(
msgWithTail,
apperrors.WithHint(hint),
apperrors.WithReason(reason),
apperrors.WithCause(err),
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
)
}
// Common flag aliases and suggestions
suggestions := map[string]string{
@@ -231,6 +268,33 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
return fmt.Errorf("%s%s", errMsg, tail)
}
func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline.FlagProtection, bool) {
if cmd == nil {
return "", "", false
}
const prefix = "unknown flag: --"
idx := strings.Index(errMsg, prefix)
if idx < 0 {
return "", "", false
}
flag := strings.TrimSpace(errMsg[idx+len(prefix):])
if i := strings.IndexAny(flag, " =\n\t"); i >= 0 {
flag = flag[:i]
}
entry, ok := cli.LookupParamAlias(cmd.CommandPath())
if !ok {
return "", "", false
}
morphed := cmdutil.Morph(flag)
if entry.IsBlocked(morphed) {
return flag, pipeline.FlagProtectionBlocked, true
}
if entry.IsAmbiguous(morphed) {
return flag, pipeline.FlagProtectionAmbiguous, true
}
return "", "", false
}
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
var raw apperrors.RawStderrError
if stderrors.As(err, &raw) {
@@ -339,7 +403,7 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
loader := cli.EnvironmentLoader{
LookupEnv: os.LookupEnv,
}
runner := newCommandRunnerWithFlags(loader, flags)
runner := rootNewCommandRunnerWithFlags(loader, flags)
root := &cobra.Command{
Use: "dws",
@@ -453,11 +517,38 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
configureRootHelp(root)
// Set custom flag error handler for better UX
root.SetFlagErrorFunc(flagErrorWithSuggestions)
installReviewedFlagProtectionHandlers(root)
root.SetContext(rootCtx)
return root
}
// installReviewedFlagProtectionHandlers makes reviewed blocked/ambiguous
// parameters authoritative even when an older command subtree has installed a
// local FlagErrorFunc. Commands without a reviewed guard keep their existing
// handler or inherit the root handler as before.
func installReviewedFlagProtectionHandlers(root *cobra.Command) {
if root == nil {
return
}
var visit func(*cobra.Command)
visit = func(cmd *cobra.Command) {
if entry, ok := cli.LookupParamAlias(cmd.CommandPath()); ok && (len(entry.Blocked) > 0 || len(entry.Ambiguous) > 0) {
previous := cmd.FlagErrorFunc()
cmd.SetFlagErrorFunc(func(current *cobra.Command, err error) error {
if _, _, guarded := reviewedFlagProtection(current, err.Error()); guarded {
return flagErrorWithSuggestions(current, err)
}
return previous(current, err)
})
}
for _, child := range cmd.Commands() {
visit(child)
}
}
visit(root)
}
func preparseProfileFlag(args []string) string {
args, _ = normalizeProfileFlagArgs(args)
for i := 0; i < len(args); i++ {
@@ -1252,13 +1343,27 @@ func newPipelineEngine() *pipeline.Engine {
// Register handler runs during command tree building.
handlers.RegisterHandler{},
// PreParse handlers run in order: alias → sticky → paramname.
// Alias normalises case first (--userId → --user-id), then
// sticky splits glued values (--limit100 → --limit 100), then
// paramname fixes near-miss typos (--limt → --limit).
// PreParse handlers run in order: alias → semantic → sticky → paramname
// → boolvalue.
// Alias normalises case first (--userId → --user-id), then semantic
// resolves reviewed synonyms to the real flag (--keyword → --query),
// then sticky splits glued values (--limit100 → --limit 100), then
// paramname fixes near-miss typos (--limt → --limit). Boolvalue runs
// last so detached values for every real boolean flag (for example
// `--dry-run false`) become explicit `--flag=false` tokens before pflag
// can interpret the bare flag as true.
handlers.AliasHandler{},
handlers.SemanticAliasHandler{
// Inject the build-time reduced alias table with native types so
// the handler package stays decoupled from cli.
Lookup: func(rawCommandPath string) (map[string]string, []string, []string, bool) {
e, ok := cli.LookupParamAlias(rawCommandPath)
return e.Aliases, e.Blocked, e.Ambiguous, ok
},
},
handlers.StickyHandler{},
handlers.ParamNameHandler{},
handlers.BoolValueHandler{},
// PostParse handlers normalise structured values.
handlers.ParamValueHandler{},
+7 -1
View File
@@ -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 }
@@ -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)), 129; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
@@ -116,8 +116,8 @@ func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
shortcutCount++
}
}
if shortcutCount != 42 {
t.Fatalf("schema chat shortcut summaries = %d, want 42", shortcutCount)
if shortcutCount != 47 {
t.Fatalf("schema chat shortcut summaries = %d, want 47", shortcutCount)
}
}
@@ -388,6 +388,7 @@ func shortcutCustomConstraintEvidence(description string) []string {
"绝对路径",
"..",
"最多 15 个字符",
"能力矩阵",
}
evidence := make([]string, 0, len(probes))
for _, probe := range probes {
+6 -3
View File
@@ -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)
}
}
}
+427
View File
@@ -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
+74
View File
@@ -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
}
+518
View File
@@ -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
}
}
}
}
+488
View File
@@ -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
}
+261
View File
@@ -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"}
]
}
}
+269
View File
@@ -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(&paramValidationFixtureSpec{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,17 +1,17 @@
{
"version": 1,
"source_hash": "sha256:eb6e44775a6e85f92ca31c90c876f67543ab928d8a8bda04f07a33dc802f7028",
"surface_hash": "sha256:29d4f5f43688cc01bc2277a4528b597de4fbab89a7b2f13fe2e56df85d1c66c4",
"source_hash": "sha256:7484b82d1ca793a6129acfaa192ff08fc0864d47a6e75ecd16d8e7fa32857e16",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"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,
"surface_tools": 845,
"tools_with_metadata": 845,
"tools_with_agent_summary": 845,
"tools_with_use_when": 845,
"tools_with_avoid_when": 845,
"tools_with_examples": 845,
"tools_with_interface_mode": 845,
"unmatched_skill_tools": 122,
"unreviewed_skill_tools": 11
},
File diff suppressed because it is too large Load Diff
+143 -13
View File
@@ -1,17 +1,17 @@
{
"version": 1,
"surface_hash": "sha256:29d4f5f43688cc01bc2277a4528b597de4fbab89a7b2f13fe2e56df85d1c66c4",
"source_hash": "sha256:c71b9bacfe2d73f5ad45151fada6fdc18bac69b266058a8b05647b6847c99835",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"source_hash": "sha256:ba691e70f1c232fc3378a743c3fcd34113960d30b46bffa7d1fc8b524115052c",
"catalog": {
"agent_metadata": {
"products_with_metadata": 26,
"source": "embedded-skill-metadata",
"source_hash": "sha256:eb6e44775a6e85f92ca31c90c876f67543ab928d8a8bda04f07a33dc802f7028",
"surface_hash": "sha256:29d4f5f43688cc01bc2277a4528b597de4fbab89a7b2f13fe2e56df85d1c66c4",
"source_hash": "sha256:7484b82d1ca793a6129acfaa192ff08fc0864d47a6e75ecd16d8e7fa32857e16",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"surface_products": 26,
"surface_tools": 840,
"tools_with_agent_summary": 840,
"tools_with_metadata": 840,
"surface_tools": 845,
"tools_with_agent_summary": 845,
"tools_with_metadata": 845,
"unmatched_skill_tools": 122,
"version": 1
},
@@ -7722,7 +7722,7 @@
"id": "chat",
"name": "群聊 / 消息 / 机器人",
"runtime": true,
"tool_count": 124,
"tool_count": 129,
"tools": [
{
"agent_metadata_source": "embedded-skill-metadata",
@@ -9800,7 +9800,7 @@
"cli_name": "+at-me",
"cli_path": "chat +at-me",
"confirmation": "not_required",
"description": "当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。",
"description": "当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。默认只读且不会发送、撤回或标记任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\n\n参数约束:\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
@@ -9811,7 +9811,7 @@
"risk": "low",
"title": "查最近 @我 的消息(自动算时间窗,投影发送人/时间/内容/会话)",
"use_when": [
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。"
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。默认只读且不会发送、撤回或标记任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
]
},
{
@@ -10152,6 +10152,32 @@
"当你想查看自己作为群主或管理员在管理哪些群时使用;只读分页返回,可用 --role OWNER/ADMIN 按角色过滤。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget"
],
"canonical_path": "chat.shortcut_chat_messages",
"cli_name": "+chat-messages",
"cli_path": "chat +chat-messages",
"confirmation": "not_required",
"description": "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\n\n参数约束:\n - --group、--conversation-id、--id、--user、--open-dingtalk-id 必须且只能指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
"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.",
"name": "shortcut_chat_messages",
"primary_cli_path": "chat +chat-messages",
"reviewed": true,
"risk": "low",
"title": "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
"use_when": [
"当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "全员禁言 / 取消全员禁言",
@@ -10684,7 +10710,7 @@
"cli_name": "+messages-mget",
"cli_path": "chat +messages-mget",
"confirmation": "not_required",
"description": "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。\n\n参数约束:\n - --msg-ids 去重后必须包含 1-50 条消息 ID\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"description": "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。\n\n参数约束:\n - --msg-ids 去重后必须包含 1-50 条消息 ID\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
@@ -10695,7 +10721,7 @@
"risk": "low",
"title": "根据消息 ID 批量查询消息(最多 50 条)",
"use_when": [
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。"
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。"
]
},
{
@@ -10750,6 +10776,32 @@
"当你想知道自己发出的某条消息有哪些人已读/未读时使用;只读,需传会话 openConversationId 和该消息 openMessageId,可指定目标成员列表。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"需要 bot/webhook 发送媒体、卡片或 thread 回复时不要假设等价支持;改用真实存在的专用下层命令,缺少下层能力时停止"
],
"canonical_path": "chat.shortcut_messages_send",
"cli_name": "+messages-send",
"cli_path": "chat +messages-send",
"confirmation": "user_required",
"description": "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。\n\n参数约束:\n - --text、--markdown、--media-id、--file、--file-path 至少指定一个\n - --text、--markdown、--media-id、--file、--file-path 互斥,最多指定一个\n - --identity、--as 互斥,最多指定一个\n - --group、--chat-id 互斥,最多指定一个\n - --user、--open-dingtalk-id 互斥,最多指定一个\n - --uuid、--idempotency-key 互斥,最多指定一个\n - 目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标;webhook 必须指定 webhook-token;幂等键仅 user 支持",
"effect": "write",
"idempotency": "unknown",
"interface_mode": "composite",
"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.",
"name": "shortcut_messages_send",
"primary_cli_path": "chat +messages-send",
"reviewed": true,
"risk": "medium",
"title": "统一发送文本、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 等价能力。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "自定义机器人 Webhook 发送群消息",
@@ -10776,6 +10828,32 @@
"当你只有自定义机器人的 Webhook token、想往其所在群推送消息时使用;会实际通过 Webhook 发群消息,需传 token、标题、正文,可 @手机号/userId 或 @所有人。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "创建流式卡片,可在同一次调用中写入内容并结束",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"
],
"canonical_path": "chat.shortcut_messages_send_card",
"cli_name": "+messages-send-card",
"cli_path": "chat +messages-send-card",
"confirmation": "user_required",
"description": "当你要发送一张流式卡片消息时使用;群 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 完成,避免卡片停留在加载中。\n\n参数约束:\n - --group、--receiver、--receiver-open-dingtalk-id 必须且只能指定一个\n - --flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content",
"effect": "write",
"idempotency": "unknown",
"interface_mode": "composite",
"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.",
"name": "shortcut_messages_send_card",
"primary_cli_path": "chat +messages-send-card",
"reviewed": true,
"risk": "medium",
"title": "创建流式卡片,可在同一次调用中写入内容并结束",
"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 完成,避免卡片停留在加载中。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "流式更新卡片内容(最后一次 --flow-status 应为 3)",
@@ -10828,6 +10906,32 @@
"当你想快速看一眼自己都加入了哪些群、以及每个群的会话ID、名称、群主和人数,而不想翻分页或盯着原始返回时使用;内部分页拉取你加入的群列表,把每个群防御式地投影成 会话id / 名称 / 群主 / 人数 / 类型 等关键字段,输出成干净的结果。可选 --type 在本地按群类型过滤(底层接口本身不带类型参数,故为客户端过滤)。这是只读操作,不会改动任何群或成员关系。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "多维搜索消息,可全量翻页并批量富化详情",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"
],
"canonical_path": "chat.shortcut_search_msg",
"cli_name": "+search-msg",
"cli_path": "chat +search-msg",
"confirmation": "not_required",
"description": "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\n\n参数约束:\n - 至少指定一个内容、身份、会话或消息类型过滤条件\n - 需与 --end 一起传\n - 需与 --start 一起传\n - --groups、--chat-id 互斥,最多指定一个\n - --senders、--sender 互斥,最多指定一个\n - --at-me、--is-at-me 互斥,最多指定一个\n - --conversation-type、--chat-type 互斥,最多指定一个\n - --limit、--page-size 互斥,最多指定一个\n - --cursor、--page-token 互斥,最多指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.",
"name": "shortcut_search_msg",
"primary_cli_path": "chat +search-msg",
"reviewed": true,
"risk": "low",
"title": "多维搜索消息,可全量翻页并批量富化详情",
"use_when": [
"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "按群名直接给群发消息(自动搜群解析 openConversationId)",
@@ -10854,6 +10958,32 @@
"当你只知道群的名字、想直接往这个群里发一条消息而不想先手动查群 ID 时使用;内部先按群名搜索群聊解析出唯一 openConversationId 再发送,群名匹配到多个群时会列出候选让你区分、绝不自行假定。会真实发出群消息。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
"agent_summary_source": "dws-agent-selection/chat",
"availability": "available",
"avoid_when": [
"要回复 Thread 或发送新回复时不要使用此读取入口;当前没有经过验证的 thread writer Shortcut"
],
"canonical_path": "chat.shortcut_thread_replies",
"cli_name": "+thread-replies",
"cli_path": "chat +thread-replies",
"confirmation": "not_required",
"description": "当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么),而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表,可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。\n\n参数约束:\n - --thread-id 与兼容参数 --topic-id 必须且只能指定一个\n - --output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
"effect": "read",
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed thread reader adapter: it accepts threadId/topicId, reads lower topic replies, projects stable message fields, and optionally downloads reply resources safely.",
"name": "shortcut_thread_replies",
"primary_cli_path": "chat +thread-replies",
"reviewed": true,
"risk": "low",
"title": "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
"use_when": [
"当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么),而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表,可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
]
},
{
"agent_metadata_source": "embedded-skill-metadata",
"agent_summary": "列出我有未读消息的会话(投影会话名/未读数/会话ID)",
@@ -26514,6 +26644,6 @@
}
],
"source": "embedded-command-catalog",
"tool_count": 840
"tool_count": 845
}
}
File diff suppressed because it is too large Load Diff
+1 -6
View File
@@ -168,7 +168,6 @@
"chat +chat-get-by-id",
"chat +chat-members-get",
"chat +chat-members-list",
"chat +chat-messages",
"chat +chat-mute-member",
"chat +chat-quit",
"chat +chat-remove-bot",
@@ -205,15 +204,11 @@
"chat +messages-reply",
"chat +messages-resource-download",
"chat +messages-resource-url",
"chat +messages-send",
"chat +messages-send-by-bot",
"chat +messages-send-card",
"chat +messages-set-pin",
"chat +messages-set-top",
"chat +messages-unset-pin",
"chat +messages-unset-top",
"chat +search-msg",
"chat +thread-replies"
"chat +messages-unset-top"
]
}
]
@@ -496,6 +496,26 @@
{
"canonical_path": "chat.set_top_conversation",
"cli_path": "chat set-top"
},
{
"canonical_path": "chat.shortcut_chat_messages",
"cli_path": "chat +chat-messages"
},
{
"canonical_path": "chat.shortcut_messages_send",
"cli_path": "chat +messages-send"
},
{
"canonical_path": "chat.shortcut_messages_send_card",
"cli_path": "chat +messages-send-card"
},
{
"canonical_path": "chat.shortcut_search_msg",
"cli_path": "chat +search-msg"
},
{
"canonical_path": "chat.shortcut_thread_replies",
"cli_path": "chat +thread-replies"
}
]
}
+73 -2
View File
@@ -1152,6 +1152,32 @@
"cli_path": "chat +chat-role-query-user",
"runtime_gate": "none"
},
"chat.shortcut_chat_messages": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed built-in Shortcut adapter: it routes group or direct-message history reads, projects a stable message shape, and optionally orchestrates safe resource downloads with a failure ledger.",
"reviewed": true,
"review_reason": "Reviewed against the executable Shortcut and group/direct lower read tools. Optional downloads stay read/not_required under the existing safe local-download convention: workspace-relative paths, no overwrite by default, and atomic publication.",
"cli_path": "chat +chat-messages",
"runtime_gate": "none"
},
"chat.shortcut_messages_send": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed composite send adapter: it selects current-user, bot, or webhook transport; current-user additionally supports live-compatible contact search with exact userId matching, mediaId images, and the native init/upload/commit local-file flow.",
"reviewed": true,
"review_reason": "Reviewed against every executable identity/content/target validation branch, authenticated contact-search response shape, and the real lower tool mappings; bot and webhook media are intentionally rejected because no equivalent lower capability exists.",
"cli_path": "chat +messages-send",
"runtime_gate": "typed_yes"
},
"chat.shortcut_messages_send_by_webhook": {
"effect": "write",
"risk": "medium",
@@ -1165,6 +1191,25 @@
"cli_path": "chat +messages-send-by-webhook",
"runtime_gate": "typed_yes"
},
"chat.shortcut_messages_send_card": {
"effect": "write",
"risk": "medium",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed card lifecycle adapter: it can resolve a userId through contact search with exact matching, call create_and_send_card alone, or compose creation with update_streaming_card after extracting the returned bizId.",
"parameters": {
"receiver-open-dingtalk-id": {
"property": "receiverOpenDingTalkId",
"required": false
}
},
"reviewed": true,
"review_reason": "Reviewed against authenticated contact-search resolution, create-only, two-action dry-run, create/update success, missing bizId, and partial-failure behavior that preserves the created bizId; the explicit openDingTalkId flag is bound to the lower interface's exact receiverOpenDingTalkId property.",
"cli_path": "chat +messages-send-card",
"runtime_gate": "typed_yes"
},
"chat.shortcut_messages_list_direct": {
"effect": "read",
"risk": "low",
@@ -1200,7 +1245,7 @@
"availability": "available",
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"cli_path": "chat +messages-mget",
"runtime_gate": "none"
},
@@ -1265,7 +1310,7 @@
"availability": "available",
"interface_reason": "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
"reviewed": true,
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command: publishes the executable risk gate and composite interface without inventing a direct MCP identity.",
"review_reason": "Reviewed against the built-in Shortcut registry and mounted Cobra command. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"cli_path": "chat +at-me",
"runtime_gate": "none"
},
@@ -1321,6 +1366,19 @@
"cli_path": "chat +my-groups",
"runtime_gate": "none"
},
"chat.shortcut_search_msg": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.",
"reviewed": true,
"review_reason": "Reviewed against the executable advanced-search flags plus page and enrichment ledgers. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"cli_path": "chat +search-msg",
"runtime_gate": "none"
},
"chat.shortcut_send_to_group": {
"effect": "write",
"risk": "medium",
@@ -1334,6 +1392,19 @@
"cli_path": "chat +send-to-group",
"runtime_gate": "typed_yes"
},
"chat.shortcut_thread_replies": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed thread reader adapter: it accepts threadId/topicId, reads lower topic replies, projects stable message fields, and optionally downloads reply resources safely.",
"reviewed": true,
"review_reason": "Reviewed against the executable thread/topic alias contract and real list_topic_replies mapping. Optional downloads stay read/not_required under the existing safe local-download convention and therefore do not create a non-interactive confirmation no-op.",
"cli_path": "chat +thread-replies",
"runtime_gate": "none"
},
"chat.shortcut_unread_chats": {
"effect": "read",
"risk": "low",
@@ -7,7 +7,7 @@
"channel": "open-source"
},
"coverage": {
"source_tools": 840,
"source_tools": 845,
"matched_tools": 71
},
"tools": {
+102 -2
View File
@@ -2110,6 +2110,46 @@
"ShortcutRegistry:chat +chat-role-query-user"
]
},
"chat.shortcut_chat_messages": {
"agent_summary": "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
"use_when": [
"当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget"
],
"examples": [
"dws chat +chat-messages --group <openConversationId> --direction older",
"dws chat +chat-messages --open-dingtalk-id <openDingTalkId> --download-resources --output-dir ./downloads"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified group/direct routing, stable projection, pagination, and optional safe resource workflow; it distinguishes conversation history from cross-conversation search and exact-ID mget.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_chat_messages",
"cobra-help:dws chat +chat-messages",
"ShortcutRegistry:chat +chat-messages"
]
},
"chat.shortcut_messages_send": {
"agent_summary": "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
"use_when": [
"当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。"
],
"avoid_when": [
"需要 bot/webhook 发送媒体、卡片或 thread 回复时不要假设等价支持;改用真实存在的专用下层命令,缺少下层能力时停止"
],
"examples": [
"dws chat +messages-send --as user --chat-id <openConversationId> --markdown \"## 周报\" --idempotency-key <key>",
"dws chat +messages-send --as user --user <userId> --msg-type file --file ./report.pdf"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified identity/content capability matrix, live-compatible contact search with exact userId matching, native local-file upload flow, and fail-closed rejection of unsupported bot/webhook media.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_messages_send",
"cobra-help:dws chat +messages-send",
"ShortcutRegistry:chat +messages-send"
]
},
"chat.shortcut_messages_send_by_webhook": {
"agent_summary": "自定义机器人 Webhook 发送群消息",
"use_when": [
@@ -2129,6 +2169,26 @@
"ShortcutRegistry:chat +messages-send-by-webhook"
]
},
"chat.shortcut_messages_send_card": {
"agent_summary": "创建流式卡片,可在同一次调用中写入内容并结束",
"use_when": [
"当你要发送一张流式卡片消息时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。"
],
"avoid_when": [
"已有 bizId、只需要追加或更新现有卡片内容时使用 +messages-update-card"
],
"examples": [
"dws chat +messages-send-card --group <openConversationId> --content \"任务已完成\"",
"dws chat +messages-send-card --receiver <userId>"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified create-only and create-then-update lifecycle, live-compatible contact search with exact userId matching, explicit userId/openDingTalkId routing, two-action dry-run, bizId extraction, and partial-failure reporting.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_messages_send_card",
"cobra-help:dws chat +messages-send-card",
"ShortcutRegistry:chat +messages-send-card"
]
},
"chat.shortcut_messages_list_direct": {
"agent_summary": "拉取单聊会话消息",
"use_when": [
@@ -2170,7 +2230,7 @@
"chat.shortcut_messages_mget": {
"agent_summary": "根据消息 ID 批量查询消息(最多 50 条)",
"use_when": [
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。"
"当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
@@ -2265,7 +2325,7 @@
"chat.shortcut_at_me": {
"agent_summary": "查最近 @我 的消息(自动算时间窗,投影发送人/时间/内容/会话)",
"use_when": [
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。"
"当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息,再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。默认只读且不会发送、撤回或标记任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
@@ -2359,6 +2419,26 @@
"ShortcutRegistry:chat +my-groups"
]
},
"chat.shortcut_search_msg": {
"agent_summary": "多维搜索消息,可全量翻页并批量富化详情",
"use_when": [
"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"
],
"examples": [
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
"dws chat +search-msg --group <openConversationId> --message-type file --download-resources --output-dir ./downloads"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified filter mapping, cursor pagination, batched mget enrichment, completeness ledger, and shared safe resource workflow.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_search_msg",
"cobra-help:dws chat +search-msg",
"ShortcutRegistry:chat +search-msg"
]
},
"chat.shortcut_send_to_group": {
"agent_summary": "按群名直接给群发消息(自动搜群解析 openConversationId)",
"use_when": [
@@ -2378,6 +2458,26 @@
"ShortcutRegistry:chat +send-to-group"
]
},
"chat.shortcut_thread_replies": {
"agent_summary": "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
"use_when": [
"当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么),而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表,可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"要回复 Thread 或发送新回复时不要使用此读取入口;当前没有经过验证的 thread writer Shortcut"
],
"examples": [
"dws chat +thread-replies --group <openConversationId> --thread-id <threadId>",
"dws chat +thread-replies --group <openConversationId> --topic-id <topicId> --download-resources --output-dir ./downloads"
],
"reviewed": true,
"review_reason": "Agent-authored from the verified thread/topic alias, lower list_topic_replies mapping, stable projection, group-context fallback, and optional safe resource workflow.",
"source_refs": [
"internal/cli/schema_command_registry.json#chat.shortcut_thread_replies",
"cobra-help:dws chat +thread-replies",
"ShortcutRegistry:chat +thread-replies"
]
},
"chat.shortcut_unread_chats": {
"agent_summary": "列出我有未读消息的会话(投影会话名/未读数/会话ID)",
"use_when": [
+1 -1
View File
@@ -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() {
+1
View File
@@ -44,6 +44,7 @@ func WithServerDiag(diag ServerDiagnostics) Option {
// Override retryable if server explicitly specified.
if diag.ServerRetryable != nil {
e.Retryable = *diag.ServerRetryable
e.RetryableSet = true
}
}
}
+59 -18
View File
@@ -20,6 +20,7 @@ import (
"fmt"
"io"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
@@ -41,20 +42,23 @@ const (
// Error is the structured repository-local error model for the Go rewrite.
type Error struct {
Category Category
Message string
Operation string
ServerKey string
Retryable bool
Reason string
Hint string
Actions []string
AvailableFlags []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
Category Category
Message string
Operation string
ServerKey string
Retryable bool
RetryableSet bool
RetryAfterSeconds *int64
NextRetryAt *time.Time
Reason string
Hint string
Actions []string
AvailableFlags []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
}
func (e *Error) Error() string {
@@ -107,6 +111,31 @@ func WithServerKey(serverKey string) Option {
func WithRetryable(retryable bool) Option {
return func(err *Error) {
err.Retryable = retryable
err.RetryableSet = true
}
}
// WithRetryAfterSeconds records the server-recommended delay before a retry.
// A zero delay is meaningful and is therefore preserved; negative values are
// ignored as invalid server guidance.
func WithRetryAfterSeconds(seconds int64) Option {
return func(err *Error) {
if seconds < 0 {
return
}
value := seconds
err.RetryAfterSeconds = &value
}
}
// WithNextRetryAt records the absolute time at which a retry may be attempted.
func WithNextRetryAt(next time.Time) Option {
return func(err *Error) {
if next.IsZero() {
return
}
value := next.UTC()
err.NextRetryAt = &value
}
}
@@ -266,8 +295,14 @@ func PrintJSON(w io.Writer, err error) error {
if typed.ServerKey != "" {
errorPayload["server_key"] = typed.ServerKey
}
if typed.Retryable {
errorPayload["retryable"] = true
if typed.RetryableSet {
errorPayload["retryable"] = typed.Retryable
}
if typed.RetryAfterSeconds != nil {
errorPayload["retry_after_seconds"] = *typed.RetryAfterSeconds
}
if typed.NextRetryAt != nil {
errorPayload["next_retry_at"] = typed.NextRetryAt.UTC().Format(time.RFC3339)
}
if typed.Hint != "" {
errorPayload["hint"] = typed.Hint
@@ -383,8 +418,14 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" {
lines = append(lines, tui.Dim(line))
}
if typed.Retryable {
lines = append(lines, tui.Warning("Retryable: true"))
if typed.RetryableSet {
lines = append(lines, tui.Warning(fmt.Sprintf("Retryable: %t", typed.Retryable)))
}
if typed.RetryAfterSeconds != nil {
lines = append(lines, tui.Warning(fmt.Sprintf("Retry After: %ds", *typed.RetryAfterSeconds)))
}
if typed.NextRetryAt != nil {
lines = append(lines, tui.Warning("Next Retry At: "+typed.NextRetryAt.UTC().Format(time.RFC3339)))
}
// Always shown when present: Trace ID, Server Code
+94
View File
@@ -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()
+866
View File
@@ -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
+179 -42
View File
@@ -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 &copy
}
return nil
}
func firstTimePointer(values ...*time.Time) *time.Time {
for _, value := range values {
if value == nil || value.IsZero() {
continue
}
copy := value.UTC()
return &copy
}
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)
}
+279
View File
@@ -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)
}
}
+42
View File
@@ -13,6 +13,7 @@ import (
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
@@ -289,6 +290,47 @@ func normalizeAtPlaceholders(text string, ids []string, wrapAngle bool) string {
return text
}
// NormalizeMessageMentions applies the placeholder convention required by the
// selected sender identity and ensures a declared @all is present in the body.
// Current-user messages use <@id>/<@all>; bot and webhook messages use
// @id/@mobile/@all.
func NormalizeMessageMentions(text string, ids []string, atAll, wrapAngle bool) string {
text = normalizeAtPlaceholders(text, ids, wrapAngle)
allPlaceholder := "@all"
if wrapAngle {
text = normalizeAtPlaceholders(text, []string{"all"}, true)
allPlaceholder = "<@all>"
} else {
text = strings.ReplaceAll(text, "<@all>", "@all")
}
if atAll && !containsMessageMention(text, allPlaceholder) {
text = allPlaceholder + " " + text
}
return text
}
func containsMessageMention(text, placeholder string) bool {
if strings.HasPrefix(placeholder, "<") {
return strings.Contains(text, placeholder)
}
for searchFrom := 0; ; {
offset := strings.Index(text[searchFrom:], placeholder)
if offset < 0 {
return false
}
end := searchFrom + offset + len(placeholder)
if end == len(text) {
return true
}
next, _ := utf8.DecodeRuneInString(text[end:])
if !unicode.IsLetter(next) && !unicode.IsDigit(next) &&
next != '_' && next != '-' {
return true
}
searchFrom = end
}
}
func resolveOpenDingTalkID(ctx context.Context, value string) (string, error) {
ids, err := resolveOpenDingTalkIDs(ctx, []string{value})
if err != nil {
@@ -114,6 +114,18 @@ func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) {
for _, wrap := range []bool{true, false} {
_ = normalizeAtPlaceholders("hello @u1 <@u2>", []string{"", "u1", "u2"}, wrap)
}
if got := NormalizeMessageMentions("hello @u1", []string{"u1"}, true, true); got != "<@all> hello <@u1>" {
t.Fatalf("current-user mention normalization = %q", got)
}
if got := NormalizeMessageMentions("<@all> <@u1>", []string{"u1"}, true, false); got != "@all @u1" {
t.Fatalf("bot mention normalization = %q", got)
}
if got := NormalizeMessageMentions("@alliance hello", nil, true, false); got != "@all @alliance hello" {
t.Fatalf("bot @all token detection = %q", got)
}
if got := NormalizeMessageMentions("hello @all", nil, true, false); got != "hello @all" {
t.Fatalf("trailing bot @all detection = %q", got)
}
}
func TestCrossPlatformCoverageChatContactMappingCoverage(t *testing.T) {
+43
View File
@@ -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)
}
+32
View File
@@ -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
+87
View File
@@ -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)
}
}
+22
View File
@@ -156,6 +156,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 +182,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 {
+245 -10
View File
@@ -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(),
}
+345 -3
View File
@@ -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" }
+18 -1
View File
@@ -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
+40
View File
@@ -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
+153
View File
@@ -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
}
+101
View File
@@ -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)
}
}
}
+12 -74
View File
@@ -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)
}
+6
View File
@@ -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)
}
}
+162
View File
@@ -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())
}
}
+74 -6
View File
@@ -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{
+10 -51
View File
@@ -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)
}
}
}
+25 -73
View File
@@ -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 "--"
+16 -2
View File
@@ -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,
},
{
+87 -2
View File
@@ -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
+335 -86
View File
@@ -14,9 +14,10 @@
package chat
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
@@ -318,7 +319,7 @@ func listMessageProjectOneWithReactions(m map[string]any, includeReactions bool)
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if resources := chatmsg.Resources(m); len(resources) > 0 {
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
@@ -525,39 +526,26 @@ var MessagesMget = shortcut.Shortcut{
Command: "+messages-mget",
Product: "im",
Description: "根据消息 ID 批量查询消息(最多 50 条)",
Intent: "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId 安全下载到工作目录内,并逐资源返回成功/失败 ledger。",
Intent: "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID", Required: true},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
{Name: "download-resources", Type: shortcut.FlagBool, Desc: "自动下载消息中的全部可识别 mediaId 资源"},
{Name: "output-dir", Type: shortcut.FlagString, Default: "./downloads", Desc: "资源输出目录;--output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖同名资源文件(默认拒绝)"},
},
Constraints: []shortcut.Constraint{
}, MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"msg-ids"},
Description: "--msg-ids 去重后必须包含 1-50 条消息 ID",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"output-dir"},
Description: "--output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
},
},
}, MessageResourceDownloadConstraints()...),
Tips: []string{`dws chat +messages-mget --msg-ids msgId1,msgId2`},
Validate: func(rt *shortcut.RuntimeContext) error {
ids := uniqueShortcutStrings(rt.StrSlice("msg-ids"))
if len(ids) < 1 || len(ids) > 50 {
return fmt.Errorf("--msg-ids 去重后必须包含 1-50 条消息 ID,当前 %d 条", len(ids))
}
if rt.Bool("download-resources") {
if err := validateResourceDownloadOutputFlag(rt.Str("output-dir"), "--output-dir"); err != nil {
return err
}
}
return nil
return ValidateMessageResourceDownload(rt)
},
Execute: func(rt *shortcut.RuntimeContext) error {
ids := uniqueShortcutStrings(rt.StrSlice("msg-ids"))
@@ -587,81 +575,194 @@ var MessagesMget = shortcut.Shortcut{
"messages": messages,
}
if rt.Bool("download-resources") {
ledger, err := downloadMgetResources(rt, rawMessages)
if err != nil {
return err
}
payload["resourceDownloads"] = ledger
payload["resourceDownloads"] = DownloadMessageResources(rt, rawMessages, "")
}
return rt.Output(payload)
},
}
func downloadMgetResources(rt *shortcut.RuntimeContext, messages []map[string]any) (map[string]any, error) {
// MessageResourceDownloadFlags returns the common opt-in resource workflow used
// by message list, search, mget, @me and thread-reading Shortcuts.
func MessageResourceDownloadFlags() []shortcut.Flag {
return []shortcut.Flag{
{Name: "download-resources", Type: shortcut.FlagBool, Desc: "自动下载消息中的全部可识别 mediaId/fileId 资源"},
{Name: "output-dir", Type: shortcut.FlagString, Default: "./downloads", Desc: "资源输出目录;必须是工作目录内的相对路径,禁止绝对路径和 .. 逃逸"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖同名资源文件(默认拒绝)"},
}
}
// MessageResourceDownloadConstraints publishes the shared safe-output rule.
func MessageResourceDownloadConstraints() []shortcut.Constraint {
return []shortcut.Constraint{{
Kind: shortcut.ConstraintCustom,
Flags: []string{"output-dir"},
Description: "--output-dir 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
}}
}
// ValidateMessageResourceDownload validates the output path only when the
// caller opts into local writes.
func ValidateMessageResourceDownload(rt *shortcut.RuntimeContext) error {
if !rt.Bool("download-resources") {
return nil
}
return validateResourceDownloadOutputFlag(rt.Str("output-dir"), "--output-dir")
}
// DownloadMessageResources downloads every unique message resource reference
// and returns a per-resource success/failure ledger. A fallback conversation
// ID lets group/thread list commands supply context when a mediaId item's lower
// response omits it; fileId resources route through the existing drive leaf.
func DownloadMessageResources(
rt *shortcut.RuntimeContext,
messages []map[string]any,
fallbackConversationID string,
) map[string]any {
resources := make([]map[string]any, 0)
for _, message := range messages {
resources = append(resources, chatmsg.Resources(message)...)
resources = append(resources, chatmsg.ResourcesDeep(message)...)
}
discoveredCount := len(resources)
uniqueResources := make([]map[string]any, 0, len(resources))
seen := map[string]bool{}
for _, resource := range resources {
resourceType := strings.TrimSpace(fmt.Sprint(resource["type"]))
if canonicalType, ok := canonicalMessageResourceType(resourceType); ok {
resourceType = canonicalType
}
resourceID := strings.TrimSpace(fmt.Sprint(resource["resourceId"]))
download, _ := resource["download"].(map[string]any)
arguments, _ := download["arguments"].(map[string]any)
messageID := strings.TrimSpace(fmt.Sprint(arguments["message-id"]))
if messageID == "<nil>" {
messageID = ""
}
key := strings.ToLower(resourceType) + "\x00" + resourceID
if strings.EqualFold(resourceType, "mediaId") {
conversationID := strings.TrimSpace(fmt.Sprint(arguments["open-conversation-id"]))
key += "\x00" + messageID + "\x00" + conversationID
}
if resourceID != "" && resourceID != "<nil>" {
if seen[key] {
continue
}
seen[key] = true
}
uniqueResources = append(uniqueResources, resource)
}
resources = uniqueResources
if rt.DryRun() {
return map[string]any{
"dryRun": true,
"requestedCount": len(resources),
"resources": resources,
}, nil
"dryRun": true,
"discoveredCount": discoveredCount,
"requestedCount": len(resources),
"deduplicatedCount": discoveredCount - len(resources),
"resources": resources,
}
}
if len(resources) == 0 {
return map[string]any{
"ok": true,
"partial": false,
"discoveredCount": discoveredCount,
"requestedCount": 0,
"deduplicatedCount": discoveredCount,
"downloadedCount": 0,
"failedCount": 0,
"downloads": []map[string]any{},
"failures": []map[string]any{},
}
}
cwd, err := resourceGetwd()
if err != nil {
return nil, fmt.Errorf("读取工作目录失败: %w", err)
return map[string]any{
"ok": false,
"partial": false,
"discoveredCount": discoveredCount,
"requestedCount": len(resources),
"deduplicatedCount": discoveredCount - len(resources),
"downloadedCount": 0,
"failedCount": len(resources),
"downloads": []map[string]any{},
"failures": []map[string]any{{
"stage": "output-directory",
"affectedCount": len(resources),
"error": fmt.Sprintf("读取工作目录失败: %v", err),
}},
}
}
outputDir := strings.TrimRight(rt.Str("output-dir"), `/\`) + string(os.PathSeparator)
outputDir := strings.TrimRight(rt.Str("output-dir"), `/\`)
downloads := make([]map[string]any, 0, len(resources))
failures := make([]map[string]any, 0)
downloadedNames := map[string]bool{}
for _, resource := range resources {
resourceType := strings.TrimSpace(fmt.Sprint(resource["type"]))
if canonicalType, ok := canonicalMessageResourceType(resourceType); ok {
resourceType = canonicalType
}
resourceID := strings.TrimSpace(fmt.Sprint(resource["resourceId"]))
download, _ := resource["download"].(map[string]any)
arguments, _ := download["arguments"].(map[string]any)
messageID := strings.TrimSpace(fmt.Sprint(arguments["message-id"]))
conversationID := strings.TrimSpace(fmt.Sprint(arguments["open-conversation-id"]))
if download["ready"] != true || resourceID == "" || messageID == "" || conversationID == "" {
if messageID == "<nil>" {
messageID = ""
}
if conversationID == "" || conversationID == "<nil>" {
conversationID = strings.TrimSpace(fallbackConversationID)
}
missingMediaContext := resourceType == "mediaId" &&
(messageID == "" || messageID == "<nil>" ||
conversationID == "" || conversationID == "<nil>")
if resourceID == "" || resourceID == "<nil>" || missingMediaContext {
failures = append(failures, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"error": "资源引用缺少 message-id 或 open-conversation-id",
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"error": "资源引用缺少 resource-id,或 mediaId 缺少 message-id/open-conversation-id",
})
continue
}
data, callErr := rt.CallMCPData("im", "get_resource_download_url", map[string]any{
"resourceType": "mediaId",
"resourceId": resourceID,
"openMessageId": messageID,
"openConversationId": conversationID,
})
data, callErr := resolveMessageResourceDownloadData(
rt, resourceType, resourceID, messageID, conversationID)
if callErr != nil {
failures = append(failures, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"error": callErr.Error(),
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"error": callErr.Error(),
})
continue
}
resourceURL, headers, infoErr := resourceDownloadInfo(data)
if infoErr != nil {
failures = append(failures, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"error": infoErr.Error(),
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"error": infoErr.Error(),
})
continue
}
preferredName := resourceDownloadPreferredName(data)
filename := resourceDownloadFilename(resourceURL, preferredName)
filename = disambiguateResourceDownloadFilename(filename, downloadedNames)
output := filepath.Join(outputDir, filename)
destPath, relativePath, pathErr := resolveResourceDownloadPath(
cwd, outputDir, resourceURL, rt.Bool("overwrite"))
cwd,
output,
resourceURL,
rt.Bool("overwrite"),
preferredName,
)
if pathErr != nil {
failures = append(failures, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"error": pathErr.Error(),
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"error": pathErr.Error(),
})
continue
}
@@ -669,28 +770,47 @@ func downloadMgetResources(rt *shortcut.RuntimeContext, messages []map[string]an
rt.Command().Context(), nil, resourceURL, headers, destPath, rt.Bool("overwrite"))
if downloadErr != nil {
failures = append(failures, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"error": downloadErr.Error(),
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"error": downloadErr.Error(),
})
continue
}
downloadedNames[strings.ToLower(filepath.Base(relativePath))] = true
downloads = append(downloads, map[string]any{
"resourceId": resourceID,
"messageId": messageID,
"localPath": filepath.ToSlash(relativePath),
"sizeBytes": size,
"resourceType": resourceType,
"resourceId": resourceID,
"messageId": messageID,
"localPath": filepath.ToSlash(relativePath),
"sizeBytes": size,
})
}
return map[string]any{
"ok": len(failures) == 0,
"partial": len(downloads) > 0 && len(failures) > 0,
"requestedCount": len(resources),
"downloadedCount": len(downloads),
"failedCount": len(failures),
"downloads": downloads,
"failures": failures,
}, nil
"ok": len(failures) == 0,
"partial": len(downloads) > 0 && len(failures) > 0,
"discoveredCount": discoveredCount,
"requestedCount": len(resources),
"deduplicatedCount": discoveredCount - len(resources),
"downloadedCount": len(downloads),
"failedCount": len(failures),
"downloads": downloads,
"failures": failures,
}
}
func disambiguateResourceDownloadFilename(filename string, used map[string]bool) string {
if !used[strings.ToLower(filename)] {
return filename
}
extension := filepath.Ext(filename)
stem := strings.TrimSuffix(filename, extension)
for sequence := 2; ; sequence++ {
candidate := fmt.Sprintf("%s (%d)%s", stem, sequence, extension)
if !used[strings.ToLower(candidate)] {
return candidate
}
}
}
func uniqueShortcutStrings(values []string) []string {
@@ -888,41 +1008,170 @@ var MessagesCreateTextEmotion = shortcut.Shortcut{
},
}
// MessagesSendCard creates and pushes a streaming card (create_and_send_card, im).
// MessagesSendCard creates and optionally completes a streaming card by
// composing create_and_send_card with update_streaming_card.
var MessagesSendCard = shortcut.Shortcut{
Service: "chat",
Command: "+messages-send-card",
Product: "im",
Description: "创建并推送流式卡片(需配合 messages-update-card)",
Intent: "当你要发送一张可后续流式更新的卡片消息(如 AI 逐字输出)时使用;会实际推送卡片并返回 bizId,群 openConversationId 或单聊接收者 userId 二选一,配合 messages-update-card 更新。",
Description: "创建流式卡片,可在同一次调用中写入内容并结束",
Intent: "当你要发送一张流式卡片消息时使用;群 openConversationId、单聊 userId、单聊 openDingTalkId 严格三选一,分别使用 --group、--receiver、--receiver-open-dingtalk-id。--receiver 始终按 userId 通过通讯录关键词搜索做精确匹配,即使值以 D/d 开头也不会猜成 openDingTalkId;已有 openDingTalkId 时必须用显式参数直传。userId 包括在 --dry-run 时也会先解析。只传目标时创建卡片并返回 bizId,供后续 messages-update-card 流式更新;同时传 --content 时会自动串联创建和更新,默认以 flowStatus=3 完成,避免卡片停留在加载中。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与 --receiver 互斥)"},
{Name: "receiver", Type: shortcut.FlagString, Desc: "单聊接收者 userId(与 --group 互斥)"},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(与两个单聊接收者参数互斥)"},
{Name: "receiver", Type: shortcut.FlagString, Desc: "单聊接收者 userId(与 --group/--receiver-open-dingtalk-id 互斥);始终通过通讯录搜索精确匹配 openDingTalkId,包括 --dry-run 和 D/d 开头的 userId"},
{Name: "receiver-open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(与 --group/--receiver 互斥);显式直传且不做通讯录解析"},
{Name: "content", Type: shortcut.FlagString, Desc: "创建后立即写入的卡片内容;省略时仅创建并返回 bizId"},
{Name: "flow-status", Type: shortcut.FlagInt, Default: "3", Desc: "自动更新状态:1处理中/2输入中/3完成/4执行中/5错误;--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "receiver"}},
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "receiver", "receiver-open-dingtalk-id"}},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"flow-status"},
Description: "--flow-status 必须在 1-5 之间,且显式指定时必须同时提供 --content",
},
},
Tips: []string{
`dws chat +messages-send-card --group <openConversationId>`,
`dws chat +messages-send-card --group <openConversationId> --content "任务已完成"`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
if status := rt.Int("flow-status"); status < 1 || status > 5 {
return fmt.Errorf("--flow-status 必须在 1-5 之间")
}
if rt.Changed("flow-status") && rt.Str("content") == "" {
return fmt.Errorf("--flow-status 只有与 --content 一起使用才有意义")
}
return nil
},
Tips: []string{`dws chat +messages-send-card --group <openConversationId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
group := rt.Str("group")
receiver := rt.Str("receiver")
if group == "" && receiver == "" {
return fmt.Errorf("--group 或 --receiver 必填其一")
}
if group != "" && receiver != "" {
return fmt.Errorf("--group 与 --receiver 互斥")
}
receiverOpenID := rt.Str("receiver-open-dingtalk-id")
params := map[string]any{}
if group != "" {
switch {
case group != "":
params["openConversationId"] = group
} else {
params["receiverUid"] = receiver
case receiver != "":
openID, err := resolveUserOpenDingTalkID(rt, receiver)
if err != nil {
return err
}
params["receiverOpenDingTalkId"] = openID
default:
params["receiverOpenDingTalkId"] = receiverOpenID
}
return rt.CallMCP("create_and_send_card", params)
content := rt.Str("content")
if content == "" {
return rt.CallMCP("create_and_send_card", params)
}
status := rt.Int("flow-status")
if rt.DryRun() {
return rt.Output(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"actionCount": 2,
"failedCount": 0,
"actions": []map[string]any{
{
"tool": "create_and_send_card",
"arguments": params,
},
{
"tool": "update_streaming_card",
"arguments": map[string]any{
"bizId": "<from create_and_send_card>",
"msgContent": content,
"flowStatus": status,
},
},
},
})
}
created, err := rt.CallMCPWriteData("im", "create_and_send_card", params)
if err != nil {
return err
}
bizID := findCardBizID(created)
if bizID == "" {
return fmt.Errorf("卡片已创建但下层未返回 bizId,无法自动更新;请检查 create_and_send_card 响应")
}
updated, err := rt.CallMCPWriteData("im", "update_streaming_card", map[string]any{
"bizId": bizID,
"msgContent": content,
"flowStatus": status,
})
if err != nil {
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err)
}
return rt.Output(map[string]any{
"ok": true,
"bizId": bizID,
"flowStatus": status,
"created": created,
"updated": updated,
})
},
}
func findCardBizID(value any) string {
switch typed := value.(type) {
case map[string]any:
directKeys := []string{"bizId", "bizID", "biz_id"}
for _, key := range directKeys {
if candidate, ok := typed[key].(string); ok && strings.TrimSpace(candidate) != "" {
candidate = strings.TrimSpace(candidate)
return candidate
}
}
// Prefer documented response envelopes before scanning extension fields.
// Map iteration order is deliberately randomized by Go, so an unordered
// recursive walk could select a stale metadata bizId.
envelopeKeys := []string{"result", "data", "card", "response"}
visited := make(map[string]struct{}, len(directKeys)+len(envelopeKeys))
for _, key := range directKeys {
visited[key] = struct{}{}
}
for _, key := range envelopeKeys {
visited[key] = struct{}{}
if candidate := findCardBizID(typed[key]); candidate != "" {
return candidate
}
}
remainingKeys := make([]string, 0, len(typed))
for key := range typed {
if _, ok := visited[key]; !ok {
remainingKeys = append(remainingKeys, key)
}
}
sort.Strings(remainingKeys)
for _, key := range remainingKeys {
if candidate := findCardBizID(typed[key]); candidate != "" {
return candidate
}
}
case []any:
for _, child := range typed {
if candidate := findCardBizID(child); candidate != "" {
return candidate
}
}
case string:
trimmed := strings.TrimSpace(typed)
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var nested any
if json.Unmarshal([]byte(trimmed), &nested) == nil {
return findCardBizID(nested)
}
}
}
return ""
}
// MessagesUpdateCard streams updated card content (update_streaming_card, im).
var MessagesUpdateCard = shortcut.Shortcut{
Service: "chat",
@@ -4,10 +4,11 @@
package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
@@ -25,7 +26,7 @@ func TestCrossPlatformCoverageListMessageRichProjection(t *testing.T) {
"content": `{"mediaId":"@image"}`,
"quotedMessage": map[string]any{
"openMessageId": "quoted",
"content": "quoted",
"content": `{"mediaId":"@quoted-image"}`,
},
},
}}})
@@ -37,13 +38,23 @@ func TestCrossPlatformCoverageListMessageRichProjection(t *testing.T) {
t.Errorf("projection missing %s: %#v", key, rows[0])
}
}
resources := rows[0]["resourceRefs"].([]map[string]any)
if len(resources) != 2 {
t.Fatalf("projected resources = %#v", resources)
}
quotedArgs := resources[1]["download"].(map[string]any)["arguments"].(map[string]any)
if resources[1]["resourceId"] != "@quoted-image" ||
quotedArgs["message-id"] != "quoted" ||
quotedArgs["open-conversation-id"] != "cid" {
t.Fatalf("quoted resource context = %#v", resources[1])
}
}
func TestCrossPlatformCoverageMgetResourceDownloadOutcomes(t *testing.T) {
baseArgs := []string{"chat", "+messages-mget", "--msg-ids", "msg", "--download-resources"}
baseArgs := []string{"chat", "+messages-mget", "--msg-ids", "msg", "--download-resources", "--yes"}
readyMget := `{"result":[{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@file\"}"}]}`
missingContextMget := `{"result":[{"content":"{\"mediaId\":\"@file\"}"}]}`
validInfo := `{"result":{"resourceUrl":"https://example.test/resource.bin"}}`
validInfo := `{"result":{"resourceUrl":"https://download.dingtalk.com/resource.bin"}}`
t.Run("dry run", func(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
@@ -62,9 +73,53 @@ func TestCrossPlatformCoverageMgetResourceDownloadOutcomes(t *testing.T) {
"im/list_messages_by_ids": readyMget,
}})
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(baseArgs)
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "工作目录") {
t.Fatalf("getwd error = %v", err)
if err := root.Execute(); err != nil {
t.Fatalf("getwd ledger error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
ledger, _ := payload["resourceDownloads"].(map[string]any)
if ledger["requestedCount"] != float64(1) ||
ledger["failedCount"] != ledger["requestedCount"] {
t.Fatalf("getwd ledger = %#v", ledger)
}
})
t.Run("zero resources skip getwd", func(t *testing.T) {
resetResourceDownloadHooks(t)
getwdCalled := false
resourceGetwd = func() (string, error) {
getwdCalled = true
return "", errors.New("getwd")
}
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/list_messages_by_ids": `{"result":[{"openMessageId":"msg","openConversationId":"cid","content":"plain text"}]}`,
}})
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(baseArgs)
if err := root.Execute(); err != nil {
t.Fatalf("zero-resource download error = %v", err)
}
if getwdCalled {
t.Fatal("zero-resource download unnecessarily read the working directory")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
ledger, _ := payload["resourceDownloads"].(map[string]any)
failures, _ := ledger["failures"].([]any)
if ledger["ok"] != true ||
ledger["requestedCount"] != float64(0) ||
ledger["failedCount"] != float64(0) ||
len(failures) != 0 {
t.Fatalf("zero-resource ledger = %#v", ledger)
}
})
@@ -120,3 +175,48 @@ func TestCrossPlatformCoverageMgetResourceDownloadOutcomes(t *testing.T) {
})
}
}
func TestCrossPlatformCoverageMgetDownloadRunsWithoutConfirmation(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
dest string,
_ bool,
) (int64, error) {
return 7, nil
}
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_messages_by_ids": `{"result":[{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@file\"}"}]}`,
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://download.dingtalk.com/resource.bin"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetIn(bytes.NewBuffer(nil))
root.SetArgs([]string{
"chat", "+messages-mget",
"--msg-ids", "msg",
"--download-resources",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 ||
fake.calls[0].tool != "list_messages_by_ids" ||
fake.calls[1].tool != "get_resource_download_url" {
t.Fatalf("download calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
ledger, _ := payload["resourceDownloads"].(map[string]any)
if ledger["downloadedCount"] != float64(1) || ledger["failedCount"] != float64(0) {
t.Fatalf("download ledger = %#v", ledger)
}
}
@@ -120,17 +120,17 @@ func TestCrossPlatformCoverageChatCreateAndReplyFailures(t *testing.T) {
},
{
name: "explicit sender lookup",
caller: &larkAlignmentCaller{failProductTool: "contact/get_user_info_by_user_ids"},
caller: &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "user-id", "--text", "收到", "--yes"},
wantError: "解析为 openDingTalkId",
},
{
name: "explicit sender unresolved",
caller: &larkAlignmentCaller{responses: map[string]string{
"contact/get_user_info_by_user_ids": `{"result":[]}`,
"contact/search_contact_by_key_word": `{"result":[]}`,
}},
args: []string{"chat", "+messages-reply", "--conversation-id", "cid", "--message-id", "msg", "--ref-sender", "user-id", "--text", "收到", "--yes"},
wantError: "无法把 --ref-sender",
wantError: "没有精确匹配",
},
{
name: "referenced message lookup",
@@ -234,7 +234,7 @@ func TestCrossPlatformCoverageFlagAndMgetValidation(t *testing.T) {
fake := &larkAlignmentCaller{failProductTool: "im/list_messages_by_ids"}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+messages-mget", "--msg-ids", "msg"})
root.SetArgs([]string{"chat", "+messages-mget", "--msg-ids", "msg", "--yes"})
if err := root.Execute(); err == nil {
t.Fatal("mget lower error was swallowed")
}
+887
View File
@@ -0,0 +1,887 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestMessagesSendPublishesCompleteIdentityConstraintInputs(t *testing.T) {
want := []string{
"identity", "as", "group", "chat-id", "user", "open-dingtalk-id",
"users", "open-dingtalk-ids", "robot-code", "webhook-token",
"uuid", "idempotency-key",
}
var got []string
for _, constraint := range MessagesSend.Constraints {
if constraint.Kind == shortcut.ConstraintCustom {
got = constraint.Flags
break
}
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("identity constraint flags = %#v, want %#v", got, want)
}
wantSet := make(map[string]bool, len(want))
for _, name := range want {
wantSet[name] = true
}
for _, flag := range MessagesSend.Flags {
if !wantSet[flag.Name] {
continue
}
if !strings.Contains(flag.Desc, "能力矩阵") {
t.Errorf("--%s does not publish identity matrix semantics: %q", flag.Name, flag.Desc)
}
if flag.Name == "user" && !strings.Contains(flag.Desc, "--dry-run") {
t.Errorf("--user does not publish dry-run contact resolution: %q", flag.Desc)
}
}
}
func TestCrossPlatformCoverageSafeResourceDownloadsStayReadOnly(t *testing.T) {
for _, command := range []shortcut.Shortcut{MessagesMget, MessagesResourceDownload} {
if command.Risk != shortcut.RiskRead {
t.Errorf("%s risk contract = %q", command.Command, command.Risk)
}
}
}
func TestCrossPlatformCoverageMessagesSendCurrentUserImageAndUserResolution(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send",
"--user", "user-id",
"--msg-type", "image",
"--media-id", "@image",
"--idempotency-key", "image-key",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 {
t.Fatalf("calls = %#v, want contact resolution + send", fake.calls)
}
if fake.calls[0].product != "contact" || fake.calls[0].tool != "search_contact_by_key_word" {
t.Fatalf("resolution call = %#v", fake.calls[0])
}
if got := fake.calls[0].args["keyword"]; got != "user-id" {
t.Fatalf("resolution keyword = %#v, want user-id", got)
}
send := fake.calls[1]
if send.product != "chat" || send.tool != "send_personal_message" {
t.Fatalf("send call = %#v", send)
}
for key, want := range map[string]any{
"receiverOpenDingTalkId": "D-resolved",
"msgType": "image",
"uuid": "image-key",
} {
if !reflect.DeepEqual(send.args[key], want) {
t.Errorf("%s = %#v, want %#v", key, send.args[key], want)
}
}
var content map[string]string
if err := json.Unmarshal([]byte(send.args["content"].(string)), &content); err != nil {
t.Fatal(err)
}
if content["mediaId"] != "@image" {
t.Fatalf("image content = %#v", content)
}
}
func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileFlow(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("report.txt", []byte("gap-fill"), 0o600); err != nil {
t.Fatal(err)
}
var uploaded []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
t.Errorf("upload method = %s", r.Method)
}
var err error
uploaded, err = io.ReadAll(r.Body)
if err != nil {
t.Error(err)
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(server.Close)
fake := &larkAlignmentCaller{responses: map[string]string{
"im/init_conversation_file_upload": `{"resourceUrl":"` + server.URL + `","uploadKey":"upload-key"}`,
"im/commit_conversation_file_upload": `{"result":{"dentryId":11,"spaceId":22}}`,
"chat/send_personal_message": `{"result":{"openMessageId":"sent-file"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send",
"--group", "cid",
"--msg-type", "audio",
"--file-path", "./report.txt",
"--uuid", "file-key",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if string(uploaded) != "gap-fill" || len(fake.calls) != 3 {
t.Fatalf("uploaded = %q, calls = %#v", uploaded, fake.calls)
}
if fake.calls[0].tool != "init_conversation_file_upload" ||
fake.calls[1].tool != "commit_conversation_file_upload" ||
fake.calls[2].tool != "send_personal_message" {
t.Fatalf("file flow calls = %#v", fake.calls)
}
send := fake.calls[2]
if send.args["msgType"] != "file" || send.args["openConversationId"] != "cid" ||
send.args["uuid"] != "file-key" {
t.Fatalf("file send args = %#v", send.args)
}
var content map[string]any
if err := json.Unmarshal([]byte(send.args["content"].(string)), &content); err != nil {
t.Fatal(err)
}
if content["dentryId"] != float64(11) || content["spaceId"] != float64(22) ||
content["fileName"] != "report.txt" {
t.Fatalf("file content = %#v", content)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["requestedMessageType"] != "audio" || payload["effectiveMessageType"] != "file" {
t.Fatalf("file output = %#v", payload)
}
}
func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("fixture.bin", []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(uploadServer.Close)
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send",
"--open-dingtalk-id", "D-target",
"--file", "./fixture.bin",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("file dry-run made lower calls: %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["actionCount"] != float64(2) || payload["preview_kind"] != "plan" {
t.Fatalf("dry-run payload = %#v", payload)
}
for _, tc := range []struct {
name string
args []string
}{
{"unsafe path", []string{"--group", "cid", "--file", "../outside"}},
{"missing file", []string{"--group", "cid", "--file", "./missing"}},
{"media mismatch", []string{"--group", "cid", "--media-id", "@image", "--msg-type", "file"}},
{"file mismatch", []string{"--group", "cid", "--file", "./fixture.bin", "--msg-type", "image"}},
{"missing media", []string{"--group", "cid", "--text", "x", "--msg-type", "image"}},
{"bot media", []string{"--identity", "bot", "--robot-code", "robot", "--group", "cid", "--media-id", "@image"}},
{"webhook media", []string{"--identity", "webhook", "--webhook-token", "token", "--media-id", "@image"}},
{"user target conflict", []string{"--group", "cid", "--user", "u1", "--text", "x"}},
{"user media at", []string{"--group", "cid", "--media-id", "@image", "--at-all"}},
{"text flag with markdown type", []string{"--group", "cid", "--text", "x", "--msg-type", "markdown"}},
{"markdown flag with text type", []string{"--group", "cid", "--markdown", "x", "--msg-type", "text"}},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{})
command := newPlatformCoverageRoot()
args := append([]string{"chat", "+messages-send"}, tc.args...)
args = append(args, "--yes")
command.SetArgs(args)
if err := command.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", tc.args)
}
})
}
for _, tc := range []struct {
name string
fake *larkAlignmentCaller
}{
{
name: "upload init error",
fake: &larkAlignmentCaller{failProductTool: "im/init_conversation_file_upload"},
},
{
name: "commit response missing IDs",
fake: &larkAlignmentCaller{responses: map[string]string{
"im/init_conversation_file_upload": `{"resourceUrl":"` + uploadServer.URL + `","uploadKey":"key"}`,
"im/commit_conversation_file_upload": `{}`,
}},
},
{
name: "message send error",
fake: &larkAlignmentCaller{
failProductTool: "chat/send_personal_message",
responses: map[string]string{
"im/init_conversation_file_upload": `{"resourceUrl":"` + uploadServer.URL + `","uploadKey":"key"}`,
"im/commit_conversation_file_upload": `{"dentryId":1,"spaceId":2}`,
},
},
},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.fake)
command := newPlatformCoverageRoot()
command.SetArgs([]string{
"chat", "+messages-send",
"--group", "cid",
"--file", "./fixture.bin",
"--yes",
})
if err := command.Execute(); err == nil {
t.Fatal("file failure scenario unexpectedly succeeded")
}
})
}
}
func TestCrossPlatformCoverageMessagesSendUserResolutionFailures(t *testing.T) {
for _, tc := range []struct {
name string
fake *larkAlignmentCaller
}{
{"lookup error", &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"}},
{"no exact user id", &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"userId":"other-user","openDingTalkId":"D-other"}]}`,
}}},
{"missing open id", &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"userId":"user-id","name":"Resolved User"}]}`,
}}},
{"ambiguous open id", &larkAlignmentCaller{responses: map[string]string{
"contact/search_contact_by_key_word": `{"result":[{"userId":"user-id","openDingTalkId":"D-one"},{"userId":"user-id","openDingTalkId":"D-two"}]}`,
}}},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send",
"--user", "user-id",
"--text", "hello",
"--yes",
})
if err := root.Execute(); err == nil {
t.Fatal("unresolved user was accepted")
}
if len(tc.fake.calls) != 1 {
t.Fatalf("calls = %#v", tc.fake.calls)
}
})
}
}
func TestCrossPlatformCoverageMessagesSendTextModesAndExecuteGuard(t *testing.T) {
for _, args := range [][]string{
{"--group", "cid", "--markdown", "## markdown"},
{"--group", "cid", "--text", "plain", "--msg-type", "text"},
} {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs(append(append([]string{"chat", "+messages-send"}, args...), "--yes"))
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "send_personal_message" {
t.Fatalf("text mode calls = %#v", fake.calls)
}
}
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+gap-send-execute-guard",
Flags: MessagesSend.Flags,
Execute: executeMessagesSend,
})
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+gap-send-execute-guard",
"--group", "cid",
"--media-id", "@image",
"--msg-type", "file",
})
if err := root.Execute(); err == nil {
t.Fatal("execute-time content mismatch was accepted")
}
}
func TestCrossPlatformCoverageMessagesSendCardOneCallLifecycle(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"card":{"biz_id":"biz-1"}}}`,
"im/update_streaming_card": `{"result":{"updated":true}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--content", "完成",
"--flow-status", "3",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].tool != "create_and_send_card" ||
fake.calls[1].tool != "update_streaming_card" {
t.Fatalf("card calls = %#v", fake.calls)
}
if fake.calls[1].args["bizId"] != "biz-1" || fake.calls[1].args["msgContent"] != "完成" ||
fake.calls[1].args["flowStatus"] != 3 {
t.Fatalf("card update args = %#v", fake.calls[1].args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["bizId"] != "biz-1" || payload["ok"] != true {
t.Fatalf("card output = %#v", payload)
}
}
func TestCrossPlatformCoverageMessagesSendCardResolvesReceiverForLowerTool(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--receiver", "d-user-id",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 ||
fake.calls[0].product != "contact" ||
fake.calls[0].tool != "search_contact_by_key_word" ||
fake.calls[1].product != "im" ||
fake.calls[1].tool != "create_and_send_card" {
t.Fatalf("card receiver calls = %#v", fake.calls)
}
if got := fake.calls[1].args["receiverOpenDingTalkId"]; got != "D-resolved" {
t.Fatalf("receiverOpenDingTalkId = %#v, want D-resolved", got)
}
if got := fake.calls[0].args["keyword"]; got != "d-user-id" {
t.Fatalf("D/d-prefixed userId resolution args = %#v", fake.calls[0].args)
}
if _, exists := fake.calls[1].args["receiverUid"]; exists {
t.Fatalf("obsolete receiverUid leaked to lower tool: %#v", fake.calls[1].args)
}
}
func TestCrossPlatformCoverageMessagesSendCardUsesExplicitOpenReceiver(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--receiver-open-dingtalk-id", "D-direct",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 ||
fake.calls[0].product != "im" ||
fake.calls[0].tool != "create_and_send_card" {
t.Fatalf("card open receiver calls = %#v", fake.calls)
}
if got := fake.calls[0].args["receiverOpenDingTalkId"]; got != "D-direct" {
t.Fatalf("receiverOpenDingTalkId = %#v, want D-direct", got)
}
}
func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *testing.T) {
t.Run("create only", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "create_and_send_card" {
t.Fatalf("create-only calls = %#v", fake.calls)
}
})
t.Run("dry run", func(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-send-card",
"--receiver", "user",
"--content", "处理中",
"--flow-status", "1",
"--dry-run",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 ||
fake.calls[0].product != "contact" ||
fake.calls[0].tool != "search_contact_by_key_word" {
t.Fatalf("card dry-run receiver resolution calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
actions, _ := payload["actions"].([]any)
create, _ := actions[0].(map[string]any)
arguments, _ := create["arguments"].(map[string]any)
if payload["actionCount"] != float64(2) ||
arguments["receiverOpenDingTalkId"] != "D-resolved" {
t.Fatalf("card plan = %#v", payload)
}
})
t.Run("receiver resolution error", func(t *testing.T) {
fake := &larkAlignmentCaller{failProductTool: "contact/search_contact_by_key_word"}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--receiver", "user-id",
"--yes",
})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "解析为 openDingTalkId 失败") {
t.Fatalf("receiver resolution error = %v", err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "search_contact_by_key_word" {
t.Fatalf("receiver resolution calls = %#v", fake.calls)
}
})
for _, tc := range []struct {
name string
fake *larkAlignmentCaller
wantError string
}{
{
name: "create error",
fake: &larkAlignmentCaller{failProductTool: "im/create_and_send_card"},
wantError: "fixture lower call failed",
},
{
name: "missing biz id",
fake: &larkAlignmentCaller{responses: map[string]string{
"im/create_and_send_card": `{"result":{"created":true}}`,
}},
wantError: "未返回 bizId",
},
{
name: "update error preserves id",
fake: &larkAlignmentCaller{
failProductTool: "im/update_streaming_card",
responses: map[string]string{
"im/create_and_send_card": `{"bizId":"biz-preserved"}`,
},
},
wantError: "biz-preserved",
},
} {
t.Run(tc.name, func(t *testing.T) {
helpers.InitDeps(tc.fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-send-card",
"--group", "cid",
"--content", "完成",
"--yes",
})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("error = %v, want substring %q", err, tc.wantError)
}
})
}
for _, args := range [][]string{
{"--group", "cid", "--content", "x", "--flow-status", "6"},
{"--group", "cid", "--flow-status", "2"},
{"--group", "cid", "--receiver-open-dingtalk-id", "D-direct"},
{"--receiver", "user-id", "--receiver-open-dingtalk-id", "D-direct"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+messages-send-card"}, args...))
if err := root.Execute(); err == nil {
t.Fatalf("invalid card args succeeded: %v", args)
}
}
}
func TestCrossPlatformCoverageFindCardBizIDResponseShapes(t *testing.T) {
for _, tc := range []struct {
value any
want string
}{
{map[string]any{"bizId": "direct"}, "direct"},
{map[string]any{"bizID": "caps"}, "caps"},
{map[string]any{"biz_id": "snake"}, "snake"},
{map[string]any{"result": []any{map[string]any{"bizId": "nested"}}}, "nested"},
{map[string]any{
"metadata": map[string]any{"bizId": "stale"},
"result": map[string]any{"bizId": "current"},
}, "current"},
{map[string]any{
"extension": map[string]any{"bizId": "fallback"},
}, "fallback"},
{map[string]any{
"bizId": map[string]any{"x": 1},
"result": map[string]any{"bizId": "typed"},
}, "typed"},
{map[string]any{"bizId": map[string]any{"x": 1}}, ""},
{`{"result":{"bizId":"json"}}`, "json"},
{`[{"bizId":"array-json"}]`, "array-json"},
{`{"bizId":`, ""},
{"plain", ""},
{42, ""},
} {
if got := findCardBizID(tc.value); got != tc.want {
t.Errorf("findCardBizID(%#v) = %q, want %q", tc.value, got, tc.want)
}
}
}
func TestCrossPlatformCoverageMessageResourceDownloadKeepsNestedMessageContext(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
dest string,
_ bool,
) (int64, error) {
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return 0, err
}
return 7, nil
}
fake := &larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://download.dingtalk.com/resource.bin"}}`,
}}
helpers.InitDeps(fake)
message := map[string]any{
"openMessageId": "msg",
"content": `{"mediaId":"@same","nested":{"mediaId":"@same"}}`,
"quotedMessage": map[string]any{
"openMessageId": "msg-quoted",
"content": `{"mediaId":"@quoted"}`,
},
}
secondMessage := map[string]any{
"openMessageId": "msg-2",
"content": `{"mediaId":"@same"}`,
}
var ledger map[string]any
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+gap-resource-download",
Flags: MessageResourceDownloadFlags(),
Execute: func(rt *shortcut.RuntimeContext) error {
ledger = DownloadMessageResources(rt, []map[string]any{message, message, secondMessage}, "cid-fallback")
return nil
},
})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+gap-resource-download", "--output-dir", "./downloads"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if ledger["discoveredCount"] != 5 || ledger["requestedCount"] != 3 ||
ledger["deduplicatedCount"] != 2 || ledger["downloadedCount"] != 3 {
t.Fatalf("download ledger = %#v", ledger)
}
if len(fake.calls) != 3 {
t.Fatalf("resource lookup = %#v", fake.calls)
}
gotMessageIDs := map[string]bool{}
for _, call := range fake.calls {
if call.args["openConversationId"] != "cid-fallback" {
t.Errorf("resource conversation = %#v", call.args)
}
gotMessageIDs[call.args["openMessageId"].(string)] = true
}
for _, messageID := range []string{"msg", "msg-quoted", "msg-2"} {
if !gotMessageIDs[messageID] {
t.Errorf("missing resource lookup for %q: %#v", messageID, fake.calls)
}
}
}
func TestCrossPlatformCoverageMessageFileResourceDownloadUsesDriveAndPreservesName(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
dest string,
_ bool,
) (int64, error) {
if err := os.WriteFile(dest, []byte("drive-file"), 0o600); err != nil {
return 0, err
}
return int64(len("drive-file")), nil
}
fake := &larkAlignmentCaller{responses: map[string]string{
"drive/download_file": `{"result":{"downloadUrl":"https://download.dingtalk.com/opaque","fileName":"fixture.txt"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-resource-download",
"--type", "fileId",
"--resource-id", "drive-file",
"--output", "./downloads/",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(filepath.Join("downloads", "fixture.txt"))
if err != nil || string(content) != "drive-file" {
t.Fatalf("downloaded content = %q, err = %v", content, err)
}
if len(fake.calls) != 1 ||
fake.calls[0].product != "drive" ||
fake.calls[0].tool != "download_file" ||
fake.calls[0].args["fileId"] != "drive-file" {
t.Fatalf("drive call = %#v", fake.calls)
}
var ledger map[string]any
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+gap-file-resource-download",
Flags: MessageResourceDownloadFlags(),
Execute: func(rt *shortcut.RuntimeContext) error {
ledger = DownloadMessageResources(rt, []map[string]any{{
"content": "[文件] fixture.txt fileId: drive-file",
}}, "")
return nil
},
})
root = newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+gap-file-resource-download", "--output-dir", "./batch-downloads"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if ledger["downloadedCount"] != 1 || ledger["failedCount"] != 0 {
t.Fatalf("file download ledger = %#v", ledger)
}
downloads := ledger["downloads"].([]map[string]any)
if downloads[0]["resourceType"] != "fileId" ||
downloads[0]["localPath"] != "batch-downloads/fixture.txt" ||
downloads[0]["messageId"] != "" {
t.Fatalf("file download = %#v", downloads[0])
}
}
func TestCrossPlatformCoverageMessageResourceDownloadDisambiguatesSameNames(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
destinations := make([]string, 0, 2)
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
dest string,
_ bool,
) (int64, error) {
destinations = append(destinations, filepath.Base(dest))
return 1, nil
}
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"drive/download_file": `{"result":{"downloadUrl":"https://download.dingtalk.com/opaque","fileName":"fixture.txt"}}`,
}})
var ledger map[string]any
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+gap-colliding-resource-download",
Flags: MessageResourceDownloadFlags(),
Execute: func(rt *shortcut.RuntimeContext) error {
ledger = DownloadMessageResources(rt, []map[string]any{
{"fileId": "file-a"},
{"fileId": "file-b"},
}, "")
return nil
},
})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+gap-colliding-resource-download", "--output-dir", "./downloads"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(destinations, []string{"fixture.txt", "fixture (2).txt"}) {
t.Fatalf("download destinations = %#v", destinations)
}
if ledger["downloadedCount"] != 2 || ledger["failedCount"] != 0 {
t.Fatalf("download ledger = %#v", ledger)
}
}
func TestCrossPlatformCoverageResourceDownloadFilenameCollisionSequence(t *testing.T) {
used := map[string]bool{}
first := disambiguateResourceDownloadFilename("a.txt", used)
used[strings.ToLower(first)] = true
second := disambiguateResourceDownloadFilename("a.txt", used)
used[strings.ToLower(second)] = true
third := disambiguateResourceDownloadFilename("a (2).txt", used)
if first != "a.txt" || second != "a (2).txt" || third != "a (2) (2).txt" {
t.Fatalf("collision sequence = %q, %q, %q", first, second, third)
}
}
func TestCrossPlatformCoverageFailedResourceDownloadDoesNotConsumeFilename(t *testing.T) {
resetResourceDownloadHooks(t)
t.Chdir(t.TempDir())
destinations := make([]string, 0, 2)
attempt := 0
resourceDownload = func(
_ context.Context,
_ *http.Client,
_ string,
_ map[string]string,
dest string,
_ bool,
) (int64, error) {
destinations = append(destinations, filepath.Base(dest))
attempt++
if attempt == 1 {
return 0, errors.New("fixture download failed")
}
return 1, nil
}
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"drive/download_file": `{"result":{"downloadUrl":"https://download.dingtalk.com/opaque","fileName":"fixture.txt"}}`,
}})
var ledger map[string]any
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+gap-failed-colliding-resource-download",
Flags: MessageResourceDownloadFlags(),
Execute: func(rt *shortcut.RuntimeContext) error {
ledger = DownloadMessageResources(rt, []map[string]any{
{"fileId": "file-a"},
{"fileId": "file-b"},
}, "")
return nil
},
})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+gap-failed-colliding-resource-download", "--output-dir", "./downloads"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(destinations, []string{"fixture.txt", "fixture.txt"}) {
t.Fatalf("failed download consumed a filename: %#v", destinations)
}
if ledger["downloadedCount"] != 1 || ledger["failedCount"] != 1 {
t.Fatalf("download ledger = %#v", ledger)
}
}
func TestCrossPlatformCoverageMessageResourceDownloadRequiresMediaContextOnly(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-resource-download",
"--resource-id", "@media",
})
if err := root.Execute(); err == nil ||
!strings.Contains(err.Error(), "--type mediaId") {
t.Fatalf("missing media context error = %v", err)
}
root = newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+messages-resource-download",
"--type", "fileId",
"--resource-id", "drive-file",
"--output", "../outside",
})
if err := root.Execute(); err == nil ||
!strings.Contains(err.Error(), "--output") {
t.Fatalf("unsafe output error = %v", err)
}
}
func TestCrossPlatformCoverageReadHelperRejectsWriteToolDirectly(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
if _, err := helpers.CallMCPReadToolTextOnServer("chat", "send_personal_message", nil); err == nil {
t.Fatal("write tool was accepted by the helper read channel")
}
if len(fake.calls) != 0 {
t.Fatalf("rejected write reached lower caller: %#v", fake.calls)
}
}
+4 -9
View File
@@ -139,13 +139,13 @@ var MessagesReply = shortcut.Shortcut{
Command: "+messages-reply",
Product: "chat",
Description: "以当前用户身份引用回复消息(自动补全原发送者)",
Intent: "当你要以当前用户身份对已有消息发送纯文本引用回复时使用;提供会话和被引用消息即可,默认通过 mget 自动读取原发送者,也可显式传 openDingTalkId/userId。它不支持 bot 身份、富媒体、卡片或 thread 内回复。",
Intent: "当你要以当前用户身份对已有消息发送纯文本引用回复时使用;提供会话和被引用消息即可,默认通过 mget 自动读取原发送者,也可显式传 openDingTalkId/userId;userId 会通过通讯录搜索精确匹配 openDingTalkId。它不支持 bot 身份、富媒体、卡片或 thread 内回复。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
{Name: "ref-msg-id", Type: shortcut.FlagString, Desc: "被引用消息 openMessageId"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "--ref-msg-id 的 lark-cli 对齐别名"},
{Name: "ref-sender", Type: shortcut.FlagString, Desc: "原消息发送者 openDingTalkId/userId(不传则自动读取)"},
{Name: "ref-sender", Type: shortcut.FlagString, Desc: "原消息发送者 openDingTalkId/userId(userId 通过通讯录搜索精确匹配;不传则自动读取)"},
{Name: "text", Type: shortcut.FlagString, Desc: "纯文本回复内容", Required: true},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(可选)"},
{Name: "idempotency-key", Type: shortcut.FlagString, Desc: "--uuid 的 lark-cli 对齐别名"},
@@ -184,16 +184,11 @@ func resolveReplySender(rt *shortcut.RuntimeContext) (string, error) {
if isOpenID(value) {
return value, nil
}
data, err := rt.CallMCPData("contact", "get_user_info_by_user_ids", map[string]any{
"user_id_list": []string{value},
})
openID, err := resolveUserOpenDingTalkID(rt, value)
if err != nil {
return "", fmt.Errorf("把 --ref-sender userId 解析为 openDingTalkId 失败: %w", err)
}
if openID := findOpenDingTalkID(data); openID != "" {
return openID, nil
}
return "", apperrors.NewValidation("无法把 --ref-sender 解析为 openDingTalkId")
return openID, nil
}
messageID := replyMessageID(rt)
+42 -6
View File
@@ -44,7 +44,24 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string,
case "contact/get_current_user_profile":
text = `{"result":[{"orgEmployeeModel":{"userId":"self-user"}}]}`
case "contact/get_user_info_by_user_ids":
text = `{"result":[{"userId":"user-id","openDingTalkId":"D-resolved"}]}`
text = `{"result":[{"orgEmployeeModel":{"orgUserId":"user-id","orgUserName":"Resolved User"}}]}`
case "contact/search_contact_by_key_word":
keyword, _ := args["keyword"].(string)
payload, _ := json.Marshal(map[string]any{
"result": []map[string]any{
{
"name": "Fuzzy Neighbor",
"userId": keyword + "-other",
"openDingTalkId": "D-other",
},
{
"name": "Resolved User",
"userId": keyword,
"openDingTalkId": "D-resolved",
},
},
})
text = string(payload)
case "im/create_group_conversation":
text = `{"result":{"cid":"internal-cid","openCid":"open-cid"}}`
case "im/list_messages_by_ids":
@@ -117,27 +134,35 @@ func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
product string
tool string
want map[string]any
bodyKey string
body string
}{
{
name: "user",
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello", "--idempotency-key", "u1", "--yes"},
args: []string{"chat", "+messages-send", "--as", "user", "--chat-id", "cid", "--markdown", "hello @D1", "--at-open-dingtalk-ids", "D1", "--at-all", "--idempotency-key", "u1", "--yes"},
product: "chat",
tool: "send_personal_message",
want: map[string]any{"openConversationId": "cid", "msgType": "markdown", "uuid": "u1"},
bodyKey: "content",
body: "<@all> hello <@D1>",
},
{
name: "bot",
args: []string{"chat", "+messages-send", "--identity", "bot", "--robot-code", "robot", "--group", "cid", "--text", "hello", "--at-all", "--yes"},
args: []string{"chat", "+messages-send", "--identity", "bot", "--robot-code", "robot", "--group", "cid", "--text", "<@u1> hello", "--at-user-ids", "u1", "--at-all", "--yes"},
product: "bot",
tool: "send_robot_group_message",
want: map[string]any{"robotCode": "robot", "openConversationId": "cid", "isAtAll": "true"},
bodyKey: "markdown",
body: "@all @u1 hello",
},
{
name: "webhook",
args: []string{"chat", "+messages-send", "--identity", "webhook", "--webhook-token", "token", "--text", "hello", "--at-all", "--yes"},
args: []string{"chat", "+messages-send", "--identity", "webhook", "--webhook-token", "token", "--text", "<@13800000000> hello", "--at-mobiles", "13800000000", "--at-all", "--yes"},
product: "bot",
tool: "send_message_by_custom_robot",
want: map[string]any{"robotToken": "token", "isAtAll": true},
bodyKey: "text",
body: "@all @13800000000 hello",
},
}
for _, tt := range tests {
@@ -161,6 +186,17 @@ func TestMessagesSendRoutesIdentitySpecificTransports(t *testing.T) {
t.Errorf("%s = %#v, want %#v", key, call.args[key], want)
}
}
if tt.bodyKey == "content" {
var content map[string]string
if err := json.Unmarshal([]byte(call.args[tt.bodyKey].(string)), &content); err != nil {
t.Fatal(err)
}
if content["text"] != tt.body {
t.Errorf("content text = %q, want %q", content["text"], tt.body)
}
} else if call.args[tt.bodyKey] != tt.body {
t.Errorf("%s = %#v, want %q", tt.bodyKey, call.args[tt.bodyKey], tt.body)
}
})
}
}
@@ -384,7 +420,7 @@ func TestMessagesMgetDryRunPublishesMultiResourceDownloadPlan(t *testing.T) {
}
}
func TestMessagesReplyResolvesUserIDBeforeExecution(t *testing.T) {
func TestCrossPlatformCoverageMessagesReplyResolvesUserIDBeforeExecution(t *testing.T) {
fake := &larkAlignmentCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
@@ -401,7 +437,7 @@ func TestMessagesReplyResolvesUserIDBeforeExecution(t *testing.T) {
}
if len(fake.calls) != 2 ||
fake.calls[0].product != "contact" ||
fake.calls[0].tool != "get_user_info_by_user_ids" ||
fake.calls[0].tool != "search_contact_by_key_word" ||
fake.calls[1].tool != "send_personal_message" {
t.Fatalf("calls = %#v", fake.calls)
}
+200 -29
View File
@@ -18,6 +18,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
@@ -54,16 +55,16 @@ var MessagesResourceDownload = shortcut.Shortcut{
Service: "chat",
Command: "+messages-resource-download",
Product: "im",
Description: "安全下载消息资源(图片/视频/语音)到本地",
Intent: "当你需要拿到消息里的实际图片、视频或语音文件,而不只是临时 URL 时使用;" +
"先用消息和会话身份换取下载地址,再安全写入工作目录内的相对路径。" +
"默认不覆盖已有文件,只有显式传 --overwrite 才覆盖。",
Description: "安全下载消息资源(图片/视频/语音/文件)到本地",
Intent: "当你需要拿到消息里的实际图片、视频、语音或钉盘文件,而不只是资源 ID 时使用;" +
"mediaId 用消息和会话身份换取下载地址,fileId 复用钉盘下载能力,再安全写入工作目录内的相对路径。" +
"默认不覆盖已有文件,只有显式传 --overwrite 才覆盖;按既有安全本地下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId"}},
{Name: "resource-id", Type: shortcut.FlagString, Desc: "资源 ID(消息中的 mediaId)", Required: true},
{Name: "message-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId", "fileId"}},
{Name: "resource-id", Type: shortcut.FlagString, Desc: "消息中的 mediaId 或 fileId", Required: true},
{Name: "message-id", Type: shortcut.FlagString, Desc: "mediaId 所属消息的 openMessageId"},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "mediaId 所属会话的 openConversationId"},
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内的相对文件或目录路径"},
{Name: "overwrite", Type: shortcut.FlagBool, Desc: "允许覆盖已存在的目标文件(默认拒绝)"},
},
@@ -73,17 +74,34 @@ var MessagesResourceDownload = shortcut.Shortcut{
Flags: []string{"output"},
Description: "--output 必须是工作目录内的相对路径,不允许绝对路径或 .. 逃逸",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"type", "message-id", "open-conversation-id"},
Description: "--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id;fileId 不需要消息上下文",
},
},
Tips: []string{
`dws chat +messages-resource-download --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId>`,
`dws chat +messages-resource-download --type fileId --resource-id <fileId> --output ./downloads/`,
`dws chat +messages-resource-download --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/`,
},
Validate: func(rt *shortcut.RuntimeContext) error {
return validateResourceDownloadOutput(rt.Str("output"))
if err := validateResourceDownloadOutput(rt.Str("output")); err != nil {
return err
}
resourceType, _ := canonicalMessageResourceType(rt.Str("type"))
if resourceType == "mediaId" &&
(strings.TrimSpace(rt.Str("message-id")) == "" ||
strings.TrimSpace(rt.Str("open-conversation-id")) == "") {
return apperrors.NewValidation(
"--type mediaId 时必须同时提供 --message-id 和 --open-conversation-id")
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
resourceType, _ := canonicalMessageResourceType(rt.Str("type"))
plan := map[string]any{
"resourceType": rt.Str("type"),
"resourceType": resourceType,
"resourceId": rt.Str("resource-id"),
"messageId": rt.Str("message-id"),
"openConversationId": rt.Str("open-conversation-id"),
@@ -101,12 +119,13 @@ var MessagesResourceDownload = shortcut.Shortcut{
return rt.Output(plan)
}
data, err := rt.CallMCPData("im", "get_resource_download_url", map[string]any{
"resourceType": rt.Str("type"),
"resourceId": rt.Str("resource-id"),
"openMessageId": rt.Str("message-id"),
"openConversationId": rt.Str("open-conversation-id"),
})
data, err := resolveMessageResourceDownloadData(
rt,
resourceType,
rt.Str("resource-id"),
rt.Str("message-id"),
rt.Str("open-conversation-id"),
)
if err != nil {
return err
}
@@ -119,7 +138,12 @@ var MessagesResourceDownload = shortcut.Shortcut{
return apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
}
destPath, relativePath, err := resolveResourceDownloadPath(
cwd, rt.Str("output"), resourceURL, rt.Bool("overwrite"))
cwd,
rt.Str("output"),
resourceURL,
rt.Bool("overwrite"),
resourceDownloadPreferredName(data),
)
if err != nil {
return err
}
@@ -131,13 +155,46 @@ var MessagesResourceDownload = shortcut.Shortcut{
return rt.Output(map[string]any{
"messageId": rt.Str("message-id"),
"resourceId": rt.Str("resource-id"),
"resourceType": rt.Str("type"),
"resourceType": resourceType,
"localPath": filepath.ToSlash(relativePath),
"sizeBytes": size,
})
},
}
func resolveMessageResourceDownloadData(
rt *shortcut.RuntimeContext,
resourceType, resourceID, messageID, conversationID string,
) (map[string]any, error) {
resourceType, ok := canonicalMessageResourceType(resourceType)
if !ok {
return nil, apperrors.NewValidation(fmt.Sprintf(
"不支持的消息资源类型 %q;仅支持 mediaId 或 fileId", resourceType))
}
if resourceType == "fileId" {
return rt.CallMCPData("drive", "download_file", map[string]any{
"fileId": resourceID,
})
}
return rt.CallMCPData("im", "get_resource_download_url", map[string]any{
"resourceType": "mediaId",
"resourceId": resourceID,
"openMessageId": messageID,
"openConversationId": conversationID,
})
}
func canonicalMessageResourceType(resourceType string) (string, bool) {
switch {
case strings.EqualFold(strings.TrimSpace(resourceType), "mediaId"):
return "mediaId", true
case strings.EqualFold(strings.TrimSpace(resourceType), "fileId"):
return "fileId", true
default:
return strings.TrimSpace(resourceType), false
}
}
func validateResourceDownloadOutput(output string) error {
return validateResourceDownloadOutputFlag(output, "--output")
}
@@ -195,10 +252,12 @@ func resourceDownloadInfo(data map[string]any) (string, map[string]string, error
parsed.Scheme = "https"
resourceURL = parsed.String()
}
if parsed.Scheme != "https" {
return "", nil, apperrors.NewAPI(
"资源下载接口未返回合法的 HTTPS 下载地址")
parsed, err = validateResourceDownloadURL(resourceURL)
if err != nil {
return "", nil, apperrors.NewAPI(fmt.Sprintf(
"资源下载接口返回了不受信任的下载地址: %v", err))
}
resourceURL = parsed.String()
headers := map[string]string{}
if values, ok := data["headers"].(map[string]any); ok {
@@ -211,14 +270,65 @@ func resourceDownloadInfo(data map[string]any) (string, map[string]string, error
return resourceURL, headers, nil
}
func resourceDownloadPreferredName(data map[string]any) string {
for {
result, ok := data["result"].(map[string]any)
if !ok {
break
}
data = result
}
name, _ := data["fileName"].(string)
return strings.TrimSpace(name)
}
func isAliyunOSSHost(host string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
return host == "aliyuncs.com" || strings.HasSuffix(host, ".aliyuncs.com")
if !strings.HasSuffix(host, ".aliyuncs.com") {
return false
}
for _, label := range strings.Split(strings.TrimSuffix(host, ".aliyuncs.com"), ".") {
if (label == "oss" || strings.HasPrefix(label, "oss-")) &&
!strings.Contains(label, "internal") {
return true
}
}
return false
}
func validateResourceDownloadURL(rawURL string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil ||
parsed.Scheme != "https" ||
strings.TrimSpace(parsed.Host) == "" ||
parsed.User != nil {
return nil, apperrors.NewValidation("资源下载地址必须是受信任域名上的 HTTPS URL")
}
host := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(parsed.Hostname()), "."))
if host == "" || net.ParseIP(host) != nil || !isResourceDownloadAllowedHost(host) {
return nil, apperrors.NewValidation(fmt.Sprintf(
"资源下载地址域名 %q 不属于受信任的钉钉或 OSS 域名", host))
}
if port := parsed.Port(); port != "" && port != "443" {
return nil, apperrors.NewValidation("资源下载地址只允许使用 HTTPS 默认端口")
}
return parsed, nil
}
func isResourceDownloadAllowedHost(host string) bool {
host = strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), "."))
// Lower tools may return signed headers with the URL. Keep those headers
// confined to platform-owned public download families; extend this list
// only after observing another official production download host.
return isAliyunOSSHost(host) ||
host == "dingtalk.com" ||
strings.HasSuffix(host, ".dingtalk.com")
}
func resolveResourceDownloadPath(
baseDir, output, resourceURL string,
overwrite bool,
preferredName ...string,
) (absolutePath, relativePath string, err error) {
if err := validateResourceDownloadOutput(output); err != nil {
return "", "", err
@@ -242,7 +352,7 @@ func resolveResourceDownloadPath(
directoryIntent ||
output == "."
if isDirectory {
candidate = filepath.Join(candidate, resourceDownloadFilename(resourceURL))
candidate = filepath.Join(candidate, resourceDownloadFilename(resourceURL, preferredName...))
}
parent := filepath.Dir(candidate)
@@ -321,13 +431,17 @@ func ensureResourceDownloadParent(baseDir, parent string) error {
return nil
}
func resourceDownloadFilename(resourceURL string) string {
func resourceDownloadFilename(resourceURL string, preferredName ...string) string {
if len(preferredName) > 0 {
if name := safeResourceDownloadFilename(preferredName[0]); name != "" {
return name
}
}
parsed, err := url.Parse(resourceURL)
if err == nil {
name, unescapeErr := url.PathUnescape(filepath.Base(parsed.Path))
if unescapeErr == nil {
name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
if name != "" && name != "." && name != string(os.PathSeparator) {
if name = safeResourceDownloadFilename(name); name != "" {
return name
}
}
@@ -335,6 +449,43 @@ func resourceDownloadFilename(resourceURL string) string {
return "download"
}
// safeResourceDownloadFilename returns a portable basename or an empty string
// for names that are unsafe or unusable on a supported platform. Server-provided
// file names are untrusted input, and downloads may be prepared on one OS then
// consumed on another.
func safeResourceDownloadFilename(raw string) string {
normalized := strings.ReplaceAll(raw, "\\", "/")
if strings.TrimSpace(normalized) != normalized {
return ""
}
name := filepath.Base(normalized)
if name == "" || name == "." || name == ".." ||
strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
return ""
}
for _, char := range name {
if char < 0x20 || char == 0x7f || strings.ContainsRune(`<>:"/\|?*`, char) {
return ""
}
}
stem := name
if index := strings.IndexByte(stem, '.'); index >= 0 {
stem = stem[:index]
}
stem = strings.ToUpper(strings.TrimRight(stem, " ."))
switch stem {
case "CON", "PRN", "AUX", "NUL":
return ""
}
if len(stem) == 4 &&
(stem[:3] == "COM" || stem[:3] == "LPT") &&
stem[3] >= '1' && stem[3] <= '9' {
return ""
}
return name
}
func downloadResourceAtomically(
ctx context.Context,
client *http.Client,
@@ -346,22 +497,42 @@ func downloadResourceAtomically(
if client == nil {
client = &http.Client{Timeout: resourceDownloadTimeout}
}
parsedResourceURL, err := validateResourceDownloadURL(resourceURL)
if err != nil {
return 0, err
}
clientCopy := *client
client = &clientCopy
originalRedirect := client.CheckRedirect
initialHost := strings.ToLower(parsedResourceURL.Hostname())
headersConfinedToInitialHost := true
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if req.URL.Scheme != "https" || strings.TrimSpace(req.URL.Host) == "" {
return apperrors.NewValidation("资源下载重定向必须使用 HTTPS")
if _, redirectErr := validateResourceDownloadURL(req.URL.String()); redirectErr != nil {
return apperrors.NewValidation(fmt.Sprintf(
"资源下载重定向指向了不受信任的地址: %v", redirectErr))
}
if len(via) >= 10 {
return apperrors.NewAPI("资源下载重定向次数过多")
}
if !strings.EqualFold(req.URL.Hostname(), initialHost) {
headersConfinedToInitialHost = false
}
// net/http rebuilds redirect headers from the initial request on every
// hop. Once a chain leaves the original host, strip lower-service
// headers on every later hop so a same-host redirect on the new origin
// cannot silently restore them.
if !headersConfinedToInitialHost {
for key := range headers {
req.Header.Del(key)
}
}
if originalRedirect != nil {
return originalRedirect(req, via)
}
return nil
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)
request, err := http.NewRequestWithContext(
ctx, http.MethodGet, parsedResourceURL.String(), nil)
if err != nil {
return 0, apperrors.NewValidation(fmt.Sprintf("创建资源下载请求失败: %v", err))
}
@@ -9,7 +9,6 @@ import (
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
@@ -58,6 +57,7 @@ func TestCrossPlatformCoverageResourceDownloadCommandOutcomes(t *testing.T) {
"--resource-id", "@image",
"--message-id", "msg",
"--open-conversation-id", "cid",
"--yes",
}
t.Run("dry run", func(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{})
@@ -89,7 +89,7 @@ func TestCrossPlatformCoverageResourceDownloadCommandOutcomes(t *testing.T) {
resetResourceDownloadHooks(t)
resourceGetwd = func() (string, error) { return "", errors.New("getwd") }
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/file"}}`,
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://download.dingtalk.com/file"}}`,
}})
root := newPlatformCoverageRoot()
root.SetArgs(baseArgs)
@@ -101,7 +101,7 @@ func TestCrossPlatformCoverageResourceDownloadCommandOutcomes(t *testing.T) {
resetResourceDownloadHooks(t)
resourceAbs = func(string) (string, error) { return "", errors.New("path") }
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/file"}}`,
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://download.dingtalk.com/file"}}`,
}})
root := newPlatformCoverageRoot()
root.SetArgs(baseArgs)
@@ -132,7 +132,7 @@ func TestCrossPlatformCoverageResourceDownloadCommandOutcomes(t *testing.T) {
resetResourceDownloadHooks(t)
resourceDownload = tc.download
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://example.test/file"}}`,
"im/get_resource_download_url": `{"result":{"resourceUrl":"https://download.dingtalk.com/file"}}`,
}})
root := newPlatformCoverageRoot()
root.SetArgs(append(append([]string{}, baseArgs...), "--output", filepath.Join(t.TempDir(), "file")))
@@ -164,7 +164,7 @@ func TestCrossPlatformCoverageResourceDownloadValidationAndInfo(t *testing.T) {
}
}
resourceURL, headers, err := resourceDownloadInfo(map[string]any{
"resourceUrl": "https://example.test/file",
"resourceUrl": "https://download.dingtalk.com/file",
"headers": map[string]any{
"": "ignored",
"X-Count": 3,
@@ -175,14 +175,29 @@ func TestCrossPlatformCoverageResourceDownloadValidationAndInfo(t *testing.T) {
t.Fatalf("resource info = %q %#v %v", resourceURL, headers, err)
}
for host, want := range map[string]bool{
"ALIYUNCS.COM.": true,
"bucket.aliyuncs.com": true,
"example.com": false,
"ALIYUNCS.COM.": false,
"bucket.aliyuncs.com": false,
"bucket.oss-cn-hangzhou.aliyuncs.com": true,
"bucket.oss-internal.aliyuncs.com": false,
"bucket.oss-cn-hangzhou-internal.aliyuncs.com": false,
"example.com": false,
} {
if got := isAliyunOSSHost(host); got != want {
t.Errorf("isAliyunOSSHost(%q) = %v, want %v", host, got, want)
}
}
for host, want := range map[string]bool{
"DINGTALK.COM.": true,
"download.dingtalk.com": true,
"bucket.oss-cn-hangzhou.aliyuncs.com": true,
"aliyuncs.com.evil.test": false,
"evildingtalk.com": false,
"download.example.invalid": false,
} {
if got := isResourceDownloadAllowedHost(host); got != want {
t.Errorf("isResourceDownloadAllowedHost(%q) = %v, want %v", host, got, want)
}
}
}
func TestCrossPlatformCoverageResourceDownloadPathErrors(t *testing.T) {
@@ -348,6 +363,24 @@ func TestCrossPlatformCoverageResourceDownloadFilenameFallbacks(t *testing.T) {
if got := resourceDownloadFilename("https://example.test/a%20b.txt"); got != "a b.txt" {
t.Fatalf("decoded filename = %q", got)
}
for _, unsafeName := range []string{
"..",
"CON",
"nul.txt",
"COM1.log",
"trailing.",
"trailing ",
"line\nbreak.txt",
`bad:name.txt`,
} {
got := resourceDownloadFilename(
"https://download.dingtalk.com/fallback.bin",
unsafeName,
)
if got != "fallback.bin" {
t.Errorf("unsafe preferred name %q produced %q", unsafeName, got)
}
}
}
type resourceRoundTripper func(*http.Request) (*http.Response, error)
@@ -375,63 +408,105 @@ func TestCrossPlatformCoverageDownloadResourceHTTPFailures(t *testing.T) {
errorClient := &http.Client{Transport: resourceRoundTripper(func(*http.Request) (*http.Response, error) {
return nil, errors.New("transport")
})}
if _, err := downloadResourceAtomically(context.Background(), errorClient, "https://example.test/file", nil, dest, false); err == nil {
if _, err := downloadResourceAtomically(context.Background(), errorClient, "https://download.dingtalk.com/file", nil, dest, false); err == nil {
t.Fatal("transport error was swallowed")
}
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(500, "", 0), "https://example.test/file", nil, dest, false); err == nil {
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(500, "", 0), "https://download.dingtalk.com/file", nil, dest, false); err == nil {
t.Fatal("HTTP 500 was accepted")
}
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(200, "x", 2), "https://example.test/file", nil, dest, false); err == nil {
if _, err := downloadResourceAtomically(context.Background(), resourceResponseClient(200, "x", 2), "https://download.dingtalk.com/file", nil, dest, false); err == nil {
t.Fatal("content-length mismatch was accepted")
}
plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
if request.Header.Get("X-Test") != "ok" {
t.Errorf("missing forwarded header")
}
_, _ = w.Write([]byte("body"))
}))
t.Cleanup(plain.Close)
nilClientDest := filepath.Join(t.TempDir(), "nil-client")
if _, err := downloadResourceAtomically(context.Background(), nil, plain.URL, map[string]string{"X-Test": "ok"}, nilClientDest, true); err != nil {
t.Fatal(err)
if _, err := downloadResourceAtomically(
context.Background(), nil, "https://evil.example/file",
map[string]string{"X-Test": "ok"}, nilClientDest, true,
); err == nil {
t.Fatal("nil-client path accepted an untrusted URL")
}
}
func TestCrossPlatformCoverageDownloadResourceRedirectGuards(t *testing.T) {
httpRedirect := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Redirect(w, &http.Request{}, "http://example.test/file", http.StatusFound)
}))
t.Cleanup(httpRedirect.Close)
httpRedirect := &http.Client{Transport: resourceRoundTripper(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusFound,
Body: io.NopCloser(strings.NewReader("")),
Header: http.Header{"Location": []string{"http://evil.example/file"}},
}, nil
})}
if _, err := downloadResourceAtomically(
context.Background(), httpRedirect.Client(), httpRedirect.URL, nil,
context.Background(), httpRedirect, "https://download.dingtalk.com/start", nil,
filepath.Join(t.TempDir(), "http-redirect"), false,
); err == nil {
t.Fatal("HTTP redirect was accepted")
}
var loop *httptest.Server
loop = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
http.Redirect(w, &http.Request{}, loop.URL, http.StatusFound)
}))
t.Cleanup(loop.Close)
loop := &http.Client{Transport: resourceRoundTripper(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusFound,
Body: io.NopCloser(strings.NewReader("")),
Header: http.Header{"Location": []string{"https://download.dingtalk.com/loop"}},
}, nil
})}
if _, err := downloadResourceAtomically(
context.Background(), loop.Client(), loop.URL, nil,
context.Background(), loop, "https://download.dingtalk.com/loop", nil,
filepath.Join(t.TempDir(), "loop"), false,
); err == nil {
t.Fatal("redirect loop was accepted")
}
original := loop.Client()
original := *loop
original.CheckRedirect = func(*http.Request, []*http.Request) error {
return errors.New("original redirect policy")
}
if _, err := downloadResourceAtomically(
context.Background(), original, loop.URL, nil,
context.Background(), &original, "https://download.dingtalk.com/loop", nil,
filepath.Join(t.TempDir(), "original"), false,
); err == nil {
t.Fatal("original redirect rejection was swallowed")
}
redirectCount := 0
crossHost := &http.Client{Transport: resourceRoundTripper(func(request *http.Request) (*http.Response, error) {
redirectCount++
switch redirectCount {
case 1:
if request.Header.Get("X-Resource-Token") != "secret" {
t.Fatal("initial signed header missing")
}
return &http.Response{
StatusCode: http.StatusFound,
Body: io.NopCloser(strings.NewReader("")),
Header: http.Header{"Location": []string{"https://bucket.oss-cn-hangzhou.aliyuncs.com/intermediate"}},
}, nil
case 2:
if request.Header.Get("X-Resource-Token") != "" {
t.Fatal("server-supplied header leaked on first cross-host hop")
}
return &http.Response{
StatusCode: http.StatusFound,
Body: io.NopCloser(strings.NewReader("")),
Header: http.Header{"Location": []string{"https://bucket.oss-cn-hangzhou.aliyuncs.com/final"}},
}, nil
default:
if request.Header.Get("X-Resource-Token") != "" {
t.Fatal("server-supplied header was restored on later same-host hop")
}
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("ok")),
ContentLength: 2,
Header: make(http.Header),
}, nil
})}
if _, err := downloadResourceAtomically(
context.Background(), crossHost, "https://download.dingtalk.com/start",
map[string]string{"X-Resource-Token": "secret"},
filepath.Join(t.TempDir(), "cross-host"), false,
); err != nil {
t.Fatal(err)
}
}
func TestCrossPlatformCoverageDownloadResourceFileFailures(t *testing.T) {
@@ -439,7 +514,7 @@ func TestCrossPlatformCoverageDownloadResourceFileFailures(t *testing.T) {
run := func(t *testing.T, overwrite bool) error {
t.Helper()
_, err := downloadResourceAtomically(
context.Background(), client, "https://example.test/file", nil,
context.Background(), client, "https://download.dingtalk.com/file", nil,
filepath.Join(t.TempDir(), "resource"), overwrite)
return err
}
@@ -516,7 +591,7 @@ func TestCrossPlatformCoverageDownloadResourceCopySuccessWithBuffer(t *testing.T
dest := filepath.Join(t.TempDir(), "resource")
if _, err := downloadResourceAtomically(
context.Background(), resourceResponseClient(200, "ok", 2),
"https://example.test/file", nil, dest, true,
"https://download.dingtalk.com/file", nil, dest, true,
); err != nil {
t.Fatal(err)
}
@@ -15,18 +15,41 @@ package chat
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestResourceDownloadInfo(t *testing.T) {
func TestCrossPlatformCoverageCanonicalMessageResourceType(t *testing.T) {
for input, want := range map[string]string{
"mediaId": "mediaId",
"MEDIAID": "mediaId",
" fileid ": "fileId",
"FileId": "fileId",
} {
got, ok := canonicalMessageResourceType(input)
if !ok || got != want {
t.Errorf("canonicalMessageResourceType(%q) = %q, %v; want %q, true",
input, got, ok, want)
}
}
if got, ok := canonicalMessageResourceType("attachment"); ok || got != "attachment" {
t.Errorf("unsupported resource type = %q, %v", got, ok)
}
if _, err := resolveMessageResourceDownloadData(
nil, "attachment", "resource", "message", "conversation",
); err == nil || !strings.Contains(err.Error(), "不支持的消息资源类型") {
t.Fatalf("unsupported resolver type error = %v", err)
}
}
func TestCrossPlatformCoverageResourceDownloadInfo(t *testing.T) {
url, headers, err := resourceDownloadInfo(map[string]any{
"result": map[string]any{
"resourceUrl": []any{"https://download.example.test/path/image.png"},
"resourceUrl": []any{"https://download.dingtalk.com/path/image.png"},
"headers": map[string]any{
"X-Test": "value",
},
@@ -35,7 +58,7 @@ func TestResourceDownloadInfo(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if url != "https://download.example.test/path/image.png" {
if url != "https://download.dingtalk.com/path/image.png" {
t.Fatalf("url = %q", url)
}
if headers["X-Test"] != "value" {
@@ -57,6 +80,19 @@ func TestResourceDownloadInfo(t *testing.T) {
}); err == nil {
t.Fatal("plain HTTP URL unexpectedly accepted")
}
for _, resourceURL := range []string{
"https://evil.example/file",
"https://aliyuncs.com.evil.example/file",
"https://127.0.0.1/file",
"https://user:secret@download.dingtalk.com/file",
"https://download.dingtalk.com:8443/file",
} {
if _, _, err := resourceDownloadInfo(
map[string]any{"resourceUrl": resourceURL},
); err == nil {
t.Fatalf("untrusted URL %q unexpectedly accepted", resourceURL)
}
}
}
func TestResolveResourceDownloadPath(t *testing.T) {
@@ -115,16 +151,23 @@ func TestResolveResourceDownloadPathRejectsSymlinkParentBeforeCreatingOutside(t
}
}
func TestDownloadResourceAtomically(t *testing.T) {
func TestCrossPlatformCoverageDownloadResourceAtomically(t *testing.T) {
const body = "verified download bytes"
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(body))
}))
t.Cleanup(server.Close)
client := &http.Client{Transport: resourceRoundTripper(func(request *http.Request) (*http.Response, error) {
if request.URL.String() != "https://download.dingtalk.com/resource.bin" {
t.Fatalf("request URL = %q", request.URL)
}
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
ContentLength: int64(len(body)),
Header: make(http.Header),
}, nil
})}
dest := filepath.Join(t.TempDir(), "resource.bin")
size, err := downloadResourceAtomically(
context.Background(), server.Client(), server.URL, nil, dest, false)
context.Background(), client, "https://download.dingtalk.com/resource.bin", nil, dest, false)
if err != nil {
t.Fatal(err)
}
@@ -139,7 +182,7 @@ func TestDownloadResourceAtomically(t *testing.T) {
t.Fatalf("body = %q", got)
}
if _, err := downloadResourceAtomically(
context.Background(), server.Client(), server.URL, nil, dest, false,
context.Background(), client, "https://download.dingtalk.com/resource.bin", nil, dest, false,
); err == nil || !strings.Contains(err.Error(), "已存在") {
t.Fatalf("no-clobber error = %v", err)
}
+296 -29
View File
@@ -5,41 +5,51 @@
package chat
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// MessagesSend is the identity-aware common text/Markdown entry point. Media
// upload remains on the native message send command until DWS has one shared
// upload contract; this Shortcut does not pretend webhook/bot/current-user
// transports support identical capabilities.
const messagesSendFileUploadTimeout = 10 * time.Minute
// MessagesSend is the identity-aware common sending entry point. The current
// user branch reuses the native message leaf's reviewed file-upload flow and
// existing-mediaId image path. Bot and webhook remain text/Markdown-only
// because their lower transports do not expose equivalent media contracts.
var MessagesSend = shortcut.Shortcut{
Service: "chat",
Command: "+messages-send",
Product: "chat",
Description: "统一发送文本/Markdown(current user、bot、webhook)",
Intent: "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送文本/Markdown 时使用;命令会按身份校验目标和凭据并路由到真实下层。current-user 支持幂等键,bot 支持群聊或批量单聊,webhook 的目标由 token 所在群决定。媒体上传仍使用原生命令。",
Description: "统一发送文本、Markdown、当前用户文件或已有 mediaId 图片",
Intent: "当你希望用同一个入口选择 current-user、bot 或 webhook 身份发送消息时使用;命令会按身份校验目标、内容和凭据并路由到真实下层。current-user 支持文本/Markdown、已有 mediaId 图片、安全相对路径文件上传和幂等键;--user 传 userId 时包括在 --dry-run 中也会先通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId。bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。不会把 user 文件能力伪装成 bot/webhook 等价能力。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "identity", Type: shortcut.FlagString, Default: "user", Enum: []string{"user", "bot", "webhook"}, Desc: "发送身份"},
{Name: "as", Type: shortcut.FlagString, Enum: []string{"user", "bot", "webhook"}, Desc: "--identity 的 lark-cli 对齐别名"},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(user/bot 群聊)"},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的 lark-cli 对齐别名"},
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(user)"},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 userId(bot)"},
{Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 openDingTalkId(bot)"},
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code(bot 必填)"},
{Name: "webhook-token", Type: shortcut.FlagString, Desc: "自定义机器人 Webhook token(webhook 必填)"},
{Name: "identity", Type: shortcut.FlagString, Default: "user", Enum: []string{"user", "bot", "webhook"}, Desc: "发送身份;目标、凭据和幂等参数受发送身份能力矩阵约束"},
{Name: "as", Type: shortcut.FlagString, Enum: []string{"user", "bot", "webhook"}, Desc: "--identity 的 lark-cli 对齐别名;受发送身份能力矩阵约束"},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(user/bot 群聊);受发送身份能力矩阵约束"},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的 lark-cli 对齐别名;受发送身份能力矩阵约束"},
{Name: "user", Type: shortcut.FlagString, Desc: "单聊接收者 userId(user;包括 --dry-run 也会先通过通讯录搜索精确匹配 openDingTalkId);受发送身份能力矩阵约束"},
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊接收者 openDingTalkId(user);受发送身份能力矩阵约束"},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 userId(bot);受发送身份能力矩阵约束"},
{Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "批量单聊接收者 openDingTalkId(bot);受发送身份能力矩阵约束"},
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code(identity=bot 时使用);受发送身份能力矩阵约束"},
{Name: "webhook-token", Type: shortcut.FlagString, Desc: "自定义机器人 Webhook token(identity=webhook 时使用);受发送身份能力矩阵约束"},
{Name: "title", Type: shortcut.FlagString, Desc: "消息标题(不传则从正文生成)"},
{Name: "text", Type: shortcut.FlagString, Desc: "纯文本正文"},
{Name: "markdown", Type: shortcut.FlagString, Desc: "Markdown 正文"},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(仅 user)"},
{Name: "idempotency-key", Type: shortcut.FlagString, Desc: "--uuid 的 lark-cli 对齐别名(仅 user)"},
{Name: "msg-type", Type: shortcut.FlagString, Enum: []string{"text", "markdown", "image", "file", "audio", "video"}, Desc: "内容类型;省略时根据正文、--media-id 或 --file 自动推断"},
{Name: "media-id", Type: shortcut.FlagString, Desc: "已有图片 mediaId(仅 user 的 image)"},
{Name: "file", Type: shortcut.FlagString, Desc: "工作目录内安全相对文件路径(仅 user 的 file/audio/video)"},
{Name: "file-path", Type: shortcut.FlagString, Desc: "--file 的兼容别名"},
{Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键(仅 user);受发送身份能力矩阵约束"},
{Name: "idempotency-key", Type: shortcut.FlagString, Desc: "--uuid 的 lark-cli 对齐别名(仅 user);受发送身份能力矩阵约束"},
{Name: "at-open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 openDingTalkId(user/bot 群聊)"},
{Name: "at-user-ids", Type: shortcut.FlagStringSlice, Desc: "@ 的 userId(bot/webhook)"},
{Name: "at-mobiles", Type: shortcut.FlagStringSlice, Desc: "@ 的手机号(webhook)"},
@@ -47,19 +57,21 @@ var MessagesSend = shortcut.Shortcut{
shortcut.AIMessageTagFlag(),
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"text", "markdown"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"text", "markdown"}},
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"text", "markdown", "media-id", "file", "file-path"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"text", "markdown", "media-id", "file", "file-path"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"identity", "as"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-id"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"user", "open-dingtalk-id"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"uuid", "idempotency-key"}},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"identity", "as", "group", "chat-id", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key"},
Description: "user 必须指定 group/open-dingtalk-id 之一;bot 必须指定 robot-code 和群聊/批量单聊目标之一;webhook 必须指定 webhook-token;幂等键仅 user 支持",
Flags: []string{"identity", "as", "group", "chat-id", "user", "open-dingtalk-id", "users", "open-dingtalk-ids", "robot-code", "webhook-token", "uuid", "idempotency-key"},
Description: "目标、凭据和幂等参数受发送身份能力矩阵约束:user 必须指定一个群聊或单聊目标;bot 必须指定 robot-code 和一类目标;webhook 必须指定 webhook-token;幂等键仅 user 支持",
},
},
Tips: []string{
`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 --idempotency-key <key>`,
`dws chat +messages-send --as bot --robot-code <robotCode> --users userId1,userId2 --text "请提交周报"`,
},
Validate: validateMessagesSend,
@@ -69,16 +81,28 @@ var MessagesSend = shortcut.Shortcut{
func validateMessagesSend(rt *shortcut.RuntimeContext) error {
identity := messagesSendIdentity(rt)
group := rt.StrFirst("chat-id", "group")
userID := rt.Str("user")
openID := rt.Str("open-dingtalk-id")
users := uniqueShortcutStrings(rt.StrSlice("users"))
openIDs := uniqueShortcutStrings(rt.StrSlice("open-dingtalk-ids"))
atOpenIDs := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))
atUserIDs := uniqueShortcutStrings(rt.StrSlice("at-user-ids"))
atMobiles := uniqueShortcutStrings(rt.StrSlice("at-mobiles"))
contentType, err := messagesSendContentType(rt)
if err != nil {
return err
}
if contentType == "text" && rt.Str("markdown") != "" {
return apperrors.NewValidation("--msg-type text 必须与 --text 一起使用")
}
if contentType == "markdown" && rt.Str("text") != "" {
return apperrors.NewValidation("--msg-type markdown 必须与 --markdown 一起使用")
}
switch identity {
case "user":
if (group == "") == (openID == "") {
return apperrors.NewValidation("--identity user 时 --group 与 --open-dingtalk-id 必须且只能指定一个")
targetCount := nonEmptyStringCount(group, userID, openID)
if targetCount != 1 {
return apperrors.NewValidation("--identity user 时 --group、--user、--open-dingtalk-id 必须且只能指定一个")
}
if len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" || rt.Str("webhook-token") != "" {
return apperrors.NewValidation("--identity user 不接受 bot/webhook 凭据或批量目标")
@@ -86,9 +110,12 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if len(atUserIDs) > 0 || len(atMobiles) > 0 {
return apperrors.NewValidation("--identity user 只接受 --at-open-dingtalk-ids")
}
if openID != "" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
if (userID != "" || openID != "") && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
return apperrors.NewValidation("user 单聊不接受 @ 参数;@ 只适用于群聊")
}
if contentType != "text" && contentType != "markdown" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) {
return apperrors.NewValidation("user image/file/audio/video 当前不接受 @ 参数")
}
case "bot":
if rt.Str("robot-code") == "" {
return apperrors.NewValidation("--identity bot 必须指定 --robot-code")
@@ -97,8 +124,8 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if (group != "") == hasDirect {
return apperrors.NewValidation("--identity bot 时 --group 与批量单聊目标必须且只能指定一类")
}
if openID != "" || rt.Str("webhook-token") != "" {
return apperrors.NewValidation("--identity bot 不接受 --open-dingtalk-id 或 --webhook-token")
if userID != "" || openID != "" || rt.Str("webhook-token") != "" {
return apperrors.NewValidation("--identity bot 不接受 --user、--open-dingtalk-id 或 --webhook-token")
}
if len(atMobiles) > 0 {
return apperrors.NewValidation("--identity bot 不接受 --at-mobiles")
@@ -109,11 +136,14 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if messagesSendIdempotencyKey(rt) != "" {
return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持")
}
if contentType != "text" && contentType != "markdown" {
return apperrors.NewValidation("--identity bot 当前下层只支持 text/markdown")
}
case "webhook":
if rt.Str("webhook-token") == "" {
return apperrors.NewValidation("--identity webhook 必须指定 --webhook-token")
}
if group != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" {
if group != "" || userID != "" || openID != "" || len(users) > 0 || len(openIDs) > 0 || rt.Str("robot-code") != "" {
return apperrors.NewValidation("--identity webhook 的目标由 token 所在群决定,不接受其他目标或 bot Code")
}
if len(atOpenIDs) > 0 {
@@ -122,6 +152,9 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error {
if messagesSendIdempotencyKey(rt) != "" {
return apperrors.NewValidation("--uuid 当前仅 user 身份的下层支持")
}
if contentType != "text" && contentType != "markdown" {
return apperrors.NewValidation("--identity webhook 当前下层只支持 text/markdown")
}
}
return nil
}
@@ -135,12 +168,43 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error {
}
switch identity {
case "user":
contentType, err := messagesSendContentType(rt)
if err != nil {
return err
}
group, openID, err := messagesSendUserTarget(rt)
if err != nil {
return err
}
if contentType == "image" {
content, _ := json.Marshal(map[string]string{"mediaId": rt.Str("media-id")})
params := rt.AddAIMessageTag(map[string]any{
"msgType": "image",
"content": string(content),
})
addMessagesSendUserTarget(params, group, openID)
if value := messagesSendIdempotencyKey(rt); value != "" {
params["uuid"] = value
}
return executeUnifiedMessageWrite(rt, "chat", "send_personal_message", params)
}
if contentType == "file" || contentType == "audio" || contentType == "video" {
return executeMessagesSendUserFile(rt, group, openID, contentType)
}
if group != "" {
body = helpers.NormalizeMessageMentions(
body,
uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")),
rt.Bool("at-all"),
true,
)
}
content, _ := json.Marshal(map[string]string{"title": title, "text": body})
params := rt.AddAIMessageTag(map[string]any{
"msgType": "markdown",
"content": string(content),
})
if group := rt.StrFirst("chat-id", "group"); group != "" {
if group != "" {
params["openConversationId"] = group
if values := uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids")); len(values) > 0 {
params["atOpenDingTalkIds"] = values
@@ -149,13 +213,22 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error {
params["atAll"] = true
}
} else {
params["receiverOpenDingTalkId"] = rt.Str("open-dingtalk-id")
params["receiverOpenDingTalkId"] = openID
}
if value := messagesSendIdempotencyKey(rt); value != "" {
params["uuid"] = value
}
return executeUnifiedMessageWrite(rt, "chat", "send_personal_message", params)
case "bot":
body = helpers.NormalizeMessageMentions(
body,
append(
uniqueShortcutStrings(rt.StrSlice("at-user-ids")),
uniqueShortcutStrings(rt.StrSlice("at-open-dingtalk-ids"))...,
),
rt.Bool("at-all"),
false,
)
params := map[string]any{
"robotCode": rt.Str("robot-code"),
"title": title,
@@ -185,6 +258,15 @@ func executeMessagesSend(rt *shortcut.RuntimeContext) error {
}
return executeUnifiedMessageWrite(rt, "bot", "batch_send_robot_msg_to_users", params)
case "webhook":
body = helpers.NormalizeMessageMentions(
body,
append(
uniqueShortcutStrings(rt.StrSlice("at-user-ids")),
uniqueShortcutStrings(rt.StrSlice("at-mobiles"))...,
),
rt.Bool("at-all"),
false,
)
params := map[string]any{
"robotToken": rt.Str("webhook-token"),
"title": title,
@@ -241,6 +323,191 @@ func messagesSendBody(rt *shortcut.RuntimeContext) string {
return rt.StrFirst("markdown", "text")
}
func messagesSendContentType(rt *shortcut.RuntimeContext) (string, error) {
contentType := strings.ToLower(rt.Str("msg-type"))
switch {
case rt.Str("media-id") != "":
if contentType != "" && contentType != "image" {
return "", apperrors.NewValidation("--media-id 只支持 --msg-type image")
}
return "image", nil
case rt.StrFirst("file", "file-path") != "":
if contentType == "" {
return "file", nil
}
if contentType != "file" && contentType != "audio" && contentType != "video" {
return "", apperrors.NewValidation("--file 只支持 --msg-type file/audio/video")
}
return contentType, nil
default:
if contentType == "" {
if rt.Str("markdown") != "" {
return "markdown", nil
}
return "text", nil
}
if contentType != "text" && contentType != "markdown" {
return "", apperrors.NewValidation(fmt.Sprintf("--msg-type %s 需要匹配的 --media-id 或 --file", contentType))
}
return contentType, nil
}
}
func messagesSendUserTarget(rt *shortcut.RuntimeContext) (group, openID string, err error) {
group = rt.StrFirst("chat-id", "group")
openID = rt.Str("open-dingtalk-id")
if openID != "" || group != "" {
return group, openID, nil
}
openID, err = resolveUserOpenDingTalkID(rt, rt.Str("user"))
if err != nil {
return "", "", err
}
return "", openID, nil
}
func resolveUserOpenDingTalkID(rt *shortcut.RuntimeContext, userID string) (string, error) {
userID = strings.TrimSpace(userID)
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
"keyword": userID,
})
if err != nil {
return "", fmt.Errorf("通过通讯录搜索把 userId %q 解析为 openDingTalkId 失败: %w", userID, err)
}
exactRows := 0
openIDs := make([]string, 0, 1)
for _, user := range shortcutMapSlice(data["result"]) {
if shortcutString(user, "userId", "userID") != userID {
continue
}
exactRows++
if openID := shortcutString(user, "openDingTalkId", "openDingtalkId"); openID != "" {
openIDs = appendUniqueShortcutString(openIDs, openID)
}
}
switch {
case len(openIDs) == 1:
return openIDs[0], nil
case len(openIDs) > 1:
return "", apperrors.NewValidation(fmt.Sprintf(
"通讯录中 userId %q 精确匹配到多个不同的 openDingTalkId,无法安全选择",
userID,
))
case exactRows > 0:
return "", apperrors.NewValidation(fmt.Sprintf(
"通讯录已精确匹配 userId %q,但结果未返回 openDingTalkId",
userID,
))
default:
return "", apperrors.NewValidation(fmt.Sprintf(
"通讯录搜索结果中没有精确匹配的 userId %q",
userID,
))
}
}
func executeMessagesSendUserFile(
rt *shortcut.RuntimeContext,
group, openID, requestedType string,
) error {
rawPath := rt.StrFirst("file", "file-path")
safePath, err := apperrors.SafeInputPath(rawPath)
if err != nil {
return err
}
meta, err := helpers.BuildConversationLocalFileMeta(safePath, "", "")
if err != nil {
return err
}
targetArgs := map[string]any{}
addMessagesSendUserTarget(targetArgs, group, openID)
idempotencyKey := messagesSendIdempotencyKey(rt)
if rt.DryRun() {
return rt.Output(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"actionCount": 2,
"failedCount": 0,
"actions": []map[string]any{
{
"identity": "user",
"tool": "init/commit_conversation_file_upload",
"file": map[string]any{
"path": rawPath,
"name": meta.FileName,
"sizeBytes": meta.FileSize,
},
},
{
"identity": "user",
"tool": "send_personal_message",
"requestedMessageType": requestedType,
"effectiveMessageType": "file",
"target": targetArgs,
},
},
})
}
uploadContext, cancelUpload := context.WithTimeout(
rt.Command().Context(), messagesSendFileUploadTimeout)
defer cancelUpload()
commitText, err := helpers.UploadConversationLocalFile(
uploadContext, targetArgs, meta, idempotencyKey)
if err != nil {
return err
}
dentryID, spaceID, err := helpers.ParseConversationFileSendIDs(commitText)
if err != nil {
return err
}
content, _ := helpers.BuildConversationFileContent(dentryID, spaceID, meta)
params := rt.AddAIMessageTag(map[string]any{
"msgType": "file",
"content": content,
})
addMessagesSendUserTarget(params, group, openID)
if idempotencyKey != "" {
params["uuid"] = idempotencyKey
}
data, err := rt.CallMCPWriteData("chat", "send_personal_message", params)
if err != nil {
return err
}
return rt.Output(map[string]any{
"ok": true,
"identity": "user",
"tool": "send_personal_message",
"requestedMessageType": requestedType,
"effectiveMessageType": "file",
"file": map[string]any{
"path": rawPath,
"name": meta.FileName,
"sizeBytes": meta.FileSize,
},
"result": data,
})
}
func addMessagesSendUserTarget(params map[string]any, group, openID string) {
if group != "" {
params["openConversationId"] = group
return
}
params["receiverOpenDingTalkId"] = openID
}
func nonEmptyStringCount(values ...string) int {
count := 0
for _, value := range values {
if strings.TrimSpace(value) != "" {
count++
}
}
return count
}
func messagesSendIdempotencyKey(rt *shortcut.RuntimeContext) string {
return rt.StrFirst("idempotency-key", "uuid")
}
+156 -26
View File
@@ -207,21 +207,25 @@ func firstMessageValue(m map[string]any, keys ...string) any {
return nil
}
// Resources extracts actionable media references from both structured message
// fields and the textual mediaId notation returned by older DingTalk message
// APIs. Every reference publishes the exact Shortcut arguments already known
// from the message, plus ready=false and missing fields when a follow-up lookup
// is still required. This shared shape is used by list, search, mget, quoted
// messages and thread replies.
// Resources extracts actionable media and drive-file references from both
// structured message fields and the textual mediaId/fileId notation returned
// by older DingTalk message APIs. Every reference publishes the exact Shortcut
// arguments already known from the message, plus ready=false and missing fields
// when a follow-up lookup is still required. This shared shape is used by list,
// search, mget, quoted messages and thread replies.
func Resources(m map[string]any) []map[string]any {
if m == nil {
return nil
}
ids := make([]string, 0)
collectMediaIDs(m, &ids)
ids = uniqueMediaIDs(ids)
sort.Strings(ids)
if len(ids) == 0 {
mediaIDs := make([]string, 0)
collectResourceIDs(m, "mediaid", mediaIDTextRE, &mediaIDs)
mediaIDs = uniqueResourceIDs(mediaIDs)
sort.Strings(mediaIDs)
fileIDs := make([]string, 0)
collectResourceIDs(m, "fileid", fileIDTextRE, &fileIDs)
fileIDs = uniqueResourceIDs(fileIDs)
sort.Strings(fileIDs)
if len(mediaIDs) == 0 && len(fileIDs) == 0 {
return nil
}
@@ -234,8 +238,8 @@ func Resources(m map[string]any) []map[string]any {
conversationID = ""
}
out := make([]map[string]any, 0, len(ids))
for _, id := range ids {
out := make([]map[string]any, 0, len(mediaIDs)+len(fileIDs))
for _, id := range mediaIDs {
arguments := map[string]any{
"type": "mediaId",
"resource-id": id,
@@ -262,36 +266,94 @@ func Resources(m map[string]any) []map[string]any {
},
})
}
for _, id := range fileIDs {
out = append(out, map[string]any{
"type": "fileId",
"resourceId": id,
"download": map[string]any{
"shortcut": "+messages-resource-download",
"arguments": map[string]any{
"type": "fileId",
"resource-id": id,
},
"ready": true,
"missing": []string{},
},
})
}
return out
}
var mediaIDTextRE = regexp.MustCompile(`(?i)media[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
// ResourcesDeep returns resources from a message and each nested quoted,
// replied-to or forwarded message. Every nested resource is projected from the
// child message that owns it, so its download arguments never reuse the parent
// message ID. A missing child conversation ID inherits the enclosing
// conversation because quoted and forwarded records often omit that duplicate
// field.
func ResourcesDeep(m map[string]any) []map[string]any {
return resourcesDeep(m, "", 0)
}
func collectMediaIDs(value any, out *[]string) {
const maxResourceMessageDepth = 32
func resourcesDeep(m map[string]any, inheritedConversationID string, depth int) []map[string]any {
if m == nil || depth > maxResourceMessageDepth {
return nil
}
conversationID := strings.TrimSpace(fmt.Sprint(ConversationID(m)))
if conversationID == "" || conversationID == "<nil>" {
conversationID = inheritedConversationID
}
owned := m
if ConversationID(m) == nil && conversationID != "" {
owned = make(map[string]any, len(m)+1)
for key, value := range m {
owned[key] = value
}
owned["openConversationId"] = conversationID
}
out := append([]map[string]any(nil), Resources(owned)...)
if depth == maxResourceMessageDepth {
return out
}
for _, child := range nestedMessageChildren(m) {
out = append(out, resourcesDeep(child, conversationID, depth+1)...)
}
return out
}
var mediaIDTextRE = regexp.MustCompile(`(?i)\bmedia[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
var fileIDTextRE = regexp.MustCompile(`(?i)\bfile[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
func collectResourceIDs(value any, targetKey string, textPattern *regexp.Regexp, out *[]string) {
switch typed := value.(type) {
case map[string]any:
resourceType := strings.TrimSpace(fmt.Sprint(firstMessageValue(typed, "resourceType", "resource_type")))
for key, child := range typed {
normalizedKey := strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
if normalizedKey == "mediaid" || (normalizedKey == "resourceid" && strings.EqualFold(resourceType, "mediaId")) {
if id := mediaIDScalar(child); id != "" {
normalizedKey := normalizeMessageKey(key)
if normalizedKey == targetKey ||
(normalizedKey == "resourceid" && strings.EqualFold(resourceType, targetKey)) {
if id := resourceIDScalar(child); id != "" {
*out = append(*out, id)
}
}
collectMediaIDs(child, out)
if isNestedMessageBoundaryKey(normalizedKey) {
continue
}
collectResourceIDs(child, targetKey, textPattern, out)
}
case []any:
for _, child := range typed {
collectMediaIDs(child, out)
collectResourceIDs(child, targetKey, textPattern, out)
}
case []map[string]any:
for _, child := range typed {
collectMediaIDs(child, out)
collectResourceIDs(child, targetKey, textPattern, out)
}
case string:
for _, match := range mediaIDTextRE.FindAllStringSubmatch(typed, -1) {
for _, match := range textPattern.FindAllStringSubmatch(typed, -1) {
if len(match) > 1 {
if id := mediaIDScalar(match[1]); id != "" {
if id := resourceIDScalar(match[1]); id != "" {
*out = append(*out, id)
}
}
@@ -300,13 +362,81 @@ func collectMediaIDs(value any, out *[]string) {
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var decoded any
if json.Unmarshal([]byte(trimmed), &decoded) == nil {
collectMediaIDs(decoded, out)
collectResourceIDs(decoded, targetKey, textPattern, out)
}
}
}
}
func mediaIDScalar(value any) string {
func normalizeMessageKey(key string) string {
return strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
}
func isNestedMessageBoundaryKey(key string) bool {
switch key {
case "quotedmessage", "replymessage", "quoted", "replytomessage",
"forwardmessages", "forwardedmessages", "forwarded":
return true
default:
return false
}
}
func nestedMessageMaps(value any) []map[string]any {
switch typed := value.(type) {
case map[string]any:
return []map[string]any{typed}
case []map[string]any:
return typed
case []any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
if child, ok := item.(map[string]any); ok {
out = append(out, child)
}
}
return out
case string:
var decoded any
if json.Unmarshal([]byte(strings.TrimSpace(typed)), &decoded) == nil {
return nestedMessageMaps(decoded)
}
}
return nil
}
func nestedMessageChildren(value any) []map[string]any {
out := make([]map[string]any, 0)
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
if isNestedMessageBoundaryKey(normalizeMessageKey(key)) {
out = append(out, nestedMessageMaps(child)...)
continue
}
out = append(out, nestedMessageChildren(child)...)
}
case []any:
for _, child := range typed {
out = append(out, nestedMessageChildren(child)...)
}
case []map[string]any:
for _, child := range typed {
out = append(out, nestedMessageChildren(child)...)
}
case string:
trimmed := strings.TrimSpace(typed)
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var decoded any
if json.Unmarshal([]byte(trimmed), &decoded) == nil {
out = append(out, nestedMessageChildren(decoded)...)
}
}
}
return out
}
func resourceIDScalar(value any) string {
text, ok := value.(string)
if !ok {
return ""
@@ -314,7 +444,7 @@ func mediaIDScalar(value any) string {
return strings.Trim(strings.TrimSpace(text), `"'`)
}
func uniqueMediaIDs(values []string) []string {
func uniqueResourceIDs(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
+116
View File
@@ -189,6 +189,111 @@ func TestQuotedMessageIsBoundedAndSemantic(t *testing.T) {
}
}
func TestCrossPlatformCoverageResourcesRespectNestedMessageOwnership(t *testing.T) {
message := map[string]any{
"openMessageId": "parent",
"openConversationId": "cid-parent",
"content": map[string]any{"mediaId": "media-parent"},
"quotedMessage": map[string]any{
"openMessageId": "quoted",
"content": map[string]any{"mediaId": "media-quoted"},
},
"forwardMessages": []any{
map[string]any{
"openMessageId": "forwarded",
"openConversationId": "cid-forwarded",
"content": map[string]any{"mediaId": "media-forwarded"},
},
},
}
owned := Resources(message)
if len(owned) != 1 || owned[0]["resourceId"] != "media-parent" {
t.Fatalf("parent resources crossed message boundary: %#v", owned)
}
deep := ResourcesDeep(message)
if len(deep) != 3 {
t.Fatalf("deep resources = %#v", deep)
}
argumentsByResource := map[string]map[string]any{}
for _, resource := range deep {
download := resource["download"].(map[string]any)
argumentsByResource[resource["resourceId"].(string)] = download["arguments"].(map[string]any)
}
for resourceID, want := range map[string][2]string{
"media-parent": {"parent", "cid-parent"},
"media-quoted": {"quoted", "cid-parent"},
"media-forwarded": {"forwarded", "cid-forwarded"},
} {
arguments := argumentsByResource[resourceID]
if arguments["message-id"] != want[0] ||
arguments["open-conversation-id"] != want[1] {
t.Errorf("%s arguments = %#v, want message=%q conversation=%q",
resourceID, arguments, want[0], want[1])
}
}
}
func TestCrossPlatformCoverageResourceBoundaryHelpers(t *testing.T) {
encoded := map[string]any{
"openMessageId": "parent",
"openConversationId": "cid",
"content": `{"quotedMessage":{"openMessageId":"encoded-child","mediaId":"nested"}}`,
}
if got := Resources(encoded); got != nil {
t.Fatalf("encoded nested resource bound to parent: %#v", got)
}
if got := ResourcesDeep(encoded); len(got) != 1 {
t.Fatalf("encoded nested resources = %#v", got)
} else {
arguments := got[0]["download"].(map[string]any)["arguments"].(map[string]any)
if arguments["message-id"] != "encoded-child" ||
arguments["open-conversation-id"] != "cid" {
t.Fatalf("encoded child arguments = %#v", arguments)
}
}
if got := resourcesDeep(map[string]any{"mediaId": "x"}, "", maxResourceMessageDepth+1); got != nil {
t.Fatalf("over-depth resources = %#v", got)
}
if got := resourcesDeep(
map[string]any{"mediaId": "x"},
"",
maxResourceMessageDepth,
); len(got) != 1 {
t.Fatalf("max-depth owned resources = %#v", got)
}
if got := nestedMessageMaps([]map[string]any{{"id": "a"}}); len(got) != 1 {
t.Fatalf("map slice = %#v", got)
}
if got := nestedMessageMaps(`[{"id":"a"}]`); len(got) != 1 {
t.Fatalf("encoded message list = %#v", got)
}
if got := nestedMessageMaps([]any{"bad", map[string]any{"id": "a"}}); len(got) != 1 {
t.Fatalf("mixed message list = %#v", got)
}
if got := nestedMessageMaps("{"); got != nil {
t.Fatalf("invalid encoded messages = %#v", got)
}
if got := nestedMessageChildren([]map[string]any{
{"content": "plain"},
{"content": `{"forwardedMessages":[{"openMessageId":"m"}]}`},
}); len(got) != 1 {
t.Fatalf("nested message children = %#v", got)
}
if got := nestedMessageChildren([]any{
"plain",
map[string]any{"quoted": map[string]any{"openMessageId": "m"}},
}); len(got) != 1 {
t.Fatalf("mixed nested message children = %#v", got)
}
if got := nestedMessageChildren("{"); len(got) != 0 {
t.Fatalf("invalid nested children = %#v", got)
}
if isNestedMessageBoundaryKey("content") {
t.Fatal("ordinary content treated as a message boundary")
}
}
func TestUpdateTimeOmitsUneditedEcho(t *testing.T) {
if got := UpdateTime(map[string]any{
"createTime": "2026-07-19 13:37:03",
@@ -326,6 +431,17 @@ func TestResourcesReportsMissingDownloadContext(t *testing.T) {
}
}
func TestResourcesTextMediaIDRequiresWordBoundary(t *testing.T) {
resources := Resources(map[string]any{
"openMessageId": "msg-1",
"openConversationId": "cid-1",
"content": `notmediaId=@false mediaId=@real`,
})
if len(resources) != 1 || resources[0]["resourceId"] != "@real" {
t.Fatalf("resources = %#v, want only the bounded mediaId", resources)
}
}
func TestForwarded(t *testing.T) {
var project func(m map[string]any) map[string]any
project = func(m map[string]any) map[string]any {
@@ -29,14 +29,27 @@ func TestCrossPlatformCoverageQuotedResourcesAndScalarVariants(t *testing.T) {
resources := Resources(map[string]any{
"attachments": []map[string]any{
{"resourceType": "mediaId", "resourceId": "@file-a"},
{"mediaId": 42},
{"resourceType": "fileId", "resourceId": "drive-file"},
{"mediaId": 42, "fileId": 42},
},
"content": `[文件] report.txt fileId: drive-file`,
})
if len(resources) != 1 || resources[0]["resourceId"] != "@file-a" {
if len(resources) != 2 ||
resources[0]["resourceId"] != "@file-a" ||
resources[1]["resourceId"] != "drive-file" ||
resources[1]["type"] != "fileId" {
t.Fatalf("resources = %#v", resources)
}
if got := mediaIDScalar(42); got != "" {
t.Fatalf("non-string media ID = %q", got)
fileDownload := resources[1]["download"].(map[string]any)
fileArguments := fileDownload["arguments"].(map[string]any)
if fileDownload["ready"] != true ||
fileDownload["shortcut"] != "+messages-resource-download" ||
fileArguments["type"] != "fileId" ||
fileArguments["resource-id"] != "drive-file" {
t.Fatalf("file download = %#v", fileDownload)
}
if got := resourceIDScalar(42); got != "" {
t.Fatalf("non-string resource ID = %q", got)
}
}
+4 -11
View File
@@ -172,15 +172,7 @@ func dryRunWriteError(product, tool string) error {
}
func looksReadTool(tool string) bool {
tool = strings.TrimSpace(strings.ToLower(tool))
for _, prefix := range []string{
"get_", "list_", "query_", "search_", "unread_",
} {
if strings.HasPrefix(tool, prefix) {
return true
}
}
return false
return helpers.IsReadToolName(tool)
}
func (rt *RuntimeContext) callMCPData(product, tool string, params map[string]any) (map[string]any, error) {
@@ -469,10 +461,11 @@ func validateConstraints(rt *RuntimeContext, s Shortcut) error {
// --dry-run is set. Read-only shortcuts never prompt. Returns false when the
// user declines.
func confirmRisk(rt *RuntimeContext, s Shortcut) bool {
if s.risk() == RiskRead || rt.DryRun() || rt.Yes() {
risk := s.risk()
if risk == RiskRead || rt.DryRun() || rt.Yes() {
return true
}
fmt.Fprintf(rt.cmd.ErrOrStderr(), "即将执行 %s %s(%s),确认继续?(yes/no): ", s.Service, s.Command, s.risk())
fmt.Fprintf(rt.cmd.ErrOrStderr(), "即将执行 %s %s(%s),确认继续?(yes/no): ", s.Service, s.Command, risk)
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
+9 -9
View File
@@ -435,7 +435,7 @@
},
"+messages-send": {
"disposition": "semantic_adapter",
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 文本/Markdown 发送;按身份校验目标与凭据,幂等键只在真实支持的 user 分支开放,媒体上传仍诚实留在 native leaf。",
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 发送;current-user 支持文本、Markdown、mediaId 图片、安全相对路径本地文件上传、userId 姓名解析与幂等键,bot/webhook 仍只暴露下层真实支持的文本/Markdown 能力。",
"risk": "write",
"public": true,
"reviewed": true
@@ -487,7 +487,7 @@
},
"+messages-mget": {
"disposition": "semantic_adapter",
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 复用 HTTPS、相对路径、无覆盖和原子落盘防护,逐资源返回下载失败 ledger。",
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 统一下载 mediaId 与 fileId,复用受信任下载域、相对路径、无覆盖和原子落盘防护,对重复资源去重并逐资源返回下载失败 ledger;安全本地下载沿用 read/not_required 契约,不产生非交互确认盲区。",
"risk": "read",
"public": true,
"reviewed": true
@@ -542,8 +542,8 @@
"reviewed": true
},
"+messages-send-card": {
"disposition": "schema_leaf",
"semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。",
"disposition": "semantic_adapter",
"semantic_delta": "既可只创建流式卡片,也可在一次调用中创建、提取 bizId、写入内容并设置流式状态;dry-run 输出两步执行计划,更新失败时保留已创建的 bizId。",
"risk": "write",
"public": true,
"reviewed": true
@@ -564,7 +564,7 @@
},
"+messages-resource-download": {
"disposition": "primary_smart",
"semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。",
"semantic_delta": "统一承接消息 mediaId 与钉盘 fileId:分别复用 IM 临时资源 URL 和 drive.download_file,只允许钉钉/OSS HTTPS 下载域且重定向复验并隔离跨域凭据,再通过工作目录内安全路径、默认不覆盖、临时文件下载和原子发布输出结构化结果。",
"risk": "read",
"public": true,
"reviewed": true
@@ -627,7 +627,7 @@
},
"+at-me": {
"disposition": "primary_smart",
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。",
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性;可选对资源去重后安全落盘并返回逐项失败 ledger。",
"risk": "read",
"public": true,
"reviewed": true
@@ -641,7 +641,7 @@
},
"+chat-messages": {
"disposition": "primary_smart",
"semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。",
"semantic_delta": "统一群聊与两类单聊目标;省略时间时自动以当前时间向前读取最近消息,并输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文;可选对列表内资源去重后安全落盘并返回逐项失败 ledger。",
"risk": "read",
"public": true,
"reviewed": true
@@ -677,7 +677,7 @@
},
"+search-msg": {
"disposition": "primary_smart",
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。",
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;展开下层按会话分组的 conversationMessagesList,支持精确时间窗、page-all、50 条一组 mget 富化,可选安全下载命中消息资源,并以 failure ledger 显式报告截断、富化或下载失败。",
"risk": "read",
"public": true,
"reviewed": true
@@ -691,7 +691,7 @@
},
"+thread-replies": {
"disposition": "primary_smart",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"risk": "read",
"public": true,
"reviewed": true
+37
View File
@@ -15,6 +15,7 @@ package shortcut
import (
"bytes"
"os"
"strings"
"testing"
@@ -40,6 +41,42 @@ func TestCrossPlatformCoverageRiskDefaultsToRead(t *testing.T) {
}
}
func TestCrossPlatformCoverageConfirmRiskPromptsForStaticWrite(t *testing.T) {
cmd := &cobra.Command{}
var stderr bytes.Buffer
cmd.SetErr(&stderr)
rt := &RuntimeContext{cmd: cmd}
s := Shortcut{
Service: "chat",
Command: "+messages-send",
Risk: RiskWrite,
}
reader, writer, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
previousStdin := os.Stdin
os.Stdin = reader
t.Cleanup(func() {
os.Stdin = previousStdin
_ = reader.Close()
_ = writer.Close()
})
if _, err := writer.WriteString("yes\n"); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
if !confirmRisk(rt, s) {
t.Fatal("static write risk was not confirmed")
}
if got := stderr.String(); !strings.Contains(got, "chat +messages-send(write)") {
t.Fatalf("confirmation prompt = %q", got)
}
}
func TestCrossPlatformCoverageMountRegistersFlagsAndUse(t *testing.T) {
s := Shortcut{
Service: "contact",
+13 -7
View File
@@ -19,6 +19,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
@@ -37,9 +38,9 @@ import (
// so it honours --format/--jq/--fields. When the response carries no
// recognisable message list we fall back to printing the raw payload.
//
// This replaces manually working out the millisecond time window and copying the
// list-mentions incantation. Read-only: it only searches and reshapes, never
// sends, recalls or marks anything.
// This replaces manually working out the millisecond time window and copying
// the list-mentions incantation. The default path only searches and reshapes;
// --download-resources additionally writes resource files locally.
//
// dws chat +at-me
// dws chat +at-me --days 3
@@ -51,18 +52,20 @@ var AtMe = shortcut.Shortcut{
Intent: "当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;" +
"内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息," +
"再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。" +
"这是纯只读操作,只做搜索与本地投影,不会发送、撤回或标记任何消息。",
"默认只读且不会发送、撤回或标记任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(可选,默认 7)", Default: "7", Required: false},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50)", Default: "50"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
},
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: chatshortcut.MessageResourceDownloadConstraints(),
Tips: []string{
`dws chat +at-me`,
`dws chat +at-me --days 3`,
},
Validate: chatshortcut.ValidateMessageResourceDownload,
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — look-back window [now-Nd, now] in epoch millis. days defaults
// to 7; guard against non-positive overrides so the window stays sane.
@@ -99,6 +102,9 @@ var AtMe = shortcut.Shortcut{
}
payload := map[string]any{"messages": results}
chatmsg.ApplyPagination(payload, data)
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, "")
}
return rt.Output(payload)
},
}
@@ -233,7 +239,7 @@ func atMeProjectWithReactions(m map[string]any, includeReactions bool) map[strin
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if resources := chatmsg.Resources(m); len(resources) > 0 {
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
+29 -11
View File
@@ -15,11 +15,19 @@ package smart
import (
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
var dingTalkMessageLocation = time.FixedZone("CST", 8*60*60)
func formatDingTalkMessageBoundary(now time.Time) string {
return now.In(dingTalkMessageLocation).Format("2006-01-02 15:04:05")
}
// ChatMessages: fetch the message list of one conversation (group OR single
// chat) and print a clean projected list (speaker / text / time) instead of a
// raw dump.
@@ -35,8 +43,8 @@ import (
// aliases and one level of nesting;
// 3. print via rt.Output as {messages, count} so it honours --format/--jq/--fields.
//
// Read-only: it only reads a conversation's messages and reshapes them locally,
// never posts or mutates anything.
// The default path only reads and reshapes conversation messages;
// --download-resources additionally writes resource files locally.
//
// dws chat +chat-messages --group <openconversation_id> --time "2025-03-01 00:00:00"
// dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --limit 50
@@ -47,39 +55,43 @@ var ChatMessages = shortcut.Shortcut{
Description: "拉取某个会话(群聊或单聊)的消息列表并投影出发言人/文本/时间",
Intent: "当你想快速看某个会话里的消息(谁在什么时间说了什么),而不想拿到一大坨原始消息字段时使用;" +
"群聊传 --group(群会话 ID,openConversationId),单聊传 --user(对方 userId),两者互斥且必须二选一。" +
"可选 --time 指定起始时间、--limit 指定每页条数、--direction newer/older 控制时间方向(newer 从给定时间往现在拉,older 往以前拉)。" +
"省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。" +
"内部据此调用群聊或单聊的消息列表接口,再在本地投影出每条消息的发言人、文本和时间。" +
"这是纯只读操作,只做拉取与本地投影,不会发送或修改任何消息。",
"默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId),与 --user 互斥"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"},
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"},
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"(可选)"},
{Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";省略时从当前时间向前读取最近消息"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的消息条数(可选)"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
{Name: "direction", Type: shortcut.FlagString, Desc: "时间方向 newer/older(可选,newer 从给定时间往现在拉,older 往以前拉)"},
{Name: "direction", Type: shortcut.FlagString, Enum: []string{"newer", "older"}, Desc: "时间方向 newer/older;省略时为 older,从时间边界向前读取"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
},
Constraints: []shortcut.Constraint{
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "user", "open-dingtalk-id"}},
},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +chat-messages --group <openconversation_id> --time "2025-03-01 00:00:00"`,
`dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --limit 50`,
`dws chat +chat-messages --group <openconversation_id> --direction older`,
},
Validate: chatshortcut.ValidateMessageResourceDownload,
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — build params and pick the right tool. Param keys
// (openconversation_id / userId / time / forward / limit) match the MCP server
// schema for group and direct message listing.
var tool string
params := map[string]any{}
fallbackConversationID := ""
if rt.Changed("time") && rt.Str("time") != "" {
params["time"] = rt.Str("time")
} else {
params["time"] = formatDingTalkMessageBoundary(time.Now())
}
if limit := rt.IntFirst("limit", "size"); limit > 0 {
params["limit"] = limit
@@ -94,11 +106,14 @@ var ChatMessages = shortcut.Shortcut{
case "older":
params["forward"] = false
}
} else {
params["forward"] = false
}
if group := rt.StrFirst("group", "conversation-id", "id"); group != "" {
tool = "list_conversation_message_v2"
params["openconversation_id"] = group
fallbackConversationID = group
} else if openID := strings.TrimSpace(rt.Str("open-dingtalk-id")); openID != "" {
tool = "list_individual_chat_message"
params["openDingTalkId"] = openID
@@ -126,6 +141,9 @@ var ChatMessages = shortcut.Shortcut{
}
direction := strings.TrimSpace(strings.ToLower(rt.Str("direction")))
chatmsg.ApplyMessagePagination(payload, data, items, direction)
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, fallbackConversationID)
}
return rt.Output(payload)
},
}
@@ -198,7 +216,7 @@ func projectChatMessageWithReactions(m map[string]any, includeReactions bool) ma
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if resources := chatmsg.Resources(m); len(resources) > 0 {
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
@@ -67,7 +67,7 @@ func TestCrossPlatformCoverageRichMessageProjections(t *testing.T) {
"emotionReplyList": []any{map[string]any{"emojiName": "赞", "replyCount": 1}},
"quotedMessage": map[string]any{
"openMessageId": "quoted",
"content": "quoted body",
"content": `{"mediaId":"@quoted-image"}`,
},
}
for name, row := range map[string]map[string]any{
@@ -80,6 +80,16 @@ func TestCrossPlatformCoverageRichMessageProjections(t *testing.T) {
t.Errorf("%s projection missing %s: %#v", name, key, row)
}
}
resources := row["resourceRefs"].([]map[string]any)
if len(resources) != 2 {
t.Fatalf("%s projected resources = %#v", name, resources)
}
quotedArgs := resources[1]["download"].(map[string]any)["arguments"].(map[string]any)
if resources[1]["resourceId"] != "@quoted-image" ||
quotedArgs["message-id"] != "quoted" ||
quotedArgs["open-conversation-id"] != "cid" {
t.Fatalf("%s quoted resource context = %#v", name, resources[1])
}
}
}
@@ -89,7 +99,7 @@ func TestCrossPlatformCoverageChatMessagesOpenIDRoute(t *testing.T) {
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "D-user", "--limit", "1"})
root.SetArgs([]string{"chat", "+chat-messages", "--open-dingtalk-id", "D-user", "--limit", "1", "--yes"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
@@ -202,7 +212,7 @@ func TestCrossPlatformCoverageSearchValidationAndTimeErrors(t *testing.T) {
for _, tail := range cases {
helpers.InitDeps(&smartCoverageCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+search-msg"}, tail...))
root.SetArgs(append([]string{"chat", "+search-msg", "--yes"}, tail...))
if err := root.Execute(); err == nil {
t.Errorf("invalid search args succeeded: %v", tail)
}
@@ -247,7 +257,7 @@ func TestCrossPlatformCoverageSearchPaginationFailureModes(t *testing.T) {
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+search-msg"}, tc.args...))
root.SetArgs(append([]string{"chat", "+search-msg", "--yes"}, tc.args...))
err := root.Execute()
if (err != nil) != tc.wantError {
t.Fatalf("error = %v, wantError=%v", err, tc.wantError)
+217
View File
@@ -0,0 +1,217 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package smart
import (
"bytes"
"encoding/json"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageSafeResourceQueryDownloadsStayReadOnly(t *testing.T) {
for _, command := range []shortcut.Shortcut{AtMe, ChatMessages, SearchMsg, ThreadReplies} {
if command.Risk != shortcut.RiskRead {
t.Errorf("%s risk contract = %q", command.Command, command.Risk)
}
}
}
func TestCrossPlatformCoverageMessageReadShortcutsPublishResourceDownloadPlans(t *testing.T) {
message := `{"openMessageId":"msg","openConversationId":"cid","content":"{\"mediaId\":\"@image\"}","quotedMessage":{"openMessageId":"quoted","content":"{\"fileId\":\"@quoted-file\"}"}}`
tests := []struct {
name string
tool string
response string
args []string
resultKey string
}{
{
name: "chat messages",
tool: "chat/list_conversation_message_v2",
response: `{"result":{"messages":[` + message + `]}}`,
args: []string{"chat", "+chat-messages", "--group", "cid"},
resultKey: "messages",
},
{
name: "search",
tool: "im/search_messages",
response: `{"result":{"messages":[` + message + `],"hasMore":false}}`,
args: []string{"chat", "+search-msg", "--query", "x", "--no-enrich"},
resultKey: "messages",
},
{
name: "at me",
tool: "chat/search_at_me_message",
response: `{"result":{"conversationMessagesList":[{"openConversationId":"cid","messages":[` + message + `]}]}}`,
args: []string{"chat", "+at-me"},
resultKey: "messages",
},
{
name: "thread replies",
tool: "chat/list_topic_replies",
response: `{"result":{"messages":[` + message + `]}}`,
args: []string{"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread"},
resultKey: "replies",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &smartCoverageCaller{responses: map[string][]string{
tc.tool: {tc.response},
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
args := append([]string{}, tc.args...)
args = append(args, "--download-resources", "--output-dir", "./downloads", "--dry-run")
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if caller.counts[tc.tool] != 1 {
t.Fatalf("lower calls = %#v", caller.counts)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
rows, ok := payload[tc.resultKey].([]any)
if !ok || len(rows) != 1 {
t.Fatalf("payload missing %s: %#v", tc.resultKey, payload)
}
row, _ := rows[0].(map[string]any)
resources, _ := row["resourceRefs"].([]any)
if len(resources) != 2 {
t.Fatalf("visible resources = %#v", resources)
}
ledger, _ := payload["resourceDownloads"].(map[string]any)
if ledger["dryRun"] != true ||
ledger["discoveredCount"] != float64(2) ||
ledger["requestedCount"] != float64(2) {
t.Fatalf("resource plan = %#v", ledger)
}
})
}
}
func TestCrossPlatformCoverageMessageReadShortcutResourceOutputValidation(t *testing.T) {
for _, args := range [][]string{
{"chat", "+chat-messages", "--group", "cid"},
{"chat", "+search-msg", "--query", "x", "--no-enrich"},
{"chat", "+at-me"},
{"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread"},
} {
helpers.InitDeps(&smartCoverageCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append(args, "--download-resources", "--output-dir", "../outside", "--yes"))
if err := root.Execute(); err == nil {
t.Fatalf("unsafe output accepted: %v", args)
}
}
}
func TestCrossPlatformCoverageChatMessagesDefaultsToRecentHistory(t *testing.T) {
caller := &platformCoverageCaller{}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
before := time.Now().Add(-2 * time.Second)
root.SetArgs([]string{"chat", "+chat-messages", "--group", "cid", "--limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
after := time.Now().Add(2 * time.Second)
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
call := caller.calls[0]
if call.args["forward"] != false {
t.Fatalf("default forward = %#v, want false", call.args["forward"])
}
boundary, err := time.ParseInLocation(
"2006-01-02 15:04:05",
call.args["time"].(string),
dingTalkMessageLocation,
)
if err != nil {
t.Fatal(err)
}
if boundary.Before(before) || boundary.After(after) {
t.Fatalf("default time = %s, want current boundary", boundary)
}
}
func TestFormatDingTalkMessageBoundaryDoesNotDependOnProcessTimezone(t *testing.T) {
now := time.Date(2026, time.July, 29, 1, 2, 3, 0, time.UTC)
if got := formatDingTalkMessageBoundary(now); got != "2026-07-29 09:02:03" {
t.Fatalf("UTC process boundary = %q, want DingTalk UTC+8 wall time", got)
}
}
func TestCrossPlatformCoverageChatMessagesPreservesExplicitTime(t *testing.T) {
caller := &platformCoverageCaller{}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+chat-messages",
"--group", "cid",
"--time", "2026-07-01 12:34:56",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 ||
caller.calls[0].args["time"] != "2026-07-01 12:34:56" {
t.Fatalf("explicit time call = %#v", caller.calls)
}
}
func TestCrossPlatformCoverageSearchMsgFlattensRealGroupedResponse(t *testing.T) {
original := map[string]any{"openMessageId": "m1"}
items := searchMsgItems(map[string]any{
"result": map[string]any{
"conversationMessagesList": []any{
"invalid-group",
map[string]any{"messages": "invalid"},
map[string]any{
"openConversationId": "cid",
"title": "会话",
"singleChat": true,
"messages": []any{
"invalid-message",
original,
map[string]any{
"openMessageId": "m2",
"openConversationId": "own-cid",
"conversationTitle": "own-title",
"singleChat": false,
},
},
},
},
},
})
if len(items) != 2 {
t.Fatalf("grouped items = %#v", items)
}
if items[0]["openConversationId"] != "cid" || items[0]["conversationTitle"] != "会话" ||
items[0]["singleChat"] != true {
t.Fatalf("injected group context = %#v", items[0])
}
if items[1]["openConversationId"] != "own-cid" || items[1]["conversationTitle"] != "own-title" ||
items[1]["singleChat"] != false {
t.Fatalf("message context was overwritten: %#v", items[1])
}
if _, mutated := original["openConversationId"]; mutated {
t.Fatalf("source message mutated: %#v", original)
}
if searchMsgChildMap(map[string]any{"result": "invalid"}, "result") != nil {
t.Fatal("non-map child was accepted")
}
}
+83 -11
View File
@@ -21,6 +21,7 @@ import (
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
@@ -35,9 +36,10 @@ var SearchMsg = shortcut.Shortcut{
Product: "im",
Description: "多维搜索消息,可全量翻页并批量富化详情",
Intent: "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天," +
"也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。",
"也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
"--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词"},
{Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true},
{Name: "group", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
@@ -65,17 +67,22 @@ var SearchMsg = shortcut.Shortcut{
{Name: "page-limit", Type: shortcut.FlagInt, Desc: "--page-all 的最大页数(1-40)", Default: "20"},
{Name: "no-enrich", Type: shortcut.FlagBool, Desc: "不再按消息 ID 批量查询完整详情"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出命中消息的 reaction(默认输出)"},
},
Constraints: []shortcut.Constraint{
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"query", "group", "groups", "chat-id", "senders", "sender", "at-me", "is-at-me", "at-ids", "message-type", "only-robot", "conversation-type", "chat-type"},
Kind: shortcut.ConstraintAtLeastOne,
Flags: []string{"query", "keyword", "group", "conversation-id", "id", "groups", "chat-id", "senders", "sender", "at-me", "is-at-me", "at-ids", "message-type", "only-robot", "conversation-type", "chat-type"},
Description: "至少指定一个内容、身份、会话或消息类型过滤条件",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"start", "end", "days"},
Description: "--start 与 --end 必须同时指定;否则使用 --days 时间窗",
Flags: []string{"start"},
Description: "需与 --end 一起传",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"end"},
Description: "需与 --start 一起传",
},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"groups", "chat-id"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"senders", "sender"}},
@@ -83,12 +90,12 @@ var SearchMsg = shortcut.Shortcut{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"conversation-type", "chat-type"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "page-size"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}},
},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +search-msg --group <openConversationId> --query "changefree"`,
`dws chat +search-msg --senders <openDingTalkId> --at-me --days 3 --page-all`,
},
Validate: validateSearchMsg,
Validate: validateSearchMsgWithResources,
Execute: func(rt *shortcut.RuntimeContext) error {
params, err := searchMsgParams(rt)
if err != nil {
@@ -200,10 +207,20 @@ var SearchMsg = shortcut.Shortcut{
if hasMore && nextCursor != "" && nextCursor != "<nil>" {
payload["nextCursor"] = nextCursor
}
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, messages, "")
}
return rt.Output(payload)
},
}
func validateSearchMsgWithResources(rt *shortcut.RuntimeContext) error {
if err := validateSearchMsg(rt); err != nil {
return err
}
return chatshortcut.ValidateMessageResourceDownload(rt)
}
func validateSearchMsg(rt *shortcut.RuntimeContext) error {
hasFilter := rt.StrFirst("query", "keyword", "group", "conversation-id", "id", "message-type", "conversation-type", "chat-type") != "" ||
len(rt.StrSlice("groups")) > 0 ||
@@ -392,6 +409,14 @@ func searchMsgItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
for _, root := range []map[string]any{data, searchMsgChildMap(data, "result")} {
if root == nil {
continue
}
if groups, ok := root["conversationMessagesList"].([]any); ok {
return searchMsgFlattenGroups(groups)
}
}
keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"}
for _, key := range keys {
if arr, ok := data[key].([]any); ok {
@@ -408,6 +433,53 @@ func searchMsgItems(data map[string]any) []map[string]any {
return nil
}
func searchMsgChildMap(data map[string]any, key string) map[string]any {
if value, ok := data[key].(map[string]any); ok {
return value
}
return nil
}
func searchMsgFlattenGroups(groups []any) []map[string]any {
out := make([]map[string]any, 0)
for _, rawGroup := range groups {
group, ok := rawGroup.(map[string]any)
if !ok {
continue
}
messages, ok := group["messages"].([]any)
if !ok {
continue
}
conversationID := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
conversationTitle := strings.TrimSpace(fmt.Sprint(group["title"]))
singleChat, hasSingleChat := group["singleChat"]
for _, rawMessage := range messages {
message, ok := rawMessage.(map[string]any)
if !ok {
continue
}
item := make(map[string]any, len(message)+3)
for key, value := range message {
item[key] = value
}
if _, exists := item["openConversationId"]; !exists &&
conversationID != "" && conversationID != "<nil>" {
item["openConversationId"] = conversationID
}
if _, exists := item["conversationTitle"]; !exists &&
conversationTitle != "" && conversationTitle != "<nil>" {
item["conversationTitle"] = conversationTitle
}
if _, exists := item["singleChat"]; !exists && hasSingleChat {
item["singleChat"] = singleChat
}
out = append(out, item)
}
}
return out
}
func searchMsgToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
@@ -453,7 +525,7 @@ func searchMsgProjectWithReactions(m map[string]any, includeReactions bool) map[
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if resources := chatmsg.Resources(m); len(resources) > 0 {
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {

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