Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
415ce12905 | ||
|
|
a1d223015d | ||
|
|
0283cb4f79 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Privacy-safe CLI telemetry** (#1009) — reports reviewed command outcomes and profile identity dimensions while excluding command arguments, output, paths, device fingerprints, and automatic system dimensions; `DO_NOT_TRACK=1` disables reporting.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat IM ID flags** (#954) — standardizes chat command entry points on `--conversation-id` for conversation IDs and `--message-id` for message IDs, so help, Schema, and Agent recommendations use the same canonical flags.
|
||||
- **Legacy chat flag compatibility** (#954) — keeps older chat IM ID flags such as `--group`, `--id`, `--chat`, `--open-conversation-id`, `--msg-id`, and `--open-message-id` working as compatibility aliases where applicable, while hiding migrated aliases from recommended help and Schema surfaces.
|
||||
- **Chat group bots target flag** (#954) — keeps `dws chat group bots` on the visible `--group` flag; this command does not register `--group-name`, and `--group` accepts either an openConversationId or a uniquely resolved group name.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write.
|
||||
- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent.
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
|
||||
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
|
||||
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
|
||||
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
|
||||
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
|
||||
the first failing folder with its depth and reason, and emit a recovery command that
|
||||
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
|
||||
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
|
||||
quoted so a URL query string or a shell metacharacter cannot change how the copied command
|
||||
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
|
||||
containing metacharacters are not inlined at all: the command carries a placeholder and the
|
||||
original value is shown on a separate line marked as data rather than an executable command.
|
||||
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
|
||||
Remote-controlled folder names and server error text are stripped of ANSI escapes and
|
||||
control characters before they reach the plain-text stderr message. The internal `sortTime`
|
||||
sort key no longer leaks into `drive list --depth` output on any path.
|
||||
+47
-257
@@ -151,10 +151,6 @@ jobs:
|
||||
filename.startsWith('scripts/') ||
|
||||
filename.startsWith('verify/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
// Shortcut declarations feed the live command tree and Schema
|
||||
// assembly. Their reverse dependencies include the expensive
|
||||
// app and generator packages, which must run in separate shards.
|
||||
filename.startsWith('internal/shortcut/') ||
|
||||
filename.startsWith('internal/generator/') ||
|
||||
filename.startsWith('internal/cli/schema') ||
|
||||
// Parameter aliases are reduced against the live command tree.
|
||||
@@ -513,28 +509,11 @@ jobs:
|
||||
run: node .github/reviewer-routing.test.js
|
||||
|
||||
test-focused:
|
||||
name: "Test (focused: ${{ matrix.shard }})"
|
||||
name: Test (changed packages)
|
||||
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
|
||||
# Each shard owns one bounded slice of the impacted set, so no single job
|
||||
# carries internal/app together with every reverse dependency. The shard
|
||||
# list and per-shard execution below mirror test-race, which runs the same
|
||||
# shards at full-suite scope; release-scripts is included because its
|
||||
# dedicated job only runs at full-suite or release-sensitive scope, and
|
||||
# dropping it here would stop testing test/scripts changes entirely.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app
|
||||
- generators
|
||||
- helpers
|
||||
- cli
|
||||
- smoke
|
||||
- remaining
|
||||
- release-scripts
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -563,77 +542,23 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Select impacted packages for shard
|
||||
id: select
|
||||
- name: Test changed packages and reverse dependencies
|
||||
shell: bash
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list-shard "$TEST_SHARD" "$TEST_BASE_REF" "$TEST_HEAD_REF"
|
||||
list "$TEST_BASE_REF" "$TEST_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$package_output" ]; then
|
||||
echo "No buildable Go package in shard $TEST_SHARD is affected by this revision." \
|
||||
echo "No buildable Go package is affected by this revision." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
echo "affected=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# The package list travels through a file rather than a step output:
|
||||
# reading it with `mapfile < file` has unambiguous line semantics,
|
||||
# whereas a here-string over a multi-line output would append an extra
|
||||
# empty element if the value ever carried a trailing newline, and an
|
||||
# empty element would reach go test as an empty package argument.
|
||||
printf '%s\n' "$package_output" > "$RUNNER_TEMP/focused-shard-packages.txt"
|
||||
echo "affected=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build
|
||||
if: ${{ matrix.shard == 'remaining' && steps.select.outputs.affected == 'true' }}
|
||||
run: make build
|
||||
|
||||
- name: Install archive tooling
|
||||
if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }}
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Test shard with Race Detection
|
||||
if: ${{ steps.select.outputs.affected == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
TEST_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t packages < "$RUNNER_TEMP/focused-shard-packages.txt"
|
||||
test "${#packages[@]}" -gt 0
|
||||
for package in "${packages[@]}"; do
|
||||
test -n "$package" || {
|
||||
echo "shard package list contains an empty entry" >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
if [ "$TEST_SHARD" = "app" ]; then
|
||||
# A single long-lived app test process retains every constructed
|
||||
# command tree in framework registries. Isolate Schema assembly and
|
||||
# bounded name ranges so each process releases that state on exit.
|
||||
test "${#packages[@]}" -eq 1
|
||||
./scripts/ci/run-app-race-tests.sh run "${packages[0]}"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$TEST_SHARD" = "release-scripts" ]; then
|
||||
# Mirror the dedicated release-contract job: these suites shell out
|
||||
# to archive tooling and are not race-instrumented there.
|
||||
go test -v -count=1 -timeout=10m "${packages[@]}"
|
||||
exit 0
|
||||
fi
|
||||
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
|
||||
# give them a dedicated package timeout on slower hosted runners.
|
||||
timeout_budget=12m
|
||||
if [ "$TEST_SHARD" = "cli" ] ||
|
||||
[ "$TEST_SHARD" = "smoke" ]; then
|
||||
timeout_budget=15m
|
||||
fi
|
||||
go test -v -race -count=1 -timeout="$timeout_budget" "${packages[@]}"
|
||||
mapfile -t packages <<< "$package_output"
|
||||
go test -v -race -count=1 -timeout=15m "${packages[@]}"
|
||||
|
||||
test-race:
|
||||
name: "Test (race: ${{ matrix.shard }})"
|
||||
@@ -786,7 +711,7 @@ jobs:
|
||||
failed=0
|
||||
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
|
||||
for shard in \
|
||||
"focused shards:$FOCUSED_RESULT" \
|
||||
"changed packages:$FOCUSED_RESULT" \
|
||||
"race shards:$RACE_RESULT" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT" \
|
||||
@@ -815,7 +740,7 @@ jobs:
|
||||
release_expected=success
|
||||
fi
|
||||
for shard in \
|
||||
"focused shards:$FOCUSED_RESULT:$focused_expected" \
|
||||
"changed packages:$FOCUSED_RESULT:$focused_expected" \
|
||||
"race shards:$RACE_RESULT:$race_expected" \
|
||||
"release scripts:$RELEASE_SCRIPTS_RESULT:$release_expected" \
|
||||
"cross-platform compile:$CROSS_PLATFORM_RESULT:success"
|
||||
@@ -986,7 +911,7 @@ jobs:
|
||||
coverage-current:
|
||||
name: Coverage (current)
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite != 'true' }}
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
@@ -1001,6 +926,10 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -1022,33 +951,41 @@ jobs:
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Run scoped unit tests with coverage
|
||||
- name: Run current unit tests with coverage
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
changed_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
impacted_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
|
||||
printf 'mode: atomic\n' > coverage.txt
|
||||
echo "No buildable Go package needs scoped coverage." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
mapfile -t changed_packages <<< "$changed_output"
|
||||
mapfile -t impacted_packages <<< "$impacted_output"
|
||||
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
|
||||
if [ "$FULL_SUITE" = true ]; then
|
||||
go test -count=1 -p 1 \
|
||||
-coverpkg="$coverpkg" \
|
||||
-coverprofile=coverage.txt \
|
||||
-covermode=atomic \
|
||||
"${impacted_packages[@]}"
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
else
|
||||
changed_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
changed "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
impacted_output="$(
|
||||
./scripts/ci/changed-test-packages.sh \
|
||||
list "$COVERAGE_BASE_REF" "$COVERAGE_HEAD_REF"
|
||||
)"
|
||||
if [ -z "$changed_output" ] || [ -z "$impacted_output" ]; then
|
||||
printf 'mode: atomic\n' > coverage.txt
|
||||
echo "No buildable Go package needs scoped coverage." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
mapfile -t changed_packages <<< "$changed_output"
|
||||
mapfile -t impacted_packages <<< "$impacted_output"
|
||||
coverpkg="$(IFS=,; echo "${changed_packages[*]}")"
|
||||
go test -count=1 -p 1 \
|
||||
-coverpkg="$coverpkg" \
|
||||
-coverprofile=coverage.txt \
|
||||
-covermode=atomic \
|
||||
"${impacted_packages[@]}"
|
||||
fi
|
||||
fi
|
||||
if [ "$(wc -l < coverage.txt)" -gt 1 ]; then
|
||||
go tool cover -func=coverage.txt
|
||||
@@ -1061,66 +998,6 @@ jobs:
|
||||
path: coverage.txt
|
||||
retention-days: 1
|
||||
|
||||
coverage-current-full:
|
||||
name: "Coverage (current: ${{ matrix.shard }})"
|
||||
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: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard:
|
||||
- app
|
||||
- cli
|
||||
- generators
|
||||
- helpers
|
||||
- remaining
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
# Each shard keeps -p 1 so the authoritative measurement stays serial
|
||||
# inside one instrumented process group; shards run on isolated runners,
|
||||
# and scripts/ci/test-packages.sh verify proves the shard union equals
|
||||
# the previous single full-suite package set exactly once.
|
||||
- name: Run current shard tests with coverage
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
COVERAGE_SHARD: ${{ matrix.shard }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
package_output="$(./scripts/ci/test-packages.sh list-coverage "$COVERAGE_SHARD")"
|
||||
test -n "$package_output"
|
||||
mapfile -t packages <<< "$package_output"
|
||||
test "${#packages[@]}" -gt 0
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile="coverage-shard-$COVERAGE_SHARD.txt" \
|
||||
-covermode=atomic \
|
||||
"${packages[@]}"
|
||||
go tool cover -func="coverage-shard-$COVERAGE_SHARD.txt" | tail -n 1
|
||||
|
||||
- name: Upload current shard coverage profile
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-current-shard-${{ matrix.shard }}
|
||||
path: coverage-shard-${{ matrix.shard }}.txt
|
||||
retention-days: 1
|
||||
|
||||
coverage-supporting:
|
||||
name: Coverage (supporting)
|
||||
needs: lint
|
||||
@@ -1174,11 +1051,14 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
@@ -1196,34 +1076,7 @@ jobs:
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
# The merge-base full-suite profile is a pure function of the base
|
||||
# commit. Reuse the profile published by the last green push run of
|
||||
# exactly that commit instead of re-running the whole suite; any key
|
||||
# mismatch falls back to authoritative recomputation. Exact key only,
|
||||
# never prefix fallback: a near-miss profile would compare the
|
||||
# candidate against the wrong commit.
|
||||
- name: Restore cached merge-base coverage profile
|
||||
id: baseline-cache
|
||||
if: needs.lint.outputs.full_suite == 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Materialize cached merge-base coverage profile
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
cp coverage-cache.txt coverage-base.txt
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
|
||||
- name: Run baseline unit tests with coverage
|
||||
if: steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
@@ -1272,21 +1125,6 @@ jobs:
|
||||
fi
|
||||
)
|
||||
|
||||
- name: Prepare merge-base coverage profile cache
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-base.txt
|
||||
test "$(head -n 1 coverage-base.txt)" = "mode: atomic"
|
||||
cp coverage-base.txt coverage-cache.txt
|
||||
|
||||
- name: Save merge-base coverage profile cache
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Upload baseline coverage profile
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -1299,7 +1137,6 @@ jobs:
|
||||
needs:
|
||||
- lint
|
||||
- coverage-current
|
||||
- coverage-current-full
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
- coverage-darwin
|
||||
@@ -1315,7 +1152,6 @@ jobs:
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
PLATFORM_SENSITIVE: ${{ needs.lint.outputs.platform_sensitive }}
|
||||
CURRENT_RESULT: ${{ needs.coverage-current.result }}
|
||||
CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }}
|
||||
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
|
||||
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
|
||||
DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
|
||||
@@ -1323,7 +1159,6 @@ jobs:
|
||||
run: |
|
||||
failed=0
|
||||
current_expected=success
|
||||
current_full_expected=skipped
|
||||
supporting_expected=skipped
|
||||
baseline_expected=success
|
||||
native_expected=skipped
|
||||
@@ -1331,8 +1166,6 @@ jobs:
|
||||
current_expected=skipped
|
||||
baseline_expected=skipped
|
||||
elif [ "$FULL_SUITE" = true ]; then
|
||||
current_expected=skipped
|
||||
current_full_expected=success
|
||||
supporting_expected=success
|
||||
fi
|
||||
if [ "$CHANGELOG_ONLY" != true ] &&
|
||||
@@ -1343,7 +1176,6 @@ jobs:
|
||||
|
||||
for profile in \
|
||||
"current:$CURRENT_RESULT:$current_expected" \
|
||||
"current shards:$CURRENT_FULL_RESULT:$current_full_expected" \
|
||||
"supporting:$SUPPORTING_RESULT:$supporting_expected" \
|
||||
"baseline:$BASELINE_RESULT:$baseline_expected"
|
||||
do
|
||||
@@ -1379,7 +1211,6 @@ jobs:
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
@@ -1403,12 +1234,11 @@ jobs:
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Download current coverage profiles
|
||||
- name: Download current coverage profile
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: coverage-current-*
|
||||
merge-multiple: true
|
||||
name: coverage-current-profile
|
||||
path: .
|
||||
|
||||
- name: Download supporting coverage profiles
|
||||
@@ -1425,26 +1255,6 @@ jobs:
|
||||
name: coverage-baseline-profile
|
||||
path: .
|
||||
|
||||
# Shard profiles cover disjoint package sets, so their block-level
|
||||
# concatenation is the same candidate profile one serial run produced.
|
||||
# Every expected shard must be present; a missing shard would silently
|
||||
# shrink the scope-matched overall comparison.
|
||||
- name: Assemble full-suite coverage profile
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test ! -f coverage.txt
|
||||
for shard in app cli generators helpers remaining; do
|
||||
profile="coverage-shard-$shard.txt"
|
||||
test -f "$profile"
|
||||
test "$(head -n 1 "$profile")" = "mode: atomic"
|
||||
done
|
||||
printf 'mode: atomic\n' > coverage.txt
|
||||
for shard in app cli generators helpers remaining; do
|
||||
tail -n +2 "coverage-shard-$shard.txt" >> coverage.txt
|
||||
done
|
||||
|
||||
- name: Enforce coverage gate
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
env:
|
||||
@@ -1465,26 +1275,6 @@ jobs:
|
||||
COVERAGE_ADDITIONAL_DIFF_PROFILE="$additional_profile" \
|
||||
make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||
|
||||
# Publish this push's full-suite profile as the merge-base cache for
|
||||
# future PRs whose merge-base is exactly this commit. Saved only after
|
||||
# the gate passed so a broken run never becomes a baseline. Both producer
|
||||
# and consumer use coverage-cache.txt because the cache version includes
|
||||
# the configured path as well as the compression tool.
|
||||
- name: Prepare push coverage profile as merge-base cache
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage.txt
|
||||
test "$(head -n 1 coverage.txt)" = "mode: atomic"
|
||||
cp coverage.txt coverage-cache.txt
|
||||
|
||||
- name: Save push coverage profile as merge-base cache
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Generate coverage report
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
run: |
|
||||
|
||||
@@ -20,10 +20,6 @@ test/cli_compat/testdata/
|
||||
.gitignore
|
||||
.worktrees/
|
||||
.qoder/
|
||||
_logs/
|
||||
_docs/
|
||||
_output/
|
||||
vendor/
|
||||
|
||||
# Secrets & credentials
|
||||
.env
|
||||
|
||||
@@ -6,70 +6,6 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.59-beta.1] - 2026-08-14
|
||||
|
||||
### Added
|
||||
|
||||
- **Drive list type/time filtering** (#942) — `dws drive list` gains `--type
|
||||
file|folder`, `--start`, and `--end` for client-side filtering by node type
|
||||
and modification time on both the pan and workspace routes. Filtering runs
|
||||
a bounded full scan of the target directory (2000-entry cap, reported via
|
||||
`truncated=true`), composes with `--latest`/`--pattern`/`--depth`, and is
|
||||
mutually exclusive with `--versions`/`--cursor`/`--order-by`/`--order`/
|
||||
`--limit`. Time values accept relative forms (`24h`/`7d`/`2w`), RFC 3339,
|
||||
zone-less ISO 8601 (Asia/Shanghai), or a plain date.
|
||||
|
||||
- **Drive folder synchronization** — adds `dws drive status`, `dws drive pull`,
|
||||
`dws drive push`, and `dws drive sync` for file-level comparison and transfer
|
||||
between a local folder and a Drive folder. Differences come from exact MD5 by
|
||||
default or from modification time with `--quick`; `status` is read-only, `pull`
|
||||
and `push` are one-directional with `--if-exists skip|smart|overwrite`, and
|
||||
`sync` is bidirectional with `--on-conflict remote-wins|local-wins|keep-both|ask`.
|
||||
Only regular files are transferred — online documents and shortcuts are skipped,
|
||||
neither side deletes extra files, downloads are staged through a temporary file
|
||||
and committed with an atomic rename, and remote names that would escape
|
||||
`--local-folder` are reported as failures instead of being written. Every command
|
||||
prints a structured summary on stdout and exits non-zero when any item fails.
|
||||
|
||||
- **International DingTalk region support** — adds `.io` login and MCP routing, pre-release endpoint overrides, and profile-aware gateway selection while preserving the existing `.com` flow.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat identity routing** — validates explicit `openDingTalkId` inputs and improves name, `userId`, and `openDingTalkId` routing for message shortcuts.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Drive `--latest` refuses incomplete Top-N** (#899) — `dws drive list --latest` used to
|
||||
exit 0 with a "Top-N" computed over a partially scanned tree whenever a directory read
|
||||
failed mid-recursion (permission denied, API error), letting an incomplete set pose as the
|
||||
globally newest files. Truncation at the 2000-item scan cap and mid-recursion directory
|
||||
failures now both fail closed (`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`), report
|
||||
the first failing folder with its depth and reason, and emit a recovery command that
|
||||
reproduces the original candidate set — query domain, `--folder`, `--pattern`, `--type`,
|
||||
`--start` and `--end` are all carried over. On POSIX shells each user-supplied value is
|
||||
quoted so a URL query string or a shell metacharacter cannot change how the copied command
|
||||
parses. On Windows no quoting form is safe for both `cmd.exe` and PowerShell, so values
|
||||
containing metacharacters are not inlined at all: the command carries a placeholder and the
|
||||
original value is shown on a separate line marked as data rather than an executable command.
|
||||
Unrecoverable errors under `--latest` return the root cause instead of a partial result.
|
||||
Remote-controlled folder names and server error text are stripped of ANSI escapes and
|
||||
control characters before they reach the plain-text stderr message. The internal `sortTime`
|
||||
sort key no longer leaks into `drive list --depth` output on any path.
|
||||
|
||||
- **Drive list pattern filtering** (#942) — `dws drive list --pattern` on the
|
||||
single-layer pan route now filters the returned page by name pattern; the
|
||||
flag was previously accepted but silently ignored.
|
||||
|
||||
- **Drive list `--type folder --latest` composition** (#942) — `--latest` now
|
||||
ranks the filtered entries (folders included when `--type folder` is set)
|
||||
instead of unconditionally dropping folders, so the documented combination
|
||||
returns the most recently modified folders rather than an empty list.
|
||||
|
||||
- **Chat message time defaults** (#973) — default omitted `chat message list-all` time bounds in `Asia/Shanghai` when emitting timezone-less `yyyy-MM-dd HH:mm:ss` values, matching parsing semantics and rejecting reversed windows.
|
||||
|
||||
- **Doc and Drive parameter aliases** — normalizes reviewed identifier, pagination, path, version, and role synonyms while blocking ambiguous values before dispatch.
|
||||
|
||||
|
||||
## [1.0.58] - 2026-08-13
|
||||
|
||||
This release promotes the sealed `v1.0.58-beta.6` contents to stable.
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.59-beta.1"
|
||||
version "1.0.58-beta.6"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-arm64.tar.gz"
|
||||
sha256 "36a30f3496e0f759c15c0b09f67dbd23b8ecdfff2eebe572f88125b26485830f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-arm64.tar.gz"
|
||||
sha256 "8f55497b84113f81b318e087c723016a02eded1cb5784cbd0811fe527d5852ca"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-amd64.tar.gz"
|
||||
sha256 "e7a04906380efd8da88cd112e6a512bb6470a3956dc370150037ed6e314db445"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-darwin-amd64.tar.gz"
|
||||
sha256 "b1762f1640310fb4100634fe54d9baace46384bb270c59148fe306275554d491"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-arm64.tar.gz"
|
||||
sha256 "f59ab055f3e841e4cebc964ae3ef969668475548abaaf8bede44afdca9a3e28d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-arm64.tar.gz"
|
||||
sha256 "55393310ef0e1f24ea2c0dc22f00c0eb3b343edc2deb18d0db7838cda65af25d"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-amd64.tar.gz"
|
||||
sha256 "2c8f919489d958c7d49262615e81faac70a9fbcae2d589ab54a0bb3c5700a057"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-linux-amd64.tar.gz"
|
||||
sha256 "3830f77d09b4da4aa39f0c08d772ff3fa1ffb837eb15bb417f97edbccb073b7d"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-skills.zip"
|
||||
sha256 "25f4a7e1d01fa4d771d79201b34b11ee8a24182bdcdc94bfb98d2bd5845bed3b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.6/dws-skills.zip"
|
||||
sha256 "f304a883a4f9e938b26a44692cd5a8d3d8704ba70ee7f33ba7c288434da72b6f"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -18,7 +18,7 @@ help:
|
||||
@printf "Available targets:\n"
|
||||
@printf " make build - Build the dws CLI binary\n"
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n"
|
||||
@printf " make test-plan - Verify every default Go package belongs to one CI test shard\n"
|
||||
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
|
||||
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
|
||||
@printf " make format-check - Check all repository Go source files with gofmt\n"
|
||||
|
||||
+2
-66
@@ -15,76 +15,12 @@ package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
|
||||
)
|
||||
|
||||
var (
|
||||
appExecute = app.ExecuteWithTelemetry
|
||||
resolveTelemetryIdentity = app.ResolveTelemetryIdentity
|
||||
trackRun = func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
clitrack.New(cfg).Run(execute, exitCode)
|
||||
}
|
||||
)
|
||||
|
||||
// trackedExitError tells clitrack that the command failed without asking it to
|
||||
// print the error a second time. The already-rendered message is published via
|
||||
// ExtraFields c5, while app.Execute remains the sole owner of presentation.
|
||||
type trackedExitError struct{}
|
||||
|
||||
func (trackedExitError) Error() string { return "" }
|
||||
|
||||
func trackerConfig(identity app.TelemetryIdentity, commandPath, errorMessage *string) clitrack.Config {
|
||||
return clitrack.Config{
|
||||
PID: "wcCRwZ",
|
||||
App: "dws",
|
||||
Version: app.RawVersion(),
|
||||
UID: identity.UserID,
|
||||
Username: identity.UserName,
|
||||
NoCommandLine: true,
|
||||
NoCwd: true,
|
||||
NoAutomaticDimensions: true,
|
||||
ExtraFields: func() map[string]string {
|
||||
fields := map[string]string{"c9": *commandPath}
|
||||
if identity.CorpID != "" {
|
||||
fields["c10"] = identity.CorpID
|
||||
}
|
||||
if *errorMessage != "" {
|
||||
fields["c5"] = *errorMessage
|
||||
}
|
||||
return fields
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func telemetryOptedOut() bool {
|
||||
return strings.TrimSpace(os.Getenv("DO_NOT_TRACK")) != ""
|
||||
}
|
||||
var exit = os.Exit
|
||||
|
||||
func main() {
|
||||
optedOut := telemetryOptedOut()
|
||||
identity := app.TelemetryIdentity{}
|
||||
if !optedOut {
|
||||
identity = resolveTelemetryIdentity(os.Args[1:])
|
||||
}
|
||||
exitCode := 0
|
||||
commandPath := "dws"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(identity, &commandPath, &errorMessage)
|
||||
if optedOut {
|
||||
cfg.PID = ""
|
||||
}
|
||||
trackRun(
|
||||
cfg,
|
||||
func() error {
|
||||
exitCode, commandPath, errorMessage = appExecute()
|
||||
if exitCode != 0 {
|
||||
return trackedExitError{}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
func(error) int { return exitCode },
|
||||
)
|
||||
exit(app.Execute())
|
||||
}
|
||||
|
||||
+13
-206
@@ -1,220 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"gitlab.alibaba-inc.com/aes/aem-go-sdk/clitrack"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageMainRunsThroughCLITracker(t *testing.T) {
|
||||
for _, wantCode := range []int{0, 1, 3, 5} {
|
||||
t.Run(fmt.Sprintf("exit_%d", wantCode), func(t *testing.T) {
|
||||
t.Setenv("DO_NOT_TRACK", "")
|
||||
wantError := ""
|
||||
if wantCode != 0 {
|
||||
wantError = "synthetic failure"
|
||||
}
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "sheet", "read", "--profile", "corp-a"})
|
||||
testseam.Swap(t, &resolveTelemetryIdentity, func(args []string) app.TelemetryIdentity {
|
||||
if strings.Join(args, " ") != "sheet read --profile corp-a" {
|
||||
t.Fatalf("telemetry identity args = %#v", args)
|
||||
}
|
||||
return app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}
|
||||
})
|
||||
testseam.Swap(t, &appExecute, func() (int, string, string) { return wantCode, "sheet read", wantError })
|
||||
called := false
|
||||
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
called = true
|
||||
if cfg.PID != "wcCRwZ" || cfg.App != "dws" {
|
||||
t.Fatalf("tracker identity = PID %q App %q", cfg.PID, cfg.App)
|
||||
}
|
||||
if cfg.Version != app.RawVersion() {
|
||||
t.Fatalf("tracker Version = %q, want %q", cfg.Version, app.RawVersion())
|
||||
}
|
||||
if !cfg.NoCommandLine || !cfg.NoCwd || !cfg.NoAutomaticDimensions || cfg.CaptureOutput {
|
||||
t.Fatalf("tracker privacy config = NoCommandLine %v NoCwd %v NoAutomaticDimensions %v CaptureOutput %v", cfg.NoCommandLine, cfg.NoCwd, cfg.NoAutomaticDimensions, cfg.CaptureOutput)
|
||||
}
|
||||
if cfg.Env != "" || cfg.EventID != "" || cfg.Endpoint != "" || cfg.FlushTimeout != 0 || cfg.OutputMaxLen != 0 {
|
||||
t.Fatalf("tracker SDK defaults were overridden: %#v", cfg)
|
||||
}
|
||||
if cfg.UID != "user-1" || cfg.Username != "Alice" || cfg.UserType != "" {
|
||||
t.Fatalf("tracker user identity = UID %q Username %q UserType %q", cfg.UID, cfg.Username, cfg.UserType)
|
||||
}
|
||||
func TestCrossPlatformCoverageMainExitsWithSuccessfulVersionCommand(t *testing.T) {
|
||||
previousExit := exit
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
exit = previousExit
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
err := execute()
|
||||
if wantCode == 0 && err != nil {
|
||||
t.Fatalf("successful tracked execute error = %v", err)
|
||||
}
|
||||
if wantCode != 0 && (err == nil || err.Error() != "") {
|
||||
t.Fatalf("failed tracked execute error = %#v, want empty sentinel", err)
|
||||
}
|
||||
if gotCode := exitCode(err); gotCode != wantCode {
|
||||
t.Fatalf("tracked exit code = %d, want %d", gotCode, wantCode)
|
||||
}
|
||||
fields := cfg.ExtraFields()
|
||||
if fields["c9"] != "sheet read" || fields["c10"] != "corp-1" || fields["c5"] != wantError {
|
||||
t.Fatalf("tracker extra fields = %#v, want command path, corp ID, and error %q", fields, wantError)
|
||||
}
|
||||
if (wantError == "" && len(fields) != 2) || (wantError != "" && len(fields) != 3) {
|
||||
t.Fatalf("tracker extra field count = %d for error %q", len(fields), wantError)
|
||||
}
|
||||
})
|
||||
|
||||
main()
|
||||
if !called {
|
||||
t.Fatalf("trackRun was not called for exit code %d", wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTrackerConfigOmitsEmptyOrganization(t *testing.T) {
|
||||
commandPath := "version"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(app.TelemetryIdentity{}, &commandPath, &errorMessage)
|
||||
if cfg.UID != "" {
|
||||
t.Fatalf("empty identity UID = %q", cfg.UID)
|
||||
}
|
||||
if cfg.Username != "" {
|
||||
t.Fatalf("empty identity Username = %q", cfg.Username)
|
||||
}
|
||||
if fields := cfg.ExtraFields(); len(fields) != 1 || fields["c9"] != "version" {
|
||||
t.Fatalf("empty organization fields = %#v", fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDefaultTrackRunNoopTracker(t *testing.T) {
|
||||
called := false
|
||||
trackRun(clitrack.Config{}, func() error {
|
||||
code := -1
|
||||
exit = func(value int) {
|
||||
called = true
|
||||
return nil
|
||||
}, nil)
|
||||
if !called {
|
||||
t.Fatal("default tracker did not execute callback")
|
||||
code = value
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMainRespectsDoNotTrack(t *testing.T) {
|
||||
t.Setenv("DO_NOT_TRACK", "1")
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "version"})
|
||||
testseam.Swap(t, &resolveTelemetryIdentity, func([]string) app.TelemetryIdentity {
|
||||
t.Fatal("DO_NOT_TRACK must skip telemetry identity reads")
|
||||
return app.TelemetryIdentity{}
|
||||
})
|
||||
testseam.Swap(t, &appExecute, func() (int, string, string) { return 0, "version", "" })
|
||||
testseam.Swap(t, &trackRun, func(cfg clitrack.Config, execute func() error, exitCode func(error) int) {
|
||||
if cfg.PID != "" || cfg.UID != "" || cfg.Username != "" {
|
||||
t.Fatalf("opted-out tracker config = %#v", cfg)
|
||||
}
|
||||
if err := execute(); err != nil {
|
||||
t.Fatalf("opted-out execution failed: %v", err)
|
||||
}
|
||||
if code := exitCode(nil); code != 0 {
|
||||
t.Fatalf("opted-out exit code = %d, want 0", code)
|
||||
}
|
||||
})
|
||||
|
||||
os.Args = []string{"dws", "version"}
|
||||
main()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTrackerPayloadUsesReviewedFieldWhitelist(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "sheet", "read", "--access-token", "must-not-leak"}
|
||||
t.Setenv("SHELL", "/bin/zsh")
|
||||
t.Setenv("TERM_SESSION_ID", "stable-session")
|
||||
t.Setenv("TMUX_PANE", "%42")
|
||||
t.Setenv("LANG", "zh_CN.UTF-8")
|
||||
t.Setenv("LC_ALL", "zh_CN.UTF-8")
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
requestBody := make(chan []byte, 1)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
body, _ := io.ReadAll(req.Body)
|
||||
requestBody <- body
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
commandPath := "sheet read"
|
||||
errorMessage := ""
|
||||
cfg := trackerConfig(app.TelemetryIdentity{UserID: "user-1", UserName: "Alice", CorpID: "corp-1"}, &commandPath, &errorMessage)
|
||||
cfg.Endpoint = server.URL
|
||||
cfg.FlushTimeout = time.Second
|
||||
clitrack.New(cfg).Run(func() error { return nil }, nil)
|
||||
|
||||
var body []byte
|
||||
select {
|
||||
case body = <-requestBody:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for telemetry request")
|
||||
}
|
||||
var envelope map[string]string
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatalf("decode telemetry request %q: %v", body, err)
|
||||
}
|
||||
decoded, err := url.QueryUnescape(envelope["gokey"])
|
||||
if err != nil {
|
||||
t.Fatalf("decode gokey: %v", err)
|
||||
}
|
||||
globalFields, err := url.ParseQuery(decoded)
|
||||
if err != nil {
|
||||
t.Fatalf("parse global telemetry fields: %v", err)
|
||||
}
|
||||
eventFields, err := url.ParseQuery(globalFields.Get("msg"))
|
||||
if err != nil {
|
||||
t.Fatalf("parse event telemetry fields: %v", err)
|
||||
}
|
||||
|
||||
assertTelemetryKeys(t, globalFields, []string{"app_name", "app_version", "env", "msg", "pid", "platform", "uid", "username", "version"})
|
||||
assertTelemetryKeys(t, eventFields, []string{"c1", "c10", "c3", "c4", "c9", "p1", "p4", "ts", "type"})
|
||||
for key, want := range map[string]string{
|
||||
"app_name": "dws", "app_version": app.RawVersion(), "env": "prod", "pid": "wcCRwZ",
|
||||
"platform": "cli", "uid": "user-1", "username": "Alice", "version": app.RawVersion(),
|
||||
} {
|
||||
if got := globalFields.Get(key); got != want {
|
||||
t.Fatalf("global telemetry field %s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"type": "event", "p1": "cli.exec", "p4": "SYS", "c1": "dws", "c3": "0", "c9": "sheet read", "c10": "corp-1",
|
||||
} {
|
||||
if got := eventFields.Get(key); got != want {
|
||||
t.Fatalf("event telemetry field %s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"device_id", "ext", "os", "os_version", "pv_id", "sdk_version", "sid", "timezone_offset"} {
|
||||
if globalFields.Has(key) {
|
||||
t.Fatalf("global telemetry leaked %s: %q", key, decoded)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"c2", "c5", "c6", "c7", "c8"} {
|
||||
if eventFields.Has(key) {
|
||||
t.Fatalf("event telemetry leaked %s: %q", key, globalFields.Get("msg"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertTelemetryKeys(t *testing.T, fields url.Values, want []string) {
|
||||
t.Helper()
|
||||
got := make([]string, 0, len(fields))
|
||||
for key := range fields {
|
||||
got = append(got, key)
|
||||
}
|
||||
sort.Strings(got)
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("telemetry keys = %v, want %v", got, want)
|
||||
if !called || code != 0 {
|
||||
t.Fatalf("main exit = called %v, code %d", called, code)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-15
@@ -201,21 +201,8 @@ base_ref=$(git merge-base HEAD origin/main)
|
||||
standard PR, CI derives changed packages and their reverse-dependency test
|
||||
closure, then generates candidate and merge-base profiles with the same test
|
||||
scope and `coverpkg`. High-risk and protected-main runs use the complete
|
||||
profiles. The complete candidate profile is produced by disjoint per-shard
|
||||
helper jobs (`scripts/ci/test-packages.sh list-coverage`, kept serial with
|
||||
`-p 1` inside each shard; `verify` proves the shard union equals the
|
||||
full-suite scope exactly once) and concatenated in the aggregate job before
|
||||
enforcement. The complete merge-base profile is restored from an exact-key
|
||||
cache written by the last green `main` push of that same commit (key:
|
||||
merge-base SHA plus resolved Go version); any miss falls back to recomputing
|
||||
it in a merge-base worktree. The trusted `main` producer and PR consumer use
|
||||
the same dedicated cache profile path because GitHub includes that path in the
|
||||
cache version; the runtime-facing candidate and baseline filenames remain
|
||||
separate. Near-miss reuse is forbidden — the caches carry no prefix restore
|
||||
keys, because a neighbouring commit's profile would compare the candidate
|
||||
against the wrong baseline. Supporting and (when
|
||||
platform-selected) native profiles are generated before the aggregate
|
||||
`Coverage` context evaluates them. The
|
||||
profiles. Supporting and (when platform-selected) native profiles are
|
||||
generated before the aggregate `Coverage` context evaluates them. The
|
||||
aggregate and native gates require 100% coverage for changed executable Go
|
||||
statements. Overall coverage remains an unrounded, zero-tolerance,
|
||||
scope-matched merge-base non-regression check. Candidate and baseline profiles
|
||||
|
||||
@@ -92,7 +92,6 @@ command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷
|
||||
| `Required` / `MarkRequired` | 非空校验 / cobra 硬必填 | 是(`required`) |
|
||||
| `RequiredHint`, `Aliases`, `EnvVar` | 校验提示、隐藏别名、环境回退 | 否(执行细节;别名不上主 parameter 表) |
|
||||
| `ArgDefault`, `Bind`, `OmitEmpty`, `Trim`, `Transform` | toolArgs 装配语义 | 否(载荷细节;`Bind` 可进 property 映射,但不另造 flag) |
|
||||
| `Input` | 额外取值来源:`@path` 读文件 / `-` 读 stdin,在 required/enum/约束/`Validate` 之前原地解析 | 否(今日:能力由作者写进 `Usage` / `SchemaDescription` 文案,是已声明事实而非推断;不另造 flag。目标形态收敛为类型化投影字段,见 RFC §5.3) |
|
||||
|
||||
#### 1.2.2 编排 / 执行字段(不算声明)
|
||||
|
||||
|
||||
@@ -292,7 +292,7 @@ Definition(仅声明;不可编译)
|
||||
|
||||
下列字段**是**框架声明面(经 `corecmd.New` 生效并嵌入 `dws.schema.*`):
|
||||
|
||||
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面;`Input` 是取值来源声明,经 `corecmd.New` 生效但**不**嵌入 `dws.schema.*`,能力靠 `Usage` 文案声明,见 §5.3)
|
||||
- `Flags`(含 Name/Kind/Default/Required/MarkRequired/Usage 等注册面)
|
||||
- `Constraints`
|
||||
- **非空** `Risk`(空值 = 运行时当只读确认,且**不**嵌入 `dws.schema.risk`)
|
||||
- `ConstParams`(载荷声明;不上用户 flag 表)
|
||||
@@ -673,52 +673,6 @@ func (k Key[T]) Declare(opts ...FlagOption[T]) FlagSpec
|
||||
- 构造时拒绝 `InputSourceInvalid`。
|
||||
- 当前没有任何 Shortcut 或 Leaf 声明 `Input`,因此 M1 增加能力且零上线表面变化。让现有命令采用它属于 §9 下的用户可见变更。
|
||||
|
||||
`Input` 的框架能力今日已在 `corecmd` 落地(声明即执行的过渡形态,语义与上文目标一致),使用指南:
|
||||
|
||||
**今日声明形态**:`FlagSpec.Input []string`,源常量 `corecmd.InputFile`(`"file"`)/ `corecmd.InputStdin`(`"stdin"`)。`helpers.LeafFlag` 是 `corecmd.FlagSpec` 别名,直接可用;`shortcut.Flag.Input` 同形声明,经 `FromShortcut` 映射到 `FlagSpec`。
|
||||
|
||||
```go
|
||||
// LeafSpec / helpers
|
||||
Flags: []helpers.LeafFlag{
|
||||
{
|
||||
Name: "content",
|
||||
Usage: "文档内容(支持 @文件路径 或 - 读 stdin)",
|
||||
Bind: "content",
|
||||
Input: []string{corecmd.InputFile, corecmd.InputStdin},
|
||||
},
|
||||
}
|
||||
|
||||
// shortcut
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "markdown", Desc: "Markdown 内容(支持 @文件路径 或 -)",
|
||||
Input: []string{"file", "stdin"}},
|
||||
}
|
||||
```
|
||||
|
||||
**运行时语义**(`resolveInputFlags`,在 `runDeclaredPreflight` 内、required/enum/约束/Validate 之前执行,原地改写 cobra flag 值):
|
||||
|
||||
- `--flag @path`:文件内容替换取值;`--flag -`:stdin 内容替换取值。
|
||||
- `--flag @@value`:转义为字面 `@value`,不做来源解析。
|
||||
- 只解析显式 CLI token(主名或别名);EnvVar 回落与注册默认值透传不解析。
|
||||
- 内容前置剥离 UTF-8 BOM;`Trim` 等既有语义照常作用于解析后的值。
|
||||
- 读取失败、源不支持、`@` 后空路径都是类型化校验错误(退出码 3);同时声明两种源而文件读取失败时附 stdin 引导 hint。
|
||||
|
||||
**作者守则**:
|
||||
|
||||
- 声明即全部能力:required/enum/约束/Validate 校验的已是解析后的真实内容,`Execute`/`Invoke` 无需任何额外代码。
|
||||
- `Usage`/`Desc` 必须写明支持 `@路径`/`-`;框架不自动改写 help 文案,今日也不向 Schema 投影(新增投影字段须先过 homology 评审,避免 catalog drift)。
|
||||
- `user_required` 确认的写命令若声明 `InputStdin`:stdin 在校验阶段被消费,交互确认将 fail-closed 为 `confirmation_required`,此类调用必须显式 `--yes`(或 `--dry-run`)。
|
||||
- **声明前先确认取值空间不会被前缀吃掉**:声明 `InputFile` 后,任何以 `@` 开头的合法值都会被当成文件路径(本产品尤其常见的是 at 提及类取值,如 `--at-user @zhangsan` 会报读取文件失败),用户只能改用 `@@` 转义;声明 `InputStdin` 后字面值 `-` 不可达(与 curl 等约定一致)。若该 flag 的正常取值可能命中这两种形态,就不要声明对应来源。
|
||||
- 声明在构造期校验(fail-closed panic):仅限 `KindString`;源值必须是 `file`/`stdin` 且不重复。
|
||||
|
||||
**今日实现与目标形态的差异**(迁移到本节目标 `FlagSpec` 时收敛):
|
||||
|
||||
| 维度 | 今日 | 目标 |
|
||||
|---|---|---|
|
||||
| 源类型 | `[]string` 常量 | 类型化 `InputSource` |
|
||||
| 路径边界 | 直接本地文件 IO | 复用 §5.5.2 本地文件 effect 边界 |
|
||||
| Schema 投影 | 无(靠作者在 Usage 声明) | 声明即最终源,随 Catalog 透传 |
|
||||
|
||||
核心 FlagSpec 故意没有:
|
||||
|
||||
- `Bind`;
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
<tr><td>节点列表 Shortcut 调错 Wiki MCP 服务。</td><td>真实后端 <code>success=false</code>,Mock/静态检查看不出。</td><td><span class="tag fixed">跨域路由</span> 明确调用 doc/list_nodes,并纳入真实 E2E。</td></tr>
|
||||
<tr><td>成员帮助宣称最大 200。</td><td>真实接口超过 50 直接参数错误。</td><td><span class="tag fixed">真实上限</span> Shortcut 与原子 Help 均改为 50,并在本地提前拒绝。</td></tr>
|
||||
<tr><td>成员写操作从最多 50 条、不可分页的名单推断成员存在或缺失。</td><td>目标在截断部分时会误报写失败,或把未验证的移除报告为已读回。</td><td><span class="tag fixed">终态证据</span> 只接受写接口 <code>success=true</code>,并在结果中明确 <code>readbackAvailable=false</code>。</td></tr>
|
||||
<tr><td>空间搜索的稳定工作流属性名与实际请求属性名不同。</td><td>直接改写已发布的 <code>query/limit</code> 会造成无版本 Schema 破坏;继续隐式转换又会让审计者误以为请求同名透传。</td><td><span class="tag fixed">显式复合适配</span> 最终 Schema 保留兼容属性并明确声明转换为 <code>keyword/pageSize</code>;回归测试同时锁定最终交付和精确请求参数。</td></tr>
|
||||
<tr><td>知识库名称帮助宣称最大 100。</td><td>真实接口超过 32 失败。</td><td><span class="tag fixed">真实上限</span> Help 与 Shortcut 校验统一为 32。</td></tr>
|
||||
<tr><td>复制/移动/创建只把无异常视为成功。</td><td>空确认、未知远端效果或移动未到目标仍可能被接受。</td><td><span class="tag fixed">读回证明</span> 在后端具备精确查询能力时检查 success、业务 ID、workspace/folder 等最终状态。</td></tr>
|
||||
</tbody></table>
|
||||
@@ -102,7 +101,7 @@
|
||||
<tr><td>节点</td><td><code>+wiki-new-doc</code></td><td>按唯一空间名创建,nodeId 与文档详情读回一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>动态</td><td><code>+feed-list</code></td><td>创建/移动操作后返回真实 feeds 数组,缺字段不会被接受。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
</tbody></table></div>
|
||||
<p class="small">可复跑入口:<code>make build</code> 后设置临时 <code>DWS_WIKI_E2E_MEMBER_ID</code>,在交互终端运行 <code>./scripts/dev/wiki-shortcut-e2e.py</code>。脚本只输出能力标签,不输出业务对象;受保护操作及最终清理均由命令逐项获取终端确认,非交互环境会在创建测试数据前拒绝运行。</p>
|
||||
<p class="small">可复跑入口:<code>make build</code> 后设置临时 <code>DWS_WIKI_E2E_MEMBER_ID</code>,运行 <code>./scripts/dev/wiki-shortcut-e2e.py</code>。脚本只输出能力标签,不输出业务对象。</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
|
||||
@@ -2,8 +2,6 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
|
||||
go 1.25.9
|
||||
|
||||
replace gitlab.alibaba-inc.com/aes/aem-go-sdk => ./third_party/aem-go-sdk
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/RealAlexandreAI/json-repair v0.0.15
|
||||
@@ -19,7 +17,6 @@ require (
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
gitlab.alibaba-inc.com/aes/aem-go-sdk v0.3.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/text v0.35.0
|
||||
|
||||
@@ -82,46 +82,6 @@ func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagErrorWithSuggestionsChatFromExplainsBothMeanings(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
search := &cobra.Command{Use: "+search-msg", Run: func(*cobra.Command, []string) {}}
|
||||
search.Flags().String("sender", "", "sender target")
|
||||
search.Flags().String("start", "", "start time")
|
||||
root.AddCommand(chat)
|
||||
chat.AddCommand(search)
|
||||
|
||||
orig := fmt.Errorf("unknown flag: --from")
|
||||
err := flagErrorWithSuggestions(search, orig)
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Reason != "ambiguous_flag" || !strings.Contains(ae.Hint, "--sender") || !strings.Contains(ae.Hint, "--start") {
|
||||
t.Fatalf("structured error = reason %q hint %q", ae.Reason, ae.Hint)
|
||||
}
|
||||
if !strings.HasSuffix(ae.Message, "See 'dws chat +search-msg --help' for usage.") {
|
||||
t.Fatalf("Message = %q", ae.Message)
|
||||
}
|
||||
|
||||
for _, flag := range []string{"from-file", "from-user"} {
|
||||
t.Run(flag, func(t *testing.T) {
|
||||
err := flagErrorWithSuggestions(search, fmt.Errorf("unknown flag: --%s", flag))
|
||||
var structured *apperrors.Error
|
||||
if stderrors.As(err, &structured) && structured.Reason == "ambiguous_flag" {
|
||||
t.Fatalf("--%s incorrectly used --from ambiguity handling: %#v", flag, structured)
|
||||
}
|
||||
if strings.Contains(err.Error(), "--from 在消息查询中含义不明确") {
|
||||
t.Fatalf("--%s incorrectly received --from ambiguity hint: %v", flag, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown flag: --"+flag) {
|
||||
t.Fatalf("error = %q, want original flag --%s", err, flag)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagErrorWithSuggestions_fallbackTailHint 验证 fallback 路径(非 unknown flag 类错误,
|
||||
// 如 missing required flag / ambiguous shorthand)也带尾部 See '<cmd> --help' for usage.
|
||||
// 这是 wukong / docker / kubectl 的通用 UX——任何 flag 解析错误都给用户一条 help 入口。
|
||||
|
||||
@@ -268,7 +268,7 @@ type frameworkFailWriter struct{}
|
||||
|
||||
func (frameworkFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
||||
|
||||
func TestCrossPlatformCoverageFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
func TestFrameworkExecutePanicBeforeEmissionUsesUnifiedFailure(t *testing.T) {
|
||||
for _, failWriter := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "emits", true: "fallback"}[failWriter], func(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
@@ -470,118 +470,6 @@ func TestCrossPlatformCoverageFrameworkExecuteRareOutcomeBranches(t *testing.T)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecuteDeterministicInterruptionBranches(t *testing.T) {
|
||||
install := func(t *testing.T, state *processSignalState, stdout, stderr io.Writer) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws"}
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
testseam.Swap(t, &rootStopAllStdioClients, func() {})
|
||||
testseam.Swap(t, &rootInstallProcessSignalContext, func(ctx context.Context, _ *output.ResultStore) (context.Context, *processSignalState, func()) {
|
||||
return ctx, state, func() {}
|
||||
})
|
||||
testseam.Swap(t, &rootNewRootCommandWithEngine, func(ctx context.Context, _ *pipeline.Engine) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
output.SetCommandRollout(cmd, output.RolloutUnifiedActive)
|
||||
cmd.SetContext(ctx)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(stderr)
|
||||
return cmd
|
||||
})
|
||||
}
|
||||
interrupted := func(primaryCompleted bool) *processSignalState {
|
||||
return &processSignalState{
|
||||
interruption: &processInterruption{signal: os.Interrupt},
|
||||
primaryCompletedAtSignal: primaryCompleted,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("preparse interruption emits unified failure", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
install(t, interrupted(false), &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return errors.New("preparse failed") })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(*cobra.Command) (*cobra.Command, error) {
|
||||
t.Fatal("preparse failure reached command execution")
|
||||
return nil, nil
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("preparse interruption = code %d summary %q stdout %q", code, summary, stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interruption before emission becomes primary error", func(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
install(t, interrupted(false), &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil })
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 130 || summary == "" || !strings.Contains(stdout.String(), `"outcome": "failure"`) {
|
||||
t.Fatalf("pre-emission interruption = code %d summary %q stdout %q", code, summary, stdout.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("late hook error preserves emitted result", func(t *testing.T) {
|
||||
install(t, interrupted(true), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, errors.New("late hook failed")
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary != "late hook failed" {
|
||||
t.Fatalf("late hook result = code %d summary %q", code, summary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("interruption after emission preserves emitted result", func(t *testing.T) {
|
||||
install(t, interrupted(false), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cmd, nil
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 0 || summary == "" {
|
||||
t.Fatalf("post-emission interruption = code %d summary %q", code, summary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publication failure replaces unobservable result", func(t *testing.T) {
|
||||
var original bytes.Buffer
|
||||
install(t, interrupted(false), io.Discard, io.Discard)
|
||||
testseam.Swap(t, &rootRunPreParse, func(*cobra.Command, *pipeline.Engine) error { return nil })
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
if err := output.StoreResult(cmd.Context(), output.Success(map[string]any{"ok": true})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := output.EmitStoredResult(cmd); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file, err := os.CreateTemp(t.TempDir(), "finished-output-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = file.Close() })
|
||||
cmd.SetContext(context.WithValue(cmd.Context(), outputFileContextKey{}, &outputSinkState{file: file, original: &original, finished: true}))
|
||||
publicationErr := newOutputPublicationError("publish", errors.New("rename failed"))
|
||||
if _, handled, emitErr := emitOutputPublicationFailure(cmd, publicationErr); !handled || emitErr != nil {
|
||||
t.Fatalf("precondition publication failure = handled %v error %v unified %v state %v", handled, emitErr, output.UsesUnifiedResult(cmd), outputSinkForCommand(cmd) != nil)
|
||||
}
|
||||
return cmd, publicationErr
|
||||
})
|
||||
if code, _, summary := ExecuteWithTelemetry(); code != 5 || summary == "" {
|
||||
t.Fatalf("publication failure = code %d summary %q output %q", code, summary, original.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type frameworkPanicWriter struct{}
|
||||
|
||||
func (frameworkPanicWriter) Write([]byte) (int, error) { panic("writer panic") }
|
||||
|
||||
@@ -479,11 +479,7 @@ func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPaylo
|
||||
if err != nil {
|
||||
t.Fatalf("alias execution failed: %v", err)
|
||||
}
|
||||
if alias == "open-conversation-id" {
|
||||
if ctx == nil || len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("alias corrections = %#v", ctx)
|
||||
}
|
||||
} else if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
|
||||
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) {
|
||||
|
||||
@@ -66,32 +66,6 @@ func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePreparseProfileFlagUsesLastOccurrence(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
valid bool
|
||||
}{
|
||||
{name: "space then equals", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, want: "corp-b", valid: true},
|
||||
{name: "equals then space", args: []string{"--profile=corp-a", "version", "--profile", "corp-b"}, want: "corp-b", valid: true},
|
||||
{name: "last multi", args: []string{"--profile=corp-a", "--profile", "corp-b,", "corp-c", "version"}, want: "corp-b,corp-c", valid: true},
|
||||
{name: "empty equals clears earlier", args: []string{"--profile=corp-a", "version", "--profile="}},
|
||||
{name: "missing value clears earlier", args: []string{"--profile=corp-a", "version", "--profile"}},
|
||||
{name: "next flag is not profile value", args: []string{"--profile=corp-a", "--profile", "--debug", "version"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := preparseProfileFlag(tc.args); got != tc.want {
|
||||
t.Fatalf("preparseProfileFlag(%#v) = %q, want %q", tc.args, got, tc.want)
|
||||
}
|
||||
_, specified, valid := preparseProfileSelection(tc.args)
|
||||
if !specified || valid != tc.valid {
|
||||
t.Fatalf("preparseProfileSelection(%#v) = specified %v valid %v, want true/%v", tc.args, specified, valid, tc.valid)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
+24
-132
@@ -80,19 +80,10 @@ var (
|
||||
rootAuthLoadTokenData = authpkg.LoadTokenData
|
||||
rootNewCommandRunnerWithFlags = newCommandRunnerWithFlags
|
||||
rootEmitResult = output.EmitResult
|
||||
rootInstallProcessSignalContext = installProcessSignalContext
|
||||
)
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() int {
|
||||
exitCode, _, _ := ExecuteWithTelemetry()
|
||||
return exitCode
|
||||
}
|
||||
|
||||
// ExecuteWithTelemetry runs the root command and additionally returns a
|
||||
// privacy-safe command path and error summary for the official CLI entrypoint.
|
||||
func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage string) {
|
||||
commandPath = "dws"
|
||||
func Execute() (exitCode int) {
|
||||
var (
|
||||
root *cobra.Command
|
||||
executed *cobra.Command
|
||||
@@ -100,16 +91,12 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
|
||||
)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errorMessage = "internal panic"
|
||||
target := executed
|
||||
if target == nil && root != nil {
|
||||
if found, _, err := root.Find(os.Args[1:]); err == nil {
|
||||
target = found
|
||||
}
|
||||
}
|
||||
if target != nil {
|
||||
commandPath = telemetryCommandPath(target)
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
exitCode = code
|
||||
if target != nil {
|
||||
@@ -134,7 +121,6 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
|
||||
CloseFileLogger()
|
||||
if executed != nil {
|
||||
if err := closeOutputSink(executed); err != nil {
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
if code, handled, emitErr := emitOutputPublicationFailure(executed, err); handled && emitErr == nil {
|
||||
exitCode = code
|
||||
} else {
|
||||
@@ -158,9 +144,7 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
|
||||
agentMetadata := readAgentMetadataSnapshot()
|
||||
if err := agentMetadata.validationError(); err != nil {
|
||||
emitEarlyAgentMetadataValidationError(err, os.Args[1:])
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
return apperrors.ExitCode(err)
|
||||
}
|
||||
|
||||
timing := NewTimingCollector()
|
||||
@@ -178,13 +162,12 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
|
||||
ctx, resultStore = output.WithResultStore(ctx)
|
||||
var signalState *processSignalState
|
||||
var stopSignals func()
|
||||
ctx, signalState, stopSignals = rootInstallProcessSignalContext(ctx, resultStore)
|
||||
ctx, signalState, stopSignals = installProcessSignalContext(ctx, resultStore)
|
||||
defer stopSignals()
|
||||
|
||||
initStart := time.Now()
|
||||
engine := newPipelineEngine()
|
||||
root = rootNewRootCommandWithEngine(ctx, engine)
|
||||
commandPath = telemetryCommandPath(root)
|
||||
timing.Record("cmd_init", time.Since(initStart))
|
||||
|
||||
// Run PreParse handlers on raw argv before Cobra parses flags.
|
||||
@@ -199,23 +182,15 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(target, result)
|
||||
if emitErr == nil {
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
return code
|
||||
}
|
||||
}
|
||||
_ = printExecutionError(root, os.Stdout, os.Stderr, err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
return apperrors.ExitCode(err)
|
||||
}
|
||||
commandPath = telemetryCommandPathForArgs(root, os.Args[1:])
|
||||
|
||||
var err error
|
||||
executed, err = rootExecuteCommand(root)
|
||||
if executed != nil {
|
||||
commandPath = telemetryCommandPath(executed)
|
||||
}
|
||||
// PersistentPostRunE normally commits or aborts the transactional output
|
||||
// sink. Finalize once more at the process boundary so custom execution
|
||||
// seams, embedding callers, or future hook changes cannot leave publication
|
||||
@@ -247,9 +222,7 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
|
||||
// successfully emitted result into a contradictory 130/143 process
|
||||
// status; likewise, a failed publication must retain its internal
|
||||
// error code instead of being relabelled as cancellation.
|
||||
errorMessage = telemetryErrorSummary(interrupted)
|
||||
exitCode = code
|
||||
return
|
||||
return code
|
||||
}
|
||||
}
|
||||
var publicationErr *outputPublicationError
|
||||
@@ -260,26 +233,20 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
|
||||
if err != nil {
|
||||
if executed == nil {
|
||||
executed = root
|
||||
commandPath = telemetryCommandPath(root)
|
||||
}
|
||||
if code, attempted, _, _ := output.StoredEmissionState(resultStore); attempted {
|
||||
var publicationErr *outputPublicationError
|
||||
if stderrors.As(err, &publicationErr) {
|
||||
errorMessage = telemetryErrorSummary(publicationErr)
|
||||
if failureCode, handled, emitErr := emitOutputPublicationFailure(executed, publicationErr); handled {
|
||||
if emitErr == nil {
|
||||
exitCode = failureCode
|
||||
return
|
||||
return failureCode
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: emit output publication failure: %v\n", emitErr)
|
||||
}
|
||||
exitCode = apperrors.ExitCode(publicationErr)
|
||||
return
|
||||
return apperrors.ExitCode(publicationErr)
|
||||
}
|
||||
fmt.Fprintf(executed.ErrOrStderr(), "Warning: command hook failed after result emission: %v\n", err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
return code
|
||||
}
|
||||
err = rewordRequiredFlagError(err)
|
||||
var raw apperrors.RawStderrError
|
||||
@@ -287,9 +254,7 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
|
||||
result := output.FailureWithExitCode(errorInfoFromExecutionError(err), apperrors.ExitCode(err))
|
||||
code, emitErr := output.EmitResult(executed, result)
|
||||
if emitErr == nil {
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = code
|
||||
return
|
||||
return code
|
||||
}
|
||||
err = apperrors.NewInternal("emit failure result: "+emitErr.Error(), apperrors.WithCause(emitErr))
|
||||
}
|
||||
@@ -299,42 +264,12 @@ func ExecuteWithTelemetry() (exitCode int, commandPath string, errorMessage stri
|
||||
_, _ = fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||||
errorMessage = telemetryErrorSummary(err)
|
||||
exitCode = apperrors.ExitCode(err)
|
||||
return
|
||||
return apperrors.ExitCode(err)
|
||||
}
|
||||
if code, emitted := output.StoredExitCode(resultStore); emitted {
|
||||
exitCode = code
|
||||
return
|
||||
return code
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func telemetryCommandPath(command *cobra.Command) string {
|
||||
if command == nil {
|
||||
return "dws"
|
||||
}
|
||||
path := strings.TrimSpace(command.CommandPath())
|
||||
root := command.Root()
|
||||
rootName := strings.TrimSpace(root.Name())
|
||||
if path == rootName {
|
||||
return rootName
|
||||
}
|
||||
if rootName != "" {
|
||||
path = strings.TrimSpace(strings.TrimPrefix(path, rootName+" "))
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func telemetryCommandPathForArgs(root *cobra.Command, args []string) string {
|
||||
if root == nil {
|
||||
return "dws"
|
||||
}
|
||||
command, _, err := root.Find(args)
|
||||
if err != nil || command == nil {
|
||||
return telemetryCommandPath(root)
|
||||
}
|
||||
return telemetryCommandPath(command)
|
||||
return 0
|
||||
}
|
||||
|
||||
// emitEarlyAgentMetadataValidationError preserves each built-in command's
|
||||
@@ -576,22 +511,6 @@ 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, ok := unknownFlagName(errMsg); ok && flag == "from" {
|
||||
switch cmd.CommandPath() {
|
||||
case "dws chat +search-msg", "dws chat +chat-messages":
|
||||
return apperrors.NewValidation(
|
||||
msgWithTail,
|
||||
apperrors.WithHint("--from 在消息查询中含义不明确:按发送者过滤请使用 --sender <姓名|userId|openDingTalkId>;指定时间起点请使用 --start <RFC3339>"),
|
||||
apperrors.WithReason("ambiguous_flag"),
|
||||
apperrors.WithCause(err),
|
||||
apperrors.WithActions(
|
||||
"Use --sender <姓名|userId|openDingTalkId> to filter by sender",
|
||||
"Use --start <RFC3339> together with --end <RFC3339> to set a time range",
|
||||
),
|
||||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||||
)
|
||||
}
|
||||
}
|
||||
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"
|
||||
@@ -660,10 +579,15 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline
|
||||
if cmd == nil {
|
||||
return "", "", false
|
||||
}
|
||||
flag, ok := unknownFlagName(errMsg)
|
||||
if !ok {
|
||||
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
|
||||
@@ -678,19 +602,6 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func unknownFlagName(errMsg string) (string, bool) {
|
||||
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]
|
||||
}
|
||||
return flag, flag != ""
|
||||
}
|
||||
|
||||
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
|
||||
var raw apperrors.RawStderrError
|
||||
if stderrors.As(err, &raw) {
|
||||
@@ -1024,36 +935,17 @@ func installReviewedFlagProtectionHandlers(root *cobra.Command) {
|
||||
}
|
||||
|
||||
func preparseProfileFlag(args []string) string {
|
||||
profile, _, valid := preparseProfileSelection(args)
|
||||
if !valid {
|
||||
return ""
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
func preparseProfileSelection(args []string) (profile string, specified, valid bool) {
|
||||
args, _ = normalizeProfileFlagArgs(args)
|
||||
valid = true
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := strings.TrimSpace(args[i])
|
||||
switch {
|
||||
case arg == "--profile":
|
||||
specified = true
|
||||
if i+1 >= len(args) || strings.HasPrefix(strings.TrimSpace(args[i+1]), "-") {
|
||||
profile = ""
|
||||
valid = false
|
||||
continue
|
||||
}
|
||||
profile = strings.TrimSpace(args[i+1])
|
||||
valid = profile != ""
|
||||
i++
|
||||
case arg == "--profile" && i+1 < len(args):
|
||||
return strings.TrimSpace(args[i+1])
|
||||
case strings.HasPrefix(arg, "--profile="):
|
||||
specified = true
|
||||
profile = strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
valid = profile != ""
|
||||
return strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
|
||||
}
|
||||
}
|
||||
return profile, specified, valid
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeProcessProfileArgs() func() {
|
||||
|
||||
@@ -37,64 +37,29 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootStopAllStdioClients = func() {}
|
||||
var executedLeaf *cobra.Command
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
sheet := &cobra.Command{Use: "sheet"}
|
||||
executedLeaf = &cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}
|
||||
sheet.AddCommand(executedLeaf)
|
||||
root.AddCommand(sheet)
|
||||
return root
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
}
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return executedLeaf, nil }
|
||||
if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 0 || commandPath != "sheet read" || errorMessage != "" {
|
||||
t.Fatalf("successful ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage)
|
||||
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("successful Execute code = %d", code)
|
||||
}
|
||||
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return errors.New("alias/canonical conflict") }
|
||||
if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "alias/canonical conflict" {
|
||||
t.Fatalf("pre-parse conflict = code %d error %q", code, errorMessage)
|
||||
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")
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
|
||||
if code, _, errorMessage := ExecuteWithTelemetry(); code == 0 || errorMessage != "unknown command" {
|
||||
t.Fatalf("failed ExecuteWithTelemetry = code %d error %q", code, errorMessage)
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("failed Execute returned zero")
|
||||
}
|
||||
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { panic("boom") }
|
||||
os.Args = []string{"dws", "sheet", "read"}
|
||||
if code, commandPath, errorMessage := ExecuteWithTelemetry(); code != 5 || commandPath != "sheet read" || errorMessage != "internal panic" {
|
||||
t.Fatalf("panic ExecuteWithTelemetry = code %d path %q error %q", code, commandPath, errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryCommandPath(t *testing.T) {
|
||||
if got := telemetryCommandPath(nil); got != "dws" {
|
||||
t.Fatalf("nil command path = %q, want dws", got)
|
||||
}
|
||||
if got := telemetryCommandPathForArgs(nil, nil); got != "dws" {
|
||||
t.Fatalf("nil root command path = %q, want dws", got)
|
||||
}
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
sheet := &cobra.Command{Use: "sheet"}
|
||||
read := &cobra.Command{Use: "read <range>"}
|
||||
sheet.AddCommand(read)
|
||||
root.AddCommand(sheet)
|
||||
if got := telemetryCommandPath(root); got != "dws" {
|
||||
t.Fatalf("root command path = %q, want dws", got)
|
||||
}
|
||||
if got := telemetryCommandPath(read); got != "sheet read" {
|
||||
t.Fatalf("leaf command path = %q, want sheet read", got)
|
||||
}
|
||||
root.PersistentFlags().String("profile", "", "")
|
||||
read.Aliases = []string{"get"}
|
||||
if got := telemetryCommandPathForArgs(root, []string{"--profile", "corp-a", "sheet", "get", "A1:B2"}); got != "sheet read" {
|
||||
t.Fatalf("pre-execution command path = %q, want sheet read", got)
|
||||
}
|
||||
if got := telemetryCommandPathForArgs(root, []string{"missing"}); got != "dws" {
|
||||
t.Fatalf("unknown pre-execution command path = %q, want dws", got)
|
||||
if code := Execute(); code != 5 {
|
||||
t.Fatalf("panic Execute code = %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
@@ -14,12 +13,6 @@ import (
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// feedbackFormURL points at the DingTalk Notable form collecting dws CLI
|
||||
// user-experience feedback. The source parameter tags submissions that
|
||||
// originated from the CLI help output so they can be told apart from
|
||||
// responses arriving through other channels.
|
||||
const feedbackFormURL = "https://alidocs.dingtalk.com/notable/share/form/v01eLbnj1bw1ELb0laN_dv19yqvsgs3oebp3pcjys_1qX0QQ0?source=dws-cli"
|
||||
|
||||
func configureRootHelp(root *cobra.Command) {
|
||||
if root == nil {
|
||||
return
|
||||
@@ -108,29 +101,6 @@ func renderRootHelp(root *cobra.Command) {
|
||||
_, _ = fmt.Fprintln(w)
|
||||
_, _ = fmt.Fprintln(w, tui.Dim(long))
|
||||
}
|
||||
|
||||
// Keep the feedback entry last: everything above it is operational guidance
|
||||
// an agent acts on, while the survey is addressed to human readers who
|
||||
// scroll to the end.
|
||||
_, _ = fmt.Fprintln(w)
|
||||
renderRootFeedback(w)
|
||||
}
|
||||
|
||||
// renderRootFeedback prints the user-experience survey entry. The URL occupies
|
||||
// its own line and is never wrapped or padded through a tabwriter: it is longer
|
||||
// than the help rule width, and breaking it would stop terminals from
|
||||
// recognizing it as a clickable hyperlink. Soft wrapping performed by the
|
||||
// terminal itself keeps the link intact.
|
||||
//
|
||||
// The label is intentionally not routed through i18n. Everything surrounding it
|
||||
// in this listing — service descriptions, utility descriptions, global flag
|
||||
// usage — is hardcoded Chinese, so translating this one line would render it in
|
||||
// English on any host whose LANG is not zh_*, leaving a single English line
|
||||
// inside an otherwise Chinese screen.
|
||||
func renderRootFeedback(w io.Writer) {
|
||||
_, _ = fmt.Fprintln(w, tui.Section("Feedback:"))
|
||||
_, _ = fmt.Fprintf(w, " %s %s\n", tui.Bullet(), tui.Dim("使用体验反馈问卷(1 分钟)"))
|
||||
_, _ = fmt.Fprintf(w, " %s\n", tui.Cyan(feedbackFormURL))
|
||||
}
|
||||
|
||||
func renderRootGlobalFlags(root *cobra.Command) {
|
||||
|
||||
@@ -53,49 +53,6 @@ func TestRootHelpHidesCompatibilityOnlyCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootHelpShowsFeedbackEntry(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("root help: %v\n%s", err, out.String())
|
||||
}
|
||||
help := out.String()
|
||||
// The label stays Chinese regardless of the host locale: the rest of this
|
||||
// listing is hardcoded Chinese, so a translated label would show up as a
|
||||
// lone English line on any host whose LANG is not zh_*.
|
||||
for _, want := range []string{"Feedback:", "使用体验反馈问卷", feedbackFormURL} {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("root help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
// The form URL is longer than the help rule width; it must stay on a
|
||||
// single unbroken line so terminals keep recognizing it as a hyperlink.
|
||||
if !strings.Contains(help, "\n "+feedbackFormURL+"\n") {
|
||||
t.Fatalf("feedback URL must occupy one unwrapped line:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
// The feedback entry is deliberately root-only: this CLI is driven mostly by
|
||||
// AI agents, and repeating a survey link in every subcommand help would be
|
||||
// pure context noise. Guard the boundary so a future refactor cannot move the
|
||||
// rendering into the shared subcommand help path unnoticed.
|
||||
func TestSubcommandHelpOmitsFeedbackEntry(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"chat", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat help: %v\n%s", err, out.String())
|
||||
}
|
||||
if help := out.String(); strings.Contains(help, feedbackFormURL) {
|
||||
t.Fatalf("subcommand help must not carry the feedback URL:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
@@ -137,8 +94,8 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
}{
|
||||
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
command := NewRootCommand()
|
||||
command.SilenceErrors = true
|
||||
|
||||
@@ -181,7 +181,7 @@ func TestPublicRootDirectExecuteClosesSinkOnHandlerError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||
func TestExecutePanicAfterEmissionPreservesSingleResultAndExitCode(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
|
||||
@@ -230,20 +230,13 @@ func TestOutputSinkUnifiedPublicationFailureFailsAndLeavesNoFinalFile(t *testing
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
||||
func TestExecuteUnifiedPublicationFailureEmitsFailureOnOriginalStdout(t *testing.T) {
|
||||
testseam.Protect(t, &os.Args)
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
// Keep argv portable: an absolute Windows path contains a volume colon,
|
||||
// which the CLI intentionally rejects as unsafe user-supplied output.
|
||||
target := "result.json"
|
||||
target := filepath.Join(dir, "result.json")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalInfo, err := os.Stat(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Args = []string{"dws", "atomic-output-unified-publication", "--output", target, "--format", "json"}
|
||||
testseam.Swap(t, &rootRenameFile, func(string, string) error { return errors.New("rename failed") })
|
||||
testseam.Swap(t, &rootNormalizeProcessProfileArgs, func() func() { return func() {} })
|
||||
@@ -284,7 +277,7 @@ func TestCrossPlatformCoverageExecuteUnifiedPublicationFailureEmitsFailureOnOrig
|
||||
if got := bytes.Count(stdout.Bytes(), []byte(`"outcome": "success"`)); got != 0 {
|
||||
t.Fatalf("rolled-back success leaked to stdout: %s", stdout.String())
|
||||
}
|
||||
assertOutputFile(t, target, "original", originalInfo.Mode().Perm())
|
||||
assertOutputFile(t, target, "original", 0o640)
|
||||
assertNoOutputTemps(t, target)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||
func TestExecuteEmitsStoredUnifiedResultAtSingleRootExit(t *testing.T) {
|
||||
oldNormalize := rootNormalizeProcessProfileArgs
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
|
||||
@@ -140,78 +140,6 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func TestDeliveryWikiSpaceSearchDeclaresCompatibilityAdapter(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "wiki +space-search")
|
||||
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("wiki +space-search interface_mode = %q, want composite", got)
|
||||
}
|
||||
reason := schemaContractString(leaf["interface_reason"])
|
||||
for _, fragment := range []string{"query/limit", "search_wikiSpaces.keyword/pageSize", "versioned Schema migration"} {
|
||||
if !strings.Contains(reason, fragment) {
|
||||
t.Fatalf("wiki +space-search interface_reason = %q, want fragment %q", reason, fragment)
|
||||
}
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
for name, want := range map[string]string{"query": "query", "limit": "limit"} {
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("wiki +space-search missing --%s parameter: %#v", name, parameters)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Fatalf("wiki +space-search --%s property = %q, want compatibility value %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
checked := 0
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service != "wiki" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
canonical := shortcutSchemaCanonical(declared)
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Fatalf("delivery schema --all is missing %s", canonical)
|
||||
}
|
||||
examples := schemaContractStringSlice(tool["examples"])
|
||||
if len(examples) == 0 {
|
||||
t.Fatalf("%s has no delivered examples", canonical)
|
||||
}
|
||||
for _, example := range examples {
|
||||
argv, err := cli.ParseAgentExampleArgv(example)
|
||||
if err != nil {
|
||||
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
|
||||
}
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Required {
|
||||
continue
|
||||
}
|
||||
names := append([]string{flag.Name}, flag.Aliases...)
|
||||
if !schemaExampleHasLongFlag(argv, names...) {
|
||||
t.Errorf("%s example %q is missing required --%s", canonical, example, flag.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != 20 {
|
||||
t.Fatalf("checked Wiki shortcut examples = %d, want 20", checked)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
for _, argument := range argv {
|
||||
for _, name := range names {
|
||||
if argument == "--"+name || strings.HasPrefix(argument, "--"+name+"=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertSchemaSummarySafety(
|
||||
t testing.TB,
|
||||
summaries map[string]map[string]any,
|
||||
@@ -279,11 +207,7 @@ func assertChatCatalogCompleteLeafContracts(t testing.TB) {
|
||||
})
|
||||
|
||||
auditJoin := executeShortcutSchemaQuery(t, "--cli-path", "chat group audit-join-validation")
|
||||
assertSchemaLeafParameterRequired(t, auditJoin, "chat group audit-join-validation", "conversation-id", true)
|
||||
assertSchemaLeafParameterEnum(t, auditJoin, "chat group audit-join-validation", "status", []string{"AuditApprove", "AuditDelete"})
|
||||
if parameters := schemaContractMap(auditJoin["parameters"]); parameters["group"] != nil {
|
||||
t.Fatalf("chat group audit-join-validation publishes hidden --group alias: %#v", parameters["group"])
|
||||
}
|
||||
}
|
||||
|
||||
func assertSchemaLeafParameterRequired(t testing.TB, leaf map[string]any, cliPath, name string, want bool) {
|
||||
|
||||
@@ -103,7 +103,7 @@ func installSignalExecuteSeams(t *testing.T, unified bool, stdout, stderr io.Wri
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||
func TestExecuteSignalEmitsOneTypedUnifiedFailure(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
signal syscall.Signal
|
||||
@@ -197,7 +197,7 @@ func TestSignalAfterFailedEmissionAttemptPreservesPublicationExitCode(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
func TestSignalBeforeEmissionAttemptPreservesPublishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
@@ -229,7 +229,7 @@ func TestCrossPlatformCoverageSignalBeforeEmissionAttemptPreservesPublishedOutco
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||
func TestSignalAfterCompletedPrimaryPreservesEstablishedOutcome(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
installSignalExecuteSeams(t, true, &stdout, io.Discard)
|
||||
testseam.Swap(t, &rootExecuteCommand, func(cmd *cobra.Command) (*cobra.Command, error) {
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
// 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"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
const maxTelemetryErrorRunes = 200
|
||||
|
||||
var (
|
||||
telemetryUnknownFlagPattern = regexp.MustCompile(`(?i)unknown flag:\s*(--[a-z0-9][a-z0-9-]*)`)
|
||||
telemetryAuthPattern = regexp.MustCompile(`(?i)\b(?:bearer|basic)\s+[a-z0-9._~+/=-]+`)
|
||||
telemetrySensitiveFlag = regexp.MustCompile(`(?i)(--(?:access-token|refresh-token|token|client-secret|client-id|password|api-key|authorization|cookie|credential|secret))(?:=|\s+)\S+`)
|
||||
telemetrySensitiveValue = regexp.MustCompile(`(?i)\b(authorization|client[-_]?secret|client[-_]?id|access[-_]?token|refresh[-_]?token|api[-_]?key|password|cookie|credential|secret|token)\b\s*[:=]\s*[^\s,;]+`)
|
||||
telemetryURLPattern = regexp.MustCompile(`(?i)\b(?:https?|wss?)://[^\s]+`)
|
||||
telemetryJSONPattern = regexp.MustCompile(`(?s)[\[{].*[\]}]`)
|
||||
telemetryUnixPathPattern = regexp.MustCompile(`(^|[\s=:])(?:~/|/)[^\s]+`)
|
||||
telemetryWindowsPathPattern = regexp.MustCompile(`(?i)(^|[\s=])[a-z]:[\\/][^\s]+`)
|
||||
telemetryRelativePathPattern = regexp.MustCompile(`(^|[\s=:])\.\.?/[^\s]+`)
|
||||
telemetryEmailPattern = regexp.MustCompile(`(?i)\b[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}\b`)
|
||||
telemetryPhonePattern = regexp.MustCompile(`\b\+?\d[\d -]{7,}\d\b`)
|
||||
telemetryOpaqueTokenPattern = regexp.MustCompile(`\b[a-zA-Z0-9_-]{16,}\b`)
|
||||
)
|
||||
|
||||
func telemetryErrorSummary(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var patError *apperrors.PATError
|
||||
if stderrors.As(err, &patError) {
|
||||
return "permission error"
|
||||
}
|
||||
var rawError apperrors.RawStderrError
|
||||
if stderrors.As(err, &rawError) {
|
||||
return "raw stderr error"
|
||||
}
|
||||
if isUnknownCommandError(err) {
|
||||
return "unknown command"
|
||||
}
|
||||
if match := telemetryUnknownFlagPattern.FindStringSubmatch(err.Error()); len(match) == 2 {
|
||||
return "unknown flag: " + match[1]
|
||||
}
|
||||
return sanitizeTelemetryErrorText(err.Error())
|
||||
}
|
||||
|
||||
func telemetryPanicMessages(value any) (display, summary string) {
|
||||
return fmt.Sprintf("internal panic: %v", value), "internal panic"
|
||||
}
|
||||
|
||||
func sanitizeTelemetryErrorText(message string) string {
|
||||
message = output.SanitizeForTerminal(message)
|
||||
message = telemetryAuthPattern.ReplaceAllString(message, "<credential>")
|
||||
message = telemetrySensitiveFlag.ReplaceAllString(message, "$1=<redacted>")
|
||||
message = telemetrySensitiveValue.ReplaceAllString(message, "$1=<redacted>")
|
||||
message = telemetryURLPattern.ReplaceAllString(message, "<url>")
|
||||
message = telemetryJSONPattern.ReplaceAllString(message, "<payload>")
|
||||
message = redactTelemetryQuotedText(message)
|
||||
message = telemetryUnixPathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryWindowsPathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryRelativePathPattern.ReplaceAllString(message, "$1<path>")
|
||||
message = telemetryEmailPattern.ReplaceAllString(message, "<email>")
|
||||
message = telemetryPhonePattern.ReplaceAllString(message, "<phone>")
|
||||
message = telemetryOpaqueTokenPattern.ReplaceAllStringFunc(message, func(value string) string {
|
||||
var hasLetter, hasDigit bool
|
||||
for _, r := range value {
|
||||
hasLetter = hasLetter || unicode.IsLetter(r)
|
||||
hasDigit = hasDigit || unicode.IsDigit(r)
|
||||
}
|
||||
if hasLetter && hasDigit {
|
||||
return "<id>"
|
||||
}
|
||||
return value
|
||||
})
|
||||
message = strings.Join(strings.Fields(message), " ")
|
||||
return truncateTelemetryText(message, maxTelemetryErrorRunes)
|
||||
}
|
||||
|
||||
func redactTelemetryQuotedText(message string) string {
|
||||
var result strings.Builder
|
||||
runes := []rune(message)
|
||||
for index := 0; index < len(runes); {
|
||||
quote := runes[index]
|
||||
if quote != '\'' && quote != '"' && quote != '`' {
|
||||
result.WriteRune(quote)
|
||||
index++
|
||||
continue
|
||||
}
|
||||
result.WriteRune(quote)
|
||||
result.WriteString("<redacted>")
|
||||
index++
|
||||
escaped := false
|
||||
for index < len(runes) {
|
||||
current := runes[index]
|
||||
index++
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if current == '\\' && quote != '`' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if current == quote {
|
||||
result.WriteRune(quote)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func truncateTelemetryText(message string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(message)
|
||||
if len(runes) <= maxRunes {
|
||||
return message
|
||||
}
|
||||
if maxRunes <= 3 {
|
||||
return string(runes[:maxRunes])
|
||||
}
|
||||
return string(runes[:maxRunes-3]) + "..."
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
type telemetryRawError string
|
||||
|
||||
func (e telemetryRawError) Error() string { return string(e) }
|
||||
func (e telemetryRawError) RawStderr() string { return string(e) }
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorSummaryFixedFamilies(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{name: "nil", want: ""},
|
||||
{name: "PAT", err: &apperrors.PATError{RawJSON: `{"token":"secret"}`}, want: "permission error"},
|
||||
{name: "raw stderr", err: telemetryRawError("raw secret"), want: "raw stderr error"},
|
||||
{name: "unknown command", err: errors.New(`unknown command "secret-value" for "dws"`), want: "unknown command"},
|
||||
{name: "unknown flag", err: errors.New("unknown flag: --token=secret-value"), want: "unknown flag: --token"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := telemetryErrorSummary(tc.err); got != tc.want {
|
||||
t.Fatalf("telemetryErrorSummary() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSanitizeTelemetryErrorText(t *testing.T) {
|
||||
message := "\x1b[31mfailed\x1b[0m " +
|
||||
"--client-secret very-secret " +
|
||||
"--access-token access-secret " +
|
||||
"Authorization: Bearer abcdefghijklmnop1234 " +
|
||||
"url=https://example.test/path?token=secret " +
|
||||
`body={"access_token":"secret"} ` +
|
||||
`user="Alice" email=alice@example.test phone=13800138000 ` +
|
||||
"path=/Users/alice/private.txt relative=./private/secrets.txt id=abcDEF1234567890XYZ"
|
||||
got := sanitizeTelemetryErrorText(message)
|
||||
for _, secret := range []string{
|
||||
"very-secret", "access-secret", "abcdefghijklmnop1234", "example.test", "access_token",
|
||||
"Alice", "alice@example.test", "13800138000", "/Users/alice", "abcDEF1234567890XYZ", "\x1b",
|
||||
"./private/secrets.txt",
|
||||
} {
|
||||
if strings.Contains(got, secret) {
|
||||
t.Fatalf("sanitized telemetry error leaked %q: %q", secret, got)
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"failed", "<redacted>", "<url>", "<payload>", "<path>", "<id>"} {
|
||||
if !strings.Contains(got, marker) {
|
||||
t.Fatalf("sanitized telemetry error missing %q: %q", marker, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorTruncationAndPanic(t *testing.T) {
|
||||
message := strings.Repeat("错", maxTelemetryErrorRunes+1)
|
||||
got := sanitizeTelemetryErrorText(message)
|
||||
if len([]rune(got)) != maxTelemetryErrorRunes || !strings.HasSuffix(got, "...") {
|
||||
t.Fatalf("truncated telemetry error rune length = %d suffix = %q", len([]rune(got)), got[len(got)-3:])
|
||||
}
|
||||
display, summary := telemetryPanicMessages("token-secret")
|
||||
if display != "internal panic: token-secret" || summary != "internal panic" || strings.Contains(summary, "token-secret") {
|
||||
t.Fatalf("panic messages = display %q summary %q", display, summary)
|
||||
}
|
||||
if got := truncateTelemetryText("value", 0); got != "" {
|
||||
t.Fatalf("zero-limit truncation = %q", got)
|
||||
}
|
||||
if got := truncateTelemetryText("value", 3); got != "val" {
|
||||
t.Fatalf("short-limit truncation = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTelemetryErrorEscapesAndOpaqueWords(t *testing.T) {
|
||||
const opaqueWord = "abcdefghijklmnop"
|
||||
got := sanitizeTelemetryErrorText(`failed "quoted \"value" ` + opaqueWord)
|
||||
if strings.Contains(got, "value") || !strings.Contains(got, opaqueWord) {
|
||||
t.Fatalf("escaped quote sanitization = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
// TelemetryIdentity is the privacy-reviewed subset of the local authentication
|
||||
// record that may be attached to a CLI execution event.
|
||||
type TelemetryIdentity struct {
|
||||
UserID string
|
||||
UserName string
|
||||
CorpID string
|
||||
}
|
||||
|
||||
var telemetryResolveProfileMetadata = authpkg.ResolveProfileMetadataReadOnly
|
||||
|
||||
// ResolveTelemetryIdentity returns a pre-execution snapshot of the identity
|
||||
// selected by args. Multi-profile executions are attributed to the current
|
||||
// default profile. Resolution is deliberately best-effort: telemetry must not
|
||||
// refresh credentials or change command behavior when local auth data is
|
||||
// missing, invalid, or unreadable.
|
||||
func ResolveTelemetryIdentity(args []string) (identity TelemetryIdentity) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
identity = TelemetryIdentity{}
|
||||
}
|
||||
}()
|
||||
|
||||
selector, specified, valid := preparseProfileSelection(args)
|
||||
if specified && !valid {
|
||||
return TelemetryIdentity{}
|
||||
}
|
||||
profile, err := resolveTelemetryProfileMetadata(defaultConfigDir(), selector)
|
||||
if err != nil || profile == nil {
|
||||
return TelemetryIdentity{}
|
||||
}
|
||||
return TelemetryIdentity{
|
||||
UserID: strings.TrimSpace(profile.UserID),
|
||||
UserName: strings.TrimSpace(profile.UserName),
|
||||
CorpID: strings.TrimSpace(profile.CorpID),
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTelemetryProfileMetadata(configDir, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" || !strings.Contains(selector, ",") {
|
||||
return telemetryResolveProfileMetadata(configDir, selector)
|
||||
}
|
||||
|
||||
// A local profile name may itself contain a comma. Match the runtime
|
||||
// resolver by trying the full selector before interpreting it as CSV.
|
||||
if profile, err := telemetryResolveProfileMetadata(configDir, selector); err == nil && profile != nil {
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
for _, part := range strings.Split(selector, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return nil, fmt.Errorf("--profile contains an empty profile selector: %q", selector)
|
||||
}
|
||||
profile, err := telemetryResolveProfileMetadata(configDir, part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile == nil {
|
||||
return nil, fmt.Errorf("profile %q not found", part)
|
||||
}
|
||||
}
|
||||
return telemetryResolveProfileMetadata(configDir, "")
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityProfileSelection(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
profiles := map[string]*authpkg.ProfileMetadata{
|
||||
"": {UserID: " default-user ", UserName: " Default User ", CorpID: " default-corp "},
|
||||
"corp-a": {UserID: "user-a", UserName: "Alice", CorpID: "corp-a"},
|
||||
"corp-b": {UserID: "user-b", UserName: "Bob", CorpID: "corp-b"},
|
||||
"alpha,beta": {UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"},
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
profiles map[string]*authpkg.ProfileMetadata
|
||||
wantCalls []string
|
||||
want TelemetryIdentity
|
||||
}{
|
||||
{name: "default profile", args: []string{"version"}, profiles: profiles, wantCalls: []string{""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
{name: "single profile", args: []string{"--profile", "corp-a", "version"}, profiles: profiles, wantCalls: []string{"corp-a"}, want: TelemetryIdentity{UserID: "user-a", UserName: "Alice", CorpID: "corp-a"}},
|
||||
{name: "equals form after command", args: []string{"version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}},
|
||||
{name: "last repeated profile", args: []string{"--profile", "corp-a", "version", "--profile=corp-b"}, profiles: profiles, wantCalls: []string{"corp-b"}, want: TelemetryIdentity{UserID: "user-b", UserName: "Bob", CorpID: "corp-b"}},
|
||||
{name: "comma profile name", args: []string{"--profile", "alpha,beta", "version"}, profiles: profiles, wantCalls: []string{"alpha,beta"}, want: TelemetryIdentity{UserID: "comma-user", UserName: "Comma User", CorpID: "comma-corp"}},
|
||||
{name: "multi profile uses default", args: []string{"--profile", "corp-a,corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
{name: "unquoted multi profile", args: []string{"--profile", "corp-a,", "corp-b", "version"}, profiles: profiles, wantCalls: []string{"corp-a,corp-b", "corp-a", "corp-b", ""}, want: TelemetryIdentity{UserID: "default-user", UserName: "Default User", CorpID: "default-corp"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var calls []string
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(configDir, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
if configDir != "/telemetry-config" {
|
||||
t.Fatalf("resolver config dir = %q", configDir)
|
||||
}
|
||||
calls = append(calls, selector)
|
||||
profile := tc.profiles[selector]
|
||||
if profile == nil {
|
||||
return nil, errors.New("profile not found")
|
||||
}
|
||||
clone := *profile
|
||||
return &clone, nil
|
||||
})
|
||||
|
||||
if got := ResolveTelemetryIdentity(tc.args); got != tc.want {
|
||||
t.Fatalf("ResolveTelemetryIdentity() = %#v, want %#v", got, tc.want)
|
||||
}
|
||||
if !reflect.DeepEqual(calls, tc.wantCalls) {
|
||||
t.Fatalf("metadata selectors = %#v, want %#v", calls, tc.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsMissingProfileValue(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"version", "--profile"},
|
||||
{"--profile=corp-a", "version", "--profile="},
|
||||
{"--profile", "--debug", "version"},
|
||||
} {
|
||||
t.Run(strings.Join(args, "_"), func(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(string, string) (*authpkg.ProfileMetadata, error) {
|
||||
t.Fatal("invalid profile syntax attempted metadata resolution")
|
||||
return nil, nil
|
||||
})
|
||||
if got := ResolveTelemetryIdentity(args); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("invalid profile identity = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityFailsClosed(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
fail := errors.New("metadata unavailable")
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
resolve func(string, string) (*authpkg.ProfileMetadata, error)
|
||||
}{
|
||||
{name: "read error", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, fail }},
|
||||
{name: "missing profile", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return nil, nil }},
|
||||
{name: "empty fields", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { return &authpkg.ProfileMetadata{}, nil }},
|
||||
{name: "resolver panic", resolve: func(string, string) (*authpkg.ProfileMetadata, error) { panic("metadata failure") }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, tc.resolve)
|
||||
if got := ResolveTelemetryIdentity(nil); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("failed-closed identity = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryIdentityRejectsInvalidMultiProfile(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", "/telemetry-config")
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
if selector == "corp-a" {
|
||||
return &authpkg.ProfileMetadata{UserID: "user-a", CorpID: "corp-a"}, nil
|
||||
}
|
||||
return nil, errors.New("profile not found")
|
||||
})
|
||||
if got := ResolveTelemetryIdentity([]string{"--profile", "corp-a,missing", "version"}); got != (TelemetryIdentity{}) {
|
||||
t.Fatalf("invalid multi-profile identity = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveTelemetryProfileMetadataRejectsMalformedMulti(t *testing.T) {
|
||||
testseam.Swap(t, &telemetryResolveProfileMetadata, func(_ string, selector string) (*authpkg.ProfileMetadata, error) {
|
||||
switch selector {
|
||||
case "corp-a,,corp-b":
|
||||
return nil, errors.New("not a literal profile")
|
||||
case "corp-a":
|
||||
return &authpkg.ProfileMetadata{UserID: "user-a"}, nil
|
||||
case "missing":
|
||||
return nil, nil
|
||||
default:
|
||||
return nil, errors.New("unexpected selector")
|
||||
}
|
||||
})
|
||||
if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,,corp-b"); err == nil || !strings.Contains(err.Error(), "empty profile selector") {
|
||||
t.Fatalf("empty multi-profile selector error = %v", err)
|
||||
}
|
||||
if _, err := resolveTelemetryProfileMetadata("/config", "corp-a,missing"); err == nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("missing multi-profile selector error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProfileMetadata is the minimal, non-sensitive identity projection exposed to
|
||||
// telemetry callers. It intentionally excludes profile names, client IDs,
|
||||
// organization names, token material, and credential status.
|
||||
type ProfileMetadata struct {
|
||||
UserID string
|
||||
UserName string
|
||||
CorpID string
|
||||
}
|
||||
|
||||
// ResolveProfileMetadataReadOnly resolves one identity exclusively from the
|
||||
// non-sensitive profiles.json metadata. It deliberately avoids auth locks,
|
||||
// token stores, Keychain access, migrations, quarantine renames, and writes.
|
||||
// A missing metadata file or an empty current profile returns (nil, nil).
|
||||
func ResolveProfileMetadataReadOnly(configDir, selector string) (*ProfileMetadata, error) {
|
||||
data, err := profilesReadFile(ProfilesPath(configDir))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read profile metadata: %w", err)
|
||||
}
|
||||
|
||||
var cfg ProfilesConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse profile metadata: %w", err)
|
||||
}
|
||||
if cfg.Version > profilesMaxVersion {
|
||||
return nil, fmt.Errorf("profile metadata version %d is newer than supported version %d", cfg.Version, profilesMaxVersion)
|
||||
}
|
||||
normalizeProfilesConfig(&cfg)
|
||||
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
selector = strings.TrimSpace(cfg.CurrentProfile)
|
||||
if selector == "" {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
profile, _, err := resolveProfileSelection("", &cfg, selector)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProfileMetadata{
|
||||
UserID: profile.UserID,
|
||||
UserName: profile.UserName,
|
||||
CorpID: profile.CorpID,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataReadOnly(t *testing.T) {
|
||||
const metadata = `{
|
||||
"version": 3,
|
||||
"currentProfile": "corp-a:user-a",
|
||||
"profiles": [
|
||||
{"name":"alpha","corpId":"corp-a","userId":"user-a","userName":"Alice"},
|
||||
{"name":"beta","corpId":"corp-b","userId":"user-b","userName":"Bob"}
|
||||
]
|
||||
}`
|
||||
reads := 0
|
||||
testseam.Swap(t, &profilesReadFile, func(path string) ([]byte, error) {
|
||||
reads++
|
||||
if !strings.HasSuffix(path, profilesJSONFile) {
|
||||
t.Fatalf("metadata path = %q", path)
|
||||
}
|
||||
return []byte(metadata), nil
|
||||
})
|
||||
|
||||
current, err := ResolveProfileMetadataReadOnly("/config", "")
|
||||
if err != nil || current == nil || current.UserID != "user-a" || current.UserName != "Alice" || current.CorpID != "corp-a" {
|
||||
t.Fatalf("current metadata profile = %#v, %v", current, err)
|
||||
}
|
||||
explicit, err := ResolveProfileMetadataReadOnly("/config", "beta")
|
||||
if err != nil || explicit == nil || explicit.UserID != "user-b" || explicit.CorpID != "corp-b" {
|
||||
t.Fatalf("explicit metadata profile = %#v, %v", explicit, err)
|
||||
}
|
||||
if reads != 2 {
|
||||
t.Fatalf("profile metadata reads = %d, want 2", reads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataReadOnlyFailsClosed(t *testing.T) {
|
||||
fail := errors.New("read failed")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
read func(string) ([]byte, error)
|
||||
wantErr string
|
||||
}{
|
||||
{name: "missing", read: func(string) ([]byte, error) { return nil, os.ErrNotExist }},
|
||||
{name: "read error", read: func(string) ([]byte, error) { return nil, fail }, wantErr: "read profile metadata"},
|
||||
{name: "corrupt", read: func(string) ([]byte, error) { return []byte("{"), nil }, wantErr: "parse profile metadata"},
|
||||
{name: "forward version", read: func(string) ([]byte, error) { return []byte(`{"version":999}`), nil }, wantErr: "newer than supported"},
|
||||
{name: "no current", read: func(string) ([]byte, error) { return []byte(`{"version":3,"profiles":[]}`), nil }},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &profilesReadFile, tc.read)
|
||||
testseam.Swap(t, &profilesRename, func(string, string) error {
|
||||
t.Fatal("read-only metadata resolution attempted a quarantine rename")
|
||||
return nil
|
||||
})
|
||||
got, err := ResolveProfileMetadataReadOnly("/config", "")
|
||||
if tc.wantErr == "" {
|
||||
if err != nil || got != nil {
|
||||
t.Fatalf("read-only metadata = %#v, %v", got, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) || got != nil {
|
||||
t.Fatalf("read-only metadata = %#v, %v; want %q", got, err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveProfileMetadataReadOnlyRejectsUnknownSelector(t *testing.T) {
|
||||
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) {
|
||||
return []byte(`{"version":3,"currentProfile":"corp-a:user-a","profiles":[{"name":"alpha","corpId":"corp-a","userId":"user-a"}]}`), nil
|
||||
})
|
||||
profile, err := ResolveProfileMetadataReadOnly("/config", "missing")
|
||||
if err == nil || profile != nil || !strings.Contains(err.Error(), "not found") {
|
||||
t.Fatalf("unknown read-only profile = %#v, %v", profile, err)
|
||||
}
|
||||
}
|
||||
@@ -177,7 +177,6 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
|
||||
aliasMap := make(map[string]string)
|
||||
blockedSet := make(map[string]bool)
|
||||
excludedSet := make(map[string]bool)
|
||||
claimedRealSet := make(map[string]bool)
|
||||
pendingReview := ov.Confirm || ov.Investigate
|
||||
|
||||
for boundFlag, conceptID := range ov.Bind {
|
||||
@@ -219,14 +218,6 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
|
||||
if len(candidates) == 0 {
|
||||
continue
|
||||
}
|
||||
for m := range eff {
|
||||
if _, isReal := realByMorph[m]; isReal {
|
||||
claimedRealSet[m] = true
|
||||
}
|
||||
}
|
||||
for _, exclude := range concept.Excludes {
|
||||
excludedSet[cmdutil.Morph(exclude)] = true
|
||||
}
|
||||
visible := distinctRealNames(candidates, true)
|
||||
var canon string
|
||||
switch len(visible) {
|
||||
@@ -276,18 +267,21 @@ func reduceLeafParamAliases(path string, realByMorph map[string][]realFlag, conc
|
||||
}
|
||||
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 {
|
||||
continue
|
||||
if _, isAlias := aliasMap[excluded]; !isAlias {
|
||||
blockedSet[excluded] = true
|
||||
}
|
||||
if claimedRealSet[excluded] {
|
||||
continue
|
||||
}
|
||||
if _, isReal := realByMorph[excluded]; isReal {
|
||||
continue
|
||||
}
|
||||
blockedSet[excluded] = true
|
||||
}
|
||||
|
||||
// (b) Command scoped aliases override concept reductions.
|
||||
|
||||
@@ -889,9 +889,13 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
Blocked: []string{"cursor"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat category add-conv",
|
||||
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
|
||||
CLIPath: "chat category add-conv",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat category create",
|
||||
@@ -916,9 +920,13 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
Blocked: []string{"category-ids"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat category remove-conv",
|
||||
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
|
||||
CLIPath: "chat category remove-conv",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"category-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat category rename",
|
||||
@@ -962,19 +970,23 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat conversation-info",
|
||||
Aliases: map[string]string{
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group audit-join-validation",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"staff-id", "uid", "user", "user-id", "userid"},
|
||||
},
|
||||
{
|
||||
@@ -994,9 +1006,12 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group dismiss",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group get-by-group-id",
|
||||
@@ -1005,9 +1020,12 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group invite-url",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group members",
|
||||
@@ -1082,37 +1100,52 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group notice create",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group notice edit",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group notice get",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group notice list",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group quit",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group rename",
|
||||
@@ -1128,17 +1161,23 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group set-admin",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"user-ids": "users",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"user-ids": "users",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group set-history",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group share-invite",
|
||||
@@ -1152,111 +1191,151 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat group transfer-owner",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-alias",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-icon",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-nick",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group update-settings",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-mute",
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
CLIPath: "chat group-mute",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-mute-member",
|
||||
Aliases: map[string]string{
|
||||
"user-ids": "users",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"user-ids": "users",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "userid"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role add",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role list",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role query-user",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role remove",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role remove-user",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role set-user",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"uid": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat group-role update",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "open-conversation-ids", "role-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat hide",
|
||||
@@ -1290,10 +1369,12 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message add-emoji",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Ambiguous: []string{"open-message-id"},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message add-favorite",
|
||||
@@ -1310,10 +1391,12 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message add-text-emotion",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Ambiguous: []string{"open-message-id"},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message combine-forward",
|
||||
@@ -1344,6 +1427,8 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
Aliases: map[string]string{
|
||||
"dest-open-cid": "dest-conversation-id",
|
||||
"destination-conversation-id": "dest-conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-message-id": "msg-id",
|
||||
"source-conversation-id": "src-conversation-id",
|
||||
"src-open-cid": "src-conversation-id",
|
||||
"target-conversation-id": "dest-conversation-id",
|
||||
@@ -1368,20 +1453,21 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message list",
|
||||
Aliases: map[string]string{
|
||||
"max-result": "limit",
|
||||
"max-results": "limit",
|
||||
"page-size": "limit",
|
||||
"per-page": "limit",
|
||||
"staff-id": "user",
|
||||
"start": "time",
|
||||
"take": "limit",
|
||||
"top": "limit",
|
||||
"uid": "user",
|
||||
"user-id": "user",
|
||||
"userid": "user",
|
||||
"chat-id": "group",
|
||||
"max-result": "limit",
|
||||
"max-results": "limit",
|
||||
"open-conversation-id": "group",
|
||||
"page-size": "limit",
|
||||
"per-page": "limit",
|
||||
"staff-id": "user",
|
||||
"start": "time",
|
||||
"take": "limit",
|
||||
"top": "limit",
|
||||
"uid": "user",
|
||||
"user-id": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "count", "cursor", "dest-conversation-id", "end", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "page", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message list-all",
|
||||
@@ -1428,9 +1514,12 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message list-mentions",
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
CLIPath: "chat message list-mentions",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message list-pin-msg",
|
||||
@@ -1445,7 +1534,8 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message list-topic-replies",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
@@ -1459,10 +1549,11 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message read-status",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"msg-id": "message-id",
|
||||
"open-message-id": "message-id",
|
||||
"user-ids": "users",
|
||||
"chat-id": "conversation-id",
|
||||
"msg-id": "message-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "message-id",
|
||||
"user-ids": "users",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "topic-id", "uid", "userid"},
|
||||
},
|
||||
@@ -1470,25 +1561,30 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
CLIPath: "chat message recall",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message recall-by-bot",
|
||||
Aliases: map[string]string{
|
||||
"robot": "robot-code",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"robot": "robot-code",
|
||||
},
|
||||
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message remove-emoji",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Ambiguous: []string{"open-message-id"},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message remove-favorite",
|
||||
@@ -1505,10 +1601,12 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message remove-text-emotion",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat-id": "conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
Ambiguous: []string{"open-message-id"},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "message-ids", "msg-ids", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "src-msg-id", "topic-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message reply",
|
||||
@@ -1522,9 +1620,12 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-id", "msg-id", "msg-ids", "name", "open-conversation-ids", "open-message-id", "source", "src-conversation-id", "src-msg-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message search",
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
CLIPath: "chat message search",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message search-advanced",
|
||||
@@ -1537,25 +1638,28 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
{
|
||||
CLIPath: "chat message send",
|
||||
Aliases: map[string]string{
|
||||
"file": "file-path",
|
||||
"staff-id": "user",
|
||||
"to-user": "user",
|
||||
"uid": "user",
|
||||
"user-id": "user",
|
||||
"userid": "user",
|
||||
"chat-id": "group",
|
||||
"file": "file-path",
|
||||
"open-conversation-id": "group",
|
||||
"staff-id": "user",
|
||||
"to-user": "user",
|
||||
"uid": "user",
|
||||
"user-id": "user",
|
||||
"userid": "user",
|
||||
},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "user-ids", "users"},
|
||||
Ambiguous: []string{"chat-id", "open-conversation-id"},
|
||||
Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "user-ids", "users"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message send-by-bot",
|
||||
Aliases: map[string]string{
|
||||
"at-users": "at-user-ids",
|
||||
"robot": "robot-code",
|
||||
"user-ids": "users",
|
||||
"at-users": "at-user-ids",
|
||||
"chat-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
"robot": "robot-code",
|
||||
"user-ids": "users",
|
||||
},
|
||||
Blocked: []string{"bot-code", "bot-id", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-bot-id", "open-conversation-ids", "robot-id", "source", "src-conversation-id", "staff-id", "target", "to-user-id", "uid", "user", "user-id", "userid"},
|
||||
Ambiguous: []string{"at-ids", "chat-id", "open-conversation-id"},
|
||||
Ambiguous: []string{"at-ids"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message send-by-webhook",
|
||||
@@ -1564,9 +1668,14 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message send-card",
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
|
||||
Ambiguous: []string{"chat", "chat-id", "open-conversation-id"},
|
||||
CLIPath: "chat message send-card",
|
||||
Aliases: map[string]string{
|
||||
"chat": "group",
|
||||
"chat-id": "group",
|
||||
"conversation-id": "group",
|
||||
"open-conversation-id": "group",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "staff-id", "target", "uid", "user", "user-id", "userid"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat message set-pin-msg",
|
||||
@@ -1575,6 +1684,8 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"chat-id": "open-conversation-id",
|
||||
"conversation-id": "open-conversation-id",
|
||||
"group": "open-conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
|
||||
},
|
||||
@@ -1585,6 +1696,8 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"chat-id": "open-conversation-id",
|
||||
"conversation-id": "open-conversation-id",
|
||||
"group": "open-conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
|
||||
},
|
||||
@@ -1595,6 +1708,8 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"chat-id": "open-conversation-id",
|
||||
"conversation-id": "open-conversation-id",
|
||||
"group": "open-conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
|
||||
},
|
||||
@@ -1605,13 +1720,17 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"chat-id": "open-conversation-id",
|
||||
"conversation-id": "open-conversation-id",
|
||||
"group": "open-conversation-id",
|
||||
"message-id": "msg-id",
|
||||
"open-message-id": "msg-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "message-ids", "msg-ids", "name", "open-conversation-ids", "open-message-ids", "open-task-id", "ref-msg-id", "resource-id", "source", "src-conversation-id", "src-msg-id", "target", "topic-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "chat mute",
|
||||
Aliases: map[string]string{
|
||||
"chat-id": "conversation-id",
|
||||
"chat-id": "conversation-id",
|
||||
"group": "conversation-id",
|
||||
"open-conversation-id": "conversation-id",
|
||||
},
|
||||
Blocked: []string{"conversation-ids", "group-id", "group-ids", "open-conversation-ids"},
|
||||
},
|
||||
@@ -2429,7 +2548,7 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"node-id": "node",
|
||||
"url": "node",
|
||||
},
|
||||
Blocked: []string{"block-id", "comment-id", "comment-key", "count", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "offset", "page", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"},
|
||||
Blocked: []string{"block-id", "comment-id", "comment-key", "dentry-id", "folder", "folder-id", "id", "job-id", "name", "parent-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"},
|
||||
Ambiguous: []string{"max-result", "max-results", "next-cursor", "next-page-token", "next-token", "per-page", "size", "take", "top"},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -237,42 +237,6 @@ func TestReduceLeafParamAliasesRemainingEdges(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReduceLeafParamAliasesBindExcludesRealFlags(t *testing.T) {
|
||||
entry, problems := reduceLeafParamAliases(
|
||||
"demo cmd",
|
||||
realMap(realFlag{name: "id"}, realFlag{name: "name"}, realFlag{name: "query"}),
|
||||
[]Concept{
|
||||
{ID: "base_id", Members: []string{"base-id", "base-token"}, Excludes: []string{"keyword", "name", "query", "unsafe"}},
|
||||
{ID: "query", Members: []string{"query", "keyword"}},
|
||||
},
|
||||
CommandOverride{Bind: map[string]string{"id": "base_id"}},
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("reduceLeafParamAliases() problems = %v", problems)
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatal("reduceLeafParamAliases() entry = nil")
|
||||
}
|
||||
if entry.Aliases["base-id"] != "id" || entry.Aliases["base-token"] != "id" {
|
||||
t.Fatalf("bound aliases = %#v, want base-id/base-token -> id", entry.Aliases)
|
||||
}
|
||||
if entry.Aliases["keyword"] != "query" {
|
||||
t.Fatalf("query alias = %#v, want keyword -> query", entry.Aliases)
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "keyword") {
|
||||
t.Fatalf("excluded alias entered blocked list: %#v", entry.Blocked)
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "name") {
|
||||
t.Fatalf("real excluded flag entered blocked list: %#v", entry.Blocked)
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "query") {
|
||||
t.Fatalf("claimed real excluded flag entered blocked list: %#v", entry.Blocked)
|
||||
}
|
||||
if !containsParamAlias(entry.Blocked, "unsafe") {
|
||||
t.Fatalf("non-real excluded flag was not blocked: %#v", entry.Blocked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasEntryLookupMethods(t *testing.T) {
|
||||
entry := ParamAliasEntry{
|
||||
Aliases: map[string]string{"uid": "user"},
|
||||
@@ -493,22 +457,6 @@ func TestReduceLeafParamAliasesExcludesProtectFuzzyButDoNotOverrideAnotherConcep
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesExcludesDoNotBlockRealFlag(t *testing.T) {
|
||||
concepts := []Concept{
|
||||
{ID: "single_id", Members: []string{"id", "item-id"}, Excludes: []string{"item-ids"}},
|
||||
}
|
||||
entry, problems := reduceLeafParamAliases("demo cmd", realMap(realFlag{name: "id"}, realFlag{name: "item-ids"}), concepts, CommandOverride{})
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("unexpected problems: %v", problems)
|
||||
}
|
||||
if entry == nil {
|
||||
t.Fatal("expected a reduced entry")
|
||||
}
|
||||
if containsParamAlias(entry.Blocked, "item-ids") {
|
||||
t.Fatalf("real exclude was blocked: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testing.T) {
|
||||
real := realMap(realFlag{name: "user-id"}, realFlag{name: "user"})
|
||||
for name, override := range map[string]CommandOverride{
|
||||
@@ -524,58 +472,6 @@ func TestReduceLeafParamAliasesRejectsProtectionOrScopedAliasOnRealFlag(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedParamAliasesBlockPluralListSpellingsOnSingleIDCommands(t *testing.T) {
|
||||
entries := make(map[string]ParamAliasEntry, len(generatedParamAliases))
|
||||
for _, entry := range generatedParamAliases {
|
||||
entries[entry.CLIPath] = entry
|
||||
}
|
||||
assertBlocked := func(path string, names ...string) {
|
||||
t.Helper()
|
||||
entry, ok := entries[path]
|
||||
if !ok {
|
||||
t.Fatalf("missing generated alias entry for %q", path)
|
||||
}
|
||||
for _, name := range names {
|
||||
if !entry.IsBlocked(cmdutil.Morph(name)) {
|
||||
t.Fatalf("%s: %q not blocked; entry = %#v", path, name, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"chat message add-emoji",
|
||||
"chat message remove-emoji",
|
||||
"chat message add-text-emotion",
|
||||
"chat message remove-text-emotion",
|
||||
} {
|
||||
assertBlocked(path, "msg-ids", "message-ids")
|
||||
}
|
||||
for _, path := range []string{
|
||||
"chat message send",
|
||||
"chat conversation-info",
|
||||
"chat category add-conv",
|
||||
"chat category remove-conv",
|
||||
"chat message list",
|
||||
"chat message list-mentions",
|
||||
"chat message recall-by-bot",
|
||||
"chat message search",
|
||||
} {
|
||||
assertBlocked(path, "conversation-ids")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedParamAliasesKeepAuditJoinUserRoleAmbiguous(t *testing.T) {
|
||||
entry, ok := LookupParamAlias("chat group audit-join-validation")
|
||||
if !ok {
|
||||
t.Fatal("missing generated alias entry for chat group audit-join-validation")
|
||||
}
|
||||
for _, name := range []string{"user", "user-id", "userid", "uid", "staff-id"} {
|
||||
if !entry.IsAmbiguous(cmdutil.Morph(name)) {
|
||||
t.Fatalf("%q not ambiguous; entry = %#v", name, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratedParamAliasesAreWellFormed guards the committed generated table
|
||||
// at the Go level, complementing the byte-identity drift gate.
|
||||
func TestGeneratedParamAliasesAreWellFormed(t *testing.T) {
|
||||
|
||||
@@ -60,17 +60,11 @@
|
||||
"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 conversation-info": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat group-mute": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat group-mute-member": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message add-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message add-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message list-mentions": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message recall-by-bot": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message remove-emoji": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message remove-text-emotion": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "ambiguous": ["open-message-id"], "note": "Native --chat/--group/--id/--conversation-id/--open-conversation-id and visible --message-id/--msg-id stay executable; numeric/list spellings are rejected."},
|
||||
"chat message search": {"ambiguous": ["chat-id", "open-conversation-id"], "note": "Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat mute": {"scoped_aliases": {"chat-id": "conversation-id"}, "block": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "note": "Native --conversation-id and hidden compatibility --group/--id/--chat/--open-conversation-id remain unchanged; reviewed chat-id reduces to --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": ["root-id", "space"], "note": "Numeric --space-id and knowledge-base --workspace are distinct routes; bare --space/--root-id cannot select a domain or folder.", "scoped_aliases": {"document-id": "node", "dentry-uuid": "node", "directory-id": "folder", "order-field": "order-by", "sort-by": "order-by", "sort-field": "order-by"}, "scope_strict": true, "block": ["dentry-id"]},
|
||||
"drive upload": {"ambiguous": ["destination-id", "space", "target-id"], "note": "Local file, display name, MIME, overwrite node, folder, storage space, and knowledge-base workspace remain distinct roles.", "scoped_aliases": {"dentry-uuid": "node", "directory-id": "folder", "overwrite-node-id": "node", "target-folder-id": "folder", "target-workspace-id": "workspace", "source-file": "file", "content-type": "mime-type", "filename": "file-name", "name": "file-name", "display-name": "file-name", "upload-name": "file-name"}, "block": ["dentry-id", "document-url", "output-path"], "scope_strict": true},
|
||||
"ding +receiver-status": {"scoped_aliases": {"id": "ding-id"}, "note": "generic id reduces to ding-id"},
|
||||
@@ -90,12 +84,12 @@
|
||||
"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"], "ambiguous": ["chat-id", "open-conversation-id"], "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. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"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"}, "ambiguous": ["chat-id", "open-conversation-id"], "note": "Recipient and local-file-path aliases are exact to this command; obsolete file metadata flags remain unsupported. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"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."},
|
||||
@@ -104,8 +98,8 @@
|
||||
"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"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat category remove-conv": {"block": ["category-id"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "This command requires a category-id list; one id is not promoted into a batch input. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"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."},
|
||||
@@ -115,7 +109,7 @@
|
||||
"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": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids", "chat-id", "open-conversation-id"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
|
||||
"doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true},
|
||||
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
|
||||
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder", "space": "workspace", "space-id": "workspace"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId. The command-scoped --space/--space-id aliases preserve the compatibility published before workspace and numeric DingDrive storage-space concepts were split; they do not make those value domains globally equivalent."},
|
||||
@@ -148,7 +142,7 @@
|
||||
"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"], "ambiguous": ["chat", "chat-id", "open-conversation-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings. Visible --conversation-id and legacy --group both remain executable outside the approved hidden-alias migration set."},
|
||||
"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."},
|
||||
"chat +category-add-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
"chat +category-list-conversations": {"block": ["category-ids"], "note": "The real --category-id is singular; list cardinality is not reduced automatically."},
|
||||
"chat +category-remove-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
@@ -334,7 +328,7 @@
|
||||
{"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": "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"},
|
||||
@@ -472,7 +466,6 @@
|
||||
{"command": "chat +flag-create", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
|
||||
{"command": "chat group audit-join-validation", "emitted": "user", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "applicant-user-id", "expect": "applicant", "via": "override:scoped-user-role"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "user-id", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
|
||||
{"command": "chat +chat-create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
|
||||
@@ -1354,16 +1354,16 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
interfaceType string
|
||||
}{
|
||||
{"chat message edit", "conversation-id", "openConversationId", true, ""},
|
||||
{"chat message edit", "message-id", "openMessageId", true, ""},
|
||||
{"chat message edit", "msg-id", "openMessageId", true, ""},
|
||||
{"chat message edit", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message update-text-emotion", "message-id", "openMsgId", true, ""},
|
||||
{"chat message send", "idempotency-key", "uuid", false, ""},
|
||||
{"chat message send-card", "at-all", "atAll", false, ""},
|
||||
{"chat message send-card", "at-open-dingtalk-ids", "atOpenDingTalkIds", false, "array"},
|
||||
{"chat message update-text-emotion", "msg-id", "openMsgId", true, ""},
|
||||
{"chat message update-text-emotion", "old-emotion-id", "oldEmotionId", true, ""},
|
||||
{"chat category batch-info", "category-ids", "categoryIds", true, "array"},
|
||||
{"chat category list-by-conv", "conversation-id", "openConversationId", true, ""},
|
||||
{"chat group update-nick", "conversation-id", "", true, ""},
|
||||
{"chat category list-by-conv", "group", "openConversationId", true, ""},
|
||||
{"chat group update-nick", "group", "openConversationId", true, ""},
|
||||
{"chat group upgrade-to-external", "extension", "extension", false, "object"},
|
||||
{"chat +messages-send-card", "receiver-open-dingtalk-id", "receiverOpenDingTalkId", false, ""},
|
||||
{"chat message list-favorites", "size", "", false, "string"},
|
||||
@@ -1397,8 +1397,7 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Manifest-covered migrations hide legacy aliases; manifest-external
|
||||
// commands keep their existing visible flags for compatibility.
|
||||
// Hidden conversation aliases must stay unpublished (merge-base parity).
|
||||
editLeaf, err := queryDeliverySchemaPayload([]string{"chat message edit"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1422,52 +1421,9 @@ func TestDeliveryCatalogChatParamDeclsFrom87910880Reviewed(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listParams := schemaMap(listByConv["parameters"])
|
||||
if _, ok := listParams["conversation-id"]; !ok {
|
||||
t.Fatalf("chat category list-by-conv missing public canonical --conversation-id")
|
||||
}
|
||||
if _, ok := listParams["group"]; !ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly hides manifest-external --group")
|
||||
}
|
||||
if _, ok := listParams["id"]; ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --id")
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"chat message add-emoji",
|
||||
"chat message remove-emoji",
|
||||
"chat message add-text-emotion",
|
||||
"chat message remove-text-emotion",
|
||||
} {
|
||||
leaf, err := queryDeliverySchemaPayload([]string{path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
params := schemaMap(leaf["parameters"])
|
||||
if _, ok := params["conversation-id"]; !ok {
|
||||
t.Fatalf("%s missing public canonical --conversation-id", path)
|
||||
}
|
||||
for _, visible := range []string{"group", "id", "chat"} {
|
||||
if _, ok := params[visible]; !ok {
|
||||
t.Fatalf("%s unexpectedly hides manifest-external --%s", path, visible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groupBots, err := queryDeliverySchemaPayload([]string{"chat group bots"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groupBotsParams := schemaMap(groupBots["parameters"])
|
||||
group := groupBotsParams["group"]
|
||||
if group == nil {
|
||||
t.Fatal("chat group bots missing public legacy --group")
|
||||
}
|
||||
if group["property"] != "openConversationId" {
|
||||
t.Fatalf("chat group bots --group property = %#v, want openConversationId", group["property"])
|
||||
}
|
||||
for _, migrated := range []string{"conversation-id", "group-name"} {
|
||||
if _, ok := groupBotsParams[migrated]; ok {
|
||||
t.Fatalf("chat group bots unexpectedly publishes migrated --%s", migrated)
|
||||
for _, hidden := range []string{"conversation-id", "id"} {
|
||||
if _, ok := listParams[hidden]; ok {
|
||||
t.Fatalf("chat category list-by-conv unexpectedly publishes hidden alias --%s", hidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,14 +43,19 @@ func init() {
|
||||
RequireOneOf: [][]string{{"conversation-id", "open-dingtalk-id", "user", "permParam"}},
|
||||
})
|
||||
registerExclusiveOneOf("chat.chat_permission_grant_cross_org_data", "target-org-id", "all")
|
||||
registerRequireOneOf("chat.add_emoji_reaction", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.add_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerExclusiveOneOf("chat.clear_conversation_messages", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.clear_conversation_red_point", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.update_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerExclusiveOneOf("chat.get_conversation_info", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.hide_conversation", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.list_conversation_message_v2", "group", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.list_individual_chat_message", "user", "open-dingtalk-id")
|
||||
registerExclusiveOneOf("chat.mark_conversation_unread", "conversation-id", "id", "chat")
|
||||
registerExclusiveOneOf("chat.mark_message_read", "conversation-id", "id", "chat")
|
||||
registerRequireOneOf("chat.remove_emoji_reaction", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.remove_text_emotion", "conversation-id", "group", "id", "chat")
|
||||
registerRequireOneOf("chat.send_personal_message", "text", "content", "msg-type")
|
||||
registerExclusiveOneOf("chat.send_robot_message", "group", "users")
|
||||
registerRequireOneOf("chat.set_group_member_mute_list", "users", "user")
|
||||
|
||||
@@ -252,8 +252,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"chat.batch_query_group_chat_settings --groups": "Reviewed unpinned adapter: chat.batch_query_group_chat_settings has no singular pinned interface_ref; --groups is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.batch_update_group_chat_settings --items": "Reviewed unpinned adapter: chat.batch_update_group_chat_settings has no singular pinned interface_ref; --items is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.create_text_emotion --background-id": "Runtime extension: the executable helper forwards backgroundId to im/create_text_emotion, but the pinned source-revision metadata does not declare that optional property; preserve the compatibility flag without advertising it as a pinned RPC field.",
|
||||
"chat.get_group_mute_config --conversation-id": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.get_group_mute_config --group": "Reviewed legacy Schema compatibility: the historical visible --group wrapper input was required but did not publish a direct interface property.",
|
||||
"chat.get_group_mute_config --group": "Reviewed unpinned adapter: chat.get_group_mute_config has no singular pinned interface_ref; --group is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -266,7 +265,6 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"chat.query_msg_read_status --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
"chat.remove_message_favorite --open-conversation-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.remove_message_favorite --open-message-id": "Reviewed unpinned adapter: chat.remove_message_favorite has no singular pinned interface_ref; --open-message-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.update_text_emotion --conversation-id": "Reviewed unpinned adapter: chat.update_text_emotion has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.reply_personal_message --ref-msg-id": "serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --ref-sender": "resolved then serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --text": "serialized into the aggregate content JSON string",
|
||||
|
||||
+12
-40
@@ -145,15 +145,6 @@ type FlagSpec struct {
|
||||
// alike) and makes a whitespace-only value count as empty in required checks.
|
||||
Trim bool
|
||||
|
||||
// Input declares extra input sources for a KindString flag beyond the
|
||||
// literal command-line value: InputFile enables @path (value replaced by
|
||||
// the file content), InputStdin enables - (value replaced by stdin).
|
||||
// "@@value" always escapes to the literal "@value". Only explicit CLI
|
||||
// tokens are resolved; EnvVar fallback and registration defaults pass
|
||||
// through unchanged. Resolution runs before required/enum/constraint/
|
||||
// Validate checks, so they see the payload content. Empty = flag value only.
|
||||
Input []string
|
||||
|
||||
// Schema parameter final facts (embedded to dws.schema.*; assembly pass-through).
|
||||
Enum []string // accepted values
|
||||
Format string // machine-readable format (e.g. uri)
|
||||
@@ -374,7 +365,6 @@ func New(spec Spec) *cobra.Command {
|
||||
validateDispatchDecl(spec)
|
||||
validateSafetySpec(spec)
|
||||
validateContractDecl(spec)
|
||||
validateInputSpecs(spec.Use, spec.Flags)
|
||||
// Help prose inherits the declaration when not authored separately:
|
||||
// Selection.Examples (already contract-validated against the real flags)
|
||||
// double as the --help Example block, keeping one authored source.
|
||||
@@ -486,11 +476,6 @@ func runDeclaredPreflight(cmd *cobra.Command, args []string, spec Spec) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Input resolution rewrites explicit @file / stdin values in place so the
|
||||
// required/enum/constraint/Validate stages below check the payload content.
|
||||
if err := resolveInputFlags(cmd, spec.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateRequired(cmd, spec.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -593,7 +578,18 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
for _, alias := range flag.Aliases {
|
||||
RegisterFlag(cmd, flag.Kind, alias, "", flag.Usage+" (alias)")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
AnnotateFlagAlias(cmd, alias, flag.Name)
|
||||
if registered := cmd.Flags().Lookup(alias); registered != nil {
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
flag.Name,
|
||||
)
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOrigin,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
}
|
||||
if flag.MarkRequired {
|
||||
_ = cmd.MarkFlagRequired(flag.Name)
|
||||
@@ -604,30 +600,6 @@ func RegisterFlags(cmd *cobra.Command, flags []FlagSpec) {
|
||||
}
|
||||
}
|
||||
|
||||
// AnnotateFlagAlias records framework-owned evidence that aliasName is a hidden
|
||||
// compatibility alias for canonicalName. It is for commands that already own
|
||||
// their Cobra flag registration outside FlagSpec but still need the same
|
||||
// interface-snapshot alias contract as FlagSpec.Aliases.
|
||||
func AnnotateFlagAlias(cmd *cobra.Command, aliasName, canonicalName string) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
registered := cmd.Flags().Lookup(aliasName)
|
||||
if registered == nil {
|
||||
return
|
||||
}
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOf,
|
||||
canonicalName,
|
||||
)
|
||||
runtimeannotate.SetFlagAnnotation(
|
||||
registered,
|
||||
runtimeannotate.AnnotationFlagAliasOrigin,
|
||||
runtimeannotate.FlagAliasOriginCorecmdV1,
|
||||
)
|
||||
}
|
||||
|
||||
// RegisterFlag registers one flag by Kind. Default is applied at registration
|
||||
// for every kind so --help DefValue matches the declared fallback.
|
||||
// Malformed KindInt / KindBool Default values panic at registration (fail-closed)
|
||||
|
||||
@@ -116,16 +116,6 @@ func TestCrossPlatformCoverageRegisterFlagsAllKinds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAnnotateFlagAliasIgnoresMissingInputs(t *testing.T) {
|
||||
AnnotateFlagAlias(nil, "alias", "canonical")
|
||||
|
||||
cmd := newTestCommand()
|
||||
AnnotateFlagAlias(cmd, "missing", "canonical")
|
||||
if flag := cmd.Flags().Lookup("missing"); flag != nil {
|
||||
t.Fatalf("unexpected missing flag registered: %#v", flag)
|
||||
}
|
||||
}
|
||||
|
||||
// ── effective value fallback chain ─────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageEffectiveValueFallbackChain(t *testing.T) {
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
// 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 corecmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
)
|
||||
|
||||
// FlagSpec.Input source constants. They declare extra input sources for a
|
||||
// KindString flag beyond the literal command-line value, mirroring the
|
||||
// lark-cli Flag.Input capability so large payloads (markdown, JSON, CSV)
|
||||
// never need shell quoting.
|
||||
const (
|
||||
// InputFile allows the flag value @path to be replaced by the file content.
|
||||
InputFile = "file"
|
||||
// InputStdin allows the flag value - to be replaced by the stdin content.
|
||||
InputStdin = "stdin"
|
||||
)
|
||||
|
||||
// utf8BOM is stripped from file/stdin content so a Windows-edited payload
|
||||
// cannot corrupt the first CSV cell or break JSON parsing downstream.
|
||||
const utf8BOM = "\ufeff"
|
||||
|
||||
// validateInputSpecs rejects malformed Input declarations at build time. Like
|
||||
// the other declaration checks this panics: a bad Input spec is a programming
|
||||
// error every test and startup path should trip immediately.
|
||||
func validateInputSpecs(use string, flags []FlagSpec) {
|
||||
for _, flag := range flags {
|
||||
if len(flag.Input) == 0 {
|
||||
continue
|
||||
}
|
||||
if flag.Kind != KindString {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q flag %q: Input is only supported on KindString flags",
|
||||
use, flag.Name))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, source := range flag.Input {
|
||||
if source != InputFile && source != InputStdin {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q flag %q: unknown Input source %q (allowed: %s, %s)",
|
||||
use, flag.Name, source, InputFile, InputStdin))
|
||||
}
|
||||
if seen[source] {
|
||||
panic(fmt.Sprintf(
|
||||
"command %q flag %q: duplicate Input source %q",
|
||||
use, flag.Name, source))
|
||||
}
|
||||
seen[source] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inputSupports reports whether the flag declared the given input source.
|
||||
func inputSupports(flag FlagSpec, source string) bool {
|
||||
for _, s := range flag.Input {
|
||||
if s == source {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// explicitInputFlagName picks the declared name that carries the user's
|
||||
// explicit token, mirroring rawValue's order and usability judgement exactly:
|
||||
// the main flag wins only when changed and usable (trim-judged only when
|
||||
// Trim is set), then declared aliases in order. Diverging here could rewrite
|
||||
// an alias that rawValue would shadow (or vice versa). EnvVar fallback and
|
||||
// registration defaults are never input-resolved — @file only applies to what
|
||||
// the user literally typed.
|
||||
func explicitInputFlagName(cmd *cobra.Command, flag FlagSpec) string {
|
||||
usable := func(v string) bool {
|
||||
if flag.Trim {
|
||||
v = strings.TrimSpace(v)
|
||||
}
|
||||
return v != ""
|
||||
}
|
||||
for _, name := range append([]string{flag.Name}, flag.Aliases...) {
|
||||
if cmd.Flags().Changed(name) && usable(cmdutil.MustGetFlag(cmd, name)) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveInputFlags rewrites the explicit values of Input-declaring flags
|
||||
// before required/enum/constraint/Validate checks, so every downstream stage
|
||||
// sees the real payload content. Semantics:
|
||||
//
|
||||
// - "-" reads stdin; a process has a single stdin, so a second Input flag
|
||||
// using "-" in the same invocation is rejected.
|
||||
// - "@path" reads the named file (leading/trailing whitespace trimmed).
|
||||
// - "@@value" escapes to the literal "@value" and is not source-resolved.
|
||||
//
|
||||
// Resolution runs once per invocation inside runDeclaredPreflight. It
|
||||
// rewrites the cobra flag value in place (Set keeps Changed=true), so
|
||||
// fallback-chain readers (EffectiveValue/BuildArgs) observe the content.
|
||||
//
|
||||
// Interaction with confirmation: stdin is consumed here, before the Safety
|
||||
// prompt of a user_required write would read it; such an interactive prompt
|
||||
// then sees EOF and fails closed with confirmation_required. Write commands
|
||||
// declaring InputStdin must be invoked with --yes (or --dry-run).
|
||||
func resolveInputFlags(cmd *cobra.Command, flags []FlagSpec) error {
|
||||
stdinConsumed := false
|
||||
for _, flag := range flags {
|
||||
if len(flag.Input) == 0 {
|
||||
continue
|
||||
}
|
||||
name := explicitInputFlagName(cmd, flag)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
raw := cmdutil.MustGetFlag(cmd, name)
|
||||
// Trim flags judge usability on the trimmed value (rawValue), so the
|
||||
// prefix check must agree or " @path" would slip through unresolved.
|
||||
if flag.Trim {
|
||||
raw = strings.TrimSpace(raw)
|
||||
}
|
||||
|
||||
switch {
|
||||
case raw == "-":
|
||||
if !inputSupports(flag, InputStdin) {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s 不支持 stdin 输入(-)", flag.Name))
|
||||
}
|
||||
if stdinConsumed {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s:stdin(-)只能被一个参数使用", flag.Name),
|
||||
apperrors.WithHint(fmt.Sprintf(
|
||||
"一个进程只有一份 stdin,其余参数请内联传值或用 @文件路径(如 --%s @./payload.json)",
|
||||
flag.Name)))
|
||||
}
|
||||
stdinConsumed = true
|
||||
data, err := io.ReadAll(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s 读取 stdin 失败:%v", flag.Name, err))
|
||||
}
|
||||
// Setting a registered string flag cannot fail.
|
||||
_ = cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM))
|
||||
|
||||
case strings.HasPrefix(raw, "@@"):
|
||||
// Escape: strip the first @, keep the rest as a literal inline value.
|
||||
_ = cmd.Flags().Set(name, raw[1:])
|
||||
|
||||
case strings.HasPrefix(raw, "@"):
|
||||
if !inputSupports(flag, InputFile) {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s 不支持文件输入(@路径)", flag.Name))
|
||||
}
|
||||
path := strings.TrimSpace(raw[1:])
|
||||
if path == "" {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s:@ 后的文件路径不能为空", flag.Name))
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
var opts []apperrors.Option
|
||||
if inputSupports(flag, InputStdin) {
|
||||
// Rejected @file paths are usually absolute (temp files).
|
||||
// Steer toward stdin rather than copying the file around.
|
||||
opts = append(opts, apperrors.WithHint(fmt.Sprintf(
|
||||
"该参数也支持 stdin:把文件内容管道进命令并传 --%s -", flag.Name)))
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("参数 --%s 读取文件 %q 失败:%v", flag.Name, path, err), opts...)
|
||||
}
|
||||
_ = cmd.Flags().Set(name, strings.TrimPrefix(string(data), utf8BOM))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
// 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 corecmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// newInputCommand builds a Spec whose Invoke captures toolArgs, so tests can
|
||||
// assert what the full pipeline (resolution → validation → BuildArgs) ships.
|
||||
func newInputCommand(flags []FlagSpec, captured *map[string]any) *cobra.Command {
|
||||
return New(Spec{
|
||||
Use: "t",
|
||||
Short: "t",
|
||||
Flags: flags,
|
||||
Invoke: func(c *Ctx, toolArgs map[string]any) error {
|
||||
*captured = toolArgs
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runInputCommand(t *testing.T, cmd *cobra.Command, args ...string) error {
|
||||
t.Helper()
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func writeInputFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "payload.txt")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsFile(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "# hello\nworld")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "# hello\nworld" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsStdin(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}},
|
||||
}, &got)
|
||||
cmd.SetIn(strings.NewReader("piped payload"))
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", "-"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "piped payload" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// The @@ escape keeps a literal leading @ inline and must not be treated as a
|
||||
// source reference even when Input is declared.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsEscapedAtStaysInline(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}},
|
||||
}, &got)
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", "@@literal@x"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "@literal@x" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsBOMStripped(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "\ufeff{\"a\":1}")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "{\"a\":1}" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// Required must be satisfied by the resolved payload, proving resolution runs
|
||||
// before the required stage.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsSatisfiesRequired(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Required: true, Input: []string{InputFile}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "payload")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", "@"+path); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "payload" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// Enum validation sees the resolved content, not the "@path" token.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsEnumValidatesResolvedContent(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "mode", Usage: "M", Bind: "mode", Enum: []string{"asc", "desc"}, Input: []string{InputFile}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "sideways")
|
||||
|
||||
err := runInputCommand(t, cmd, "--mode", "@"+path)
|
||||
if err == nil || !strings.Contains(err.Error(), "不合法") {
|
||||
t.Fatalf("expected enum rejection on resolved content, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A value passed through a declared alias is resolved exactly like the main
|
||||
// name (fallback-chain parity).
|
||||
func TestCrossPlatformCoverageResolveInputFlagsAliasResolved(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "via alias")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--body", "@"+path); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "via alias" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// When the main flag shadows a changed alias (rawValue usability order), the
|
||||
// shadowed alias must not be input-resolved: resolution targets exactly the
|
||||
// name the fallback chain will read. The whitespace main value is usable for
|
||||
// a non-Trim flag, and the alias path does not exist on purpose — a resolver
|
||||
// that wrongly picked the alias would fail the read instead of shipping " ".
|
||||
func TestCrossPlatformCoverageResolveInputFlagsShadowedAliasNotResolved(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Aliases: []string{"body"}, Input: []string{InputFile}},
|
||||
}, &got)
|
||||
missing := filepath.Join(t.TempDir(), "missing.txt")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", " ", "--body", "@"+missing); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != " " {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsFileNotSupported(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}},
|
||||
}, &got)
|
||||
|
||||
err := runInputCommand(t, cmd, "--content", "@/tmp/whatever.txt")
|
||||
if err == nil || !strings.Contains(err.Error(), "不支持文件输入") {
|
||||
t.Fatalf("expected file-input rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsStdinNotSupported(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}},
|
||||
}, &got)
|
||||
cmd.SetIn(strings.NewReader("x"))
|
||||
|
||||
err := runInputCommand(t, cmd, "--content", "-")
|
||||
if err == nil || !strings.Contains(err.Error(), "不支持 stdin") {
|
||||
t.Fatalf("expected stdin rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsSingleStdinConsumer(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "first", Usage: "F", Bind: "first", Input: []string{InputStdin}},
|
||||
{Name: "second", Usage: "S", Bind: "second", Input: []string{InputStdin}},
|
||||
}, &got)
|
||||
cmd.SetIn(strings.NewReader("x"))
|
||||
|
||||
err := runInputCommand(t, cmd, "--first", "-", "--second", "-")
|
||||
if err == nil || !strings.Contains(err.Error(), "只能被一个参数使用") {
|
||||
t.Fatalf("expected single-stdin rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// failingReader (corecmd_test.go) fails every read, making the stdin error
|
||||
// branch reachable.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsStdinReadFailure(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputStdin}},
|
||||
}, &got)
|
||||
cmd.SetIn(failingReader{})
|
||||
|
||||
err := runInputCommand(t, cmd, "--content", "-")
|
||||
if err == nil || !strings.Contains(err.Error(), "读取 stdin 失败") {
|
||||
t.Fatalf("expected stdin read failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsFileNotFound(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile, InputStdin}},
|
||||
}, &got)
|
||||
|
||||
err := runInputCommand(t, cmd, "--content", "@"+filepath.Join(t.TempDir(), "missing.txt"))
|
||||
if err == nil || !strings.Contains(err.Error(), "读取文件") {
|
||||
t.Fatalf("expected read failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveInputFlagsEmptyPathAfterAt(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Input: []string{InputFile}},
|
||||
}, &got)
|
||||
|
||||
err := runInputCommand(t, cmd, "--content", "@ ")
|
||||
if err == nil || !strings.Contains(err.Error(), "文件路径不能为空") {
|
||||
t.Fatalf("expected empty-path rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A flag without Input keeps its literal value even when it looks like a
|
||||
// source reference — resolution is strictly opt-in per declaration.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsNoInputSpecPassthrough(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "token", Usage: "T", Bind: "token"},
|
||||
}, &got)
|
||||
|
||||
if err := runInputCommand(t, cmd, "--token", "@not-a-file"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["token"] != "@not-a-file" {
|
||||
t.Fatalf("toolArgs[token] = %q", got["token"])
|
||||
}
|
||||
}
|
||||
|
||||
// Registration defaults and env fallback are never input-resolved: @file only
|
||||
// applies to what the user literally typed on the command line.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsDefaultNotResolved(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Default: "@not-a-file", Input: []string{InputFile}},
|
||||
}, &got)
|
||||
|
||||
if err := runInputCommand(t, cmd); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "@not-a-file" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// Trim flags judge usability on the trimmed value (rawValue), so a leading
|
||||
// whitespace before @ must still resolve instead of shipping as a literal.
|
||||
func TestCrossPlatformCoverageResolveInputFlagsTrimmedLeadingSpace(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := newInputCommand([]FlagSpec{
|
||||
{Name: "content", Usage: "C", Bind: "content", Trim: true, Input: []string{InputFile}},
|
||||
}, &got)
|
||||
path := writeInputFile(t, "trimmed payload")
|
||||
|
||||
if err := runInputCommand(t, cmd, "--content", " @"+path); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got["content"] != "trimmed payload" {
|
||||
t.Fatalf("toolArgs[content] = %q", got["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateInputSpecsPanics(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flag FlagSpec
|
||||
}{
|
||||
{"non-string kind", FlagSpec{Name: "n", Kind: KindInt, Input: []string{InputFile}}},
|
||||
{"unknown source", FlagSpec{Name: "s", Input: []string{"url"}}},
|
||||
{"duplicate source", FlagSpec{Name: "s", Input: []string{InputFile, InputFile}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("expected panic")
|
||||
}
|
||||
}()
|
||||
New(Spec{
|
||||
Use: "t",
|
||||
Short: "t",
|
||||
Flags: []FlagSpec{tc.flag},
|
||||
Invoke: func(c *Ctx, toolArgs map[string]any) error { return nil },
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
+446
-808
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ package helpers
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -12,7 +11,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
|
||||
@@ -34,26 +32,14 @@ func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...str
|
||||
|
||||
func runChatCoverageDirect(t *testing.T, path []string, flags map[string]string) error {
|
||||
t.Helper()
|
||||
InitDeps(&scriptedToolCaller{})
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
root := newChatCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.PersistentFlags().Bool("debug", false, "")
|
||||
root.PersistentFlags().Bool("verbose", false, "")
|
||||
command, _, err := root.Find(path)
|
||||
command, _, err := newChatCommand().Find(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for name, value := range flags {
|
||||
flag := command.Flag(name)
|
||||
if flag == nil {
|
||||
return fmt.Errorf("no such flag -%s", name)
|
||||
}
|
||||
if err := flag.Value.Set(value); err != nil {
|
||||
if err := command.Flags().Set(name, value); err != nil {
|
||||
return err
|
||||
}
|
||||
flag.Changed = true
|
||||
}
|
||||
return command.RunE(command, nil)
|
||||
}
|
||||
@@ -107,7 +93,7 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
|
||||
hint string
|
||||
}{
|
||||
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --conversation-id <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
command, remaining, err := root.Find([]string{tc.path})
|
||||
if err != nil {
|
||||
@@ -127,80 +113,6 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatAliasInstallerRemainingEdges(t *testing.T) {
|
||||
restoreChatManifestExternalVisibleFlags(nil)
|
||||
|
||||
mismatchedRoot := &cobra.Command{Use: "chat"}
|
||||
mismatchedRoot.AddCommand(&cobra.Command{Use: "other"})
|
||||
restoreChatManifestExternalVisibleFlags(mismatchedRoot)
|
||||
|
||||
missingPrimary := &cobra.Command{Use: "leaf"}
|
||||
installChatFlagAliases(missingPrimary, "conversation-id", []string{"group"}, requireChatConversationID)
|
||||
if flag := missingPrimary.Flags().Lookup("group"); flag != nil {
|
||||
t.Fatalf("alias registered without canonical flag: %#v", flag)
|
||||
}
|
||||
|
||||
skipGroup := &cobra.Command{Use: "leaf", RunE: func(cmd *cobra.Command, args []string) error { return nil }}
|
||||
skipGroup.Flags().String("conversation-id", "", "")
|
||||
skipGroup.Flags().String("group-name", "", "")
|
||||
installChatFlagAliases(skipGroup, "conversation-id", []string{"group", "chat"}, requireChatConversationID)
|
||||
if flag := skipGroup.Flags().Lookup("group"); flag != nil {
|
||||
t.Fatalf("group alias registered beside group-name: %#v", flag)
|
||||
}
|
||||
if flag := skipGroup.Flags().Lookup("chat"); flag == nil {
|
||||
t.Fatal("non-group alias was not registered")
|
||||
}
|
||||
|
||||
preRunCalled := false
|
||||
withPreRun := &cobra.Command{
|
||||
Use: "leaf",
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
preRunCalled = true
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return nil },
|
||||
}
|
||||
withPreRun.Flags().String("conversation-id", "", "")
|
||||
installChatFlagAliases(withPreRun, "conversation-id", []string{"group"}, requireChatConversationID)
|
||||
withPreRun.SetArgs([]string{"--group", "cid"})
|
||||
if err := withPreRun.ExecuteContext(context.Background()); err != nil {
|
||||
t.Fatalf("execute with alias and previous PreRunE: %v", err)
|
||||
}
|
||||
if !preRunCalled {
|
||||
t.Fatal("previous PreRunE was not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageForwardRequiresMessageID(t *testing.T) {
|
||||
caller := &productExampleCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
root := newChatCommand()
|
||||
command, _, err := root.Find([]string{"message", "forward"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"message-id", "msg-id"} {
|
||||
if flag := command.Flags().Lookup(name); flag != nil && flag.Annotations != nil {
|
||||
delete(flag.Annotations, cobra.BashCompOneRequiredFlag)
|
||||
}
|
||||
}
|
||||
if err := command.Flags().Set("src-conversation-id", "src"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := command.Flags().Set("dest-conversation-id", "dest"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = command.RunE(command, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required flag: --message-id") {
|
||||
t.Fatalf("forward missing message id error = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("tool calls = %d, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupUpdateIconAcceptsUploadedMediaIDPrefixes(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
@@ -287,10 +199,9 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T)
|
||||
{"category", "remove-conv", "--group=cid", "--category-ids=1,2"},
|
||||
{"message", "list-by-ids", "--msg-ids=" + strings.Repeat("id,", 51) + "last"},
|
||||
{"group", "transfer-owner", "--group=cid", "--new-owner=D-owner"},
|
||||
{"group", "transfer-owner", "--group=cid", "--new-owner=DAAAAAAAAAAAiE"},
|
||||
{"group", "update-icon", "--group=cid", "--icon-media-id=@valid"},
|
||||
{"group", "set-history", "--group=cid", "--option=ALL"},
|
||||
{"group", "audit-join-validation", "--conversation-id=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
|
||||
{"group", "audit-join-validation", "--group=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"},
|
||||
{"mark-read", "--conversation-id=cid", "--message-id=mid"},
|
||||
{"text", "translate", "--query=hello", "--to=zh_CN"},
|
||||
{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1"},
|
||||
@@ -382,7 +293,7 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-card",
|
||||
"--conversation-id=cid",
|
||||
"--group=cid",
|
||||
"--at-open-dingtalk-ids=D1,D2,D1",
|
||||
"--at-all",
|
||||
)
|
||||
@@ -403,13 +314,13 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "member mention rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-open-dingtalk-ids=D2"}},
|
||||
{name: "at all rejects direct message", args: []string{"--open-dingtalk-id=D1", "--at-all"}},
|
||||
{name: "member mention rejects direct message", args: []string{"--receiver=D1", "--at-open-dingtalk-ids=D2"}},
|
||||
{name: "at all rejects direct message", args: []string{"--receiver=D1", "--at-all"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
|
||||
if err == nil || !strings.Contains(err.Error(), "only supported with --conversation-id") {
|
||||
if err == nil || !strings.Contains(err.Error(), "only supported with --group") {
|
||||
t.Fatalf("error = %v, want group-only mention validation", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
@@ -419,168 +330,6 @@ func TestCrossPlatformCoverageChatNativeSendCardMentions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendCardHiddenAliasesMapToCanonicalPayload(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "group alias",
|
||||
args: []string{"--group=cid"},
|
||||
want: map[string]any{"openConversationId": "cid"},
|
||||
},
|
||||
{
|
||||
name: "receiver alias",
|
||||
args: []string{"--receiver=DAAAAAAAAAAAiE"},
|
||||
want: map[string]any{"receiverOpenDingTalkId": "DAAAAAAAAAAAiE"},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller, append([]string{"message", "send-card"}, tc.args...)...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "im" || caller.tool != "create_and_send_card" || !reflect.DeepEqual(caller.args, tc.want) {
|
||||
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationUsesCanonicalAndAliasPayload(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
flag string
|
||||
}{
|
||||
{name: "canonical conversation-id", flag: "--conversation-id=cid"},
|
||||
{name: "hidden group alias", flag: "--group=cid"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runChatCoverageCommand(t, caller,
|
||||
"group", "audit-join-validation",
|
||||
tc.flag,
|
||||
"--record-id=123",
|
||||
"--applicant=D-applicant",
|
||||
"--inviter=D-inviter",
|
||||
"--status=AuditDelete",
|
||||
"--description=deny",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"openConversationId": "cid",
|
||||
"applyRecordId": int64(123),
|
||||
"applicantUid": "D-applicant",
|
||||
"inviterUid": "D-inviter",
|
||||
"status": "AuditDelete",
|
||||
"auditDescription": "deny",
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "im" || caller.tool != "audit_join_group" || !reflect.DeepEqual(caller.args, want) {
|
||||
t.Fatalf("call = count:%d server:%q tool:%q args:%#v, want %#v", caller.calls, caller.server, caller.tool, caller.args, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatIMIDMigrationRequiredFlagErrors(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs })
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path []string
|
||||
flag map[string]string
|
||||
want string
|
||||
}{
|
||||
{name: "message list mutually exclusive targets", path: []string{"message", "list"}, flag: map[string]string{"conversation-id": "cid", "user": "u1", "time": "2026-01-01"}, want: "mutually exclusive"},
|
||||
{name: "message list missing target", path: []string{"message", "list"}, flag: map[string]string{"time": "2026-01-01"}, want: "--conversation-id, --user or --open-dingtalk-id is required"},
|
||||
{name: "topic replies missing conversation", path: []string{"message", "list-topic-replies"}, flag: map[string]string{"topic-id": "t1"}, want: "conversation-id"},
|
||||
{name: "read status missing conversation", path: []string{"message", "read-status"}, flag: map[string]string{"message-id": "m1"}, want: "conversation-id"},
|
||||
{name: "read status conflicting aliases", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid1", "group": "cid2", "message-id": "m1"}, want: "conflicts"},
|
||||
{name: "read status missing message", path: []string{"message", "read-status"}, flag: map[string]string{"conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "update text emotion missing message", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t", "background-id": "b"}, want: "message-id"},
|
||||
{name: "update text emotion missing detail flag", path: []string{"message", "update-text-emotion"}, flag: map[string]string{"conversation-id": "cid", "message-id": "m1", "old-emotion-id": "e1", "emotion-id": "e2", "emotion-name": "n", "text": "t"}, want: "background-id"},
|
||||
{name: "transfer owner missing conversation", path: []string{"group", "transfer-owner"}, flag: map[string]string{"new-owner": "D1"}, want: "conversation-id"},
|
||||
{name: "invite url missing conversation", path: []string{"group", "invite-url"}, want: "conversation-id"},
|
||||
{name: "quit missing conversation", path: []string{"group", "quit"}, want: "conversation-id"},
|
||||
{name: "update icon missing conversation", path: []string{"group", "update-icon"}, flag: map[string]string{"icon-media-id": "@media"}, want: "conversation-id"},
|
||||
{name: "update settings missing conversation", path: []string{"group", "update-settings"}, flag: map[string]string{"setting-key": "searchable"}, want: "conversation-id"},
|
||||
{name: "set admin missing conversation", path: []string{"group", "set-admin"}, flag: map[string]string{"users": "D1"}, want: "conversation-id"},
|
||||
{name: "role list missing conversation", path: []string{"group-role", "list"}, want: "group"},
|
||||
{name: "role add missing conversation", path: []string{"group-role", "add"}, flag: map[string]string{"name": "role"}, want: "conversation-id"},
|
||||
{name: "role update missing conversation", path: []string{"group-role", "update"}, flag: map[string]string{"role-id": "r1", "name": "role"}, want: "conversation-id"},
|
||||
{name: "role remove missing conversation", path: []string{"group-role", "remove"}, flag: map[string]string{"role-id": "r1"}, want: "conversation-id"},
|
||||
{name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
|
||||
{name: "role remove user missing conversation", path: []string{"group-role", "remove-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"},
|
||||
{name: "role query user missing conversation", path: []string{"group-role", "query-user"}, flag: map[string]string{"user": "D1"}, want: "conversation-id"},
|
||||
{name: "bots missing legacy group", path: []string{"group", "bots"}, want: "group"},
|
||||
{name: "bots rejects migrated conversation id", path: []string{"group", "bots"}, flag: map[string]string{"conversation-id": "cid"}, want: "no such flag"},
|
||||
{name: "dismiss missing conversation", path: []string{"group", "dismiss"}, flag: map[string]string{"yes": "true"}, want: "conversation-id"},
|
||||
{name: "set history missing conversation", path: []string{"group", "set-history"}, flag: map[string]string{"option": "ALL"}, want: "conversation-id"},
|
||||
{name: "set pin missing message", path: []string{"message", "set-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "unset pin missing message", path: []string{"message", "unset-pin-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "audit join missing conversation", path: []string{"group", "audit-join-validation"}, flag: map[string]string{"record-id": "1", "applicant": "D1", "inviter": "D2", "status": "AuditApprove"}, want: "conversation-id"},
|
||||
{name: "set top missing message", path: []string{"message", "set-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "unset top missing message", path: []string{"message", "unset-top-msg"}, flag: map[string]string{"open-conversation-id": "cid"}, want: "message-id"},
|
||||
{name: "update alias missing conversation", path: []string{"group", "update-alias"}, flag: map[string]string{"alias-title": "alias"}, want: "conversation-id"},
|
||||
{name: "notice create missing conversation", path: []string{"group", "notice", "create"}, flag: map[string]string{"content": "hello"}, want: "conversation-id"},
|
||||
{name: "notice edit missing conversation", path: []string{"group", "notice", "edit"}, flag: map[string]string{"notice-id": "n1", "content": "hello"}, want: "conversation-id"},
|
||||
{name: "notice get missing conversation", path: []string{"group", "notice", "get"}, flag: map[string]string{"notice-id": "n1"}, want: "conversation-id"},
|
||||
{name: "notice list missing conversation", path: []string{"group", "notice", "list"}, want: "conversation-id"},
|
||||
}
|
||||
|
||||
probe := newChatCommand()
|
||||
messageList, _, err := probe.Find([]string{"message", "list"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := chatConversationID(messageList); err != nil || got != "" {
|
||||
t.Fatalf("empty chatConversationID = %q, %v", got, err)
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := runChatCoverageDirect(t, tc.path, tc.flag)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageReadStatusConversationAliasesExecute(t *testing.T) {
|
||||
for _, alias := range []string{"group", "id", "chat", "open-conversation-id"} {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := runChatCoverageCommand(t, caller, "message", "read-status", "--"+alias, "cid-1", "--message-id", "msg-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.server != "im" || caller.tool != "query_msg_read_status" {
|
||||
t.Fatalf("call = %s/%s, want im/query_msg_read_status", caller.server, caller.tool)
|
||||
}
|
||||
if got := caller.args["openConversationId"]; got != "cid-1" {
|
||||
t.Fatalf("openConversationId = %#v, want cid-1", got)
|
||||
}
|
||||
if got := caller.args["openMessageId"]; got != "msg-1" {
|
||||
t.Fatalf("openMessageId = %#v, want msg-1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *testing.T) {
|
||||
previousDeps, previousArgs := deps, os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
@@ -594,7 +343,6 @@ func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *te
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--open-dingtalk-id=D1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "conversation-info", "--user=D1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[{"userId":"u1","openDingTalkId":"D1"}]}`}, {text: `{}`}}}, "conversation-info", "--user=u1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--open-dingtalk-id=D1")
|
||||
_ = runChatCoverageCommand(t, &scriptedToolCaller{}, "message", "send-card", "--receiver=D1")
|
||||
|
||||
oldGet := httpGetFile
|
||||
|
||||
@@ -17,88 +17,8 @@ import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationAliasContract(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
leaf, _, err := cmd.Find([]string{"group", "audit-join-validation"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical := leaf.Flags().Lookup("conversation-id")
|
||||
if canonical == nil || canonical.Hidden {
|
||||
t.Fatalf("conversation-id flag = %#v, want visible canonical", canonical)
|
||||
}
|
||||
legacy := leaf.Flags().Lookup("group")
|
||||
if legacy == nil || !legacy.Hidden {
|
||||
t.Fatalf("group flag = %#v, want hidden compatibility alias", legacy)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" {
|
||||
t.Fatalf("group alias_of annotation = %#v", got)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("group alias_origin annotation = %#v", got)
|
||||
}
|
||||
if got := legacy.Annotations[cobra.BashCompOneRequiredFlag]; len(got) != 0 {
|
||||
t.Fatalf("hidden group alias kept required annotation: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationRestoreRequiredNoop(t *testing.T) {
|
||||
restoreChatGroupBotsLegacyRequired(nil)
|
||||
restoreChatPendingMigrationCanonicalRequired(nil)
|
||||
root := &cobra.Command{Use: "chat"}
|
||||
root.AddCommand(&cobra.Command{Use: "other"})
|
||||
restoreChatGroupBotsLegacyRequired(root)
|
||||
restoreChatPendingMigrationCanonicalRequired(root)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupBotsKeepsLegacyGroupFlag(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
leaf, _, err := cmd.Find([]string{"group", "bots"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
group := leaf.Flags().Lookup("group")
|
||||
if group == nil || group.Hidden {
|
||||
t.Fatalf("group flag = %#v, want visible legacy flag", group)
|
||||
}
|
||||
if got := group.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" {
|
||||
t.Fatalf("group required annotation = %#v, want true", got)
|
||||
}
|
||||
if leaf.Flags().Lookup("conversation-id") != nil {
|
||||
t.Fatalf("chat group bots still exposes migrated --conversation-id")
|
||||
}
|
||||
if leaf.Flags().Lookup("group-name") != nil {
|
||||
t.Fatalf("chat group bots still exposes migrated --group-name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatPendingMigrationAliasesMatchManifest(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
leaf, _, err := cmd.Find([]string{"group", "dismiss"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical := leaf.Flags().Lookup("conversation-id")
|
||||
if canonical == nil {
|
||||
t.Fatal("missing conversation-id flag")
|
||||
}
|
||||
if got := canonical.Annotations[cobra.BashCompOneRequiredFlag]; len(got) == 0 || got[0] != "true" {
|
||||
t.Fatalf("conversation-id required annotation = %#v, want true", got)
|
||||
}
|
||||
legacy := leaf.Flags().Lookup("group")
|
||||
if legacy == nil || !legacy.Hidden {
|
||||
t.Fatalf("group flag = %#v, want hidden legacy alias", legacy)
|
||||
}
|
||||
if got := legacy.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "conversation-id" {
|
||||
t.Fatalf("group alias_of annotation = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -131,7 +51,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
|
||||
contains: []string{
|
||||
"send -> query-send-status -> edit",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"edit --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>",
|
||||
"edit --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
@@ -141,7 +61,7 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsPostSendIDChain(t *testing
|
||||
contains: []string{
|
||||
"send -> query-send-status -> recall",
|
||||
"query-send-status --open-task-id <上一步返回的openTaskId>",
|
||||
"recall --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>",
|
||||
"recall --conversation-id <上一步返回的openConversationId> --msg-id <上一步返回的openMessageId>",
|
||||
},
|
||||
notContain: "chat message list",
|
||||
},
|
||||
@@ -263,91 +183,3 @@ func TestCrossPlatformCoverageChatMessageHelpDocumentsOptionalTimeDefaults(t *te
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatReactionHelpKeepsManifestExternalAliasesVisible(t *testing.T) {
|
||||
for _, command := range []string{"add-emoji", "remove-emoji", "add-text-emotion", "remove-text-emotion"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"message", command, "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat message %s --help: %v\n%s", command, err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
if !strings.Contains(help, "--conversation-id") {
|
||||
t.Fatalf("chat message %s help missing --conversation-id:\n%s", command, help)
|
||||
}
|
||||
for _, visible := range []string{"--group", "--id", "--chat"} {
|
||||
if !strings.Contains(help, visible+" string") {
|
||||
t.Fatalf("chat message %s help hides manifest-external alias %s:\n%s", command, visible, help)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupBotsHelpKeepsLegacyGroup(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"group", "bots", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat group bots --help: %v\n%s", err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
if !strings.Contains(help, "--group string") {
|
||||
t.Fatalf("chat group bots help missing visible --group:\n%s", help)
|
||||
}
|
||||
for _, hidden := range []string{"--conversation-id", "--group-name"} {
|
||||
if strings.Contains(help, hidden) {
|
||||
t.Fatalf("chat group bots help exposes migrated flag %s:\n%s", hidden, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendCardHelpUsesCanonicalIDFlags(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"message", "send-card", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat message send-card --help: %v\n%s", err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
for _, visible := range []string{"--conversation-id", "--open-dingtalk-id"} {
|
||||
if !strings.Contains(help, visible) {
|
||||
t.Fatalf("send-card help missing %s:\n%s", visible, help)
|
||||
}
|
||||
}
|
||||
for _, visible := range []string{"--group", "--receiver"} {
|
||||
if !strings.Contains(help, visible+" string") {
|
||||
t.Fatalf("send-card help hides manifest-external alias %s:\n%s", visible, help)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupAuditJoinValidationHelpUsesCanonicalConversationID(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetErr(&output)
|
||||
cmd.SetArgs([]string{"group", "audit-join-validation", "--help"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat group audit-join-validation --help: %v\n%s", err, output.String())
|
||||
}
|
||||
|
||||
help := output.String()
|
||||
if !strings.Contains(help, "--conversation-id") {
|
||||
t.Fatalf("audit-join-validation help missing --conversation-id:\n%s", help)
|
||||
}
|
||||
if strings.Contains(help, "--group string") {
|
||||
t.Fatalf("audit-join-validation help exposes hidden --group alias:\n%s", help)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,32 +81,6 @@ func TestCrossPlatformCoverageChatUpdateTextEmotion(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "open-conversation-id alias",
|
||||
args: []string{
|
||||
"message", "update-text-emotion",
|
||||
"--open-conversation-id", "conv-3",
|
||||
"--message-id", "msg-3",
|
||||
"--old-emotion-id", "old-3",
|
||||
"--emotion-id", "new-3",
|
||||
"--emotion-name", "smile",
|
||||
"--text", "done",
|
||||
"--background-id", "im_bg_2",
|
||||
},
|
||||
want: guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "update_text_emotion",
|
||||
args: map[string]any{
|
||||
"openConversationId": "conv-3",
|
||||
"openMsgId": "msg-3",
|
||||
"oldEmotionId": "old-3",
|
||||
"emotionId": "new-3",
|
||||
"emotionName": "smile",
|
||||
"text": "done",
|
||||
"backgroundId": "im_bg_2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
@@ -153,7 +127,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
{
|
||||
name: "missing conversation-id and aliases",
|
||||
args: dropFlag("--conversation-id"),
|
||||
wantErr: "missing required flag: --conversation-id (or --group / --id / --chat / --open-conversation-id)",
|
||||
wantErr: "at least one of the flags in the group [conversation-id group id chat] is required",
|
||||
},
|
||||
{
|
||||
name: "missing old-emotion-id",
|
||||
@@ -161,7 +135,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
wantErr: `required flag(s) "old-emotion-id" not set`,
|
||||
},
|
||||
{
|
||||
name: "missing message-id and background-id",
|
||||
name: "missing msg-id and background-id",
|
||||
args: []string{
|
||||
"message", "update-text-emotion",
|
||||
"--conversation-id", "conv-1",
|
||||
@@ -170,7 +144,7 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
"--emotion-name", "like",
|
||||
"--text", "nice",
|
||||
},
|
||||
wantErr: "missing required flag: --message-id (or --msg-id / --open-message-id)",
|
||||
wantErr: `required flag(s) "background-id", "msg-id" not set`,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
@@ -189,41 +163,19 @@ func TestCrossPlatformCoverageChatUpdateTextEmotionRequiredFlags(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupGetMuteConfig(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want guardedMutationCall
|
||||
}{
|
||||
{
|
||||
name: "legacy group alias",
|
||||
args: []string{"group", "get-mute-config", "--group", "conv-1"},
|
||||
want: guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "get_group_mute_config",
|
||||
args: map[string]any{"openConversationId": "conv-1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "canonical conversation id",
|
||||
args: []string{"group", "get-mute-config", "--conversation-id", "conv-2"},
|
||||
want: guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "get_group_mute_config",
|
||||
args: map[string]any{"openConversationId": "conv-2"},
|
||||
},
|
||||
},
|
||||
} {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand, test.args...)
|
||||
if err != nil {
|
||||
t.Fatalf("get-mute-config returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], test.want) {
|
||||
t.Fatalf("tool calls = %#v, want %#v", caller.calls, test.want)
|
||||
}
|
||||
})
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "get-mute-config", "--group", "conv-1")
|
||||
if err != nil {
|
||||
t.Fatalf("get-mute-config returned error: %v", err)
|
||||
}
|
||||
want := guardedMutationCall{
|
||||
productID: "im",
|
||||
toolName: "get_group_mute_config",
|
||||
args: map[string]any{"openConversationId": "conv-1"},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("tool calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,12 +196,12 @@ func TestCrossPlatformCoverageChatGroupGetMuteConfigRecordsRawArgs(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresConversationID(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatGroupGetMuteConfigRequiresGroup(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"group", "get-mute-config")
|
||||
if err == nil || !strings.Contains(err.Error(), "--conversation-id") {
|
||||
t.Fatalf("err = %v, want message containing --conversation-id", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "--group") {
|
||||
t.Fatalf("err = %v, want message containing --group", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool calls = %#v, want none", caller.calls)
|
||||
|
||||
@@ -1226,7 +1226,7 @@ func newDriveCommand() *cobra.Command {
|
||||
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
|
||||
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
|
||||
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
|
||||
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥;扫描触发 2000 条上限或途中目录读取失败时报错,不产出不完整的 Top-N (可选)")
|
||||
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
|
||||
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
|
||||
driveListCmd.Flags().String("type", "", "按节点类型过滤: file|folder(客户端过滤:全量扫描后筛,钉盘/知识库均可用;与 --versions/--cursor/--order-by/--order/--limit 互斥)(可选)")
|
||||
driveListCmd.Flags().String("start", "", "按修改时间过滤·起始: 相对时间如 24h/7d/2w、RFC3339、YYYY-MM-DD(客户端过滤,互斥同 --type)(可选)")
|
||||
|
||||
@@ -10,14 +10,11 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -299,11 +296,6 @@ bfs:
|
||||
|
||||
if folderErr != nil {
|
||||
if driveDepthUnrecoverable(folderErr) {
|
||||
if latest > 0 {
|
||||
// 不完整集合上的 Top-N 会被误读为全局最新:latest 下不吐 partial,
|
||||
// 直接回根因错误(auth 过期 / 网络不可达比通用 token 更可操作)。
|
||||
return folderErr
|
||||
}
|
||||
// partial 照吐 stdout,错误详情走 stderr,非零退出
|
||||
errs = append(errs, newDriveDepthError(folder, folderErr))
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter); emitErr != nil {
|
||||
@@ -339,206 +331,17 @@ bfs:
|
||||
}
|
||||
}
|
||||
|
||||
// BFS 序与修改时间无关:截断与递归途中目录失败都让未扫区域可能含更新文件,
|
||||
// 此时的 Top-N 不是全局最新,两者同属一条防线——拒绝以成功状态产出。
|
||||
if latest > 0 && (truncated || len(errs) > 0) {
|
||||
return driveLatestIncompleteError(latest, truncated, errs, driveLatestScopeFromCmd(cmd, maxDepth, rootFolderID))
|
||||
if truncated && latest > 0 {
|
||||
return &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
|
||||
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
|
||||
}
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth, route, filter)
|
||||
}
|
||||
|
||||
// driveLatestScope 是原调用的完整候选集快照,用于生成不改变候选集的恢复命令。
|
||||
//
|
||||
// 恢复命令若丢掉任何一项,用户照抄后都会在**另一个集合**上拿到一份「看起来对」的 Top-N ——
|
||||
// 比直接报错更难发现:丢 --workspace/--space-id 会从知识库切到普通钉盘(或反之);丢 --folder
|
||||
// 会从子树跳到空间根;丢 --pattern/--type/--start/--end 会把全部条目纳入排序基。
|
||||
type driveLatestScope struct {
|
||||
// domain 是查询域 flag 串(如 "--workspace ws-1" / "--space-id sp-1"),无则空串。
|
||||
domain string
|
||||
// filters 是决定候选集的过滤 flag 串(--pattern/--type/--start/--end),无则空串。
|
||||
filters string
|
||||
// folder 是原调用实际使用的扫描根(已解析的 ID,非用户原始 URL),空则为空间根。
|
||||
folder string
|
||||
// depth 是原调用的 --depth 层数,让「去掉 --latest 重跑」的恢复命令给出确切层数。
|
||||
depth int
|
||||
// notes 收集无法安全内联进可执行命令的原值展示行。POSIX 构建下恒为空(单引号足够);
|
||||
// Windows 构建下含元字符的值走这里,命令里只留占位符。
|
||||
notes []string
|
||||
// 以上 domain/filters/folder 里的值全部经 driveLatestScopeValue 渲染:恢复命令是给用户
|
||||
// 直接复制到 shell 执行的,而 workspace 的常见形态就是带 & 查询串的 URL,pattern 又天然
|
||||
// 含 * 与中文,裸拼接会改变命令解析。
|
||||
}
|
||||
|
||||
// driveLatestValueRenderer 把用户值渲染成可内联的命令片段;ok 为 false 表示该值在目标 shell
|
||||
// 下无法安全内联。取成参数而非直接调用平台绑定函数,是为了让任一平台的测试都能驱动另一平台
|
||||
// 的降级分支 —— 否则「Windows 上降级为占位符」这条路在 POSIX 机器上永不可达、无法验证。
|
||||
type driveLatestValueRenderer func(string) (string, bool)
|
||||
|
||||
// value 渲染单个用户值供内联。值在目标 shell 下无法安全内联时,登记一条展示行并返回占位符
|
||||
// —— 宁可让用户手动粘一次,也不能给出一条粘贴即执行额外命令的「恢复命令」。
|
||||
// 展示行用 strconv.Quote 包裹并显式声明非可执行,与 internal/auth 侧展示 profile 标识一致。
|
||||
func (s *driveLatestScope) value(render driveLatestValueRenderer, label, v string) string {
|
||||
if inline, ok := render(v); ok {
|
||||
return inline
|
||||
}
|
||||
s.notes = append(s.notes, fmt.Sprintf("%s 原值(仅作数据展示,不是可执行命令)%s", label, strconv.Quote(v)))
|
||||
return driveLatestUnsafeValuePlaceholder
|
||||
}
|
||||
|
||||
// driveLatestUnsafeValuePlaceholder 是不可内联值在命令中的占位符。
|
||||
const driveLatestUnsafeValuePlaceholder = "<见下方原值>"
|
||||
|
||||
// driveLatestScopeFromCmd 从原命令抽完整候选集。--workspace 决定路由(知识库 vs 钉盘),判定与
|
||||
// drive list 里的路由分支同源(同一个 flagOrFallback(cmd, "workspace", "workspace-id"));
|
||||
// 钉盘侧的 --space-id 同样必须保留。目录 flag 名无需按路由切换:--folder 两条路由都接受。
|
||||
//
|
||||
// rootFolder 取 runDriveListDepth 实际使用的扫描根而非重新读 flag:用户可能传的是 URL,
|
||||
// 解析后的 ID 才是真正被扫描的目标,也是照抄时更精确的形态。
|
||||
func driveLatestScopeFromCmd(cmd *cobra.Command, depth int, rootFolder string) driveLatestScope {
|
||||
return driveLatestScopeFrom(cmd, depth, rootFolder, driveLatestScopeValue)
|
||||
}
|
||||
|
||||
// driveLatestScopeFrom 是 driveLatestScopeFromCmd 的可注入本体:render 决定用户值以何种形态
|
||||
// 进入恢复命令。生产路径固定传平台绑定的 driveLatestScopeValue;测试可传另一平台的策略,
|
||||
// 从而在单一平台上覆盖两种形态。
|
||||
func driveLatestScopeFrom(cmd *cobra.Command, depth int, rootFolder string, render driveLatestValueRenderer) driveLatestScope {
|
||||
scope := driveLatestScope{depth: depth}
|
||||
if workspaceID := flagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
|
||||
scope.domain = "--workspace " + scope.value(render, "--workspace", workspaceID)
|
||||
} else if spaceID, _ := cmd.Flags().GetString("space-id"); spaceID != "" {
|
||||
scope.domain = "--space-id " + scope.value(render, "--space-id", spaceID)
|
||||
}
|
||||
if rootFolder != "" {
|
||||
scope.folder = scope.value(render, "--folder", rootFolder)
|
||||
}
|
||||
// 顺序固定为注册顺序,保证同一组入参每次给出同一条恢复命令(便于用户比对与测试断言)。
|
||||
filters := make([]string, 0, 4)
|
||||
for _, name := range []string{"pattern", "type", "start", "end"} {
|
||||
if v, _ := cmd.Flags().GetString(name); v != "" {
|
||||
filters = append(filters, "--"+name+" "+scope.value(render, "--"+name, v))
|
||||
}
|
||||
}
|
||||
scope.filters = strings.Join(filters, " ")
|
||||
return scope
|
||||
}
|
||||
|
||||
// command 拼一条保留原候选集的恢复命令:查询域 + 指定的 --folder + 原过滤条件。
|
||||
// folderArg 传 "" 表示该条命令不带 --folder(原调用就在空间根时不应凭空塞一个)。
|
||||
func (s driveLatestScope) command(folderArg string) string {
|
||||
parts := make([]string, 0, 4)
|
||||
parts = append(parts, "dws drive list")
|
||||
if s.domain != "" {
|
||||
parts = append(parts, s.domain)
|
||||
}
|
||||
if folderArg != "" {
|
||||
parts = append(parts, "--folder "+folderArg)
|
||||
}
|
||||
if s.filters != "" {
|
||||
parts = append(parts, s.filters)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
// driveLatestIncompleteError 是排序基不完整时的拒绝产出错误。截断与目录失败共用
|
||||
// CodeContentTruncated(→ ExitAPI),但 token 分开,便于消费方区分「范围太大」与「读不到」;
|
||||
// 二者同真时两个 token 都带。拒绝产出后 errors[] 不再进 stdout,失败详情必须落在错误消息里,
|
||||
// 否则用户完全瞎。调用点已保证 truncated 与 len(errs)>0 至少一真。
|
||||
func driveLatestIncompleteError(latest int, truncated bool, errs []driveDepthError, scope driveLatestScope) error {
|
||||
// 目录失败详情排在截断之前:BFS 可以先记下可恢复目录错误、再在别的目录撞上 2000 上限,
|
||||
// 此时 permission_denied 这类 reason 是用户唯一能动手修的线索,不能被截断提示吞掉。
|
||||
causes := make([]string, 0, 2)
|
||||
if len(errs) > 0 {
|
||||
causes = append(causes, driveLatestFolderFailureCause(errs))
|
||||
}
|
||||
if truncated {
|
||||
causes = append(causes, fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断", driveDepthMaxItems))
|
||||
}
|
||||
return &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("%s,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d",
|
||||
strings.Join(causes, ";同时 "), latest),
|
||||
Suggestion: driveLatestIncompleteSuggestion(latest, truncated, len(errs) > 0, scope),
|
||||
}
|
||||
}
|
||||
|
||||
// driveLatestFolderFailureCause 组装目录失败详情,含首个失败的 folder/depth/reason。
|
||||
// folderName 空回落 folderID,两者都空回落 <root>。
|
||||
//
|
||||
// folderName / folderID / message 三项都是远端可控内容(目录名由共享目录的创建者决定,
|
||||
// message 是服务端错误文本),必须过 driveLatestSafeRemoteText。Reason 不用过:它是
|
||||
// classifyDriveDepthReason 的固定三值映射,与服务端字符串无关。
|
||||
func driveLatestFolderFailureCause(errs []driveDepthError) string {
|
||||
first := errs[0]
|
||||
folder := first.FolderName
|
||||
if folder == "" {
|
||||
folder = first.FolderID
|
||||
}
|
||||
if folder == "" {
|
||||
folder = "<root>"
|
||||
}
|
||||
return fmt.Sprintf("LATEST_SCAN_INCOMPLETE: %d 个目录未读全(首个失败 folder=%s depth=%d reason=%s: %s)",
|
||||
len(errs), driveLatestSafeRemoteText(folder), first.Depth, first.Reason,
|
||||
driveLatestSafeRemoteText(first.Message))
|
||||
}
|
||||
|
||||
// driveLatestSafeRemoteText 把远端可控文本压成可安全嵌进单行 stderr 错误消息的形式。
|
||||
//
|
||||
// 拒绝产出后 errors[] 不再进 stdout,失败详情改走纯文本错误消息 —— 而 JSON 编码会转义的
|
||||
// 控制字符在纯文本里会被终端直接执行:ANSI/OSC 序列可以伪造提示、清屏、隐藏后续内容、改窗口
|
||||
// 标题,在 AI Agent 场景还会污染上下文窗口。latest=0 的既有路径仍把原值放进 errors[] JSON,
|
||||
// 不受影响,也不该受影响(消费方需要原始数据)。
|
||||
//
|
||||
// output.SanitizeForTerminal 负责剥 ANSI/OSC、C0 控制字符与危险 Unicode,但按设计保留 \n 与
|
||||
// \t;本错误消息是单行叙述,故再把这两者折成空格,避免远端换行把一条错误拆成多行伪造输出。
|
||||
func driveLatestSafeRemoteText(s string) string {
|
||||
s = output.SanitizeForTerminal(s)
|
||||
s = strings.ReplaceAll(s, "\n", " ")
|
||||
s = strings.ReplaceAll(s, "\t", " ")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// driveLatestIncompleteSuggestion 按实际触发的成因给恢复指引。约束两条:
|
||||
// 1. 每条示例命令都带原查询域(scope.base()),照抄不会切换查询域;
|
||||
// 2. 每个子句的示例命令与该子句正文一致——「去掉 --latest」的子句示例不带 --latest,
|
||||
// 否则照抄复现同一错误。
|
||||
func driveLatestIncompleteSuggestion(latest int, truncated, folderFailed bool, scope driveLatestScope) string {
|
||||
// 「缩小范围」类命令要求用户换一个目录,故 --folder 给占位符;查询域与原过滤条件由
|
||||
// command 一并带上,否则照抄后候选集就变了(例如丢掉 --pattern 会对全部条目取 Top-N)。
|
||||
narrowed := scope.command("<可读子目录ID>")
|
||||
clauses := make([]string, 0, 3)
|
||||
if folderFailed {
|
||||
clauses = append(clauses, "确认目录权限后重试")
|
||||
}
|
||||
switch {
|
||||
case folderFailed && truncated:
|
||||
// 两个成因都要解:既要换到可读目录,也要把范围缩到 2000 条以内。
|
||||
clauses = append(clauses, fmt.Sprintf("或用 --folder 缩小到可读子目录、并降低 --depth 层数后重取 Top-%d:%s --latest %d", latest, narrowed, latest))
|
||||
case folderFailed:
|
||||
clauses = append(clauses, fmt.Sprintf("或用 --folder 缩小到可读子目录后重取 Top-%d:%s --latest %d", latest, narrowed, latest))
|
||||
default:
|
||||
clauses = append(clauses, fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 %s --latest %d", narrowed, latest))
|
||||
}
|
||||
// partial+errors[] 承诺限定 --depth>1:单层去掉 --latest 会路由回普通单层 list,本就无
|
||||
// errors[] 契约,故该子句只在多层时给出,并直接带上原层数。这是唯一一条「按原范围」命令,
|
||||
// 必须原样带回原 --folder(原调用在空间根时则不带),照抄即复现同一候选集、只是不取 Top-N。
|
||||
if folderFailed && scope.depth > 1 {
|
||||
clauses = append(clauses, fmt.Sprintf("需要看失败明细请去掉 --latest 按原范围重跑(同时输出已扫到的 partial 与 errors[] 明细):%s --depth %d", scope.command(scope.folder), scope.depth))
|
||||
}
|
||||
// Windows 构建下无法安全引用的值不会进入命令(cmd.exe 不把单引号当引号),原值改在此处以
|
||||
// 数据行给出,由用户手动替换占位符 —— 少一次复制粘贴的便利,换掉一条粘贴即执行的命令。
|
||||
if len(scope.notes) > 0 {
|
||||
clauses = append(clauses, fmt.Sprintf("命令中的 %s 请手动替换为 —— %s",
|
||||
driveLatestUnsafeValuePlaceholder, strings.Join(scope.notes, "、")))
|
||||
}
|
||||
return strings.Join(clauses, ";")
|
||||
}
|
||||
|
||||
// emitDriveDepthCancelled 处理 SIGINT 取消:吐已扫到的 partial(truncated=true)后回退出码 130。
|
||||
//
|
||||
// 这里刻意**不**套用 latest 的拒绝产出防线(只有 BFS 尾部 guard 与 unrecoverable 分支走):
|
||||
// 取消是用户主动发起的,退出码 130 本身已明确告知结果不完整,此时 partial 是用户的预期产物而
|
||||
// 非冒充全局最新的误导。sortTime 仍由 emitDriveDepthResult 统一剥除,取消路径不例外。
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int, route driveDepthRoute, filter driveListFilter) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth, route, filter); err != nil {
|
||||
return err
|
||||
@@ -586,14 +389,6 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
|
||||
maxDepth = d
|
||||
}
|
||||
}
|
||||
// sortTime 是内部排序字段(applyDriveListLatest 排 Top-N、applyDriveListFilter 筛时间区间
|
||||
// 时都已用完),任何输出路径都不得泄露进契约。放在这里一处覆盖三条 emit 路径:正常 emit、
|
||||
// SIGINT 取消、unrecoverable partial。
|
||||
// depth/parentId/rel_path 不在此处删——它们是 depth>1 的既有输出契约,
|
||||
// stripDriveDepthDecorations 仅在单层(reqDepth==1)把这套装饰整体剥掉。
|
||||
for _, item := range items {
|
||||
delete(item, "sortTime")
|
||||
}
|
||||
if (latest > 0 || filter.active()) && reqDepth == 1 {
|
||||
stripDriveDepthDecorations(items)
|
||||
}
|
||||
|
||||
@@ -1,731 +0,0 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// 本文件锁定 drive list --latest 的两个 P1 行为,独立于 pr868_*_test.go / drive_depth_test.go:
|
||||
//
|
||||
// P1-a:sortTime 是内部排序字段,任何输出路径都不得泄露进契约;
|
||||
// P1-b:递归途中目录读取失败时,Top-N 建立在不完整集合上,必须拒绝产出而非吐 partial。
|
||||
//
|
||||
// 改代码前这些断言对 origin/main 应为红:main 采集端无条件写 sortTime、emit 仅在单层
|
||||
// latest/filter 才剥;main 尾部拒绝 guard 只拦截断、不拦目录失败。
|
||||
//
|
||||
// 另锁定评审反馈的三个边界:
|
||||
//
|
||||
// 截断与目录失败同真时,失败详情(permission_denied 等)不得被截断提示吞掉;
|
||||
// 拒绝产出时给的恢复命令必须保留原查询域(--workspace / --space-id),照抄不会切换查询域;
|
||||
// 恢复命令里的用户值必须过 argv 引用,URL 查询串与 shell 元字符都不能改变命令解析。
|
||||
//
|
||||
// 关于 TestCrossPlatformCoverage 前缀:这是门禁约定,不是命名风格。平台覆盖率门禁
|
||||
// scripts/policy/run-platform-coverage-gate.sh 只跑
|
||||
// -run '^(TestAllShortcuts|TestCrossPlatformCoverage)',本包新增的生产代码若没有带该前缀的
|
||||
// 测试覆盖,Coverage (macOS) / (Windows) 会以「changed code coverage 低于 100%」失败
|
||||
// (本 PR 改名前实测 69.0476%,84 条可执行语句)。改名务必保留前缀。
|
||||
|
||||
// assertNoSortTime 断言 stdout 每个 item 都不含内部排序字段 sortTime。
|
||||
func assertNoSortTime(t *testing.T, result map[string]any) {
|
||||
t.Helper()
|
||||
items, ok := result["items"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("items missing or wrong type: %#v", result["items"])
|
||||
}
|
||||
for i, raw := range items {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("item[%d] not an object: %#v", i, raw)
|
||||
}
|
||||
if _, leaked := item["sortTime"]; leaked {
|
||||
t.Fatalf("item[%d] leaked internal sortTime into output contract: %#v", i, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestNoSortTimeLeak 覆盖 main 的覆盖漏洞:main 的
|
||||
// TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime 名字带 SortTime,
|
||||
// 却只断言 TRUNCATED、从不检查输出无 sortTime。这里把三条泄露路径都钉住。
|
||||
func TestCrossPlatformCoverageDriveLatestNoSortTimeLeak(t *testing.T) {
|
||||
twoFiles := `{"items":[{"fileId":"f1","name":"a.txt","type":"FILE","modifiedTime":1000},{"fileId":"f2","name":"b.txt","type":"FILE","modifiedTime":2000}]}`
|
||||
|
||||
// 场景 A:depth>1 --latest —— 走 applyDriveListLatest(只读 sortTime、不剥),reqDepth>1 不触发 strip。
|
||||
t.Run("depth_latest", func(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{}); err != nil {
|
||||
t.Fatalf("runDriveListDepth: %v", err)
|
||||
}
|
||||
assertNoSortTime(t, decodeDepthResult(t, out))
|
||||
})
|
||||
|
||||
// 场景 B:--depth 2 无 latest 无 filter —— 走 else 分支树序排序,同样不触发 strip。
|
||||
t.Run("depth_no_latest", func(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{}); err != nil {
|
||||
t.Fatalf("runDriveListDepth: %v", err)
|
||||
}
|
||||
assertNoSortTime(t, decodeDepthResult(t, out))
|
||||
})
|
||||
|
||||
// 场景 C:--depth 2 --type file —— #971 引入的 filter 也读 sortTime(applyDriveListFilter),
|
||||
// strip 条件 (latest>0||filter.active()) && reqDepth==1 在多层下依旧不成立,泄露面因此变大。
|
||||
t.Run("depth_filter_no_latest", func(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: twoFiles}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0, driveListFilter{nodeType: "file"}); err != nil {
|
||||
t.Fatalf("runDriveListDepth: %v", err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
if len(result["items"].([]any)) != 2 {
|
||||
t.Fatalf("filter 应保留两个 FILE: %#v", result["items"])
|
||||
}
|
||||
assertNoSortTime(t, result)
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestSigintStillEmitsPartialWithoutSortTime 同时承担两件事:
|
||||
// 1. P1-a 的第四条路径 —— SIGINT 取消也走 emitDriveDepthResult,同样不得泄露 sortTime;
|
||||
// 2. SIGINT 契约锁 —— 本次 P1-b 只在 BFS 尾部 guard 与 unrecoverable 分支生效,**不改**
|
||||
// 取消路径:SIGINT 是用户主动中断、退出码 130 已明确告知不完整,partial 是明确预期。
|
||||
// 这条测试防止后续误把 fail-closed 扩到取消路径,也为外部评测用例
|
||||
// test_sigint_exits_130_with_partial 提供本地对照。
|
||||
func TestCrossPlatformCoverageDriveLatestSigintStillEmitsPartialWithoutSortTime(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
out := installDepthCaller(t, caller)
|
||||
items := []map[string]any{
|
||||
{"fileId": "f1", "name": "a.txt", "type": "FILE", "sortTime": int64(2000), "rel_path": "a.txt", "depth": 2},
|
||||
}
|
||||
errs := []driveDepthError{
|
||||
{Depth: 1, FolderID: "fid-1", FolderName: "半路目录", Reason: "permission_denied", Message: "denied"},
|
||||
}
|
||||
// latest>0 且 reqDepth>1:main 在此不 strip,sortTime 泄露。
|
||||
err := emitDriveDepthCancelled(items, errs, "", 5, 3, newDrivePanDepthRoute(), driveListFilter{})
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
}
|
||||
if cancelErr.ExitCode() != 130 {
|
||||
t.Fatalf("exit code = %d, want 130", cancelErr.ExitCode())
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
// partial 契约不变:items 与 errors 都照吐,truncated 标记为真。
|
||||
if len(result["items"].([]any)) != 1 {
|
||||
t.Fatalf("取消路径应保留 partial items: %#v", result["items"])
|
||||
}
|
||||
if len(result["errors"].([]any)) != 1 {
|
||||
t.Fatalf("取消路径应保留 errors[]: %#v", result["errors"])
|
||||
}
|
||||
if result["truncated"] != true {
|
||||
t.Fatalf("取消结果 truncated = %#v, want true", result["truncated"])
|
||||
}
|
||||
assertNoSortTime(t, result)
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestRefusesOnFolderFailure 覆盖 P1-b:递归途中一个可恢复目录失败(403/business,
|
||||
// 非 auth 非限流 → 记 errs[] 跳过),Top-N 落在不完整集合上,必须拒绝产出。
|
||||
// 构造:根目录成功产出 FOLDER+FILE(collected>0 且 dirA 入队),子目录返回 forbidden.* →
|
||||
// recoverable → errs=[1]。旧代码尾部 `if truncated && latest>0` 不触发 → emit 吐 partial(err=nil);
|
||||
// 新代码 `len(errs)>0` → LATEST_SCAN_INCOMPLETE 且 stdout 无 items。
|
||||
func TestCrossPlatformCoverageDriveLatestRefusesOnFolderFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
|
||||
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
|
||||
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_INCOMPLETE") {
|
||||
t.Fatalf("err = %v, want LATEST_SCAN_INCOMPLETE", err)
|
||||
}
|
||||
// 拒绝产出:stdout 必须没有 items(不是 partial)。旧代码此处会吐 partial,断言随之失败。
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestRefusesOnTruncationWithFolderFailure 端到端证明「截断 + 目录失败」组合确实可达:
|
||||
// 根目录出 dirA/dirB 两个子目录 → dirA 权限失败记 errs[] → dirB 返回 2000 条触发全局截断,
|
||||
// 尾部 guard 拿到 truncated=true 且 len(errs)=1。旧实现在此让 truncated 短路,permission_denied
|
||||
// 详情整块丢失(评审反馈的阻塞点);现在两个 token 与失败详情都必须在。
|
||||
func TestCrossPlatformCoverageDriveLatestRefusesOnTruncationWithFolderFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
var bulk strings.Builder
|
||||
bulk.WriteString(`{"items":[`)
|
||||
for i := 0; i < driveDepthMaxItems; i++ {
|
||||
if i > 0 {
|
||||
bulk.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&bulk, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifiedTime":%d}`, i, i, 1000+i)
|
||||
}
|
||||
bulk.WriteString(`]}`)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"报表","type":"FOLDER"},{"fileId":"dirB","name":"dirB","type":"FOLDER"}]}`},
|
||||
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`}, // dirA:可恢复 → 记 errs[]
|
||||
{text: bulk.String()}, // dirB:撞 2000 上限 → truncated
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
msg := cliErr.Message
|
||||
if !strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("组合场景缺 TRUNCATED token: %q", msg)
|
||||
}
|
||||
// 旧实现在这里丢掉整段目录失败详情。
|
||||
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") ||
|
||||
!strings.Contains(msg, "folder=报表") ||
|
||||
!strings.Contains(msg, "permission_denied") {
|
||||
t.Fatalf("组合场景丢失目录失败详情: %q", msg)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeWiredFromCommand 端到端验证查询域从原命令一路带到恢复命令:单测构造器
|
||||
// 拿不到的是 runDriveListDepth 里的接线(driveLatestScopeFromCmd(cmd, maxDepth, rootFolderID)),这里补上。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeWiredFromCommand(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
|
||||
{text: `{"errorCode":"forbidden.noPermission","errorMsg":"denied"}`},
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := newDriveListScopeCmd(t, map[string]string{"space-id": "sp-7"})
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{"spaceId": "sp-7"}, "", 4, "", true, 5, driveListFilter{})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) {
|
||||
t.Fatalf("err = %T %v", err, err)
|
||||
}
|
||||
// 恢复命令必须带原 --space-id,且给出原 --depth 层数。
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "--space-id sp-7")
|
||||
if !strings.Contains(cliErr.Suggestion, "--depth 4") {
|
||||
t.Fatalf("恢复命令应保留原层数: %q", cliErr.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestRefusesOnUnrecoverableFailure 覆盖 P1-b 的另一半:递归途中遇不可恢复错误
|
||||
// (auth 过期 / 网络不可达)且 latest>0 时,不吐 partial,直接回根因错误。
|
||||
// 与 latest=0 的既有行为(TestCrossPlatformCoverageRunDriveListDepthUnrecoverable:partial
|
||||
// + errors[] 进 stdout 后非零退出)对照——latest 下 partial 的 Top-N 会被误读为全局最新,
|
||||
// 故必须拒绝产出;回根因错误而非 INCOMPLETE token,因为 auth/网络比通用截断提示更可操作。
|
||||
func TestCrossPlatformCoverageDriveLatestRefusesOnUnrecoverableFailure(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"fileId":"dirA","name":"dirA","type":"FOLDER"},{"fileId":"fX","name":"x.txt","type":"FILE","modifiedTime":1000}]}`},
|
||||
{text: `{"errorCode":"DWS_SERVICE_UNAUTHORIZED"}`},
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 5, driveListFilter{})
|
||||
// 回根因错误:Code 仍是 auth 过期,不被包装成 LATEST_SCAN_INCOMPLETE。
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthTokenExpired {
|
||||
t.Fatalf("err = %T %v, want CodeAuthTokenExpired", err, err)
|
||||
}
|
||||
if strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") {
|
||||
t.Fatalf("unrecoverable 应回根因错误而非 INCOMPLETE 包装: %q", cliErr.Message)
|
||||
}
|
||||
// 拒绝产出:不吐 partial(对照 latest=0 时会输出 2 条 items + 1 条 errors)。
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("expected no stdout on refusal, got: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestIncompleteErrorBranches 直接单测构造器的各条分支。
|
||||
func TestCrossPlatformCoverageDriveLatestIncompleteErrorBranches(t *testing.T) {
|
||||
// 纯截断分支:errs 为空 → 只有 TRUNCATED。
|
||||
t.Run("truncated_only", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(5, true, nil, driveLatestScope{depth: 3})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
if !strings.Contains(cliErr.Message, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("message = %q", cliErr.Message)
|
||||
}
|
||||
if strings.Contains(cliErr.Message, "LATEST_SCAN_INCOMPLETE") {
|
||||
t.Fatalf("无目录失败时不应出现 INCOMPLETE: %q", cliErr.Message)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
|
||||
})
|
||||
|
||||
// 纯目录失败分支:未截断 → 只有 INCOMPLETE,Message 含首个失败的 folder/depth/reason。
|
||||
t.Run("folder_failure_only", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(3, false, twoDriveDepthErrors(), driveLatestScope{depth: 3})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
msg := cliErr.Message
|
||||
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") ||
|
||||
!strings.Contains(msg, "报表") ||
|
||||
!strings.Contains(msg, "depth=2") ||
|
||||
!strings.Contains(msg, "permission_denied") ||
|
||||
!strings.Contains(msg, "2 个目录未读全") {
|
||||
t.Fatalf("message = %q", msg)
|
||||
}
|
||||
if strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("未截断时不应出现 TRUNCATED: %q", msg)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
|
||||
})
|
||||
|
||||
// 组合分支(评审反馈的阻塞点):截断与目录失败同真时,旧实现让 truncated 短路、
|
||||
// permission_denied 这类目录失败详情整块丢失。现在两个 token 都必须在,且失败详情不得被吞。
|
||||
t.Run("truncated_with_folder_failures", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(4, true, twoDriveDepthErrors(), driveLatestScope{depth: 3})
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeContentTruncated {
|
||||
t.Fatalf("err = %T %v, want CodeContentTruncated", err, err)
|
||||
}
|
||||
msg := cliErr.Message
|
||||
// 两个成因都要可被消费方 token 匹配到。
|
||||
if !strings.Contains(msg, "LATEST_SCAN_INCOMPLETE") || !strings.Contains(msg, "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("组合场景需同时带两个 token: %q", msg)
|
||||
}
|
||||
// 目录失败详情必须完整保留——这是用户唯一能动手修的线索。
|
||||
if !strings.Contains(msg, "报表") ||
|
||||
!strings.Contains(msg, "depth=2") ||
|
||||
!strings.Contains(msg, "permission_denied") ||
|
||||
!strings.Contains(msg, "2 个目录未读全") {
|
||||
t.Fatalf("组合场景丢失目录失败详情: %q", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "拒绝输出不完整的 Top-4") {
|
||||
t.Fatalf("message 缺少拒绝产出结论: %q", msg)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, "")
|
||||
// 组合场景的指引要同时覆盖两条补救:换可读目录 + 降层数。
|
||||
if !strings.Contains(cliErr.Suggestion, "确认目录权限") || !strings.Contains(cliErr.Suggestion, "--depth") {
|
||||
t.Fatalf("组合场景 suggestion 需同时给出权限与降层数补救: %q", cliErr.Suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
// folderName 为空回落 folderID;folderID 也空回落 <root>。
|
||||
t.Run("folder_fallback", func(t *testing.T) {
|
||||
byID := driveLatestIncompleteError(1, false, []driveDepthError{{FolderID: "fid-x"}}, driveLatestScope{depth: 2})
|
||||
if !strings.Contains(byID.Error(), "folder=fid-x") {
|
||||
t.Fatalf("fallback to folderID: %v", byID)
|
||||
}
|
||||
byRoot := driveLatestIncompleteError(1, false, []driveDepthError{{}}, driveLatestScope{depth: 2})
|
||||
if !strings.Contains(byRoot.Error(), "folder=<root>") {
|
||||
t.Fatalf("fallback to <root>: %v", byRoot)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// twoDriveDepthErrors 是两条目录失败样本,首条用于断言「首个失败」详情。
|
||||
func twoDriveDepthErrors() []driveDepthError {
|
||||
return []driveDepthError{
|
||||
{Depth: 2, FolderID: "fid-9", FolderName: "报表", Reason: "permission_denied", Message: "denied"},
|
||||
{Depth: 1, FolderID: "fid-3", FolderName: "归档", Reason: "api_error", Message: "boom"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopePreservedInSuggestion 覆盖评审反馈的第二个阻塞点:恢复命令此前固定
|
||||
// 生成 `dws drive list --folder ...`,把原调用的 --workspace / --space-id 丢掉。用户照抄后
|
||||
// 会从知识库切到普通钉盘(或从指定钉盘空间切到「我的文件」),在另一个查询域里拿到一份
|
||||
// 「看起来对」的 Top-N —— 比直接报错更难发现。
|
||||
func TestCrossPlatformCoverageDriveLatestScopePreservedInSuggestion(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
want string
|
||||
}{
|
||||
// 知识库路由:--workspace 决定路由,丢了就切到普通钉盘。
|
||||
{name: "workspace", flags: map[string]string{"workspace": "ws-1"}, want: "--workspace ws-1"},
|
||||
// 别名路径:--workspace-id 与 --workspace 同源(flagOrFallback)。
|
||||
{name: "workspace_id_alias", flags: map[string]string{"workspace-id": "ws-alias"}, want: "--workspace ws-alias"},
|
||||
// 钉盘路由:--space-id 丢了就退回「我的文件」。
|
||||
{name: "space_id", flags: map[string]string{"space-id": "sp-7"}, want: "--space-id sp-7"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, tc.flags), 3, "")
|
||||
// 三条成因组合下的恢复命令都必须带原查询域。
|
||||
for _, variant := range []struct {
|
||||
label string
|
||||
truncated bool
|
||||
errs []driveDepthError
|
||||
}{
|
||||
{"truncated_only", true, nil},
|
||||
{"folder_failure_only", false, twoDriveDepthErrors()},
|
||||
{"both", true, twoDriveDepthErrors()},
|
||||
} {
|
||||
err := driveLatestIncompleteError(5, variant.truncated, variant.errs, scope)
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) {
|
||||
t.Fatalf("%s: err = %T %v", variant.label, err, err)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, cliErr.Suggestion, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// --workspace 优先于 --space-id:与 drive list 的路由判定同序(先看 workspace 再看 space-id),
|
||||
// 否则恢复命令会把知识库查询写成钉盘查询。
|
||||
t.Run("workspace_wins_over_space_id", func(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, map[string]string{
|
||||
"workspace": "ws-1", "space-id": "sp-7",
|
||||
}), 3, "")
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if !strings.Contains(suggestion, "--workspace ws-1") || strings.Contains(suggestion, "--space-id") {
|
||||
t.Fatalf("workspace 应优先且不混入 space-id: %q", suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
// 无查询域时不得凭空造 flag(原调用就是「我的文件」根,硬塞 scope 同样是改查询域)。
|
||||
t.Run("no_scope_adds_nothing", func(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, nil), 3, "")
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if strings.Contains(suggestion, "--workspace") || strings.Contains(suggestion, "--space-id") {
|
||||
t.Fatalf("无 scope 时不应凭空造查询域 flag: %q", suggestion)
|
||||
}
|
||||
assertDriveLatestSuggestion(t, suggestion, "")
|
||||
})
|
||||
|
||||
// depth==1(知识库 --latest 单层)时不给 --depth 1:partial+errors[] 契约只在多层成立,
|
||||
// 硬塞 --depth 1 会让「去掉 --latest 看明细」的子句自相矛盾。
|
||||
t.Run("single_depth_omits_depth_flag", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), driveLatestScope{domain: "--workspace ws-1", depth: 1})
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if strings.Contains(suggestion, "--depth") {
|
||||
t.Fatalf("单层不应出现 --depth: %q", suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
// 多层时给出确切层数,用户无需把 <原层数> 换成数字。
|
||||
t.Run("multi_depth_emits_actual_depth", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), driveLatestScope{domain: "--space-id sp-7", depth: 4})
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if !strings.Contains(suggestion, "--depth 4") {
|
||||
t.Fatalf("应给出原层数 --depth 4: %q", suggestion)
|
||||
}
|
||||
if strings.Contains(suggestion, "<原层数>") {
|
||||
t.Fatalf("不应残留占位符: %q", suggestion)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeQuotesHostileValues 覆盖评审的 P1 阻断项:恢复命令是给用户直接复制到
|
||||
// shell 里执行的,查询域的值来自用户输入(workspace 常见形态就是带查询串的 URL)。未引用时
|
||||
// 一个 `&` 就把命令拆成后台任务,`;` / `$()` 更能执行额外内容。
|
||||
// 断言落在「值被完整包在单引号里」而不只是「出现过」——后者对裸拼接也成立,抓不住缺陷。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeQuotesHostileValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
flag string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
// 合法 workspace URL:& 在裸拼接下直接改变 shell 解析(前半段被丢进后台)。
|
||||
{name: "workspace_url", flag: "workspace", value: "https://alidocs.dingtalk.com/i/nodes/abc?spaceId=1&type=doc",
|
||||
want: `--workspace 'https://alidocs.dingtalk.com/i/nodes/abc?spaceId=1&type=doc'`},
|
||||
// 命令替换:裸拼接会在用户复制执行时真的跑起来。
|
||||
{name: "command_substitution", flag: "workspace", value: "$(id)", want: `--workspace '$(id)'`},
|
||||
// 分号拆语句。
|
||||
{name: "semicolon", flag: "space-id", value: "sp-7;id", want: `--space-id 'sp-7;id'`},
|
||||
// 空格拆参:裸拼接会让 --folder 收到错误的值。
|
||||
{name: "space", flag: "space-id", value: "sp 7", want: `--space-id 'sp 7'`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 显式注入 POSIX 策略:本测试断言的是「危险值被单引号正确包住」这一 POSIX 形态。
|
||||
// Windows 策略下这些值根本不内联(见 ...SuggestionNeverInlinesHostileValue),
|
||||
// 若走平台绑定,这里在 Windows runner 上会因形态不同而误报。
|
||||
scope := driveLatestScopeFrom(newDriveListScopeCmd(t, map[string]string{tc.flag: tc.value}), 3, "", driveLatestPosixScopeValue)
|
||||
err := driveLatestIncompleteError(5, true, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
if !strings.Contains(suggestion, tc.want) {
|
||||
t.Fatalf("查询域未安全引用\n want substring: %s\n got suggestion: %s", tc.want, suggestion)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopePreservesFiltersAndFolder 覆盖自动 CR 的 P2:
|
||||
// --pattern/--type/--start/--end 与 --folder 同样决定 --latest 的候选集合。恢复命令丢掉任一项,
|
||||
// 用户照抄后就是在**另一个集合**上取 Top-N —— 原调用带 --pattern 时,缺了它的重试命令会对全部
|
||||
// 条目排序,结果「看起来成功」却答非所问,比直接报错更难发现。
|
||||
func TestCrossPlatformCoverageDriveLatestScopePreservesFiltersAndFolder(t *testing.T) {
|
||||
cmd := newDriveListScopeCmd(t, map[string]string{
|
||||
"workspace": "ws-1",
|
||||
"pattern": "*日报*",
|
||||
"type": "file",
|
||||
"start": "7d",
|
||||
"end": "2026-08-01",
|
||||
})
|
||||
// 注入 POSIX 策略:`--pattern '*日报*'` 这种引用形态是 POSIX 专属,Windows 下该值会降级为
|
||||
// 占位符 + 展示行(见 ...SuggestionNeverInlinesHostileValue)。走平台绑定会在 Windows 误报。
|
||||
scope := driveLatestScopeFrom(cmd, 4, "folder-root", driveLatestPosixScopeValue)
|
||||
err := driveLatestIncompleteError(5, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
|
||||
// 每条示例命令都要带齐查询域与全部过滤条件;pattern 含 * 与中文,必须是引用后的形态。
|
||||
for _, want := range []string{
|
||||
"--workspace ws-1",
|
||||
"--pattern '*日报*'",
|
||||
"--type file",
|
||||
"--start 7d",
|
||||
"--end 2026-08-01",
|
||||
} {
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
cmdText := extractTrailingDwsCommand(clause)
|
||||
if cmdText == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(cmdText, want) {
|
||||
t.Fatalf("恢复命令丢失候选集条件 %q:\n clause: %s", want, cmdText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 「去掉 --latest 按原范围重跑」是唯一的原范围命令,必须原样带回原 --folder 而非占位符,
|
||||
// 否则它就不是「原范围」。
|
||||
var origin string
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
if strings.Contains(clause, "去掉 --latest") {
|
||||
origin = extractTrailingDwsCommand(clause)
|
||||
}
|
||||
}
|
||||
if origin == "" {
|
||||
t.Fatalf("多层场景应给出「去掉 --latest 按原范围重跑」子句: %q", suggestion)
|
||||
}
|
||||
if !strings.Contains(origin, "--folder folder-root") {
|
||||
t.Fatalf("原范围命令应保留原 --folder: %q", origin)
|
||||
}
|
||||
if strings.Contains(origin, "<可读子目录ID>") {
|
||||
t.Fatalf("原范围命令不应把原 --folder 换成占位符: %q", origin)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeOmitsFolderAtSpaceRoot 原调用就在空间根时不得凭空
|
||||
// 造 --folder:硬塞一个目录同样是改候选集。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeOmitsFolderAtSpaceRoot(t *testing.T) {
|
||||
scope := driveLatestScopeFromCmd(newDriveListScopeCmd(t, map[string]string{"space-id": "sp-7"}), 3, "")
|
||||
err := driveLatestIncompleteError(2, false, twoDriveDepthErrors(), scope)
|
||||
suggestion := err.(*CLIError).Suggestion
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
if !strings.Contains(clause, "去掉 --latest") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(extractTrailingDwsCommand(clause), "--folder") {
|
||||
t.Fatalf("空间根扫描的原范围命令不应带 --folder: %q", clause)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestErrorStripsRemoteControlChars 覆盖自动 CR 的 P2 安全项:
|
||||
// 拒绝产出后,目录名与服务端错误文本从 JSON(编码时会被转义)挪进了纯文本 stderr。若原样透传,
|
||||
// 其中的 ANSI/OSC 序列会被终端直接执行 —— 可清屏、伪造彩色「成功」、隐藏后续输出、改窗口标题,
|
||||
// 在 AI Agent 场景还会污染上下文窗口。目录名对共享目录而言是他人可控输入。
|
||||
func TestCrossPlatformCoverageDriveLatestErrorStripsRemoteControlChars(t *testing.T) {
|
||||
assertNoControlChars := func(t *testing.T, msg string) {
|
||||
t.Helper()
|
||||
for name, r := range map[string]rune{"ESC": 0x1b, "BEL": 0x07, "CR": '\r', "LF": '\n', "TAB": '\t'} {
|
||||
if strings.ContainsRune(msg, r) {
|
||||
t.Fatalf("错误消息残留 %s 控制字符: %q", name, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// folderName 路径:CSI 清屏 + 变色,外加 OSC 改标题。
|
||||
t.Run("folder_name", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(3, false, []driveDepthError{{
|
||||
Depth: 2,
|
||||
FolderName: "报表\x1b[2J\x1b[31m看起来成功\x1b[0m",
|
||||
Reason: "permission_denied",
|
||||
Message: "denied\r\n\x1b]0;pwned\x07次行",
|
||||
}}, driveLatestScope{depth: 2})
|
||||
msg := err.(*CLIError).Message
|
||||
assertNoControlChars(t, msg)
|
||||
// 清理不能把诊断信息一起抹掉:可读部分必须留下。
|
||||
if !strings.Contains(msg, "报表") || !strings.Contains(msg, "denied") || !strings.Contains(msg, "次行") {
|
||||
t.Fatalf("清理后应保留可读文本: %q", msg)
|
||||
}
|
||||
})
|
||||
|
||||
// folderName 为空时回落到 folderID,该字段同样来自服务端。
|
||||
t.Run("folder_id_fallback", func(t *testing.T) {
|
||||
err := driveLatestIncompleteError(1, true, []driveDepthError{{
|
||||
FolderID: "fid\x1b[1m-x",
|
||||
Reason: "api_error",
|
||||
Message: "boom",
|
||||
}}, driveLatestScope{depth: 1})
|
||||
msg := err.(*CLIError).Message
|
||||
assertNoControlChars(t, msg)
|
||||
if !strings.Contains(msg, "fid-x") {
|
||||
t.Fatalf("剥离控制序列后 folderID 应连成 fid-x: %q", msg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestSafeRemoteText 直接钉住清理函数的各条分支:换行/制表符
|
||||
// 折成空格(SanitizeForTerminal 按设计保留这两者,但单行错误消息里它们会拆出伪造行),
|
||||
// 首尾空白收掉,可打印内容与中文原样保留。
|
||||
func TestCrossPlatformCoverageDriveLatestSafeRemoteText(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{name: "plain", in: "denied", want: "denied"},
|
||||
{name: "cjk", in: "报表目录", want: "报表目录"},
|
||||
{name: "csi", in: "a\x1b[31mb", want: "ab"},
|
||||
{name: "osc", in: "a\x1b]0;t\x07b", want: "ab"},
|
||||
{name: "newline_to_space", in: "a\nb", want: "a b"},
|
||||
{name: "tab_to_space", in: "a\tb", want: "a b"},
|
||||
{name: "carriage_return_dropped", in: "a\rb", want: "ab"},
|
||||
{name: "trim_outer", in: "\n denied \t", want: "denied"},
|
||||
{name: "empty", in: "", want: ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := driveLatestSafeRemoteText(tc.in); got != tc.want {
|
||||
t.Fatalf("driveLatestSafeRemoteText(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestSuggestionNeverInlinesHostileValue 端到端锁定评审提出的
|
||||
// Windows 注入阻断项:`--space-id sp-7&whoami` 这种值,在 POSIX 下靠单引号关停,但 cmd.exe
|
||||
// 不把单引号当引号,粘贴后 `&whoami` 仍会执行。因此不变量必须是「恢复命令里不存在能被目标
|
||||
// shell 解释的裸元字符」,两个平台分别用各自的手段满足:POSIX 引用内联,Windows 不内联。
|
||||
//
|
||||
// 两种形态都通过注入渲染策略在本机验证,不依赖当前 GOOS —— 否则「Windows 上降级为占位符」
|
||||
// 这条路在 POSIX 机器上永不可达,就成了只有 Windows runner 才跑到的盲区(平台覆盖率门禁也会
|
||||
// 因此报未覆盖)。平台绑定本身由 TestCrossPlatformCoverageDriveLatestScopeValueBinding 覆盖。
|
||||
func TestCrossPlatformCoverageDriveLatestSuggestionNeverInlinesHostileValue(t *testing.T) {
|
||||
const hostile = "sp-7&whoami"
|
||||
newSuggestion := func(t *testing.T, render driveLatestValueRenderer) string {
|
||||
t.Helper()
|
||||
scope := driveLatestScopeFrom(newDriveListScopeCmd(t, map[string]string{"space-id": hostile}), 3, "", render)
|
||||
return driveLatestIncompleteError(5, true, twoDriveDepthErrors(), scope).(*CLIError).Suggestion
|
||||
}
|
||||
|
||||
t.Run("windows_never_inlines", func(t *testing.T) {
|
||||
suggestion := newSuggestion(t, driveLatestWindowsScopeValue)
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
cmdText := extractTrailingDwsCommand(clause)
|
||||
if cmdText == "" {
|
||||
continue
|
||||
}
|
||||
// cmd.exe 里 & 分隔命令,且单引号不是引号,故它压根不能进命令。
|
||||
if strings.Contains(cmdText, "&") {
|
||||
t.Fatalf("windows 形态的命令不得含 &: %s", cmdText)
|
||||
}
|
||||
if strings.Contains(cmdText, hostile) {
|
||||
t.Fatalf("windows 形态的命令不得内联原值: %s", cmdText)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(suggestion, driveLatestUnsafeValuePlaceholder) {
|
||||
t.Fatalf("应降级为占位符: %s", suggestion)
|
||||
}
|
||||
if !strings.Contains(suggestion, strconv.Quote(hostile)) {
|
||||
t.Fatalf("应在展示行给出原值: %s", suggestion)
|
||||
}
|
||||
if !strings.Contains(suggestion, "不是可执行命令") {
|
||||
t.Fatalf("展示行须显式声明非可执行: %s", suggestion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("posix_quotes_inline", func(t *testing.T) {
|
||||
suggestion := newSuggestion(t, driveLatestPosixScopeValue)
|
||||
if !strings.Contains(suggestion, "'"+hostile+"'") {
|
||||
t.Fatalf("posix 形态应单引号内联原值: %s", suggestion)
|
||||
}
|
||||
// POSIX 下不该无谓降级 —— 那会白白损失可复制体验。
|
||||
if strings.Contains(suggestion, driveLatestUnsafeValuePlaceholder) {
|
||||
t.Fatalf("posix 形态不应降级为占位符: %s", suggestion)
|
||||
}
|
||||
for _, clause := range strings.Split(suggestion, ";") {
|
||||
cmdText := extractTrailingDwsCommand(clause)
|
||||
if cmdText == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(cmdText, "&") && !strings.Contains(cmdText, "'"+hostile+"'") {
|
||||
t.Fatalf("posix 形态出现未引用的元字符: %s", cmdText)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// newDriveListScopeCmd 造一个带 drive list 查询域与过滤 flag 的命令。flag 名与 newDriveCommand
|
||||
// 里 driveListCmd 的注册保持一致;workspace-id 是 cross-product 别名,此处显式注册以覆盖别名路径。
|
||||
func newDriveListScopeCmd(t *testing.T, flags map[string]string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
for _, name := range []string{
|
||||
"workspace", "workspace-id", "space-id", // 查询域
|
||||
"folder", // 扫描根(scope 从 rootFolder 参数取,注册仅为对齐真实命令)
|
||||
"pattern", "type", "start", "end", // 决定候选集的过滤条件
|
||||
} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name, value := range flags {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s=%s: %v", name, value, err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// assertDriveLatestSuggestion 钉住 Suggestion 的三条约束:
|
||||
// 1. 每条示例命令都带原查询域 wantScope(空串表示原调用无查询域,此时只跳过该项检查);
|
||||
// 2. 含 --latest 的引导子句存在;
|
||||
// 3. 「去掉 --latest」子句给出的示例命令本身不带 --latest(否则照抄复现同一错误)。
|
||||
func assertDriveLatestSuggestion(t *testing.T, suggestion, wantScope string) {
|
||||
t.Helper()
|
||||
clauses := strings.Split(suggestion, ";")
|
||||
sawLatestGuide := false
|
||||
for _, clause := range clauses {
|
||||
cmd := extractTrailingDwsCommand(clause)
|
||||
if cmd == "" {
|
||||
continue
|
||||
}
|
||||
if wantScope != "" && !strings.Contains(cmd, wantScope) {
|
||||
t.Fatalf("示例命令丢失原查询域 %q(照抄会切换查询域): %q", wantScope, cmd)
|
||||
}
|
||||
if strings.Contains(clause, "去掉 --latest") {
|
||||
if strings.Contains(cmd, "--latest") {
|
||||
t.Fatalf("「去掉 --latest」子句的示例命令仍含 --latest: %q", cmd)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(cmd, "--latest") {
|
||||
sawLatestGuide = true
|
||||
}
|
||||
}
|
||||
if !sawLatestGuide {
|
||||
t.Fatalf("no --latest-bearing guidance clause in suggestion: %q", suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
// extractTrailingDwsCommand 抽子句里以 "dws " 开头的尾部命令片段(到子句末),无则空串。
|
||||
func extractTrailingDwsCommand(clause string) string {
|
||||
idx := strings.LastIndex(clause, "dws ")
|
||||
if idx < 0 {
|
||||
return ""
|
||||
}
|
||||
return clause[idx:]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package helpers
|
||||
|
||||
// driveLatestScopeValue 按**本次构建的目标 shell** 渲染恢复命令里的用户值:返回可内联的片段,
|
||||
// 第二个返回值为 false 时表示该值不能安全进入可执行命令,调用方须改用占位符。
|
||||
//
|
||||
// 非 Windows 构建面向 POSIX shell,单引号可靠地关闭所有展开,故含元字符的值引用后内联即安全。
|
||||
// Windows 构建见 drive_latest_scope_windows.go —— 两个平台的策略本体都是
|
||||
// shell_quote.go 里的纯函数,可在任意平台被测试直接调用;本文件只做编译期绑定。
|
||||
func driveLatestScopeValue(value string) (string, bool) {
|
||||
return driveLatestPosixScopeValue(value)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package helpers
|
||||
|
||||
// driveLatestScopeValue 按**本次构建的目标 shell** 渲染恢复命令里的用户值:返回可内联的片段,
|
||||
// 第二个返回值为 false 时表示该值不能安全进入可执行命令,调用方须改用占位符。
|
||||
//
|
||||
// Windows 构建下没有对 cmd.exe 与 PowerShell 同时成立的引用形式(cmd.exe 不认单引号,双引号
|
||||
// 又挡不住 %VAR% 展开),故只内联本身就安全的值;理由与取舍详见
|
||||
// driveLatestWindowsScopeValue 的注释。POSIX 构建见 drive_latest_scope_posix.go。
|
||||
func driveLatestScopeValue(value string) (string, bool) {
|
||||
return driveLatestWindowsScopeValue(value)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package helpers
|
||||
|
||||
import "strings"
|
||||
|
||||
// shellQuoteArg 按 POSIX sh 规则把 s 引用成可安全放进可复制命令的单个 argv 元素。
|
||||
//
|
||||
// 为什么需要它:错误提示里的恢复命令是给用户直接复制到 shell 执行的,其中的查询域取自用户
|
||||
// 输入(--workspace 的常见形态就是带查询串的 URL)。裸拼接下,合法 URL 里的 `&` 就会把命令
|
||||
// 拆成后台任务,空格会拆参,`;` 与 `$()` 还能执行额外内容。
|
||||
//
|
||||
// 为什么不用 strconv.Quote(internal/auth 侧展示 profile 标识的既有做法):那是 Go 语法引号,
|
||||
// 产出双引号串,而 shell 双引号内 `$()`、反引号、`$VAR` 仍会展开——正是要防的场景。auth 那处
|
||||
// 成立是因为它显式声明「仅作数据展示,不是可执行命令」,本函数的产物恰恰要能执行。
|
||||
//
|
||||
// 策略是「必要时才引用」:全由安全字符组成时原样返回,命令保持可读、在 PowerShell/cmd 下同样
|
||||
// 可复制;只要含一个非安全字符就整体单引号包裹——单引号内 POSIX sh 不做任何展开($ ` \ ! 全部
|
||||
// 字面化),是唯一无需逐字符转义的形式。单引号自身无法出现在单引号串内,按 POSIX 标准写法
|
||||
// 先闭合、拼一个反斜杠转义的单引号、再重开——即把每个单引号替换成下面这四个字符:
|
||||
//
|
||||
// '\''
|
||||
//
|
||||
// 空串必须显式引用成一对空单引号,否则该参数会从 argv 里整个消失,后面的 token 会被前一个
|
||||
// flag 吞掉。
|
||||
func shellQuoteArg(s string) string {
|
||||
if s == "" {
|
||||
return "''"
|
||||
}
|
||||
if shellValueIsBare(s) {
|
||||
return s
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// shellValueIsBare 报告 s 能否原样出现在命令行里而不改变**任何**常见 shell 的解析:非空,
|
||||
// 且每个字符都落在保守白名单内。这类值在 POSIX sh、PowerShell 与 cmd.exe 下含义一致,
|
||||
// 无需引用即可内联进可复制命令。
|
||||
func shellValueIsBare(s string) bool {
|
||||
return s != "" && !strings.ContainsFunc(s, shellNeedsQuote)
|
||||
}
|
||||
|
||||
// driveLatestPosixScopeValue 是 POSIX shell 下把用户值放进可复制命令的策略:单引号内 sh 不做
|
||||
// 任何展开,因此含元字符的值引用后即可安全内联。第二个返回值恒为 true。
|
||||
func driveLatestPosixScopeValue(value string) (string, bool) {
|
||||
return shellQuoteArg(value), true
|
||||
}
|
||||
|
||||
// driveLatestWindowsScopeValue 是 Windows 构建下的策略:只内联本身就安全的值,其余一律不进
|
||||
// 命令,由调用方降级为占位符 + 展示行。
|
||||
//
|
||||
// Windows 上不存在「一条命令同时对 cmd.exe 与 PowerShell 都安全」的引用形式:
|
||||
// - cmd.exe 根本不把单引号当引号,`--space-id 'sp-7&whoami'` 里的 & 仍然分隔命令,
|
||||
// 粘贴即执行 whoami;
|
||||
// - cmd.exe 的双引号能挡 & | < >,却挡不住 %VAR% 展开;
|
||||
// - PowerShell 的单引号是引号,但内嵌单引号写作两个连续单引号,与 POSIX 的
|
||||
// 闭合-反斜杠转义-重开写法不兼容。
|
||||
//
|
||||
// 而生成命令时无法知道用户会粘贴进哪个 shell。既然引用不可靠,就不把不受信任的值放进可执行
|
||||
// 命令——与 internal/auth 侧「标识仅作数据展示,不是可执行命令」的既有做法同一思路。
|
||||
func driveLatestWindowsScopeValue(value string) (string, bool) {
|
||||
if shellValueIsBare(value) {
|
||||
return value, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// shellNeedsQuote 报告 r 是否落在「无需引用」白名单之外。
|
||||
//
|
||||
// 用白名单而非黑名单:漏掉一个元字符就是一个注入口,而白名单漏一个字符只会多加一对无害的
|
||||
// 引号。非 ASCII 一律视为需引用——中文目录名很常见,保守处理不会错。
|
||||
//
|
||||
// 白名单的门槛是「在 POSIX sh、PowerShell、cmd.exe 下含义都一致」,因此 % 被刻意排除:
|
||||
// cmd.exe 会展开 %VAR%,一个看似普通的值(URL 里的 %20、含 %PATH% 的名字)原样内联后在
|
||||
// cmd 里就会变形甚至泄露环境变量。@ 保留:PowerShell 的 splatting 只在 @( 、@{ 与
|
||||
// @变量名 形态下生效,而那些字符本身都不在白名单里,且值总出现在 --flag 之后而非语句开头。
|
||||
func shellNeedsQuote(r rune) bool {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
return false
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return false
|
||||
case r >= '0' && r <= '9':
|
||||
return false
|
||||
}
|
||||
return !strings.ContainsRune("_@+=:,./-", r)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageShellQuoteArgRoundTrip 把引用后的串交给真 /bin/sh 求值,证明它被解析回
|
||||
// **恰好一个、且内容完全相同**的参数。上面那条表驱动测试只能证明「我以为的形态」,这条证明
|
||||
// 「shell 认的形态」——两者不是一回事,恢复命令是给用户复制到 shell 里跑的,后者才是契约。
|
||||
//
|
||||
// 它同时是注入的负向对照:一旦引用失效,$(id) / `id` 会真的执行、空格与 & 会拆参,argc 或
|
||||
// 取回的值必然不等于原值,测试立刻红。用 build tag 排除 Windows(无 POSIX sh),跨平台形态
|
||||
// 断言留在 shell_quote_test.go。
|
||||
func TestCrossPlatformCoverageShellQuoteArgRoundTrip(t *testing.T) {
|
||||
sh, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skipf("POSIX sh unavailable: %v", err)
|
||||
}
|
||||
|
||||
values := []string{
|
||||
"ws-1",
|
||||
"https://alidocs.dingtalk.com/i/nodes/x?spaceId=1&type=doc",
|
||||
"sp 7",
|
||||
"a;id",
|
||||
"a&b",
|
||||
"a|b",
|
||||
"$(id)",
|
||||
"`id`",
|
||||
"$HOME",
|
||||
"*.txt",
|
||||
"a>b",
|
||||
`a\b`,
|
||||
`a"b`,
|
||||
"a!b",
|
||||
"{a,b}",
|
||||
"(a)",
|
||||
"~/x",
|
||||
"a#b",
|
||||
"it's",
|
||||
"'",
|
||||
"a'b'c",
|
||||
"",
|
||||
"报表",
|
||||
"a\tb",
|
||||
"a\nb",
|
||||
"--not-a-flag",
|
||||
}
|
||||
|
||||
for _, want := range values {
|
||||
t.Run(want, func(t *testing.T) {
|
||||
quoted := shellQuoteArg(want)
|
||||
|
||||
// 1) 未被拆参也未被吞掉:位置参数个数必须恰好为 1。
|
||||
// 引用失效时 "sp 7" 会变 2 个、"" 会变 0 个、$(id) 会变 id 的输出词数。
|
||||
argc, err := exec.Command(sh, "-c", "set -- "+quoted+`; printf %s "$#"`).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("argc probe failed for %q (quoted %s): %v", want, quoted, err)
|
||||
}
|
||||
if string(argc) != "1" {
|
||||
t.Fatalf("argc = %s, want 1 — %q quoted as %s split or vanished", argc, want, quoted)
|
||||
}
|
||||
|
||||
// 2) 内容逐字节相同:展开、命令替换、转义都不得改动值。
|
||||
got, err := exec.Command(sh, "-c", "set -- "+quoted+`; printf %s "$1"`).Output()
|
||||
if err != nil {
|
||||
t.Fatalf("value probe failed for %q (quoted %s): %v", want, quoted, err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Fatalf("round-trip mismatch\n input: %q\n quoted: %s\n shell: %q", want, quoted, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageShellQuoteArg 表驱动锁定 argv 引用规则。分两类断言:
|
||||
// - 安全值原样返回(保持恢复命令可读,且在 PowerShell/cmd 下同样可复制);
|
||||
// - 含任何 shell 元字符的值整体单引号包裹,内嵌单引号按 POSIX 标准写法处理
|
||||
// (闭合、拼反斜杠转义的单引号、重开)。
|
||||
//
|
||||
// 往返正确性另有 Unix 下用真 sh 求值的对照测试(shell_quote_roundtrip_unix_test.go)。
|
||||
//
|
||||
// TestCrossPlatformCoverage 前缀是门禁约定:平台覆盖率门禁只跑该前缀(与 TestAllShortcuts)的
|
||||
// 测试,shell_quote.go 的覆盖全靠这一条,去掉前缀会让 Coverage (macOS)/(Windows) 直接红。
|
||||
// 详见 drive_latest_incomplete_test.go 头部说明。
|
||||
func TestCrossPlatformCoverageShellQuoteArg(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
// —— 无需引用:白名单字符 ——
|
||||
{name: "plain_id", in: "ws-1", want: "ws-1"},
|
||||
{name: "digits", in: "1234567890", want: "1234567890"},
|
||||
{name: "upper_lower", in: "abcXYZ", want: "abcXYZ"},
|
||||
{name: "all_safe_punct", in: "_@+=:,./-", want: "_@+=:,./-"},
|
||||
{name: "path_like", in: "/tmp/a.b/c-d_e", want: "/tmp/a.b/c-d_e"},
|
||||
|
||||
// —— 必须引用:真实业务形态 ——
|
||||
// workspace 常见形态就是带查询串的 URL:? 与 & 都不在白名单,& 在裸拼接下会拆命令。
|
||||
{name: "url_with_query", in: "https://alidocs.dingtalk.com/i/nodes/x?spaceId=1&type=doc",
|
||||
want: `'https://alidocs.dingtalk.com/i/nodes/x?spaceId=1&type=doc'`},
|
||||
|
||||
// —— 必须引用:shell 元字符 ——
|
||||
{name: "space", in: "sp 7", want: `'sp 7'`},
|
||||
{name: "tab", in: "a\tb", want: "'a\tb'"},
|
||||
{name: "newline", in: "a\nb", want: "'a\nb'"},
|
||||
{name: "semicolon", in: "a;id", want: `'a;id'`},
|
||||
{name: "ampersand", in: "a&b", want: `'a&b'`},
|
||||
{name: "pipe", in: "a|b", want: `'a|b'`},
|
||||
{name: "command_substitution", in: "$(id)", want: `'$(id)'`},
|
||||
{name: "backtick", in: "`id`", want: "'`id`'"},
|
||||
{name: "variable", in: "$HOME", want: `'$HOME'`},
|
||||
// % 不在白名单:cmd.exe 会展开 %VAR%,故含 % 的值一律视为需引用。
|
||||
{name: "windows_variable", in: "%PATH%", want: `'%PATH%'`},
|
||||
{name: "url_percent_escape", in: "a%20b", want: `'a%20b'`},
|
||||
{name: "glob", in: "*.txt", want: `'*.txt'`},
|
||||
{name: "redirect", in: "a>b", want: `'a>b'`},
|
||||
{name: "backslash", in: `a\b`, want: `'a\b'`},
|
||||
{name: "double_quote", in: `a"b`, want: `'a"b'`},
|
||||
{name: "history_expansion", in: "a!b", want: `'a!b'`},
|
||||
{name: "brace", in: "{a,b}", want: `'{a,b}'`},
|
||||
{name: "paren", in: "(a)", want: `'(a)'`},
|
||||
{name: "tilde", in: "~/x", want: `'~/x'`},
|
||||
{name: "hash", in: "a#b", want: `'a#b'`},
|
||||
|
||||
// —— 单引号:唯一无法直接放进单引号串的字符 ——
|
||||
{name: "single_quote", in: "it's", want: `'it'\''s'`},
|
||||
{name: "only_single_quote", in: "'", want: `''\'''`},
|
||||
{name: "two_single_quotes", in: "a'b'c", want: `'a'\''b'\''c'`},
|
||||
|
||||
// —— 边界 ——
|
||||
// 空串必须显式成 '',否则参数会从 argv 里整个消失(--workspace 吞掉下一个 token)。
|
||||
{name: "empty", in: "", want: `''`},
|
||||
// 非 ASCII 一律引用:保守优于漏字符,中文目录名很常见。
|
||||
{name: "cjk", in: "报表", want: `'报表'`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := shellQuoteArg(tc.in); got != tc.want {
|
||||
t.Fatalf("shellQuoteArg(%q)\n got: %s\nwant: %s", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeValueStrategies 直接测两条平台策略的本体。它们是
|
||||
// shell_quote.go 里的纯函数、与构建平台无关,因此**在任一平台都能验证 Windows 侧的行为** ——
|
||||
// 这一点是刻意设计:真 shell 往返测试只能在 Unix 跑,Windows 的安全属性必须由这里钉住,
|
||||
// 否则「Windows 上不会把不受信任值放进可执行命令」就成了无人复核的断言。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeValueStrategies(t *testing.T) {
|
||||
// 这些值在至少一种目标 shell 下会改变命令解析。
|
||||
hostile := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"cmd_command_separator", "sp-7&whoami"}, // cmd.exe:& 分隔命令,单引号不是引号
|
||||
{"cmd_variable", "%PATH%"}, // cmd.exe:无条件展开
|
||||
{"workspace_url", "https://x/y?a=1&b=2"}, // 合法 workspace 形态,含 &
|
||||
{"space", "sp 7"},
|
||||
{"pwsh_statement_separator", "a;id"}, // PowerShell:; 分隔语句
|
||||
{"posix_substitution", "$(id)"},
|
||||
{"backtick", "`id`"},
|
||||
{"posix_variable", "$HOME"},
|
||||
{"pipe", "a|b"},
|
||||
{"redirect", "a>b"},
|
||||
{"caret", "a^b"}, // cmd.exe 转义符
|
||||
{"single_quote", "it's"},
|
||||
{"cjk", "报表"},
|
||||
{"empty", ""},
|
||||
}
|
||||
for _, tc := range hostile {
|
||||
t.Run("hostile/"+tc.name, func(t *testing.T) {
|
||||
// POSIX:始终可内联,但必须是引用后的形态(不能等于原值)。
|
||||
got, ok := driveLatestPosixScopeValue(tc.value)
|
||||
if !ok {
|
||||
t.Fatalf("POSIX 策略应始终可内联: %q", tc.value)
|
||||
}
|
||||
if got == tc.value {
|
||||
t.Fatalf("POSIX 策略未引用危险值: %q", tc.value)
|
||||
}
|
||||
// Windows:一律拒绝内联 —— 没有对 cmd.exe 与 PowerShell 同时安全的引用形式。
|
||||
if inline, ok := driveLatestWindowsScopeValue(tc.value); ok {
|
||||
t.Fatalf("Windows 策略不得内联危险值 %q(得到 %q)", tc.value, inline)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 安全值:两条策略都原样内联,命令保持可读、跨 shell 可复制。
|
||||
for _, v := range []string{"ws-1", "sp-7", "folder-root", "modifyTime", "7d", "2026-08-01", "a.b/c-d_e", "a@b"} {
|
||||
t.Run("bare/"+v, func(t *testing.T) {
|
||||
if got, ok := driveLatestPosixScopeValue(v); !ok || got != v {
|
||||
t.Fatalf("POSIX 应原样内联安全值 %q: got %q ok=%v", v, got, ok)
|
||||
}
|
||||
if got, ok := driveLatestWindowsScopeValue(v); !ok || got != v {
|
||||
t.Fatalf("Windows 应原样内联安全值 %q: got %q ok=%v", v, got, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 空串在 POSIX 下引用成一对空单引号(否则参数会从 argv 消失);Windows 侧归入不可内联。
|
||||
if got, ok := driveLatestPosixScopeValue(""); !ok || got != "''" {
|
||||
t.Fatalf("POSIX 空串应引用成一对空单引号: got %q ok=%v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageShellBareCharsetHasNoMetacharacters 锁定「安全值可原样内联」的根本
|
||||
// 前提:白名单里不能含任何一种目标 shell 会特殊解释的字符。
|
||||
//
|
||||
// 这比「用某个 shell 跑一遍」更本质 —— 内联路径的安全性不来自引用正确,而来自字符集本身无害;
|
||||
// 而拒绝内联的路径(Windows 侧)连引用都不需要。三套元字符合并检查:POSIX sh、PowerShell、
|
||||
// cmd.exe。`%` 曾因 URL 转义(%20)被误列入白名单,而 cmd.exe 会无条件展开 %VAR%,这条测试
|
||||
// 同时是该缺陷的回归锁。
|
||||
func TestCrossPlatformCoverageShellBareCharsetHasNoMetacharacters(t *testing.T) {
|
||||
// 逐字符列出,避免用字符串字面量时漏掉转义细节。
|
||||
meta := []rune{
|
||||
'&', '|', '<', '>', ';', '(', ')', '{', '}', '[', ']', // 分隔/分组
|
||||
'$', '`', '"', '\'', '\\', // 引用与替换
|
||||
'^', '%', // cmd.exe:转义符与变量展开
|
||||
'*', '?', '~', '#', '!', // 通配、家目录、注释、历史/取反
|
||||
' ', '\t', '\n', '\r', // 空白:拆参与换行注入
|
||||
}
|
||||
for _, r := range meta {
|
||||
if !shellNeedsQuote(r) {
|
||||
t.Fatalf("shell 元字符 %q 落在「无需引用」白名单内,安全值会被原样内联", string(r))
|
||||
}
|
||||
}
|
||||
// 反向哨兵:常规标识符字符必须留在白名单内,否则恢复命令会被无谓地全量引用/降级。
|
||||
for _, r := range []rune{'a', 'Z', '0', '9', '_', '-', '.', '/', ':', ',', '=', '+', '@'} {
|
||||
if shellNeedsQuote(r) {
|
||||
t.Fatalf("常规字符 %q 被判为需引用,会让恢复命令可读性无谓下降", string(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDriveLatestScopeValueBinding 断言当前构建绑定到了正确的策略。
|
||||
// 这条测试在每个平台各自成立,把「哪个平台用哪条策略」也纳入 CI(Coverage (Windows) 会跑它)。
|
||||
func TestCrossPlatformCoverageDriveLatestScopeValueBinding(t *testing.T) {
|
||||
const hostile = "sp-7&whoami"
|
||||
inline, ok := driveLatestScopeValue(hostile)
|
||||
if runtime.GOOS == "windows" {
|
||||
if ok {
|
||||
t.Fatalf("windows 构建不得内联 %q(得到 %q)", hostile, inline)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("posix 构建应可内联 %q", hostile)
|
||||
}
|
||||
if inline != `'sp-7&whoami'` {
|
||||
t.Fatalf("posix 构建应单引号包裹: %s", inline)
|
||||
}
|
||||
}
|
||||
@@ -116,8 +116,8 @@ func requireWukongWeeklySyncConfirmation(t *testing.T, err error) {
|
||||
func TestCrossPlatformCoverageWukongWeeklyChatCategoryQueries(t *testing.T) {
|
||||
caller := &wukongWeeklySyncCaller{}
|
||||
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand, "category", "list-by-conv")
|
||||
if err == nil || !strings.Contains(err.Error(), "missing required flag: --conversation-id") {
|
||||
t.Fatalf("missing conversation ID error = %v", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "flag --group is required") {
|
||||
t.Fatalf("missing group error = %v", err)
|
||||
}
|
||||
requireWukongWeeklySyncNoCalls(t, caller)
|
||||
|
||||
@@ -378,15 +378,15 @@ func TestCrossPlatformCoverageWukongWeeklyChatUpdateNickClearSemantics(t *testin
|
||||
if findErr != nil {
|
||||
t.Fatal(findErr)
|
||||
}
|
||||
if err := updateNick.RunE(updateNick, nil); err == nil || !strings.Contains(err.Error(), "--conversation-id") {
|
||||
t.Fatalf("direct missing conversation-id error = %v", err)
|
||||
if err := updateNick.RunE(updateNick, nil); err == nil || !strings.Contains(err.Error(), "--group") {
|
||||
t.Fatalf("direct missing group error = %v", err)
|
||||
}
|
||||
|
||||
caller := &wukongWeeklySyncCaller{}
|
||||
_, _, err := executeWukongWeeklySyncCommand(t, "chat", caller, newChatCommand,
|
||||
"group", "update-nick")
|
||||
if err == nil || !strings.Contains(err.Error(), "conversation-id") {
|
||||
t.Fatalf("missing conversation-id error = %v", err)
|
||||
if err == nil || !strings.Contains(err.Error(), "group") {
|
||||
t.Fatalf("missing group error = %v", err)
|
||||
}
|
||||
requireWukongWeeklySyncNoCalls(t, caller)
|
||||
|
||||
@@ -428,8 +428,8 @@ func TestCrossPlatformCoverageWukongWeeklyChatUpgradeValidationAndSafety(t *test
|
||||
upgrade.Flags().Bool("yes", false, "")
|
||||
}
|
||||
_ = upgrade.Flags().Set("yes", "true")
|
||||
if err := upgrade.RunE(upgrade, nil); err == nil || !strings.Contains(err.Error(), "--conversation-id") {
|
||||
t.Fatalf("direct missing conversation-id error = %v", err)
|
||||
if err := upgrade.RunE(upgrade, nil); err == nil || !strings.Contains(err.Error(), "--group") {
|
||||
t.Fatalf("direct missing group error = %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
|
||||
@@ -217,7 +217,6 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec {
|
||||
RequiredError: fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc),
|
||||
Enum: append([]string(nil), f.Enum...),
|
||||
Aliases: append([]string(nil), f.Aliases...),
|
||||
Input: append([]string(nil), f.Input...),
|
||||
})
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -1644,20 +1644,13 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err)
|
||||
}
|
||||
verification, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated)
|
||||
if err != nil {
|
||||
if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated); err != nil {
|
||||
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新结果不可信: %w", bizID, cardUpdateVerificationError(bizID, err))
|
||||
}
|
||||
payload := chatmsg.ProjectStreamingCardReceipt(created, bizID)
|
||||
payload["bizId"] = bizID
|
||||
payload["flowStatus"] = status
|
||||
payload["updated"] = updated
|
||||
payload["updateAccepted"] = verification.Accepted
|
||||
payload["updateVerified"] = verification.Verified
|
||||
payload["updateVerificationEvidence"] = verification.Evidence
|
||||
if verification.Accepted && !verification.Verified {
|
||||
payload["updateWarning"] = "服务端已接受卡片更新请求,但未返回可独立证明可见内容已更新的字段;不要重复执行相同更新"
|
||||
}
|
||||
return rt.Output(payload)
|
||||
},
|
||||
}
|
||||
@@ -1800,11 +1793,11 @@ var MessagesUpdateCard = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verification, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated)
|
||||
proof, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated)
|
||||
if err != nil {
|
||||
return cardUpdateVerificationError(bizID, err)
|
||||
}
|
||||
return rt.Output(chatmsg.ProjectStreamingCardUpdate(updated, bizID, verification))
|
||||
return rt.Output(chatmsg.ProjectStreamingCardUpdate(updated, bizID, proof))
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1049,14 +1049,6 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
},
|
||||
wantError: "biz-preserved",
|
||||
},
|
||||
{
|
||||
name: "unverified update preserves id",
|
||||
fake: &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/create_and_send_card": `{"bizId":"biz-unverified"}`,
|
||||
"im/update_streaming_card": `{"result":{"updated":false}}`,
|
||||
}},
|
||||
wantError: "biz-unverified",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
helpers.InitDeps(tc.fake)
|
||||
@@ -1081,8 +1073,6 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card",
|
||||
"--group", "cid",
|
||||
@@ -1095,13 +1085,6 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
if len(fake.calls) != 2 || fake.calls[1].tool != "update_streaming_card" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["updateAccepted"] != true || payload["updateVerified"] != false || payload["updateWarning"] == "" {
|
||||
t.Fatalf("card payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
for _, args := range [][]string{
|
||||
@@ -1174,14 +1157,12 @@ func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success acknowledgement is accepted but unverified", func(t *testing.T) {
|
||||
t.Run("success acknowledgement is verified", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-update-card",
|
||||
"--biz-id", "中文乱串",
|
||||
@@ -1195,13 +1176,6 @@ func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) {
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "update_streaming_card" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["accepted"] != true || payload["verified"] != false || payload["warning"] == "" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit update evidence succeeds", func(t *testing.T) {
|
||||
|
||||
@@ -55,15 +55,11 @@ func ProjectStreamingCardReceipt(created map[string]any, bizID string) map[strin
|
||||
|
||||
// ProjectStreamingCardUpdate preserves the lower response while making the
|
||||
// verified target explicit for downstream consumers.
|
||||
func ProjectStreamingCardUpdate(updated map[string]any, bizID string, verification CardUpdateVerification) map[string]any {
|
||||
func ProjectStreamingCardUpdate(updated map[string]any, bizID, proof string) map[string]any {
|
||||
payload := cloneSendStatusMap(updated)
|
||||
payload["contractVersion"] = StreamingCardContractVersion
|
||||
payload["cardRef"] = map[string]any{"bizId": strings.TrimSpace(bizID)}
|
||||
payload["accepted"] = verification.Accepted
|
||||
payload["verified"] = verification.Verified
|
||||
payload["verificationEvidence"] = verification.Evidence
|
||||
if verification.Accepted && !verification.Verified {
|
||||
payload["warning"] = "服务端已接受卡片更新请求,但未返回可独立证明可见内容已更新的字段;不要重复执行相同更新"
|
||||
}
|
||||
payload["verified"] = true
|
||||
payload["verificationEvidence"] = proof
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestCrossPlatformCoverageProjectStreamingCardReceipt(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectStreamingCardUpdate(t *testing.T) {
|
||||
payload := ProjectStreamingCardUpdate(map[string]any{"result": map[string]any{"updated": true}}, "biz-1", CardUpdateVerification{Accepted: true, Verified: true, Evidence: "updated=true"})
|
||||
payload := ProjectStreamingCardUpdate(map[string]any{"result": map[string]any{"updated": true}}, "biz-1", "updated=true")
|
||||
if payload["contractVersion"] != StreamingCardContractVersion || payload["verified"] != true || payload["verificationEvidence"] != "updated=true" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
@@ -40,10 +40,3 @@ func TestCrossPlatformCoverageProjectStreamingCardUpdate(t *testing.T) {
|
||||
t.Fatal("lower response was not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectAcceptedUnverifiedStreamingCardUpdate(t *testing.T) {
|
||||
payload := ProjectStreamingCardUpdate(map[string]any{"success": true}, "biz-1", CardUpdateVerification{Accepted: true, Verified: false, Evidence: "success=true"})
|
||||
if payload["accepted"] != true || payload["verified"] != false || payload["warning"] == "" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,16 +27,6 @@ var (
|
||||
ErrCardUpdateBizIDDrift = errors.New("streaming card update returned a different bizId")
|
||||
)
|
||||
|
||||
// CardUpdateVerification distinguishes an accepted write from an independently
|
||||
// verified write. Some server versions return success=true without an updated
|
||||
// flag or affected count; that is sufficient to report acceptance, but not to
|
||||
// claim that the card's visible content was observed after the write.
|
||||
type CardUpdateVerification struct {
|
||||
Accepted bool
|
||||
Verified bool
|
||||
Evidence string
|
||||
}
|
||||
|
||||
// NormalizeCardBizID performs only format-independent checks. bizId is an
|
||||
// opaque server-issued identifier; a stricter character or prefix contract
|
||||
// must not be invented by the CLI without an authoritative API declaration.
|
||||
@@ -68,40 +58,33 @@ func isCardBizIDPlaceholder(value string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyStreamingCardUpdate separates server acceptance from affirmative
|
||||
// evidence that the requested write took effect.
|
||||
func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (CardUpdateVerification, error) {
|
||||
// VerifyStreamingCardUpdate requires affirmative evidence that the requested
|
||||
// write took effect. update_streaming_card may acknowledge an applied write
|
||||
// with success=true without returning an updated flag or affected count.
|
||||
func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (string, error) {
|
||||
requestedBizID = strings.TrimSpace(requestedBizID)
|
||||
observation := cardUpdateObservation{bizIDs: map[string]struct{}{}}
|
||||
observeCardUpdate(response, &observation)
|
||||
|
||||
for responseBizID := range observation.bizIDs {
|
||||
if requestedBizID != "" && responseBizID != requestedBizID {
|
||||
return CardUpdateVerification{}, fmt.Errorf("%w: requested %q, response %q", ErrCardUpdateBizIDDrift, requestedBizID, responseBizID)
|
||||
return "", fmt.Errorf("%w: requested %q, response %q", ErrCardUpdateBizIDDrift, requestedBizID, responseBizID)
|
||||
}
|
||||
}
|
||||
if observation.negativeEvidence != "" && (observation.positiveEvidence != "" || observation.acceptedEvidence != "") {
|
||||
positive := observation.positiveEvidence
|
||||
if positive == "" {
|
||||
positive = observation.acceptedEvidence
|
||||
}
|
||||
return CardUpdateVerification{}, fmt.Errorf("%w: conflicting evidence %s and %s", ErrCardUpdateUnverified, positive, observation.negativeEvidence)
|
||||
if observation.positiveEvidence != "" && observation.negativeEvidence != "" {
|
||||
return "", fmt.Errorf("%w: conflicting evidence %s and %s", ErrCardUpdateUnverified, observation.positiveEvidence, observation.negativeEvidence)
|
||||
}
|
||||
if observation.positiveEvidence != "" {
|
||||
return CardUpdateVerification{Accepted: true, Verified: true, Evidence: observation.positiveEvidence}, nil
|
||||
return observation.positiveEvidence, nil
|
||||
}
|
||||
if observation.negativeEvidence != "" {
|
||||
return CardUpdateVerification{}, fmt.Errorf("%w: %s", ErrCardUpdateNotApplied, observation.negativeEvidence)
|
||||
return "", fmt.Errorf("%w: %s", ErrCardUpdateNotApplied, observation.negativeEvidence)
|
||||
}
|
||||
if observation.acceptedEvidence != "" {
|
||||
return CardUpdateVerification{Accepted: true, Verified: false, Evidence: observation.acceptedEvidence}, nil
|
||||
}
|
||||
return CardUpdateVerification{}, ErrCardUpdateUnverified
|
||||
return "", ErrCardUpdateUnverified
|
||||
}
|
||||
|
||||
type cardUpdateObservation struct {
|
||||
bizIDs map[string]struct{}
|
||||
acceptedEvidence string
|
||||
positiveEvidence string
|
||||
negativeEvidence string
|
||||
}
|
||||
@@ -124,9 +107,6 @@ func observeCardUpdate(value any, observation *cardUpdateObservation) {
|
||||
}
|
||||
|
||||
func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservation) {
|
||||
if accepted, ok := value["success"].(bool); ok && accepted && observation.acceptedEvidence == "" {
|
||||
observation.acceptedEvidence = "success=true"
|
||||
}
|
||||
for _, key := range []string{"bizId", "bizID", "biz_id"} {
|
||||
if candidate, ok := value[key].(string); ok && strings.TrimSpace(candidate) != "" {
|
||||
observation.bizIDs[strings.TrimSpace(candidate)] = struct{}{}
|
||||
@@ -157,7 +137,14 @@ func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservati
|
||||
setNegativeCardUpdateEvidence(observation, "errorCode=non-empty")
|
||||
}
|
||||
if success, ok := value["success"].(bool); ok {
|
||||
if !success {
|
||||
if success {
|
||||
// Record success=true only when the same response envelope explicitly
|
||||
// includes its business-error field. A non-empty code is already
|
||||
// negative evidence above, so the two signals reject the conflict.
|
||||
if hasErrorCode {
|
||||
setPositiveCardUpdateEvidence(observation, "success=true")
|
||||
}
|
||||
} else {
|
||||
setNegativeCardUpdateEvidence(observation, "success=false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,19 +52,19 @@ func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
response map[string]any
|
||||
want CardUpdateVerification
|
||||
wantProof string
|
||||
wantErrIs error
|
||||
}{
|
||||
{name: "updated", response: map[string]any{"result": map[string]any{"updated": true}}, want: CardUpdateVerification{Accepted: true, Verified: true, Evidence: "updated=true"}},
|
||||
{name: "affected", response: map[string]any{"data": map[string]any{"affectedCount": float64(1)}}, want: CardUpdateVerification{Accepted: true, Verified: true, Evidence: "affectedCount=1"}},
|
||||
{name: "boolean result", response: map[string]any{"result": true}, want: CardUpdateVerification{Accepted: true, Verified: true, Evidence: "result=true"}},
|
||||
{name: "updated", response: map[string]any{"result": map[string]any{"updated": true}}, wantProof: "updated=true"},
|
||||
{name: "affected", response: map[string]any{"data": map[string]any{"affectedCount": float64(1)}}, wantProof: "affectedCount=1"},
|
||||
{name: "boolean result", response: map[string]any{"result": true}, wantProof: "result=true"},
|
||||
{name: "boolean false result", response: map[string]any{"result": false}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
{name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, want: CardUpdateVerification{Accepted: true, Verified: true, Evidence: "applied=true"}},
|
||||
{name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, wantProof: "applied=true"},
|
||||
{name: "conflicting evidence", response: map[string]any{"updated": true, "applied": false}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "zero affected", response: map[string]any{"affectedCount": 0}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
{name: "success acknowledgement", response: map[string]any{"success": true, "errorCode": nil}, want: CardUpdateVerification{Accepted: true, Verified: false, Evidence: "success=true"}},
|
||||
{name: "success acknowledgement with empty error code", response: map[string]any{"success": true, "errorCode": " "}, want: CardUpdateVerification{Accepted: true, Verified: false, Evidence: "success=true"}},
|
||||
{name: "success without explicit error code", response: map[string]any{"success": true}, want: CardUpdateVerification{Accepted: true, Verified: false, Evidence: "success=true"}},
|
||||
{name: "success acknowledgement", response: map[string]any{"success": true, "errorCode": nil}, wantProof: "success=true"},
|
||||
{name: "success acknowledgement with empty error code", response: map[string]any{"success": true, "errorCode": " "}, wantProof: "success=true"},
|
||||
{name: "success without explicit error code", response: map[string]any{"success": true}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "success conflicts with error code", response: map[string]any{"success": true, "errorCode": "InternalError"}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "success conflicts with numeric error code", response: map[string]any{"success": true, "errorCode": float64(500)}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "error code without success", response: map[string]any{"errorCode": "InternalError"}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
@@ -74,15 +74,15 @@ func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) {
|
||||
{name: "unrelated extension ignored", response: map[string]any{"extension": map[string]any{"updated": true}}, wantErrIs: ErrCardUpdateUnverified},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := VerifyStreamingCardUpdate("biz-1", test.response)
|
||||
proof, err := VerifyStreamingCardUpdate("biz-1", test.response)
|
||||
if test.wantErrIs != nil {
|
||||
if !errors.Is(err, test.wantErrIs) {
|
||||
t.Fatalf("VerifyStreamingCardUpdate error = %v, want errors.Is(_, %v)", err, test.wantErrIs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got != test.want {
|
||||
t.Fatalf("VerifyStreamingCardUpdate = %#v, %v; want %#v", got, err, test.want)
|
||||
if err != nil || proof != test.wantProof {
|
||||
t.Fatalf("VerifyStreamingCardUpdate = %q, %v; want %q", proof, err, test.wantProof)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,12 +107,6 @@ type Flag struct {
|
||||
// compatibility escape hatch for aliases that were historically public.
|
||||
Aliases []string `json:"-"`
|
||||
AliasesVisible bool `json:"-"`
|
||||
// Input declares extra input sources for a string flag beyond the literal
|
||||
// command-line value: "file" enables @path (value replaced by the file
|
||||
// content), "stdin" enables - (value replaced by stdin). "@@value" escapes
|
||||
// to the literal "@value". Resolution happens before Required/Enum/Validate
|
||||
// checks. Empty = flag value only.
|
||||
Input []string `json:"input,omitempty"`
|
||||
}
|
||||
|
||||
// ConstraintKind is a machine-readable cross-parameter or custom validation
|
||||
|
||||
@@ -17,8 +17,6 @@ import (
|
||||
|
||||
const wikiCompositeReason = "Reviewed Wiki Shortcut composite: the executable CLI owns strict response validation, pagination projection, optional multi-step orchestration, read-back verification, and confirmation; no single MCP interface represents the complete command contract."
|
||||
|
||||
const wikiSpaceSearchCompositeReason = "Reviewed Wiki space-search compatibility adapter: the published workflow properties query/limit remain stable while execution translates them to search_wikiSpaces.keyword/pageSize; redirecting an existing non-empty property requires a versioned Schema migration."
|
||||
|
||||
func wikiContract(command, description, useWhen string, avoidWhen, examples []string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params ...contract.ParamDecl) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||
cliPath := "wiki " + command
|
||||
@@ -30,11 +28,6 @@ func wikiContract(command, description, useWhen string, avoidWhen, examples []st
|
||||
}
|
||||
}
|
||||
|
||||
func wikiWithInterfaceReason(declared shortcut.Shortcut, reason string) shortcut.Shortcut {
|
||||
declared.Contract.Interface.Reason = reason
|
||||
return declared
|
||||
}
|
||||
|
||||
func wikiObjectResult(description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"additionalProperties":true}`, description))}
|
||||
}
|
||||
@@ -270,16 +263,9 @@ func collectWikiPages(rt *shortcut.RuntimeContext, operation string, pageSize in
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
maxItems := rt.Int("max-items")
|
||||
if maxItems > 0 {
|
||||
remaining := maxItems - len(all)
|
||||
if len(items) > remaining {
|
||||
items = items[:remaining]
|
||||
}
|
||||
}
|
||||
all = append(all, items...)
|
||||
lastPage = page
|
||||
if maxItems > 0 && len(all) >= maxItems {
|
||||
if maxItems := rt.Int("max-items"); maxItems > 0 && len(all) >= maxItems {
|
||||
lastPage["autoPageComplete"] = false
|
||||
lastPage["autoPageStopReason"] = "max_items"
|
||||
lastPage["pagesFetched"] = pageNumber
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
var NodeList = readShortcut("+node-list", "严格分页列出知识库节点", "浏览知识库根目录或指定文件夹;只有显式 nodes:[] 才表示空目录,并完整保留 nextCursor/hasMore。", "nodes", "dws wiki +node-list --workspace <workspaceId> --format json", []shortcut.Flag{
|
||||
var NodeList = readShortcut("+node-list", "严格分页列出知识库节点", "浏览知识库根目录或指定文件夹;只有显式 nodes:[] 才表示空目录,并完整保留 nextCursor/hasMore。", "nodes", []shortcut.Flag{
|
||||
{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "父节点 ID"}, {Name: "limit", Type: shortcut.FlagInt, Default: "50", Desc: "每页数量 1-50"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true},
|
||||
}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "folder", Property: "folderId"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
items, page, err := collectWikiPages(rt, "wiki/list_nodes", rt.Int("limit"), []string{"nodes", "items", "list"}, func(cursor string, size int) (map[string]any, error) {
|
||||
@@ -37,7 +37,7 @@ func nodeAliases() map[string][]string {
|
||||
return map[string][]string{"nodeId": {"nodeId", "id", "dentryUuid", "fileId"}, "name": {"name", "title", "nodeName", "fileName"}, "type": {"type", "nodeType", "docType", "fileType"}, "contentType": {"contentType"}, "folderId": {"folderId", "parentId"}, "workspaceId": {"workspaceId", "spaceId"}, "url": {"docUrl", "url", "webUrl"}}
|
||||
}
|
||||
|
||||
var NodeGet = readShortcut("+node-get", "获取知识库节点详情", "已知节点 ID 或在线文档 URL 时读取元数据,并在节点信息之外统一返回文档/文件属性。", "", "dws wiki +node-get --node <nodeId> --format json", []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID 或 URL"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
var NodeGet = readShortcut("+node-get", "获取知识库节点详情", "已知节点 ID 或在线文档 URL 时读取元数据,并在节点信息之外统一返回文档/文件属性。", "", []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID 或 URL"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -52,7 +52,7 @@ var NodeGet = readShortcut("+node-get", "获取知识库节点详情", "已知
|
||||
return rt.Output(object)
|
||||
})
|
||||
|
||||
var NodeSearch = readShortcut("+node-search", "严格搜索知识库节点", "在指定知识库内按关键词和扩展名搜索节点;零命中必须来自显式 documents:[]。", "nodes", "dws wiki +node-search --workspace <workspaceId> --query \"方案\" --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "query", Type: shortcut.FlagString, Required: true, Desc: "关键词"}, {Name: "extensions", Type: shortcut.FlagStringSlice, Desc: "扩展名过滤"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量 1-30"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceIds"}, {Name: "query", Property: "keyword"}, {Name: "extensions", Property: "extensions"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
var NodeSearch = readShortcut("+node-search", "严格搜索知识库节点", "在指定知识库内按关键词和扩展名搜索节点;零命中必须来自显式 documents:[]。", "nodes", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "query", Type: shortcut.FlagString, Required: true, Desc: "关键词"}, {Name: "extensions", Type: shortcut.FlagStringSlice, Desc: "扩展名过滤"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量 1-30"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceIds"}, {Name: "query", Property: "keyword"}, {Name: "extensions", Property: "extensions"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"workspaceIds": []string{rt.Str("workspace")}, "keyword": rt.Str("query"), "pageSize": rt.Int("limit")}
|
||||
if rt.Changed("extensions") {
|
||||
params["extensions"] = rt.StrSlice("extensions")
|
||||
@@ -74,7 +74,7 @@ var NodeSearch = readShortcut("+node-search", "严格搜索知识库节点", "
|
||||
return rt.Output(out)
|
||||
})
|
||||
|
||||
var NodeCreate = writeShortcut("+node-create", "创建知识库节点并读回验证", "在知识库根目录或文件夹中创建文档、表格、白板、脑图或文件夹;取得 nodeId 并读回后才成功。", "dws wiki +node-create --workspace <workspaceId> --name \"新文档\" --format json", shortcut.RiskWrite, wikiWriteSafety(false), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "name", Type: shortcut.FlagString, Required: true, Desc: "节点名称"}, {Name: "type", Type: shortcut.FlagString, Default: "adoc", Desc: "节点类型", Enum: []string{"adoc", "axls", "able", "appt", "adraw", "amind", "folder"}}, {Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "name", Property: "name"}, {Name: "type", Property: "type"}, {Name: "folder", Property: "folderId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
var NodeCreate = writeShortcut("+node-create", "创建知识库节点并读回验证", "在知识库根目录或文件夹中创建文档、表格、白板、脑图或文件夹;取得 nodeId 并读回后才成功。", shortcut.RiskWrite, wikiWriteSafety(false), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "name", Type: shortcut.FlagString, Required: true, Desc: "节点名称"}, {Name: "type", Type: shortcut.FlagString, Default: "adoc", Desc: "节点类型", Enum: []string{"adoc", "axls", "able", "appt", "adraw", "amind", "folder"}}, {Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "name", Property: "name"}, {Name: "type", Property: "type"}, {Name: "folder", Property: "folderId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"workspaceId": rt.Str("workspace"), "name": rt.Str("name"), "type": rt.Str("type")}
|
||||
if rt.Changed("folder") {
|
||||
params["folderId"] = rt.Str("folder")
|
||||
@@ -108,7 +108,7 @@ var NodeCreate = writeShortcut("+node-create", "创建知识库节点并读回
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": id, "node": verified})
|
||||
})
|
||||
|
||||
var NodeCopy = writeShortcut("+node-copy", "复制知识库节点并读回验证", "复制现有在线节点到目标知识库/文件夹;高风险确认后要求新 nodeId 并读取副本元数据。", "dws wiki +node-copy --workspace <workspaceId> --node <nodeId> --format json", shortcut.RiskHighWrite, contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "non_idempotent"}, []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "目标知识库 ID"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "源节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
var NodeCopy = writeShortcut("+node-copy", "复制知识库节点并读回验证", "复制现有在线节点到目标知识库/文件夹;高风险确认后要求新 nodeId 并读取副本元数据。", shortcut.RiskHighWrite, contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "non_idempotent"}, []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "目标知识库 ID"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "源节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"workspaceId": rt.Str("workspace"), "nodeId": rt.Str("node")}
|
||||
if rt.Changed("folder") {
|
||||
params["targetFolderId"] = rt.Str("folder")
|
||||
@@ -136,9 +136,6 @@ var NodeCopy = writeShortcut("+node-copy", "复制知识库节点并读回验证
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if firstWikiString(verified, "nodeId", "id", "fileId") != id {
|
||||
return wikiResponseError("doc/copy_document", "readback_id_mismatch", "复制后读回节点 ID 不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "sourceNodeId": rt.Str("node"), "nodeId": id, "copy": verified})
|
||||
})
|
||||
|
||||
@@ -194,11 +191,11 @@ func executeMove(rt *shortcut.RuntimeContext, toDrive bool) error {
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "node": verified})
|
||||
}
|
||||
|
||||
var Move = writeShortcut("+move", "移动节点到知识库并读回验证", "将 Wiki 节点或我的文档在线节点移动到目标知识库/文件夹;同一入口覆盖库内移动与在线文档入库场景。", "dws wiki +move --workspace <workspaceId> --node <nodeId> --format json", shortcut.RiskWrite, wikiWriteSafety(true), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "目标知识库 ID"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { return executeMove(rt, false) })
|
||||
var Move = writeShortcut("+move", "移动节点到知识库并读回验证", "将 Wiki 节点或我的文档在线节点移动到目标知识库/文件夹;同一入口覆盖库内移动与在线文档入库场景。", shortcut.RiskWrite, wikiWriteSafety(true), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "目标知识库 ID"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { return executeMove(rt, false) })
|
||||
|
||||
var MoveToDrive = writeShortcut("+move-to-drive", "移动 Wiki 节点到我的文档", "将 Wiki 在线节点同步移动到我的文档或指定文件夹,并以元数据读回证明任务完成。", "dws wiki +move-to-drive --node <nodeId> --format json", shortcut.RiskWrite, wikiWriteSafety(true), []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Required: true, Desc: "Wiki 节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "我的文档目标文件夹 ID"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { return executeMove(rt, true) })
|
||||
var MoveToDrive = writeShortcut("+move-to-drive", "移动 Wiki 节点到我的文档", "将 Wiki 在线节点同步移动到我的文档或指定文件夹,并以元数据读回证明任务完成。", shortcut.RiskWrite, wikiWriteSafety(true), []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Required: true, Desc: "Wiki 节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "我的文档目标文件夹 ID"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { return executeMove(rt, true) })
|
||||
|
||||
var NodeDelete = writeShortcut("+node-delete", "删除知识库节点", "明确确认后将节点移入回收站;先读取目标,且删除响应必须提供 success=true 终态证据。", "dws wiki +node-delete --workspace <workspaceId> --node <nodeId> --format json", shortcut.RiskHighWrite, wikiDeleteSafety(), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID,用于确认影响范围"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
var NodeDelete = writeShortcut("+node-delete", "删除知识库节点", "明确确认后将节点移入回收站;先读取目标,且删除响应必须提供 success=true 终态证据。", shortcut.RiskHighWrite, wikiDeleteSafety(), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID,用于确认影响范围"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
preflight, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -223,7 +220,7 @@ var NodeDelete = writeShortcut("+node-delete", "删除知识库节点", "明确
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "deleted": true})
|
||||
})
|
||||
|
||||
var FeedList = readShortcut("+feed-list", "严格分页列出知识库动态", "查看谁在何时创建、更新或评论了知识库内容;严格验证 feeds 数组并保留游标。", "feeds", "dws wiki +feed-list --workspace <workspaceId> --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量 1-20"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, {Name: "exclude-file", Type: shortcut.FlagBool, Desc: "排除普通文件动态"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "limit", Property: "maxResults"}, {Name: "cursor", Property: "nextToken"}, {Name: "exclude-file", Property: "excludeFile"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
var FeedList = readShortcut("+feed-list", "严格分页列出知识库动态", "查看谁在何时创建、更新或评论了知识库内容;严格验证 feeds 数组并保留游标。", "feeds", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量 1-20"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, {Name: "exclude-file", Type: shortcut.FlagBool, Desc: "排除普通文件动态"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "limit", Property: "maxResults"}, {Name: "cursor", Property: "nextToken"}, {Name: "exclude-file", Property: "excludeFile"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
items, page, err := collectWikiPages(rt, "wiki/list_workspace_feeds", rt.Int("limit"), []string{"feeds", "items", "list"}, func(cursor string, size int) (map[string]any, error) {
|
||||
params := map[string]any{"workspaceId": rt.Str("workspace"), "maxResults": size}
|
||||
if cursor != "" {
|
||||
|
||||
@@ -16,19 +16,19 @@ import (
|
||||
|
||||
var collectionAvoid = []string{"需要原始 MCP 响应或未公开底层参数时改用对应原子命令;缺失业务数组不是合法空结果"}
|
||||
|
||||
func readShortcut(command, description, intent, collection, example string, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
|
||||
func readShortcut(command, description, intent, collection string, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
|
||||
result := wikiObjectResult(description)
|
||||
if collection != "" {
|
||||
result = wikiCollectionResult(collection, description)
|
||||
}
|
||||
return shortcut.Shortcut{OutputRollout: output.RolloutUnifiedActive, Service: "wiki", Command: command, Product: "wiki", Description: description, Intent: intent, Risk: shortcut.RiskRead, Safety: wikiReadSafety(), Contract: wikiContract(command, description, intent, collectionAvoid, []string{example}, result, nil, params...), Flags: flags, Execute: execute}
|
||||
return shortcut.Shortcut{OutputRollout: output.RolloutUnifiedActive, Service: "wiki", Command: command, Product: "wiki", Description: description, Intent: intent, Risk: shortcut.RiskRead, Safety: wikiReadSafety(), Contract: wikiContract(command, description, intent, collectionAvoid, []string{"dws wiki " + command + " --format json"}, result, nil, params...), Flags: flags, Execute: execute}
|
||||
}
|
||||
|
||||
func writeShortcut(command, description, intent, example string, risk shortcut.Risk, safety contract.SafetySpec, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
|
||||
return shortcut.Shortcut{OutputRollout: output.RolloutUnifiedActive, Service: "wiki", Command: command, Product: "wiki", Description: description, Intent: intent, Risk: risk, Safety: safety, Contract: wikiContract(command, description, intent, []string{"只需读取或影响范围未确认时不要执行写操作"}, []string{example}, wikiObjectResult(description), nil, params...), Flags: flags, Execute: execute}
|
||||
func writeShortcut(command, description, intent string, risk shortcut.Risk, safety contract.SafetySpec, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
|
||||
return shortcut.Shortcut{OutputRollout: output.RolloutUnifiedActive, Service: "wiki", Command: command, Product: "wiki", Description: description, Intent: intent, Risk: risk, Safety: safety, Contract: wikiContract(command, description, intent, []string{"只需读取或影响范围未确认时不要执行写操作"}, []string{"dws wiki " + command + " --format json"}, wikiObjectResult(description), nil, params...), Flags: flags, Execute: execute}
|
||||
}
|
||||
|
||||
var SpaceList = readShortcut("+space-list", "严格分页列出知识库", "浏览有权访问的组织或个人知识库,并保留服务端分页证据;只有显式 wikiSpaces:[] 才是空结果。", "spaces", "dws wiki +space-list --limit 20 --format json", []shortcut.Flag{
|
||||
var SpaceList = readShortcut("+space-list", "严格分页列出知识库", "浏览有权访问的组织或个人知识库,并保留服务端分页证据;只有显式 wikiSpaces:[] 才是空结果。", "spaces", []shortcut.Flag{
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "orgWikiSpace", Desc: "知识库类型", Enum: []string{"orgWikiSpace", "myWikiSpace"}},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页数量 1-50(默认 20)"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true},
|
||||
}, []contract.ParamDecl{{Name: "type", Property: "wikiSpaceType"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
@@ -52,9 +52,9 @@ var SpaceList = readShortcut("+space-list", "严格分页列出知识库", "浏
|
||||
return rt.Output(out)
|
||||
})
|
||||
|
||||
var SpaceSearch = wikiWithInterfaceReason(readShortcut("+space-search", "严格搜索知识库", "按名称关键词定位知识库;严格验证搜索数组,避免内部异常被误报为零命中。", "spaces", "dws wiki +space-search --query \"产品文档\" --format json", []shortcut.Flag{
|
||||
var SpaceSearch = readShortcut("+space-search", "严格搜索知识库", "按名称关键词定位知识库;严格验证搜索数组,避免内部异常被误报为零命中。", "spaces", []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Required: true, Desc: "搜索关键词"}, {Name: "limit", Type: shortcut.FlagString, Desc: "返回数量 1-20(默认 10)"},
|
||||
}, []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "limit", Property: "limit"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
}, []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "limit", Property: "pageSize"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
pageSize, err := wikiStringInt(rt, "limit", 10, 1, 20)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -71,9 +71,9 @@ var SpaceSearch = wikiWithInterfaceReason(readShortcut("+space-search", "严格
|
||||
out := map[string]any{"count": len(spaces), "spaces": spaces}
|
||||
addWikiPagination(out, page)
|
||||
return rt.Output(out)
|
||||
}), wikiSpaceSearchCompositeReason)
|
||||
})
|
||||
|
||||
var SpaceGet = readShortcut("+space-get", "获取知识库详情", "已知 workspace ID 或知识库 URL 时读取并验证空间详情。", "", "dws wiki +space-get --workspace <workspaceId> --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
var SpaceGet = readShortcut("+space-get", "获取知识库详情", "已知 workspace ID 或知识库 URL 时读取并验证空间详情。", "", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -88,7 +88,7 @@ var SpaceGet = readShortcut("+space-get", "获取知识库详情", "已知 works
|
||||
return rt.Output(object)
|
||||
})
|
||||
|
||||
var SpaceCreate = writeShortcut("+space-create", "创建知识库并读回验证", "创建新的知识库容器;必须取得 workspaceId 并通过详情读回后才报告成功。", "dws wiki +space-create --name \"产品文档库\" --format json", shortcut.RiskWrite, wikiWriteSafety(false), []shortcut.Flag{{Name: "name", Type: shortcut.FlagString, Required: true, Desc: "知识库名称,不超过 32 个字符。--name 不超过 32 个字符;--desc 不超过 500 个字符"}, {Name: "desc", Type: shortcut.FlagString, Desc: "知识库描述,不超过 500 个字符。--name 不超过 32 个字符;--desc 不超过 500 个字符"}, {Name: "icon", Type: shortcut.FlagString, Desc: "图标标识"}}, []contract.ParamDecl{{Name: "name", Property: "name"}, {Name: "desc", Property: "description"}, {Name: "icon", Property: "icon"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
var SpaceCreate = writeShortcut("+space-create", "创建知识库并读回验证", "创建新的知识库容器;必须取得 workspaceId 并通过详情读回后才报告成功。", shortcut.RiskWrite, wikiWriteSafety(false), []shortcut.Flag{{Name: "name", Type: shortcut.FlagString, Required: true, Desc: "知识库名称,不超过 32 个字符。--name 不超过 32 个字符;--desc 不超过 500 个字符"}, {Name: "desc", Type: shortcut.FlagString, Desc: "知识库描述,不超过 500 个字符。--name 不超过 32 个字符;--desc 不超过 500 个字符"}, {Name: "icon", Type: shortcut.FlagString, Desc: "图标标识"}}, []contract.ParamDecl{{Name: "name", Property: "name"}, {Name: "desc", Property: "description"}, {Name: "icon", Property: "icon"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"name": rt.Str("name")}
|
||||
if rt.Changed("desc") {
|
||||
params["description"] = rt.Str("desc")
|
||||
@@ -125,7 +125,7 @@ var SpaceCreate = writeShortcut("+space-create", "创建知识库并读回验证
|
||||
return rt.Output(map[string]any{"success": true, "workspaceId": id, "space": verified})
|
||||
})
|
||||
|
||||
var DeleteSpace = writeShortcut("+delete-space", "删除知识库", "用户明确确认后将整个知识库移入回收站;删除前读取目标,删除响应必须有 success=true。", "dws wiki +delete-space --workspace <workspaceId> --format json", shortcut.RiskHighWrite, wikiDeleteSafety(), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
var DeleteSpace = writeShortcut("+delete-space", "删除知识库", "用户明确确认后将整个知识库移入回收站;删除前读取目标,删除响应必须有 success=true。", shortcut.RiskHighWrite, wikiDeleteSafety(), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
preflight, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -156,12 +156,12 @@ func memberFlags(withRole bool) []shortcut.Flag {
|
||||
return flags
|
||||
}
|
||||
|
||||
func memberWrite(command, tool, description, intent, example string, withRole bool) shortcut.Shortcut {
|
||||
func memberWrite(command, tool, description, intent string, withRole bool) shortcut.Shortcut {
|
||||
params := []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "users", Property: "userIds"}}
|
||||
if withRole {
|
||||
params = append(params, contract.ParamDecl{Name: "role", Property: "roleId"})
|
||||
}
|
||||
return writeShortcut(command, description, intent, example, shortcut.RiskWrite, wikiWriteSafety(false), memberFlags(withRole), params, func(rt *shortcut.RuntimeContext) error {
|
||||
return writeShortcut(command, description, intent, shortcut.RiskWrite, wikiWriteSafety(false), memberFlags(withRole), params, func(rt *shortcut.RuntimeContext) error {
|
||||
users := wikiStringSliceFirst(rt, "users", "user")
|
||||
if len(users) == 0 || len(users) > 30 {
|
||||
return fmt.Errorf("--users 必须包含 1-30 个 userId")
|
||||
@@ -193,11 +193,11 @@ func memberWrite(command, tool, description, intent, example string, withRole bo
|
||||
})
|
||||
}
|
||||
|
||||
var MemberAdd = memberWrite("+member-add", "add_member", "添加知识库成员", "向知识库授予一个或多个用户容器级角色;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", "dws wiki +member-add --workspace <workspaceId> --users <userId> --role READER --format json", true)
|
||||
var MemberUpdate = memberWrite("+member-update", "update_member", "更新知识库成员角色", "调整已有成员的知识库容器级角色;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", "dws wiki +member-update --workspace <workspaceId> --users <userId> --role EDITOR --format json", true)
|
||||
var MemberRemove = memberWrite("+member-remove", "remove_member", "移除知识库成员", "移除一个或多个用户的知识库容器级访问;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", "dws wiki +member-remove --workspace <workspaceId> --users <userId> --format json", false)
|
||||
var MemberAdd = memberWrite("+member-add", "add_member", "添加知识库成员", "向知识库授予一个或多个用户容器级角色;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", true)
|
||||
var MemberUpdate = memberWrite("+member-update", "update_member", "更新知识库成员角色", "调整已有成员的知识库容器级角色;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", true)
|
||||
var MemberRemove = memberWrite("+member-remove", "remove_member", "移除知识库成员", "移除一个或多个用户的知识库容器级访问;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", false)
|
||||
|
||||
var MemberList = readShortcut("+member-list", "严格列出知识库成员", "列出知识库成员及角色;后端不提供可续游标且单次真实上限为 50,不伪造 page-all。", "members", "dws wiki +member-list --workspace <workspaceId> --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "limit", Type: shortcut.FlagInt, Default: "30", Desc: "返回上限 1-50"}, {Name: "filter-role", Type: shortcut.FlagStringSlice, Desc: "角色过滤"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "limit", Property: "maxResults"}, {Name: "filter-role", Property: "filterRoleIds"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
var MemberList = readShortcut("+member-list", "严格列出知识库成员", "列出知识库成员及角色;后端不提供可续游标且单次真实上限为 50,不伪造 page-all。", "members", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "limit", Type: shortcut.FlagInt, Default: "30", Desc: "返回上限 1-50"}, {Name: "filter-role", Type: shortcut.FlagStringSlice, Desc: "角色过滤"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "limit", Property: "maxResults"}, {Name: "filter-role", Property: "filterRoleIds"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("limit") < 1 || rt.Int("limit") > 50 {
|
||||
return fmt.Errorf("--limit 必须在 1-50 之间;服务端不支持超过 50 或游标续页")
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
@@ -72,7 +71,6 @@ func runWikiCoverageCLI(t *testing.T, caller *wikiCoverageCaller, args ...string
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
stdout := &bytes.Buffer{}
|
||||
root.SetOut(stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
@@ -131,18 +129,9 @@ func TestCrossPlatformCoverageWikiSpaceWorkflows(t *testing.T) {
|
||||
"wiki/search_wikiSpaces": {`{"success":true,"wikiSpaces":[{"spaceId":"w2","spaceName":"Plan"}]}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-search", "--query", "Plan", "--limit", "12")
|
||||
if err != nil || out["count"] != float64(1) || len(caller.calls) != 1 {
|
||||
if err != nil || out["count"] != float64(1) || caller.calls[0].args["pageSize"] != 12 {
|
||||
t.Fatalf("search output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
if got := caller.calls[0]; got.product != "wiki" || got.tool != "search_wikiSpaces" || len(got.args) != 2 || got.args["keyword"] != "Plan" || got.args["pageSize"] != 12 {
|
||||
t.Fatalf("search call = %#v, want exact keyword/pageSize request", got)
|
||||
}
|
||||
if _, exists := caller.calls[0].args["query"]; exists {
|
||||
t.Fatalf("search request leaked compatibility property query: %#v", caller.calls[0].args)
|
||||
}
|
||||
if _, exists := caller.calls[0].args["limit"]; exists {
|
||||
t.Fatalf("search request leaked compatibility property limit: %#v", caller.calls[0].args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get requires business id", func(t *testing.T) {
|
||||
@@ -295,31 +284,6 @@ func TestCrossPlatformCoverageWikiReadWorkflows(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiWriteWorkflows(t *testing.T) {
|
||||
t.Run("confirmation gates precede every remote call", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "delete space", args: []string{"+delete-space", "--workspace", "w"}},
|
||||
{name: "copy node", args: []string{"+node-copy", "--workspace", "w", "--node", "source"}},
|
||||
{name: "move node", args: []string{"+move", "--workspace", "target", "--node", "n"}},
|
||||
{name: "move node to drive", args: []string{"+move-to-drive", "--node", "n"}},
|
||||
{name: "delete node", args: []string{"+node-delete", "--workspace", "w", "--node", "n"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{}
|
||||
_, err := runWikiCoverageCLI(t, caller, tc.args...)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "confirmation_required" {
|
||||
t.Fatalf("unconfirmed error = %#v, want confirmation_required", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed shortcut reached MCP: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("node create dry and verified", func(t *testing.T) {
|
||||
dry := &wikiCoverageCaller{}
|
||||
out, err := runWikiCoverageCLI(t, dry, "+node-create", "--workspace", "w", "--folder", "f", "--name", "Doc", "--type", "adoc", "--dry-run")
|
||||
@@ -500,38 +464,16 @@ func TestCrossPlatformCoverageWikiPaginationFailureModes(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("max items trims first page", func(t *testing.T) {
|
||||
t.Run("max items", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/list_wikiSpaces": {`{"wikiSpaces":[{"workspaceId":"w1"},{"workspaceId":"w2"}],"hasMore":true,"nextCursor":"next"}`},
|
||||
"wiki/list_wikiSpaces": {`{"wikiSpaces":[{"workspaceId":"w1"}],"hasMore":true,"nextCursor":"next"}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", "2", "--page-all", "--max-items", "1")
|
||||
spaces, ok := out["spaces"].([]any)
|
||||
if err != nil || !ok || out["count"] != float64(1) || len(spaces) != 1 || out["autoPageStopReason"] != "max_items" {
|
||||
if err != nil || out["autoPageStopReason"] != "max_items" {
|
||||
t.Fatalf("max-items output=%#v err=%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("max items trims remaining page", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/list_wikiSpaces": {
|
||||
`{"wikiSpaces":[{"workspaceId":"w1"},{"workspaceId":"w2"}],"hasMore":true,"nextCursor":"next"}`,
|
||||
`{"wikiSpaces":[{"workspaceId":"w3"},{"workspaceId":"w4"}],"hasMore":false}`,
|
||||
},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", "2", "--page-all", "--page-limit", "2", "--max-items", "3")
|
||||
spaces, ok := out["spaces"].([]any)
|
||||
if err != nil || !ok || out["count"] != float64(3) || len(spaces) != 3 || out["autoPageStopReason"] != "max_items" {
|
||||
t.Fatalf("max-items output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[1].args["pageSize"] != 1 {
|
||||
t.Fatalf("remaining page request=%#v, want pageSize=1", caller.calls)
|
||||
}
|
||||
third, ok := spaces[2].(map[string]any)
|
||||
if !ok || third["workspaceId"] != "w3" {
|
||||
t.Fatalf("trimmed spaces=%#v, want w3 as final item", spaces)
|
||||
}
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
response string
|
||||
@@ -659,10 +601,6 @@ func TestCrossPlatformCoverageWikiNodeFailureBranches(t *testing.T) {
|
||||
assertErr("copy id", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true}`}}}, copyArgs...)
|
||||
assertErr("copy readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"fileId":"n"}`}}, errors: map[string][]error{"doc/get_document_info": {backend}}}, copyArgs...)
|
||||
assertErr("copy readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"success":true}`}}}, copyArgs...)
|
||||
copyMismatch := &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"result":{"fileId":"n"}}`}, "doc/get_document_info": {`{"success":true,"result":{"nodeId":"other"}}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, copyMismatch, copyArgs...); err == nil || !strings.Contains(err.Error(), "不一致") {
|
||||
t.Fatalf("copy mismatch error=%v calls=%#v", err, copyMismatch.calls)
|
||||
}
|
||||
|
||||
moveArgs := []string{"+move", "--workspace", "target", "--node", "n", "--yes"}
|
||||
assertErr("move preflight call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, moveArgs...)
|
||||
|
||||
@@ -7,32 +7,16 @@ set -eu
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
|
||||
usage() {
|
||||
printf '%s\n' \
|
||||
"usage: $0 <changed|list> BASE_REF HEAD_REF" \
|
||||
" $0 list-shard SHARD BASE_REF HEAD_REF" >&2
|
||||
printf '%s\n' "usage: $0 <changed|list> BASE_REF HEAD_REF" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# list-shard narrows the impacted set to one test shard so focused CI runs can
|
||||
# fan the same package plan across the shard matrix instead of testing every
|
||||
# impacted package in a single long-lived job.
|
||||
SHARD=""
|
||||
case "${1:-}" in
|
||||
changed|list)
|
||||
[ "$#" -eq 3 ] || usage
|
||||
MODE="$1"
|
||||
BASE_REF="$2"
|
||||
HEAD_REF="$3"
|
||||
;;
|
||||
list-shard)
|
||||
[ "$#" -eq 4 ] || usage
|
||||
MODE="$1"
|
||||
SHARD="$2"
|
||||
BASE_REF="$3"
|
||||
HEAD_REF="$4"
|
||||
;;
|
||||
*) usage ;;
|
||||
esac
|
||||
[ "${1:-}" = changed ] || [ "${1:-}" = list ] || usage
|
||||
[ "$#" -eq 3 ] || usage
|
||||
|
||||
MODE="$1"
|
||||
BASE_REF="$2"
|
||||
HEAD_REF="$3"
|
||||
|
||||
cd "$ROOT"
|
||||
git rev-parse --verify --quiet "${BASE_REF}^{commit}" >/dev/null || {
|
||||
@@ -132,29 +116,9 @@ while IFS= read -r file; do
|
||||
[ "$embedded_owner_found" = true ] || continue
|
||||
done < "$files"
|
||||
|
||||
# emit_selection prints the planned packages, restricted to SHARD when the
|
||||
# caller asked for one. Restriction reuses scripts/ci/test-packages.sh as the
|
||||
# single source of shard membership, which also means an unknown shard name
|
||||
# aborts there instead of being reported as an empty selection: a silently empty
|
||||
# shard would let a mistyped CI shard name skip every test and still succeed.
|
||||
emit_selection() {
|
||||
selection="$1"
|
||||
if [ -z "$SHARD" ]; then
|
||||
cat "$selection"
|
||||
return 0
|
||||
fi
|
||||
shard_packages="$workdir/shard-packages"
|
||||
"$ROOT/scripts/ci/test-packages.sh" list "$SHARD" > "$shard_packages"
|
||||
LC_ALL=C sort -u "$shard_packages" -o "$shard_packages"
|
||||
LC_ALL=C sort -u "$selection" -o "$selection"
|
||||
# An empty intersection is a normal outcome for a shard no change touched, so
|
||||
# comm's silence must not be treated as an error.
|
||||
LC_ALL=C comm -12 "$selection" "$shard_packages"
|
||||
}
|
||||
|
||||
LC_ALL=C sort -u "$changed_packages" -o "$changed_packages"
|
||||
if [ "$MODE" = changed ] || [ ! -s "$changed_packages" ]; then
|
||||
emit_selection "$changed_packages"
|
||||
cat "$changed_packages"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -169,5 +133,4 @@ while IFS= read -r package; do
|
||||
done < "$all_packages" > "$impacted_packages"
|
||||
|
||||
cat "$changed_packages" >> "$impacted_packages"
|
||||
LC_ALL=C sort -u "$impacted_packages" -o "$impacted_packages"
|
||||
emit_selection "$impacted_packages"
|
||||
LC_ALL=C sort -u "$impacted_packages"
|
||||
|
||||
@@ -7,7 +7,6 @@ MODULE="$(cd "$ROOT" && go list -m)"
|
||||
usage() {
|
||||
printf '%s\n' \
|
||||
"usage: $0 list <app|generators|helpers|cli|smoke|remaining|release-scripts>" \
|
||||
" $0 list-coverage <app|cli|generators|helpers|remaining>" \
|
||||
" $0 verify" >&2
|
||||
exit 2
|
||||
}
|
||||
@@ -58,52 +57,6 @@ list_shard() {
|
||||
esac
|
||||
}
|
||||
|
||||
# Full-suite coverage measurement shards the same authoritative package set the
|
||||
# single serial run used: ./ ./cmd/... ./internal/... ./skills/... (pkg/ and
|
||||
# scripts/ belong to the supporting profile; test/ suites carry no production
|
||||
# statements for the candidate profile). Shards stay disjoint so their merged
|
||||
# profile is block-for-block equivalent to one serial invocation.
|
||||
list_coverage_scope() {
|
||||
cd "$ROOT"
|
||||
go list ./ ./cmd/... ./internal/... ./skills/...
|
||||
}
|
||||
|
||||
list_coverage_shard() {
|
||||
shard="$1"
|
||||
cd "$ROOT"
|
||||
|
||||
case "$shard" in
|
||||
app)
|
||||
go list ./internal/app/...
|
||||
;;
|
||||
cli)
|
||||
go list ./internal/cli/...
|
||||
;;
|
||||
generators)
|
||||
go list ./internal/generator/...
|
||||
;;
|
||||
helpers)
|
||||
go list ./internal/helpers/...
|
||||
;;
|
||||
remaining)
|
||||
scope_packages="$(list_coverage_scope)"
|
||||
printf '%s\n' "$scope_packages" | while IFS= read -r package; do
|
||||
case "$package" in
|
||||
"$MODULE/internal/app"|"$MODULE/internal/app/"*) ;;
|
||||
"$MODULE/internal/cli"|"$MODULE/internal/cli/"*) ;;
|
||||
"$MODULE/internal/generator"|"$MODULE/internal/generator/"*) ;;
|
||||
"$MODULE/internal/helpers"|"$MODULE/internal/helpers/"*) ;;
|
||||
*) printf '%s\n' "$package" ;;
|
||||
esac
|
||||
done
|
||||
;;
|
||||
*)
|
||||
printf 'unknown coverage package shard: %s\n' "$shard" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify_plan() {
|
||||
workdir="$(mktemp -d "${TMPDIR:-/tmp}/dws-test-packages.XXXXXX")"
|
||||
trap 'rm -rf "$workdir"' EXIT HUP INT TERM
|
||||
@@ -161,58 +114,6 @@ verify_plan() {
|
||||
|
||||
package_count="$(wc -l < "$expected" | tr -d ' ')"
|
||||
printf 'test package plan covers %s default packages exactly once\n' "$package_count"
|
||||
|
||||
coverage_expected="$workdir/coverage-expected"
|
||||
coverage_assigned="$workdir/coverage-assigned"
|
||||
coverage_unique="$workdir/coverage-unique"
|
||||
coverage_duplicates="$workdir/coverage-duplicates"
|
||||
coverage_missing="$workdir/coverage-missing"
|
||||
coverage_unexpected="$workdir/coverage-unexpected"
|
||||
|
||||
list_coverage_scope > "$workdir/coverage-scope"
|
||||
LC_ALL=C sort -u "$workdir/coverage-scope" > "$coverage_expected"
|
||||
: > "$coverage_assigned"
|
||||
|
||||
for shard in app cli generators helpers remaining; do
|
||||
shard_packages="$workdir/coverage-$shard"
|
||||
unsorted_shard_packages="$workdir/coverage-$shard.unsorted"
|
||||
list_coverage_shard "$shard" > "$unsorted_shard_packages"
|
||||
LC_ALL=C sort "$unsorted_shard_packages" > "$shard_packages"
|
||||
if [ ! -s "$shard_packages" ]; then
|
||||
printf 'coverage package shard is empty: %s\n' "$shard" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "$shard_packages" >> "$coverage_assigned"
|
||||
done
|
||||
|
||||
LC_ALL=C sort "$coverage_assigned" -o "$coverage_assigned"
|
||||
uniq -d "$coverage_assigned" > "$coverage_duplicates"
|
||||
LC_ALL=C sort -u "$coverage_assigned" > "$coverage_unique"
|
||||
comm -23 "$coverage_expected" "$coverage_unique" > "$coverage_missing"
|
||||
comm -13 "$coverage_expected" "$coverage_unique" > "$coverage_unexpected"
|
||||
|
||||
coverage_failed=0
|
||||
if [ -s "$coverage_duplicates" ]; then
|
||||
printf '%s\n' 'coverage packages assigned to more than one shard:' >&2
|
||||
sed 's/^/ /' "$coverage_duplicates" >&2
|
||||
coverage_failed=1
|
||||
fi
|
||||
if [ -s "$coverage_missing" ]; then
|
||||
printf '%s\n' 'full-suite coverage packages missing from the CI coverage plan:' >&2
|
||||
sed 's/^/ /' "$coverage_missing" >&2
|
||||
coverage_failed=1
|
||||
fi
|
||||
if [ -s "$coverage_unexpected" ]; then
|
||||
printf '%s\n' 'CI coverage plan contains packages outside its scope:' >&2
|
||||
sed 's/^/ /' "$coverage_unexpected" >&2
|
||||
coverage_failed=1
|
||||
fi
|
||||
if [ "$coverage_failed" -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
coverage_count="$(wc -l < "$coverage_expected" | tr -d ' ')"
|
||||
printf 'coverage package plan covers %s full-suite packages exactly once\n' "$coverage_count"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
@@ -220,10 +121,6 @@ case "${1:-}" in
|
||||
[ "$#" -eq 2 ] || usage
|
||||
list_shard "$2"
|
||||
;;
|
||||
list-coverage)
|
||||
[ "$#" -eq 2 ] || usage
|
||||
list_coverage_shard "$2"
|
||||
;;
|
||||
verify)
|
||||
[ "$#" -eq 1 ] || usage
|
||||
verify_plan
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run interactive, real-data Wiki Shortcut verification on a disposable space.
|
||||
"""Run destructive, real-data Wiki Shortcut verification on a disposable space.
|
||||
|
||||
Requires an authenticated `dws` session. Set DWS_WIKI_E2E_MEMBER_ID to a real
|
||||
internal user ID that may be granted temporary access to the empty fixture.
|
||||
The script prints capability labels only; business IDs, names, URLs, and raw
|
||||
responses remain in memory. Commands that require confirmation use the normal
|
||||
`dws` terminal prompt, including the disposable-space cleanup in `finally`.
|
||||
responses remain in memory. The disposable Wiki space is deleted in `finally`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,15 +26,11 @@ class E2EFailure(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def invoke(args: list[str], *, require_confirmation: bool = False) -> dict:
|
||||
def invoke(args: list[str]) -> dict:
|
||||
process = subprocess.run(
|
||||
[str(DWS), "wiki", *args, "--format", "json"],
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.PIPE,
|
||||
# Confirmation prompts are written to stderr. Keep that stream on the
|
||||
# terminal for guarded operations so dws itself obtains the user's
|
||||
# answer; ordinary calls stay quiet and redact raw backend errors.
|
||||
stderr=None if require_confirmation else subprocess.PIPE,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
@@ -67,10 +62,6 @@ def member_role(data: dict, user_id: str) -> str:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not sys.stdin.isatty() or not sys.stderr.isatty():
|
||||
raise E2EFailure(
|
||||
"run in an interactive terminal; guarded operations require an explicit dws confirmation"
|
||||
)
|
||||
if not DWS.exists():
|
||||
raise E2EFailure("build ./dws first with make build")
|
||||
if not MEMBER_ID:
|
||||
@@ -137,25 +128,16 @@ def main() -> int:
|
||||
time.sleep(2)
|
||||
check("node-search", search_ok)
|
||||
|
||||
copied = invoke(
|
||||
["+node-copy", "--workspace", workspace, "--folder", folder_id, "--node", node_id],
|
||||
require_confirmation=True,
|
||||
)
|
||||
copied = invoke(["+node-copy", "--workspace", workspace, "--folder", folder_id, "--node", node_id, "--yes"])
|
||||
copy_id = str(copied.get("nodeId") or "")
|
||||
disposable_nodes.append(copy_id)
|
||||
check("node-copy-readback", bool(copy_id) and bool(copied.get("copy")))
|
||||
|
||||
moved = invoke(
|
||||
["+move", "--workspace", workspace, "--folder", folder_id, "--node", node_id],
|
||||
require_confirmation=True,
|
||||
)
|
||||
moved = invoke(["+move", "--workspace", workspace, "--folder", folder_id, "--node", node_id, "--yes"])
|
||||
check("move-readback", moved.get("node", {}).get("workspaceId") == workspace and moved.get("node", {}).get("folderId") == folder_id)
|
||||
moved_out = invoke(["+move-to-drive", "--node", node_id], require_confirmation=True)
|
||||
moved_out = invoke(["+move-to-drive", "--node", node_id, "--yes"])
|
||||
check("move-to-drive-readback", moved_out.get("node", {}).get("workspaceId") != workspace)
|
||||
moved_back = invoke(
|
||||
["+node-move", "--workspace", workspace, "--folder", folder_id, "--node", node_id],
|
||||
require_confirmation=True,
|
||||
)
|
||||
moved_back = invoke(["+node-move", "--workspace", workspace, "--folder", folder_id, "--node", node_id, "--yes"])
|
||||
check("node-move-alias-readback", moved_back.get("node", {}).get("workspaceId") == workspace)
|
||||
|
||||
named = invoke(["+wiki-new-doc", "--space", space_name, "--title", "E2E Name Resolved"])
|
||||
@@ -185,10 +167,7 @@ def main() -> int:
|
||||
|
||||
# Exercise high-risk delete before final whole-space cleanup.
|
||||
delete_target = disposable_nodes.pop()
|
||||
deleted = invoke(
|
||||
["+node-delete", "--workspace", workspace, "--node", delete_target],
|
||||
require_confirmation=True,
|
||||
)
|
||||
deleted = invoke(["+node-delete", "--workspace", workspace, "--node", delete_target, "--yes"])
|
||||
check("node-delete-terminal", deleted.get("success") is True and deleted.get("deleted") is True)
|
||||
return 0
|
||||
finally:
|
||||
@@ -199,10 +178,7 @@ def main() -> int:
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
deleted = invoke(
|
||||
["+space-delete", "--workspace", workspace],
|
||||
require_confirmation=True,
|
||||
)
|
||||
deleted = invoke(["+space-delete", "--workspace", workspace, "--yes"])
|
||||
check("space-delete-alias-cleanup", deleted.get("success") is True and deleted.get("deleted") is True)
|
||||
except Exception as exc:
|
||||
print(f"CLEANUP FAILED: {exc}", file=sys.stderr)
|
||||
|
||||
@@ -12,7 +12,7 @@ event_skill="skills/multi/dingtalk-event/SKILL.md"
|
||||
mono_skill="skills/mono/SKILL.md"
|
||||
runtime_contract="skills/multi/dingtalk-shared/references/runtime-contract.md"
|
||||
chat_target_bytes=10000
|
||||
chat_max_overage_percent=10
|
||||
chat_max_overage_percent=5
|
||||
chat_max_bytes=$((chat_target_bytes * (100 + chat_max_overage_percent) / 100))
|
||||
doc_max_bytes=10000
|
||||
event_max_bytes=10000
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -97,7 +97,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -130,7 +130,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -163,7 +163,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -196,7 +196,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -229,7 +229,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -262,7 +262,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -295,7 +295,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -328,7 +328,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -361,7 +361,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -394,7 +394,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -427,7 +427,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -460,7 +460,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -493,7 +493,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -526,7 +526,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -559,7 +559,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -592,7 +592,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -625,7 +625,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -658,7 +658,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -691,7 +691,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -724,7 +724,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -757,7 +757,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -790,7 +790,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -823,7 +823,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -856,7 +856,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -892,7 +892,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -925,7 +925,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -991,7 +991,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -1024,7 +1024,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -1057,7 +1057,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -1090,7 +1090,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
@@ -1123,7 +1123,7 @@
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"state": "consumed",
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md"
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -36,7 +37,8 @@
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md"
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -51,7 +53,7 @@
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/message-media.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-bot.md"
|
||||
]
|
||||
},
|
||||
@@ -70,7 +72,7 @@
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/message-query.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-conversation.md"
|
||||
]
|
||||
},
|
||||
@@ -89,7 +91,7 @@
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/message-query.md"
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -101,7 +103,7 @@
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/group-admin.md"
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-group.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -113,7 +115,7 @@
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/message-actions.md"
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -67,12 +67,6 @@ Flags:
|
||||
- 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。
|
||||
- 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完);
|
||||
建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。
|
||||
- 与 `--latest` 组合时上一条不适用:排序基不完整的 Top-N 不是全局最新,故触顶截断**或**递归途中
|
||||
目录读取失败都拒绝产出并报错(`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`),不会以
|
||||
退出码 0 交出结果;错误消息里带首个失败目录的 folder/depth/reason,以及一条复现原候选集
|
||||
(查询域 + `--folder` + `--pattern`/`--type`/`--start`/`--end`)的恢复命令。Windows 构建下,
|
||||
若原值含 shell 元字符则命令里只给占位符、原值另起一行以数据形式列出(cmd.exe 与 PowerShell
|
||||
没有共同安全的引用形式),照抄时需手动替换。
|
||||
|
||||
### 获取钉盘空间列表
|
||||
|
||||
|
||||
@@ -72,8 +72,6 @@ metadata:
|
||||
|
||||
- `+dm`:姓名目标的简单文本/Markdown,参数空间最小。
|
||||
- `+send-to-group`:群名或稳定 ID 目标的简单文本/Markdown,避免暴露无关身份矩阵。
|
||||
- Markdown 中的公网图片必须写成 `` 才会内联展示;
|
||||
省略开头的 `!` 时只会显示为链接。
|
||||
- `+messages-send`:文件、Bot、Webhook、复杂 @ 或幂等控制。user 已知 ID 可直接传,也可用 `--user-query` / `--chat-query` 运行同一只读解析链;Bot 多群使用 `--groups/--groups-file`,返回 `im.batch-write.v1`;bot/webhook 只使用下层真实支持的文本/Markdown 能力。
|
||||
- 文件直接传 `+messages-send --file <相对路径>`;不要先独立上传并提取 mediaId。
|
||||
- Webhook 使用 `+messages-send --as webhook --webhook-token <token>`;不要退回原子 Webhook 命令。
|
||||
@@ -94,13 +92,10 @@ metadata:
|
||||
|
||||
| 场景 | Reference |
|
||||
|---|---|
|
||||
| 需要跨步骤传递真实结果的消息/群组合流程 | [01-messaging.md](references/01-messaging.md) |
|
||||
| 消息读取与查询 | [message-query](references/chat/message-query.md) |
|
||||
| 编辑、撤回、回复、转发、Pin、Top、Favorite 或 reaction 写入 | [message-actions](references/chat/message-actions.md) |
|
||||
| 位置、联系人名片、底层媒体与资源下载 | [message-media](references/chat/message-media.md) |
|
||||
| 群列表、群搜索、共同群、成员与群内机器人读取 | [group-discovery](references/chat/group-discovery.md) |
|
||||
| 建群、成员或已知机器人增删、管理员、公告与群设置 | [group-admin](references/chat/group-admin.md) |
|
||||
| 搜索未知机器人、机器人消息发送/撤回与 Webhook | [chat-bot.md](references/chat/chat-bot.md) |
|
||||
| 复杂发送、跨会话转发、共同群或组合流程 | [01-messaging.md](references/01-messaging.md) |
|
||||
| 编辑/撤回/引用/转发/卡片/reaction/Pin/Top/Favorite | [chat-message.md](references/chat/chat-message.md) |
|
||||
| 建群、成员、管理员、群公告、群设置 | [chat-group.md](references/chat/chat-group.md) |
|
||||
| Bot 搜索、入群、群发和撤回 | [chat-bot.md](references/chat/chat-bot.md) |
|
||||
| 会话置顶、分类、红点、免打扰和隐藏 | [chat-conversation.md](references/chat/chat-conversation.md) |
|
||||
| 低频意图之间仍需消歧 | [intent-guide.md](references/intent-guide.md) |
|
||||
| 表情名称与 ID | [chat-emoji-list.md](references/chat-emoji-list.md) |
|
||||
|
||||
@@ -13,11 +13,9 @@ Runtime 会唯一解析为 openDingTalkId;已有 openDingTalkId 时传
|
||||
或艾特占位符。
|
||||
- `--dry-run` 仍执行只读 userId 解析,只输出两步计划,不执行写入。
|
||||
|
||||
创建成功后保留真实 `bizId`。自动更新返回 `verified=true` 时已有明确生效证据;返回
|
||||
`accepted=true, verified=false` 时仅表示服务端已接受请求但未提供独立更新证据,应如实说明,
|
||||
不要重复创建或重复执行相同更新。只有错误明确标记 `retryable=true` 时,才使用原 `bizId`
|
||||
重试;明确未应用或 `bizId` 不一致时停止并保留真实错误。若结果中已经包含 `openTaskId`,
|
||||
可以按用户需要查询一次投递状态;该查询只确认消息投递,不代表卡片正文已经更新成功。
|
||||
自动更新结果不确定时,不要再次更新或重复创建;保留返回结果并告知用户。若结果中已经
|
||||
包含 `openTaskId`,可以按用户需要查询一次投递状态;该查询只确认消息投递,不代表卡片
|
||||
正文已经更新成功。
|
||||
|
||||
当前内容仅为 streaming text,不接受 Lark Card JSON、组件树或按钮 callback。
|
||||
|
||||
|
||||
@@ -7,7 +7,3 @@
|
||||
|
||||
更新是写操作,confirmation 以精确 leaf Schema 与 Runtime gate 为准。失败后保留原
|
||||
`bizId` 和状态,不创建新卡片来掩盖更新失败。
|
||||
|
||||
结果中 `verified=true` 表示已有明确更新证据;`accepted=true, verified=false` 仅表示服务端
|
||||
接受了请求但未提供独立生效证据,应如实说明且不得重复执行相同更新。只有错误明确标记
|
||||
`retryable=true` 时才重试;明确未应用或 `bizId` 不一致时停止。
|
||||
|
||||
@@ -19,12 +19,9 @@
|
||||
| 用户终点 | 返回入口 |
|
||||
|---|---|
|
||||
| 姓名/群名简单发送、文件、Bot、Webhook、复杂 @ | 根 Skill Golden Route |
|
||||
| 消息读取、条件搜索、@我、Favorite/reaction 查询和批量详情 | [message-query](chat/message-query.md) |
|
||||
| 编辑、撤回、引用、转发、reaction/Pin/Top/Favorite 写入 | [message-actions](chat/message-actions.md) |
|
||||
| 位置、名片、资源下载和特殊媒体 fallback | [message-media](chat/message-media.md) |
|
||||
| 群列表、群搜索、成员读取、Bot 列表和邀请链接 | [group-discovery](chat/group-discovery.md) |
|
||||
| 建群、改群、成员写入、管理员、禁言、公告和群设置 | [group-admin](chat/group-admin.md) |
|
||||
| 跨步骤消息/群组合流程 | [消息任务级流程](01-messaging.md) |
|
||||
| 单会话消息、跨会话搜索、资源下载 | [消息任务级流程](01-messaging.md) |
|
||||
| 引用、转发、卡片、reaction、Pin/Top/Favorite | [chat-message](chat/chat-message.md) |
|
||||
| 基础建群、成员、公告、管理员和群设置 | [chat-group](chat/chat-group.md) |
|
||||
| Bot 搜索、进群和撤回 | [chat-bot](chat/chat-bot.md) |
|
||||
| 会话置顶、状态和分组 | [chat-conversation](chat/chat-conversation.md) |
|
||||
| 相邻低频意图仍需消歧 | [intent-guide](intent-guide.md) |
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# chat-group:群聊、成员、设置与群身份
|
||||
|
||||
> 返回入口:[chat.md](../chat.md)
|
||||
|
||||
## 适用场景
|
||||
|
||||
用于群搜索、建群、成员增删、机器人进群、群设置、群主转让、群邀请分享、群公告、入群审批、群身份和群禁言。
|
||||
|
||||
<!-- dws-intent: chat.create.group -->基础建群默认使用 `dws chat +chat-create`:已知成员 ID 传
|
||||
`--users`,姓名/花名传 `--member-query`;群主默认当前用户,也可传
|
||||
`--owner-open-dingtalk-id` 或 `--owner-query`。全部自然身份唯一解析并去重后才执行一次创建。
|
||||
|
||||
## 必读约束
|
||||
|
||||
- 群聊目标统一使用 `openConversationId`。只有数字群号时,先用 `chat group get-by-group-id` 转换。
|
||||
- 群搜索唯一推荐 `dws chat +chat-search --query <群名>`;`chat search`、`chat group search`、
|
||||
`+chat-group-search` 和 `+search-group` 仅是兼容拼法,不应被写成并列默认路线。
|
||||
- 群成员操作中,`--users` 常为逗号分隔列表;具体要求以命令 `--help` 为准。
|
||||
- 解散群、踢人、转让群主、禁言、管理员设置都是高影响操作,执行前必须确认目标群和用户。
|
||||
- 发布或修改群公告会触达群成员;公告正文是 Markdown,定时公告 `--run-at` 建议带时区。
|
||||
|
||||
## 命令明细
|
||||
|
||||
### 搜索与基础信息
|
||||
|
||||
| 命令 | 用途 | 示例与要点 |
|
||||
|------|------|------------|
|
||||
| `+chat-search` | 按关键词搜索群聊 | 默认一页;要求全部候选时用 `dws chat +chat-search --query "项目冲刺" --page-all`;可用 `--page-size/--page-token` 或兼容的 `--limit/--cursor`,并检查完整性 ledger;多候选必须消歧 |
|
||||
| `chat search-common` | 搜索共同群 | `dws chat search-common --nicks "风雷,山乔" --match-mode AND --limit 20 --cursor 0` |
|
||||
| `chat group get-by-group-id` | 数字群号转 openConversationId | `dws chat group get-by-group-id --group-id 12345678` |
|
||||
| `+chat-bots` | 查看群内所有机器人 | `dws chat +chat-bots --group <群名或openConversationId>`;内部唯一解析自然群名 |
|
||||
|
||||
`search-common` 中 `--match-mode AND` 表示所有人都在群里,`OR` 表示任一人在群里。
|
||||
|
||||
### 群创建与基础操作
|
||||
|
||||
#### `dws chat group create`(底层 fallback)
|
||||
|
||||
只有需要 `+chat-create` 尚未发布的底层字段时才评估原子创建命令,并先读取精确 leaf
|
||||
Schema。`+chat-create` 已支持 `--thread` 和显式群主;普通内部/外部群创建不得回流到手工
|
||||
`aisearch → group create` 链路。
|
||||
|
||||
```bash
|
||||
dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3
|
||||
dws chat group create --name "外部合作群" --users userId1,userId2 --type EXTERNAL
|
||||
dws chat group create --name "话题圈" --users userId1,userId2 --thread
|
||||
```
|
||||
|
||||
关键 flags:
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--name` | 群名称,必填 |
|
||||
| `--users` | 成员 userId 或 openDingTalkId,逗号分隔,必填 |
|
||||
| `--type` | `INTERNAL` / `EXTERNAL` / `NORMAL`,默认 `INTERNAL` |
|
||||
| `--thread` | 开启话题模式,创建话题圈 |
|
||||
|
||||
创建成功后提取 `openConversationId`,用于发消息、成员管理、群设置。
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group rename` | 更新群名称 | `--id` `--name`;只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID,不猜 `+chat-rename` |
|
||||
| `group quit` | 当前用户退出群聊 | `--group` |
|
||||
| `group dismiss` | 解散群聊,不可逆 | `--group` |
|
||||
|
||||
### 成员与机器人
|
||||
|
||||
| 命令 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `+chat-members-list` | 全量查看群成员并分桶用户/机器人 | `--group <群名或openConversationId>`;显式 ID 也可用 `--conversation-id`,检查 buckets/complete/failures |
|
||||
| `group members add` | 添加群成员 | `dws chat group members add --id <openConversationId> --users userId1,userId2` |
|
||||
| `group members remove` | 移除群成员 | `dws chat group members remove --id <openConversationId> --users userId1,userId2` |
|
||||
| `group members list-by-ids` | 按 openDingTalkId 批量查成员详情 | `dws chat group members list-by-ids --id <openConversationId> --users openDingTalkId1,openDingTalkId2` |
|
||||
| `group members add-bot` | 将自定义机器人加入群 | `dws chat group members add-bot --id <openConversationId> --robot-code <robot-code>` |
|
||||
| `group members remove-bot` | 从群内移除机器人 | `dws chat group members remove-bot --id <openConversationId> --bot-id <openBotId>` |
|
||||
|
||||
机器人发群消息如果报“机器人不存在”,先 `group members add-bot` 再重发。
|
||||
|
||||
### 群设置与权限
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group transfer-owner` | 转让群主 | `--group` + `--user`(userId) 或 `--new-owner`(openDingTalkId) |
|
||||
| `group upgrade-to-external` | 将普通群升级为外部群(不可逆,需先确认) | `--group` `--yes`;可选 `--extension`(拓展字段) |
|
||||
| `+chat-invite-url` | 获取群邀请链接 | `--group <群名或openConversationId>`;可选 `--expires-seconds` |
|
||||
| `group share-invite` | 将指定群的邀请链接分享到另一个会话或单聊用户 | `--source` + `--target` / `--receiver` 二选一 |
|
||||
| `group update-icon` | 更新群头像 | `--group` `--icon-media-id` |
|
||||
| `group update-settings` | 更新管理员级别的群功能开关 | `--group` `--setting-key` `--status` |
|
||||
| `group user-settings query` | 批量查询当前用户自己的群会话设置 | `--groups` |
|
||||
| `group user-settings set` | 批量更新当前用户自己的群会话设置 | `--items` |
|
||||
| `group update-nick` | 设置或清除当前用户群昵称 | `--group`,可选 `--nick`;不传则清除群昵称 |
|
||||
| `group update-alias` | 设置当前用户群备注 | `--group` `--alias-title` |
|
||||
| `group set-history` | 设置新成员可查看历史消息范围 | `--group` `--option` |
|
||||
| `group get-mute-config` | 查询群用户禁言配置 | `--group` |
|
||||
| `group-mute` | 全员禁言/解除全员禁言 | `--group`,默认禁言,`--off` 解除 |
|
||||
| `group-mute-member` | 指定成员禁言/解除禁言 | `--group` `--user`/`--users`;禁言需 `--mute-time` |
|
||||
| `group set-admin` | 设置/取消管理员 | `--group` `--user`/`--users`;`--off` 取消 |
|
||||
|
||||
`update-settings` 是管理员级别的群功能开关操作,常用 settingKey:`authority`、`joinValidation`、`onlyAdminCanAtAll`、`searchable`、`addFriendForbidden`、`onlyAdminCanDING`、`onlyAdminCanPinMsg`、`onlyAdminCanSendFile`、`groupEmailDisabled`、`groupLiveAuthority`、`groupBillAuthority`。
|
||||
|
||||
`group user-settings` 是当前登录用户自己的群会话设置批量入口(置顶、免打扰、群昵称、群备注等),不是管理员级别的群功能开关;单个群昵称/群备注仍优先使用 `group update-nick` / `group update-alias`,管理员级别的群功能开关继续使用 `group update-settings`。
|
||||
|
||||
`group user-settings set --items` 传 JSON 数组,每个元素表示一个群会话的当前用户设置。字段含义:
|
||||
|
||||
| 字段 | 含义 | 值说明 |
|
||||
|------|------|--------|
|
||||
| `openConversationId` | 群会话 ID | 必填,来自 `chat search` / `group list-all` 等真实返回 |
|
||||
| `top` | 当前用户是否置顶该群会话 | `true`=置顶,`false`=取消置顶 |
|
||||
| `mute` | 当前用户是否开启该群会话免打扰 | `true`=开启免打扰,`false`=关闭免打扰 |
|
||||
| `groupNick` | 当前用户在该群里的群昵称 | 字符串;空字符串表示清空昵称 |
|
||||
| `groupAlias` | 当前用户给该群设置的备注 | 字符串;空字符串表示清空备注 |
|
||||
|
||||
只传本次要改的字段;不要补用户没要求修改的字段。批量设置多个群时,`items` 放多个对象。
|
||||
|
||||
`group-mute-member --mute-time` 单位毫秒,常用值:`300000`、`3600000`、`86400000`、`604800000`、`2592000000`。
|
||||
|
||||
`group share-invite` 的 `--source` 是被分享群的 `openConversationId`;`--target` 是接收分享消息的会话,`--receiver` 是接收分享消息的单聊用户 `openDingTalkId`,二者必须二选一。
|
||||
|
||||
```bash
|
||||
dws chat group share-invite --source <sourceOpenConversationId> --target <targetOpenConversationId>
|
||||
dws chat group share-invite --source <sourceOpenConversationId> --receiver <receiverOpenDingTalkId>
|
||||
dws chat group share-invite --source <sourceOpenConversationId> --target <targetOpenConversationId> --expires-seconds 86400 --uuid <uuid>
|
||||
dws chat group user-settings query --groups <openConversationId1>,<openConversationId2>
|
||||
dws chat group user-settings set --items '[{"openConversationId":"cid1","top":true,"mute":false}]'
|
||||
```
|
||||
|
||||
### 群公告
|
||||
|
||||
群公告正文使用 Markdown。支持标题、加粗、斜体、删除线、行内代码、链接、代码块、列表、表格、引用、分割线、图片、段落和换行;下划线、字体色、背景色、字号属于编辑器专属能力,无法通过 Markdown 表达。
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group notice create` | 发布群公告,支持置顶、DING 和定时发布 | `--group` `--content` |
|
||||
| `group notice edit` | 整体替换指定群公告内容 | `--group` `--notice-id` `--content` |
|
||||
| `group notice get` | 查询单条群公告详情 | `--group` `--notice-id` |
|
||||
| `group notice list` | 分页拉取群公告列表 | `--group` |
|
||||
|
||||
```bash
|
||||
dws chat group notice create --group <openConversationId> --content "今晚 22 点系统维护,请提前保存工作内容"
|
||||
dws chat group notice create --group <openConversationId> --content "# 重要通知\n\n请大家查收" --sticky --send-ding
|
||||
dws chat group notice create --group <openConversationId> --content "明早九点例会" --run-at "2026-07-03T09:00:00+08:00"
|
||||
dws chat group notice list --group <openConversationId> --limit 20 --cursor <nextPageCursor>
|
||||
dws chat group notice get --group <openConversationId> --notice-id <dataId>
|
||||
dws chat group notice edit --group <openConversationId> --notice-id <dataId> --content "更新后的公告内容"
|
||||
```
|
||||
|
||||
注意事项:
|
||||
|
||||
- `notice edit` 会整体替换原公告正文,必须传完整的新内容。
|
||||
- `notice list --scheduled` 查询尚未到发布时间的定时公告;默认查询已发布公告。
|
||||
- `hasMore=true` 时,用返回的 `nextPageCursor` 继续翻页。
|
||||
- `notice get` 返回正文摘要、置顶状态、发布者、已读人数/应收人数、点赞/评论数、是否可编辑、是否已读、是否定时公告等信息。
|
||||
|
||||
### 群列表与入群审批
|
||||
|
||||
| 命令 | 用途 | 示例与要点 |
|
||||
|------|------|------------|
|
||||
| `group list-my-groups` | 拉取我创建/管理的群 | 可选 `--role OWNER/ADMIN`、`--limit`、`--exclude-muted` |
|
||||
| `group list-all` | 分页拉取我加入的所有群 | `--limit` 默认 100,最大 200;翻页用 `nextCursor` |
|
||||
| `group list-join-validations` | 拉取入群验证记录 | 包括自己被拒绝的记录以及作为审批者的记录 |
|
||||
| `group audit-join-validation` | 审批入群验证 | `--group` `--record-id` `--applicant` `--inviter` `--status` |
|
||||
|
||||
审批动作 `--status`:`AuditApprove`、`AuditDelete`、`AuditIgnore`、`AuditRefuse`、`AuditBlock`。
|
||||
|
||||
```bash
|
||||
dws chat group list-join-validations --limit 20
|
||||
dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <openDingTalkId> --inviter <openDingTalkId> --status AuditApprove
|
||||
```
|
||||
|
||||
### 群身份
|
||||
|
||||
`chat group-role` 管理群内自定义身份标签。
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group-role list` | 查看群身份列表 | `--group` |
|
||||
| `group-role add` | 新增群身份 | `--group` `--name` |
|
||||
| `group-role update` | 修改群身份名称 | `--group` `--role-id` `--name` |
|
||||
| `group-role remove` | 删除群身份 | `--group` `--role-id` |
|
||||
| `group-role set-user` | 覆盖用户全部群身份,空 `--role-ids` 表示清除 | `--group` `--user` `--role-ids` |
|
||||
| `group-role remove-user` | 移除用户指定群身份 | `--group` `--user` `--role-ids` |
|
||||
| `group-role query-user` | 查询用户当前群身份 | `--group` `--user` |
|
||||
|
||||
`openRoleId` 来自 `group-role list` 返回。
|
||||
|
||||
## 常见工作流
|
||||
|
||||
### 搜索群并发消息
|
||||
|
||||
```bash
|
||||
dws chat +send-to-group --group "项目冲刺" --text "请大家看一下最新进展" --format json
|
||||
```
|
||||
|
||||
### 建群并拉人
|
||||
|
||||
```bash
|
||||
dws chat +chat-create --name "Q1 项目冲刺群" --member-query "张三,李四" --format json
|
||||
dws chat +chat-create --name "合作群" --member-query "张三,李四" --owner-query "王五" --format json
|
||||
dws chat group members add --id <openConversationId> --users userId3,userId4 --format json
|
||||
```
|
||||
|
||||
### 分享群邀请并发布公告
|
||||
|
||||
```bash
|
||||
dws chat group share-invite --source <sourceOpenConversationId> --target <targetOpenConversationId> --format json
|
||||
dws chat group notice create --group <openConversationId> --content "# 项目公告\n\n请大家关注最新安排" --send-ding --format json
|
||||
```
|
||||
|
||||
### 设置管理员并禁言成员
|
||||
|
||||
```bash
|
||||
dws chat group set-admin --group <openConversationId> --users userId1,userId2 --format json
|
||||
dws chat group-mute-member --group <openConversationId> --users userId3 --mute-time 3600000 --format json
|
||||
```
|
||||
|
||||
## 常见错误与回退
|
||||
|
||||
- 只有数字群号:先 `group get-by-group-id`,不要直接当 `--group`。
|
||||
- 找不到群:使用 `+chat-search --query` 扩大关键词;零命中或多候选时停止,不臆测 openConversationId。
|
||||
- 入群审批缺参数:从 `group list-join-validations` 提取 `record-id`、`applicant`、`inviter`。
|
||||
- 机器人进群失败:确认当前用户有群管理权限。
|
||||
- 分享群邀请目标不明确:`--target` 和 `--receiver` 只能二选一,先确认是发到群/会话还是发给个人。
|
||||
- 修改公告前没有完整新正文:先向用户确认完整公告内容,不要只传增量片段。
|
||||
@@ -0,0 +1,410 @@
|
||||
# chat-message:消息、文件、搜索与卡片
|
||||
|
||||
> 返回入口:[chat.md](../chat.md)
|
||||
|
||||
## 适用场景
|
||||
|
||||
用于当前用户发消息、拉消息、搜索消息、撤回、已读状态、文件/图片/音频/视频/位置/名片发送、话题回复、转发、Pin/Top、表情回应、文本翻译和流式卡片。
|
||||
|
||||
## 默认入口
|
||||
|
||||
| 用户终点 | 默认入口 |
|
||||
|---|---|
|
||||
| <!-- dws-intent: chat.send.dm -->按姓名发送简单文本/Markdown | `dws chat +dm` |
|
||||
| <!-- dws-intent: chat.send.group -->按群名发送简单文本/Markdown | `dws chat +send-to-group` |
|
||||
| <!-- dws-intent: chat.send.advanced -->文件、Bot、Webhook、复杂 @、已知 ID 或幂等发送 | `dws chat +messages-send` |
|
||||
| <!-- dws-intent: chat.read.conversation -->读取或导出指定群聊/单聊,可附带发送者姓名 | `dws chat +chat-messages`;姓名用非必填 `--sender-query` |
|
||||
| <!-- dws-intent: chat.search.filtered -->直接按发送者、关键词、@对象或消息类型搜索,可限定单个或跨多个会话 | `dws chat +search-msg` |
|
||||
| <!-- dws-intent: chat.reply.quote -->引用回复已有消息 | `dws chat +messages-reply` |
|
||||
| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名> --page-all` |
|
||||
| 撤回当前用户消息 | `dws chat +messages-recall --msg-id <openMessageId>` |
|
||||
|
||||
以下原子命令只用于 Shortcut 未暴露的底层字段、原始响应或精确分页控制。不得把它们重新写成
|
||||
上述高频任务的默认路径;写入原子命令若与 Golden Shortcut 的 confirmation 不一致,停止并
|
||||
报告交付漂移,不以文档确认代替 Runtime gate。
|
||||
|
||||
## 必读约束
|
||||
|
||||
- 发消息前必须核对接收对象、消息内容、@ 对象、附件路径和消息类型;不明确时先问用户。
|
||||
- `--group`、`--user`、`--open-dingtalk-id` 通常互斥,群聊用 `--group`,单聊用 `--user` 或 `--open-dingtalk-id`。
|
||||
- 发送本地文件、音频、视频默认用 `dws chat +messages-send --as user --file <相对路径>`;
|
||||
`audio` / `video` 的具体类型以 leaf Schema 为准。只有 Shortcut 未暴露的位置、名片等底层
|
||||
类型才进入本文件的原子 fallback。
|
||||
- 发送位置消息前必须确认纬度、经度、地址名称;地图缩略图需先通过旧媒体上传链路拿到 mediaId。
|
||||
- 分享联系人名片前必须确认联系人 `openDingTalkId`,不要把 userId 直接当 `--contact-id`。
|
||||
- 消息内容按 Markdown 渲染,换行必须是真实换行符;需要换行效果时用空行、行尾两个空格或 `<br>`。
|
||||
- 图文混排 Markdown 中,公网图片 URL 需要写成 `` 才会以内联图片展示;省略开头的 `!` 时会按链接/URL 展示,不会渲染为图片。
|
||||
- 建议发送时带 `--uuid`,失败重试复用同一个值。
|
||||
- Bot/Webhook 只支持文本/Markdown;Bot 多群使用 `+messages-send --groups/--groups-file` 的逐项
|
||||
ledger。不要把 user 文件/图片能力外推到 Bot。
|
||||
- `+at-me` 和 `+messages-list-direct` 要求全量时使用 `--page-all`,并检查 `complete`、
|
||||
`hasMore`、`stopReason` 和 `failures`;`+messages-list-direct` 的续页时间来自下层毫秒
|
||||
`nextCursor`,不得用只有秒精度的消息展示时间手工拼接。
|
||||
|
||||
## 原子 fallback 命令明细
|
||||
|
||||
### 发送消息的底层 fallback
|
||||
|
||||
#### `dws chat message send`(非默认入口)
|
||||
|
||||
以当前用户身份发送群聊或单聊消息。
|
||||
|
||||
```bash
|
||||
# 文本/Markdown
|
||||
dws chat message send --group <openConversationId> --text "hello"
|
||||
dws chat message send --user <userId> --text "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --text "请查收"
|
||||
dws chat message send --group <openConversationId> --title "周报提醒" --text "请大家本周五前提交周报" --uuid <uuid>
|
||||
dws chat message send --group <openConversationId> --text $'这是图文说明\n\n'
|
||||
|
||||
# @ 群成员
|
||||
dws chat message send --group <openConversationId> --at-all "<@all> 请大家注意"
|
||||
dws chat message send --group <openConversationId> --at-open-dingtalk-ids odt1,odt2 "<@odt1> <@odt2> 请查收"
|
||||
|
||||
# 图片/文件/音频/视频,一条命令直发
|
||||
dws chat message send --group <openConversationId> --msg-type file --file-path ./screenshot.png
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./report.pdf
|
||||
dws chat message send --group <openConversationId> --msg-type audio --file-path ./voice.mp3
|
||||
dws chat message send --group <openConversationId> --msg-type video --file-path ./demo.mp4
|
||||
|
||||
# 位置/联系人名片
|
||||
dws chat message send --group <openConversationId> --msg-type location --latitude <纬度> --longitude <经度> --location-name <地址名称> --map-thumbnail-url "@mediaId"
|
||||
dws chat message send --group <openConversationId> --msg-type profile --contact-id <openDingTalkId>
|
||||
```
|
||||
|
||||
关键 flags:
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--group` | 群聊 openConversationId;别名 `--id` / `--chat` / `--conversation-id` |
|
||||
| `--user` | 单聊接收人 userId |
|
||||
| `--open-dingtalk-id` | 单聊接收人 openDingTalkId |
|
||||
| `--text` | 消息内容,推荐使用;也支持位置参数 |
|
||||
| `--title` | 消息标题,未传时使用安全标题 |
|
||||
| `--at-all` | 群聊 @所有人,正文需含 `<@all>` |
|
||||
| `--at-open-dingtalk-ids` | 群聊 @指定 openDingTalkId,正文需含 `<@id>` |
|
||||
| `--msg-type` | `file` / `audio` / `video` / `image` / `location` / `profile`;本地音视频用 `audio` / `video`,底层按 `file` 发送 |
|
||||
| `--file-path` | 本地文件路径,`msg-type=file/audio/video` 时自动上传并发送 |
|
||||
| `--media-id` | 旧图片链路 mediaId |
|
||||
| `--latitude` / `--longitude` / `--location-name` | 位置消息参数 |
|
||||
| `--map-thumbnail-url` | 位置消息缩略图 mediaId,形如 `@mediaId` |
|
||||
| `--contact-id` | 联系人名片 openDingTalkId |
|
||||
| `--uuid` | 幂等 UUID,24h 内相同值不重复投递 |
|
||||
|
||||
当前没有经过验证的 Thread writer。`openConvThreadId` 只用于 `+thread-replies` 读取或
|
||||
`+messages-forward-topic` 转发;不要把它作为普通 `--group` 猜测写入。引用回复使用
|
||||
`+messages-reply`,但这不等于 Thread 内新增回复。
|
||||
|
||||
读取话题回复可直接传话题主消息 `--message-id`,CLI 会先通过只读消息详情解析出
|
||||
`conversationId/threadId`;也可显式传 `--group` 加 `--thread-id/--topic-id`。前一种模式如果同时传
|
||||
`--group`,会校验它与消息解析出的会话一致;解析失败只会报错,不会错误转去查询通讯录。
|
||||
|
||||
默认只读一页;完整读取必须显式加 `--page-all`。可用
|
||||
`--limit/--page-size` 控制每页条数、用 `--page-limit` 限制最大页数;自动续页使用下层返回的
|
||||
毫秒级 `nextCursor` 无损生成下一次 `startTime`,不能使用只有秒级精度的回复 `createTime`。
|
||||
输出默认 `--order desc`(兼容 `--sort`)。由于下层的 `newer/older` 表示读取方向而不是结果排序,
|
||||
`asc` 只允许与 `--page-all` 一起使用:完整拉取后对整体结果升序排列,避免把单个“最新页”的本地反转
|
||||
伪装为全局升序。结果中的 `orderScope=complete_result` 表示完整结果排序;读取被页数上限或错误截断时为
|
||||
`fetched_pages`,并仍须结合完整性 ledger 判断。
|
||||
必须检查 `complete`、`hasMore`、`stopReason` 和
|
||||
`failures`,`complete=false` 时不得声称已经拿到全部回复。
|
||||
|
||||
```bash
|
||||
dws chat +thread-replies --message-id <rootOpenMessageId> --page-all --order asc
|
||||
dws chat +thread-replies --group <openConversationId> --thread-id <openConvThreadId> --page-all --page-limit 50
|
||||
```
|
||||
|
||||
### 拉取消息的底层 fallback
|
||||
|
||||
默认使用 `dws chat +chat-messages`。群聊的 `--group` 可传群名或 openConversationId;
|
||||
也可用 `--chat-query` 显式按群名解析、用 `--conversation-id` 显式传稳定 ID。全量读取加 `--page-all`,必要时用 `--page-limit`、
|
||||
`--max-results` 控制边界,用 `--output <相对.json>` 原子导出。只有需要原始响应或显式手工
|
||||
continuation 时才使用下表;原子 `message list` 不代表自动全量分页。
|
||||
|
||||
可附带非必填的 `--sender-query <姓名>` 做读取后筛选。未传姓名时正常返回全部;姓名未解析出稳定 ID 时保留全部并记录失败;唯一解析出 userId/openDingTalkId 后按消息 `senderId` 筛选,覆盖最终 `messages/count` 并返回 `resolvedFilters`。该调用已经完成读取、解析与筛选,不要补跑 `+search-msg`。
|
||||
|
||||
时间范围参数同样公开但非必填:`--start`(包含)、`--end`(不包含)、`--order asc|desc`,
|
||||
兼容别名为 `--start-time/--end-time/--sort`。范围固定为 `[start,end)`;仅开始时间表示到本次执行当前时间,
|
||||
仅结束时间只支持 `desc`,`asc` 必须提供开始时间。旧 `--time/--direction` 保持兼容但不能和范围模式混用。
|
||||
|
||||
```bash
|
||||
dws chat +chat-messages --group "项目群" --sender-query "测试用户甲" --page-all --format json
|
||||
dws chat +chat-messages --group "项目群" --start "2026-08-01T00:00:00+08:00" --end "2026-08-02T00:00:00+08:00" --order asc --page-all --format json
|
||||
```
|
||||
|
||||
当会话已经确定、任务只需要完整读取结果中可由消息字段判断的子集时,不新增按条件专用的
|
||||
Shortcut 参数。在同一次 `+chat-messages` 调用中使用全局 `--jq`,让 Runtime 完成读取后、
|
||||
在 stdout 前筛选;表达式必须保留根信封、用筛选结果覆盖 `messages` 并同步重算 `count`,
|
||||
不得丢失 `complete`、`hasMore`、`failures` 等完整性 ledger。不要先输出全量 JSON,再由
|
||||
Agent 或另一条命令二次处理。
|
||||
|
||||
```bash
|
||||
# 例:读取完整会话后,只返回存在 reaction 的消息
|
||||
dws chat +chat-messages --group "项目群" --page-all --format json \
|
||||
--jq '. as $root | [.messages[] | select((.reactions // []) | length > 0)] as $matched | $root | .messages = $matched | .count = ($matched | length)'
|
||||
```
|
||||
|
||||
发送者姓名不是普通结果字段条件:仍用 `--sender-query <姓名>` 先解析稳定身份,再按
|
||||
`senderId` 筛选,不能用 `--jq` 对展示名做字符串匹配。
|
||||
|
||||
| 命令 | 用途 | 示例与要点 |
|
||||
|------|------|------------|
|
||||
| `message list` | 拉取指定群聊或单聊消息 | `dws chat message list --group <cid> --time "2025-03-01 00:00:00" --direction older`;目标三选一,`--direction newer/older` 优先于旧 `--forward` |
|
||||
| `message list-all` | 时间范围内全部会话消息 | `dws chat message list-all --start <ISO> --end <ISO> --limit 100 --cursor 0`;默认一页,完整遍历加 `--page-all`,保留并合并 `result.conversationMessagesList` |
|
||||
| `message list-by-sender` | 查指定发送者消息 | `--sender-user-id` 与 `--sender-open-dingtalk-id` 二选一,跨单聊+群聊;完整遍历加 `--page-all`,同一会话跨页合并 messages |
|
||||
| `message list-mentions` | 查 @ 我的消息 | 可传 `--group` 限定群,不传查全部;完整遍历加 `--page-all`,同一会话跨页合并 messages |
|
||||
| `message list-focused` | 查特别关注人消息 | 零参数可用,可加 `--limit` / `--cursor`;完整遍历加 `--page-all`,cursor 为 int64 |
|
||||
| `message list-unread-conversations` | 未读会话列表 | 可选 `--count` |
|
||||
| `message list-topic-replies` | 拉取话题回复 | `--group <openConversationId> --topic-id <openConvThreadId>` |
|
||||
| `message list-by-ids` | 按消息 ID 批量查询 | `--msg-ids msgId1,msgId2`,最多 50 条 |
|
||||
|
||||
Typed `chat message` 自动翻页只由 `--page-all` 触发;只传 `--page-limit`、`--max-items` 或 `--page-delay` 仍保持默认单页 fallback。`conversationMessagesList` 结构会保留并按 `openConversationId` 合并 messages。分页元数据输出到顶层 `paging`,包含 `truncated`、`hasMore`、`lastCursor`、`pages`、`total`;非第一页失败时会输出 partial 结果和 `failedPage` / `failedCursor` / `pagesFetched` / `itemsFetched`。
|
||||
|
||||
`message list` 注意事项:
|
||||
|
||||
- `--group`、`--user`、`--open-dingtalk-id` 互斥且必须指定一个。
|
||||
- `--time` 格式为 `yyyy-MM-dd HH:mm:ss`。
|
||||
- `hasMore=true` 时,用结果中的边界 `createTime` 作为下次 `--time`。
|
||||
- 返回 `openConvThreadId` 表示话题消息,完整内容需再拉 `list-topic-replies`。
|
||||
|
||||
### 搜索消息的底层 fallback
|
||||
|
||||
直接按发送者、关键词、@对象或消息类型检索时优先使用 `dws chat +search-msg`;搜索范围可以是单个、多个或全部会话。若已选择 `+chat-messages` 读取指定会话,可由其非必填 `--sender-query` 在同一次调用完成姓名解析和筛选。
|
||||
|
||||
- 搜索内容使用公开参数 `--query`。
|
||||
- 已知稳定会话 ID 使用 `--group` / `--groups`;已知稳定发送者 ID 使用 `--senders`。
|
||||
- 只有群名时使用非必填参数 `--chat-query`,由 CLI 唯一解析会话。
|
||||
- 只有发送者姓名时使用非必填参数 `--sender-query`,由 CLI 唯一解析人员。
|
||||
- 不要把群名传给只接受稳定 ID 的会话参数,也不要把姓名传给 `--senders`。零命中或多候选时停止,不选择第一项。
|
||||
- 不传会话过滤时搜索全部会话;`--page-all` 只翻完当前时间范围内的游标页,默认时间范围是最近 7 天。
|
||||
- 精确范围使用成对的 `--start/--end`(兼容 `--start-time/--end-time`);`--order`(兼容 `--sort`)稳定排列本次实际取得的结果。未 `--page-all` 或 `complete=false` 时不能称为完整范围的全局排序。
|
||||
|
||||
需要 Shortcut 未暴露的原始过滤字段或响应时,才评估 `message search-advanced`。它是原子 `message search` 的严格超集,但不是 Agent 高频默认入口。
|
||||
|
||||
```bash
|
||||
# 单群 + 发送者姓名
|
||||
dws chat +search-msg --chat-query "项目群" --sender-query "测试用户甲" --page-all --format json
|
||||
|
||||
# 单群 + 关键词
|
||||
dws chat +search-msg --chat-query "项目群" --query "发布计划" --page-all --format json
|
||||
|
||||
# 跨全部会话 + 发送者姓名
|
||||
dws chat +search-msg --sender-query "测试用户甲" --page-all --format json
|
||||
dws chat +search-msg --query "发布计划" --start-time "2026-08-01T00:00:00+08:00" --end-time "2026-08-02T00:00:00+08:00" --sort asc --page-all --format json
|
||||
```
|
||||
|
||||
```bash
|
||||
dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
|
||||
dws chat message search-advanced --user <userId> --start <ISO> --end <ISO>
|
||||
dws chat message search-advanced --at-me --start <ISO> --end <ISO>
|
||||
dws chat message search-advanced --conversation-ids <cid1>,<cid2> --query "合同" --limit 50 --cursor 0
|
||||
dws chat message search-advanced --query "周报" --start <ISO> --end <ISO> --page-all --max-items 200
|
||||
dws chat message search-advanced --message-type file --search-conv-type group_chat --query "附件"
|
||||
dws chat message search-advanced --only-robot-messages --query "通知"
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--query` | 搜索关键词,可选 |
|
||||
| `--user` / `--users` | 发送者 userId |
|
||||
| `--sender-ids` | 发送者 openDingTalkId |
|
||||
| `--at-me` / `--at-ids` | @ 我 / @ 指定 openDingTalkId |
|
||||
| `--conversation-ids` | 多个群聊或单聊 openConversationId;别名 `--groups` |
|
||||
| `--message-type` | 按消息类型过滤,例如 `file` |
|
||||
| `--search-conv-type` | 按会话类型过滤,例如 `group_chat` |
|
||||
| `--only-robot-messages` | 只搜索机器人消息 |
|
||||
| `--start` / `--end` | ISO-8601 时间范围 |
|
||||
| `--cursor` / `--limit` | 分页,翻页用 `nextCursor` |
|
||||
| `--page-all` / `--page-limit` / `--max-items` / `--page-delay` | 自动翻页;`--page-all` 是唯一触发开关 |
|
||||
|
||||
仅简单关键词搜索时可用:
|
||||
|
||||
```bash
|
||||
dws chat message search --query "changefree" --start <ISO> --end <ISO> --limit 50 --cursor 0
|
||||
dws chat message search --query "codereview" --group <openConversationId> --start <ISO> --end <ISO>
|
||||
dws chat message search --query "发布计划" --start <ISO> --end <ISO> --page-all --page-delay 0
|
||||
```
|
||||
|
||||
### 消息状态与撤回
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `message query-send-status` | 查询当前用户发消息任务状态 | `--open-task-id`,来自 `message send` 返回 |
|
||||
| `+messages-recall` | 撤回当前用户消息 | `--msg-id`;可选 `--conversation-id`,省略时 CLI 从消息详情补齐;兼容单值 `--message-ids` |
|
||||
| `message edit` | 编辑已发送消息内容 | `--conversation-id` `--msg-id`,并在 `--text` / `--content` 中二选一;可选 `--title` `--at-all` `--at-open-dingtalk-ids` |
|
||||
| `message read-status` | 查消息已读/未读状态 | `--group` `--message-id`;可选目标用户 |
|
||||
|
||||
刚由 `message send` 发出的消息会返回 `openTaskId`。先用 `message query-send-status` 查询,成功结果中的 `openMessageId` 和 `openConversationId` 可直接传给 `message edit` 或 `message recall`,无需再按消息内容从列表反查 ID。
|
||||
|
||||
```bash
|
||||
# 1. 发送后保留 openTaskId
|
||||
dws chat message send --group <openConversationId> --text "原始内容"
|
||||
# 2. 查询得到 openMessageId 和 openConversationId
|
||||
dws chat message query-send-status --open-task-id <openTaskId>
|
||||
# 3. 编辑消息
|
||||
dws chat message edit --conversation-id <openConversationId> --msg-id <openMessageId> --text "更新后的内容"
|
||||
|
||||
# 发送后撤回使用同一 ID 链
|
||||
dws chat message send --group <openConversationId> --text "待撤回的内容"
|
||||
dws chat message query-send-status --open-task-id <openTaskId>
|
||||
dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>
|
||||
```
|
||||
|
||||
`+messages-recall` 与 `recall-by-bot` 不同:前者使用 `openMessageId`,缺少会话 ID 时先只读查询消息详情;后者撤回机器人消息,需要 `robot-code + processQueryKey`。不要把 `processQueryKey` 当 `openMessageId`。
|
||||
|
||||
编辑消息使用 `message edit`。推荐传 `--text`,CLI 会生成 markdown content JSON:`{"title":"标题","text":"正文"}`;可选 `--title`,不传时会从正文自动生成标题。高级场景可直接传 `--content`,此时必须是完整 markdown content JSON,且不能同时传 `--text`。
|
||||
|
||||
@ 规则:`--at-all` 会传 `atAll=true`,正文应包含 `<@all>`,未包含时 CLI 会自动补到开头;`--at-open-dingtalk-ids` 会传 `atOpenDingTalkIds`,正文需包含对应 `<@openDingTalkId>` 占位符,裸 `@openDingTalkId` 会自动补成尖括号格式。
|
||||
|
||||
```bash
|
||||
dws chat message edit --conversation-id <openConversationId> --msg-id <openMessageId> --text "更新后的内容"
|
||||
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --title "标题" --text "更新后的内容"
|
||||
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --text "<@all> 请查看" --at-all
|
||||
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --text "<@openDingTalkId1> 请查看" --at-open-dingtalk-ids <openDingTalkId1>
|
||||
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --content '{"title":"标题","text":"更新后的内容"}'
|
||||
```
|
||||
|
||||
### 回复与转发的底层 fallback
|
||||
|
||||
引用回复默认使用 `dws chat +messages-reply`。以下原子 reply 只保留底层字段 fallback;转发
|
||||
仍按各自精确 Shortcut/leaf Schema 选择。
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `message reply` | 引用回复,单聊/群聊均可;群聊可 @指定成员或 @所有人 | `--conversation-id` `--ref-msg-id` `--ref-sender` `--text`;可选 `--at-open-dingtalk-ids` `--at-all` |
|
||||
| `message forward` | 转发单条消息,源/目标均支持单聊/群聊 | `--src-conversation-id` `--msg-id` `--dest-conversation-id` |
|
||||
| `message combine-forward` | 多条消息合并为一条转发 | `--src-conversation-id` `--msg-ids` `--dest-conversation-id`,可选 `--uuid` |
|
||||
| `message forward-topic` | 转发话题消息 | `--src-msg-id` `--src-conversation-id` `--src-thread-id` `--dest-conversation-id` |
|
||||
|
||||
群聊引用回复使用 `--at-open-dingtalk-ids` 传 `atOpenDingTalkIds`;正文缺少对应 `<@openDingTalkId>` 时自动补齐,已有裸 `@openDingTalkId` 会规范化。`--at-all` 会传 `atAll=true`,正文缺少 `<@all>` 时自动补齐。
|
||||
|
||||
```bash
|
||||
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <senderOpenDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>
|
||||
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <senderOpenDingTalkId> --text "请大家确认" --at-all
|
||||
```
|
||||
|
||||
### 话题与卡片
|
||||
|
||||
话题完整读取流程:
|
||||
|
||||
1. `dws chat message list --group <openConversationId> --time ...` 获取话题主消息。
|
||||
2. 如果返回 `openConvThreadId`,执行 `dws chat message list-topic-replies --group <openConversationId> --topic-id <openConvThreadId>`。
|
||||
|
||||
流式卡片优先使用公开 Shortcut;创建可选在同一次调用中写入内容:
|
||||
|
||||
```bash
|
||||
dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <mentionedOpenDingTalkId> --content "开始处理" --flow-status 1
|
||||
dws chat +messages-update-card --biz-id <bizId> --content "更新的卡片内容" --flow-status 2
|
||||
dws chat +messages-update-card --biz-id <bizId> --content "最终内容" --flow-status 3
|
||||
```
|
||||
|
||||
`flow-status`:1=处理中,2=输入中,3=完成,4=执行中,5=错误,Runtime 拒绝范围外值。
|
||||
群聊还可传 `--at-all`;两种艾特参数只随创建请求发送。send-card 同时带正文时,
|
||||
Runtime 会把创建响应的 `atTag` 自动放在正文前;不要自行写 ID 或占位符。
|
||||
当前只支持 streaming text;不支持 Card JSON 组件或 action callback。精确边界见
|
||||
[card references](../card/schema.md)。
|
||||
|
||||
### Pin / Top / Favorite
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `message set-pin-msg` / `unset-pin-msg` | 钉住/取消钉住消息 | `--open-conversation-id` `--msg-id` |
|
||||
| `message list-pin-msg` | 拉取钉住消息列表 | `--open-conversation-id`,可选 `--cursor` `--size` |
|
||||
| `message set-top-msg` / `unset-top-msg` | 置顶/取消置顶会话内某条消息 | `--open-conversation-id` `--msg-id` |
|
||||
| `message add-favorite` | 收藏消息 | `--open-message-id` `--open-conversation-id` |
|
||||
| `message remove-favorite` | 取消收藏消息 | `--open-message-id` `--open-conversation-id` |
|
||||
| `+flag-list` | 查询收藏消息列表的默认 Shortcut | 默认一页;要求全部时加 `--page-all`;`--page-size/--size` 范围为 1-30,可用 `--page-token` 或兼容的 `--cursor` 续页,检查 `complete` / `failures` |
|
||||
| `message list-favorites` | typed 收藏消息列表 | 默认一页;完整遍历加 `--page-all`,数字 cursor,聚合 `result.items`;可选 `--cursor` `--size` |
|
||||
|
||||
`+flag-list` 查询钉钉 message favorite,底层使用数字 cursor;它与消息 Pin、消息 Top 和会话置顶属于不同对象层级。
|
||||
`message list-favorites --page-all` 仍遵守 `--size` 1-30;`--page-limit` 只控制最多请求页数,`--max-items` 可精确截断输出。
|
||||
消息置顶 `set-top-msg` 与会话置顶 `chat set-top` 不同:前者置顶会话内消息,后者置顶整个会话。
|
||||
|
||||
### 表情回应
|
||||
|
||||
优先查 [chat-emoji-list.md](../chat-emoji-list.md) 中的默认表情名称。
|
||||
|
||||
| 命令 | 场景 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `message add-emoji` / `remove-emoji` | 默认表情命中时使用 | `--conversation-id` `--msg-id` `--emoji` |
|
||||
| `message create-text-emotion` | 默认表情没有合适项时先创建 | `--emotion-name` `--text`,可选 `--background-id` |
|
||||
| `message add-text-emotion` / `remove-text-emotion` | 添加/移除文字表情 | `--conversation-id` `--msg-id` `--emotion-id` `--emotion-name` `--text` `--background-id` |
|
||||
| `message update-text-emotion` | 用新的文字表情替换消息上的原回应 | `--conversation-id` `--msg-id` `--old-emotion-id` `--emotion-id` `--emotion-name` `--text` `--background-id` |
|
||||
| `message list-emotion-replies` | 批量查询消息的表情回复和文字回复 | `--msg-ids` |
|
||||
|
||||
```bash
|
||||
dws chat message list-emotion-replies --msg-ids msgId1,msgId2,msgId3
|
||||
```
|
||||
|
||||
消息 ID 可通过 `dws chat message list` 获取;该命令用于一次性查看多条消息上的 emoji 回应和文字表情回应。
|
||||
|
||||
### 文本工具
|
||||
|
||||
#### `dws chat text translate`
|
||||
|
||||
将指定文本翻译成目标语言。用户只说“翻译这段文字”时使用;不要误走 `message send`。
|
||||
|
||||
```bash
|
||||
dws chat text translate --query "你好世界" --to en_US
|
||||
dws chat text translate --query "Hello World" --to zh_CN
|
||||
dws chat text translate --query "Bonjour" --to ja_JP
|
||||
```
|
||||
|
||||
关键 flags:
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--query` | 待翻译文本,必填 |
|
||||
| `--to` | 目标语言代码,必填,默认 `en_US` |
|
||||
|
||||
### 文件与媒体
|
||||
|
||||
#### `dws chat message download-media`
|
||||
|
||||
```bash
|
||||
dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/
|
||||
```
|
||||
|
||||
`resource-id` 来自消息内容中的 mediaId,`message-id` 来自 `openMessageId`,会话 ID 来自 `chat search` 或 `conversation-info`。
|
||||
|
||||
公开 `+messages-resource-download` 使用工作目录内安全相对路径、默认不覆盖、整文件临时落盘后
|
||||
原子发布。当前没有 Range/断点续传;失败时保留 ledger 或错误,显式重试整个文件,不拼接残片。
|
||||
|
||||
## 常见工作流
|
||||
|
||||
### 群聊发文字与文件
|
||||
|
||||
```bash
|
||||
dws chat +send-to-group --group "项目冲刺" --text "请大家本周五前提交周报" --format json
|
||||
dws chat +messages-send --as user --chat-query "项目冲刺" --file ./report.pdf --uuid <uuid> --format json
|
||||
dws chat +messages-send --as user --chat-query "项目冲刺" --msg-type audio --file ./voice.mp3 --uuid <uuid> --format json
|
||||
dws chat +messages-send --as user --chat-query "项目冲刺" --msg-type video --file ./demo.mp4 --uuid <uuid> --format json
|
||||
|
||||
# 仅 Shortcut 尚未覆盖的位置/名片底层类型才使用原子 fallback
|
||||
dws chat message send --group <openConversationId> --msg-type location --latitude <纬度> --longitude <经度> --location-name <地址名称> --map-thumbnail-url "@mediaId" --format json
|
||||
dws chat message send --group <openConversationId> --msg-type profile --contact-id <openDingTalkId> --format json
|
||||
```
|
||||
|
||||
### 查消息并撤回
|
||||
|
||||
```bash
|
||||
dws chat +chat-messages --group <openConversationId> --direction older --format json
|
||||
dws chat +messages-recall --msg-id <openMessageId> --format json
|
||||
```
|
||||
|
||||
### 多维度搜索
|
||||
|
||||
```bash
|
||||
dws chat message search-advanced --query "合同" --conversation-ids <cid1>,<cid2> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0 --format json
|
||||
dws chat message search-advanced --message-type file --search-conv-type group_chat --query "附件" --format json
|
||||
```
|
||||
|
||||
## 常见错误与回退
|
||||
|
||||
- 发送目标不唯一:保留 resolver 返回的候选并让用户消歧;不要退回手工搜索后选择第一项。
|
||||
- `unknown flag`:立即执行对应命令 `--help`,不要猜参数。
|
||||
- 文件/音视频发送失败:确认本地路径可读;新链路使用 `--msg-type file|audio|video --file-path`。
|
||||
- 位置消息参数不完整:先确认经纬度、地址名称和缩略图 mediaId。
|
||||
- 名片发送失败:确认 `--contact-id` 是 openDingTalkId,不是 userId。
|
||||
- 话题回复缺失:检查是否只拉了主消息,需继续用 `list-topic-replies`。
|
||||
- `search-advanced` 无条件:至少提供 query、sender、@、conversation、时间等任一有效过滤条件。
|
||||
@@ -1,144 +0,0 @@
|
||||
# group-admin:群创建、成员写入与管理
|
||||
|
||||
> 返回入口:[DingTalk Chat Skill](../../SKILL.md)
|
||||
|
||||
用于建群、修改群资料、成员增删、邀请卡片分享、群主和管理员、禁言、公告、群设置、
|
||||
入群审批、群身份、退出、解散和升级外部群。只读群发现、成员读取和邀请链接使用
|
||||
[group-discovery.md](group-discovery.md)。
|
||||
|
||||
## 安全与目标
|
||||
|
||||
- 群目标统一使用当前 profile 下真实 `openConversationId`;支持自然群名的 Shortcut 由 CLI
|
||||
唯一解析,多候选时停止。
|
||||
- 解散群、踢人、转让群主、禁言、管理员和外部群升级都是高影响操作;以最终 Runtime gate
|
||||
和精确 leaf Schema 为准确认对象、动作与影响。
|
||||
- 所有自然成员和群主必须先完成唯一解析并按稳定 ID 去重,再开始任何写入;不得边解析边
|
||||
产生部分副作用。
|
||||
- 群公告会触达成员;`notice edit` 是整体替换,必须有完整新正文。
|
||||
|
||||
## 建群与基础资料
|
||||
|
||||
<!-- dws-intent: chat.create.group -->基础建群使用 `dws chat +chat-create`。已知成员 ID 传 `--users`,
|
||||
姓名/花名传 `--member-query`;群主默认当前用户,也可传 `--owner-open-dingtalk-id` 或
|
||||
`--owner-query`。任一自然身份未唯一解析时,创建前整体停止。
|
||||
|
||||
```bash
|
||||
dws chat +chat-create --name "项目冲刺群" --member-query "测试用户甲,测试用户乙" --format json
|
||||
dws chat +chat-create --name "合作群" --member-query "测试用户甲" \
|
||||
--owner-query "测试用户乙" --type EXTERNAL --format json
|
||||
```
|
||||
|
||||
修改群名称优先使用接受群名或稳定 ID 的 `+chat-update`:
|
||||
|
||||
```bash
|
||||
dws chat +chat-update --group <群名或openConversationId> --name "新群名" --format json
|
||||
```
|
||||
|
||||
群头像和管理员级群开关使用 `+chat-update-icon`、`+chat-update-settings`;只有 Shortcut
|
||||
尚未发布真实必需字段时才评估原子 `group rename/update-icon/update-settings`。
|
||||
|
||||
原子 `chat group create` 只用于 `+chat-create` 未发布的真实底层字段,并先读取精确 leaf
|
||||
Schema。普通内部/外部群、话题群和显式群主已经由 `+chat-create` 覆盖,不回流到手工
|
||||
`aisearch → group create` 链路。
|
||||
|
||||
## 成员与机器人写入
|
||||
|
||||
| 动作 | 入口与关键参数 |
|
||||
|---|---|
|
||||
| 添加成员 | `group members add --id <cid> --users <userIds>` |
|
||||
| 移除成员 | `group members remove --id <cid> --users <userIds>` |
|
||||
| 添加已知机器人 | `+chat-add-bot` 或精确原子 `group members add-bot` |
|
||||
| 查看群内机器人 | `+chat-bots --group <群名或cid>` |
|
||||
| 移除群内机器人 | `+chat-remove-bot` 或精确原子 `group members remove-bot` |
|
||||
|
||||
普通成员增删的 `--users` 只接受组织 `userId`,必须来自真实人员解析结果;不得把
|
||||
`+chat-members-list` / `+chat-members-get` 返回的 `openDingTalkId` 直接传入。添加已知机器人
|
||||
使用 `robotCode`;移除机器人使用当前群 `+chat-bots` 返回的真实 `openBotId`,两者不能互换。
|
||||
缺少 `openBotId` 时在同一流程中先执行 `+chat-bots`,不必额外读取群发现 reference。只有需要
|
||||
搜索未知机器人、区分 `bot search` / `bot find`、机器人发送或撤回、Webhook 时,才读取
|
||||
[chat-bot.md](chat-bot.md)。
|
||||
|
||||
## 邀请卡片、群主、管理员与禁言
|
||||
|
||||
邀请链接只读走 `+chat-invite-url`。实际分享邀请卡片使用 `group share-invite`:`--source`
|
||||
是被分享群,接收端在 `--target` 会话和 `--receiver` 单聊用户之间二选一。
|
||||
|
||||
```bash
|
||||
dws chat group share-invite --source <sourceCid> --target <targetCid> --format json
|
||||
dws chat group share-invite --source <sourceCid> --receiver <openDingTalkId> --format json
|
||||
```
|
||||
|
||||
| 动作 | 入口与关键参数 |
|
||||
|---|---|
|
||||
| 转让群主 | `+chat-transfer-owner --group <cid> --new-owner <稳定ID>` |
|
||||
| 设置/取消管理员 | `group set-admin --group <cid> --users <ids> [--off]` |
|
||||
| 全员禁言/解除 | `group-mute --group <cid> [--off]` |
|
||||
| 成员禁言/解除 | `+chat-mute-member` 或 `group-mute-member` |
|
||||
| 查询禁言配置 | `group get-mute-config --group <cid>` |
|
||||
|
||||
原子 `group-mute-member --mute-time` 单位为毫秒。不要用展示名称代替稳定用户 ID,也不要
|
||||
在未确认影响时执行转让、踢人或禁言。
|
||||
|
||||
## 群设置与当前用户偏好
|
||||
|
||||
管理员级群开关使用 `+chat-update-settings` 或原子 `group update-settings`。常见 settingKey
|
||||
包括 `authority`、`joinValidation`、`onlyAdminCanAtAll`、`searchable`、
|
||||
`addFriendForbidden`、`onlyAdminCanDING`、`onlyAdminCanPinMsg` 和
|
||||
`onlyAdminCanSendFile`、`groupEmailDisabled`、`groupLiveAuthority`、
|
||||
`groupBillAuthority`;只修改用户明确要求的字段。
|
||||
|
||||
新成员历史消息可见范围使用 `group set-history --group <cid> --option <值>`;`option` 只取
|
||||
精确 leaf Schema 发布值,不按自然语言猜枚举。
|
||||
|
||||
当前登录用户自己的置顶、免打扰、群昵称和群备注使用 `group user-settings query/set`,
|
||||
不是管理员群开关。单个群昵称/备注优先 `group update-nick/update-alias`。
|
||||
|
||||
```bash
|
||||
dws chat group user-settings query --groups <cid1>,<cid2> --format json
|
||||
dws chat group user-settings set \
|
||||
--items '[{"openConversationId":"cid1","top":true,"mute":false}]' --format json
|
||||
```
|
||||
|
||||
批量设置只传本次要改的字段;空字符串清除昵称或备注,不补用户未要求的值。
|
||||
|
||||
## 群公告
|
||||
|
||||
| 动作 | 原子入口 |
|
||||
|---|---|
|
||||
| 发布公告 | `group notice create --group <cid> --content <完整Markdown>` |
|
||||
| 修改公告 | `group notice edit --group <cid> --notice-id <id> --content <完整Markdown>` |
|
||||
| 查询公告 | `group notice get/list` |
|
||||
|
||||
定时公告 `--run-at` 使用带时区时间;`notice list --scheduled` 查询待发布公告。分页时沿真实
|
||||
`nextPageCursor` 继续。修改前必须取得完整替换正文,不把增量片段当整篇公告。
|
||||
|
||||
## 入群审批与群身份
|
||||
|
||||
先用 `group list-join-validations` 取得真实 `record-id/applicant/inviter`,再执行
|
||||
`group audit-join-validation` 或 `+chat-audit-join`。审批状态只使用精确 leaf Schema 发布值。
|
||||
|
||||
群身份使用 `group-role` / `+chat-role-*`:
|
||||
|
||||
- `list/add/update/remove` 管理身份定义;
|
||||
- `set-user/remove-user/query-user` 管理成员身份;
|
||||
- `openRoleId` 必须来自真实身份列表。
|
||||
|
||||
覆盖或清除成员身份前确认用户、群和完整角色集合,不能用展示名称猜 `openRoleId`。
|
||||
|
||||
## 退出、解散与外部群升级
|
||||
|
||||
- 当前用户退出群:`+chat-quit` 或精确原子 `group quit`。
|
||||
- 解散群:`group dismiss`,不可逆。
|
||||
- 普通群升级外部群:`group upgrade-to-external`,不可逆。
|
||||
|
||||
这些动作必须以最终 Runtime gate 为准,不把示例中的确认参数当固定事实。
|
||||
|
||||
## 完成与错误
|
||||
|
||||
- 创建或更新后保留真实 `openConversationId` 和任务结果;只对查询结果真实返回的字段执行读回验证。
|
||||
- 写接口成功但现有查询未返回目标设置时,报告真实写入回执和不可独立读回的边界;不用群名、
|
||||
成员数等其他字段代替验证,也不猜未发布的读回命令。
|
||||
- 任一自然目标零命中或多候选时,在写入前整体停止。
|
||||
- 逐项写入保留 succeeded/failed/unknown ledger,不用重试抹掉失败项。
|
||||
- 分享邀请时 `--target` 与 `--receiver` 只能二选一;接收对象不明确时先确认。
|
||||
- 机器人进群失败时确认机器人身份和当前用户管理权限,不连续切换同义原子命令。
|
||||
@@ -1,121 +0,0 @@
|
||||
# group-discovery:群发现、列表与成员读取
|
||||
|
||||
> 返回入口:[DingTalk Chat Skill](../../SKILL.md)
|
||||
|
||||
用于只读的群列表、群搜索、共同群、群成员、群机器人和邀请链接。建群、改群、成员增删、
|
||||
邀请卡片分享、公告、禁言和其他群管理写操作读取 [group-admin.md](group-admin.md)。
|
||||
|
||||
## 入口选择
|
||||
|
||||
| 用户终点 | 唯一推荐入口 |
|
||||
|---|---|
|
||||
| 我加入的全部群 | `dws chat +my-groups --page-all` |
|
||||
| 我创建或管理的群 | `dws chat +chat-list-mine` |
|
||||
| 只看群主群或管理员群 | `+chat-list-mine --role OWNER|ADMIN` |
|
||||
| 按关键词搜索群 | `dws chat +chat-search --query <关键词>` |
|
||||
| 查看指定群全部成员 | `dws chat +chat-members-list --group <群名或ID>` |
|
||||
| 已知成员 openDingTalkId 批量查群内详情 | `dws chat +chat-members-get --id <cid> --users <ids>` |
|
||||
| 获取群邀请链接 | `dws chat +chat-invite-url --group <群名或ID>` |
|
||||
| 查看群机器人 | `dws chat +chat-bots --group <群名或ID>` |
|
||||
|
||||
“全部群”与“全部会话”不同:`+my-groups` 只列当前用户加入的群;
|
||||
`+conversation-list --page-all` 可能同时包含单聊和群聊,不能替代群成员关系。
|
||||
|
||||
## 群列表、分页与角色
|
||||
|
||||
`+my-groups` 返回当前用户加入的群,包括作为群主、管理员和普通成员加入的群:
|
||||
|
||||
- 要求完整列表时使用 `--page-all`;Runtime 沿真实 `nextCursor` 读取后续页,并按
|
||||
`openConversationId` 合并去重,读完后再应用可选 `--type` 本地过滤。
|
||||
- `--limit` 是每页数量,不是最终结果上限;`--cursor` 只用于从已有 `nextCursor` 手工续读。
|
||||
- `--page-limit` 只与 `--page-all` 一起使用,用于限制最多读取页数。达到上限后仍有下一页时,
|
||||
结果不完整。
|
||||
- 只有 `complete=true` 且 `hasMore=false` 才能声称已经读取全部;否则保留 `nextCursor`、
|
||||
`stopReason` 和 `failures` 并说明结果不完整。
|
||||
|
||||
```bash
|
||||
dws chat +my-groups --page-all --page-limit 50 --format json
|
||||
```
|
||||
|
||||
`+chat-list-mine` 只返回当前用户作为群主或管理员的群。只要群集合时不传 `--role`,
|
||||
一次取得 OWNER 和 ADMIN。要求逐项标明身份时,直接分别查询 `--role OWNER` 和
|
||||
`--role ADMIN`,不先执行无角色查询或读取 Help;按 `openConversationId` 合并去重后,
|
||||
再应用一次全局数量上限,不得把两个分支直接拼接。
|
||||
|
||||
```bash
|
||||
dws chat +chat-list-mine --limit 20 --format json
|
||||
dws chat +chat-list-mine --role OWNER --format json
|
||||
dws chat +chat-list-mine --role ADMIN --exclude-muted --format json
|
||||
```
|
||||
|
||||
`+my-groups` 不提供当前用户角色。用户明确要求普通成员群时,使用
|
||||
`chat group list-all --limit 200`;返回 `hasMore=true` 时,必须把真实 `nextCursor`
|
||||
传给下一次调用并继续读取,直到 `hasMore=false`,不得把继续翻页交给用户。读完后按
|
||||
`openConversationId` 去重,仅筛选真实返回的 `myRole=普通成员`;不得给 `+my-groups`
|
||||
编造 `--role MEMBER`,也不得用“全部群减去 OWNER/ADMIN 群”推断。
|
||||
|
||||
## 群搜索与稳定 ID
|
||||
|
||||
群搜索默认使用 `+chat-search`。要求全部候选时加 `--page-all`;可用
|
||||
`--page-size/--page-token` 或兼容 `--limit/--cursor`。零命中或多候选时停止并展示候选,
|
||||
不要选择第一项。
|
||||
|
||||
```bash
|
||||
dws chat +chat-search --query "项目冲刺" --page-all --format json
|
||||
```
|
||||
|
||||
只有数字群号时,使用 `chat group get-by-group-id --group-id <数字>` 转换为
|
||||
`openConversationId`。需要搜索共同群时使用原子 `chat search-common`;`AND` 表示所有人
|
||||
都在群里,`OR` 表示任一人在群里。自然人员必须先解析为当前 profile 的真实身份。
|
||||
|
||||
```bash
|
||||
dws chat search-common --nicks "测试用户甲,测试用户乙" --match-mode AND --limit 20 --cursor 0
|
||||
```
|
||||
|
||||
## 群成员
|
||||
|
||||
`+chat-members-list` 接受群名或 `openConversationId`,唯一解析后全量读取,并把用户与机器人
|
||||
分桶。结果必须检查 `buckets/complete/failures`。
|
||||
|
||||
```bash
|
||||
dws chat +chat-members-list --group "项目群" --format json
|
||||
dws chat +chat-members-list --conversation-id <openConversationId> --format json
|
||||
```
|
||||
|
||||
先检查 `+chat-members-list` 的稳定结果。只有结果未包含用户要求的群昵称、角色或其他群内字段时,
|
||||
才使用其中的真实 `openDingTalkId` 批量调用:
|
||||
|
||||
```bash
|
||||
dws chat +chat-members-get --id <openConversationId> \
|
||||
--users <openDingTalkId1>,<openDingTalkId2> --format json
|
||||
```
|
||||
|
||||
不要为了群内详情默认切换到企业通讯录;只有用户明确要求部门、岗位、直属主管等企业资料时,
|
||||
才把真实 userId 交给 `dingtalk-contact`。
|
||||
|
||||
## 邀请链接与机器人
|
||||
|
||||
`+chat-invite-url` 是只读获取链接,可选 `--expires-seconds`;`group share-invite` 会实际把
|
||||
邀请卡片发送给另一个会话或用户,属于 [group-admin.md](group-admin.md)。
|
||||
|
||||
`+chat-bots` 返回稳定 `bots[]` 和 `openBotId`,供后续移除。搜索可用机器人、机器人发送和
|
||||
撤回读取 [chat-bot.md](chat-bot.md)。
|
||||
|
||||
## 原子 fallback
|
||||
|
||||
| 原子命令 | 仅用于 |
|
||||
|---|---|
|
||||
| `chat search` / `search-common` | Shortcut 未发布的搜索字段或共同群 |
|
||||
| `chat group get-by-group-id` | 数字群号转换 |
|
||||
| `chat group members` | 需要原始成员分页响应 |
|
||||
| `chat group members list-by-ids` | 需要原始批量成员详情 |
|
||||
| `chat group list-all` / `list-my-groups` | 需要 Shortcut 未投影的真实底层字段 |
|
||||
|
||||
使用原子 fallback 前读取精确 leaf Schema;不得把 fallback 写成与 Shortcut 并列的默认路线。
|
||||
|
||||
## 完成与错误
|
||||
|
||||
- 分页完成只以真实 `complete/hasMore/nextCursor/failures` 判断,不看过滤后的 `count` 猜测。
|
||||
- 所有稳定 ID 必须来自同一 profile 的真实返回。
|
||||
- 找不到群或出现多候选时停止,不臆测 `openConversationId`。
|
||||
- 任务从只读发现转为写操作时,使用 [group-admin.md](group-admin.md) 的目标和安全规则。
|
||||
@@ -1,132 +0,0 @@
|
||||
# message-actions:消息编辑、撤回、回复与对象操作
|
||||
|
||||
> 返回入口:[DingTalk Chat Skill](../../SKILL.md)
|
||||
|
||||
用于对真实消息执行编辑、撤回、引用回复、转发、Pin、Top、Favorite 和表情回应写操作,
|
||||
并包含必要的紧邻验证。需要跨多个阶段传递真实结果的组合流程由
|
||||
[01-messaging.md](../01-messaging.md) 说明;本文件不重复完整工作流。
|
||||
|
||||
## 入口选择
|
||||
|
||||
| 用户终点 | 推荐入口 |
|
||||
|---|---|
|
||||
| 撤回当前用户消息 | `dws chat +messages-recall --msg-id <openMessageId>` |
|
||||
| <!-- dws-intent: chat.reply.quote -->引用回复 | `dws chat +messages-reply` |
|
||||
| 编辑已发送消息 | `dws chat message edit` |
|
||||
| 单条/合并/话题转发 | `+messages-forward` / `+messages-combine-forward` / `+messages-forward-topic` |
|
||||
| Pin / Unpin | `+messages-set-pin` / `+messages-unset-pin` |
|
||||
| 消息 Top / 取消 Top | `+messages-set-top` / `+messages-unset-top` |
|
||||
| Favorite / 取消 Favorite | `+flag-create` / `+flag-cancel` |
|
||||
| 默认 emoji 回应 | `+messages-add-emoji` / `+messages-remove-emoji` |
|
||||
|
||||
所有写操作以最终 Runtime gate 和精确 leaf Schema 为准。确认对象、消息和影响后再执行;
|
||||
不要因为文档示例自行制造或省略 confirmation。
|
||||
|
||||
## 稳定 ID 规则
|
||||
|
||||
- `openTaskId` 是发送任务 ID,不是消息 ID。
|
||||
- 撤回、编辑、回复、转发、Pin、Top 和 reaction 使用真实查询结果中的 `messageId`。
|
||||
- 同时保留消息的 `conversationId`、thread、发送者和引用上下文。
|
||||
- 子消息使用自己的 `messageId`;只在缺会话 ID 时继承父消息 `conversationId`。
|
||||
- Bot 撤回使用 `processQueryKey`,不使用本文件的 `openMessageId` 路线。
|
||||
|
||||
刚由用户身份发送的消息如果只得到 `openTaskId`,先查询发送状态:
|
||||
|
||||
```text
|
||||
+messages-send 或 message send
|
||||
→ openTaskId
|
||||
→ message query-send-status
|
||||
→ openMessageId + openConversationId
|
||||
→ 编辑或撤回
|
||||
```
|
||||
|
||||
## 撤回与编辑
|
||||
|
||||
`+messages-recall` 可只传 `--msg-id`;省略会话 ID 时 CLI 会通过只读消息详情补齐。
|
||||
兼容单值 `--message-ids`,但不要把 `processQueryKey` 当消息 ID。
|
||||
|
||||
```bash
|
||||
dws chat +messages-recall --msg-id <openMessageId> --format json
|
||||
dws chat +messages-recall --conversation-id <openConversationId> --msg-id <openMessageId> --format json
|
||||
```
|
||||
|
||||
编辑使用 `message edit --conversation-id <cid> --msg-id <id>`,并在 `--text` 与 `--content`
|
||||
中二选一。`--text` 由 CLI 生成 Markdown content;`--content` 必须是完整 content JSON。
|
||||
|
||||
```bash
|
||||
dws chat message edit --conversation-id <openConversationId> --msg-id <openMessageId> --text "更新后的内容"
|
||||
dws chat message edit --conversation-id <openConversationId> --msg-id <openMessageId> --content '{"title":"标题","text":"更新后的内容"}'
|
||||
```
|
||||
|
||||
群聊 @所有人使用 `--at-all`;指定人员使用 `--at-open-dingtalk-ids`。正文中的占位符以
|
||||
Runtime 规范化结果为准,不把裸展示名当稳定身份。
|
||||
|
||||
## 引用回复与转发
|
||||
|
||||
引用回复默认使用 `+messages-reply`。`--conversation-id` 和消息 ID 来自真实查询;
|
||||
`--ref-sender` 可省略时让 CLI 只读补齐,不手工猜发送者身份。
|
||||
|
||||
```bash
|
||||
dws chat +messages-reply --conversation-id <openConversationId> \
|
||||
--message-id <openMessageId> --text "收到" --format json
|
||||
```
|
||||
|
||||
| 动作 | 入口 | 关键上下文 |
|
||||
|---|---|---|
|
||||
| 单条转发 | `+messages-forward` | 源消息 ID、源会话、目标会话 |
|
||||
| 合并转发 | `+messages-combine-forward` | 多个真实消息 ID、源/目标会话 |
|
||||
| 话题转发 | `+messages-forward-topic` | 源消息、源会话、源 thread、目标会话 |
|
||||
|
||||
只有 Shortcut 尚未发布真实必需字段时,才评估原子 `message reply`、`forward`、
|
||||
`combine-forward` 或 `forward-topic`,并先读取精确 leaf Schema。不要复制正文伪装原生转发。
|
||||
|
||||
## Pin、Top 与 Favorite
|
||||
|
||||
| 对象 | 写入入口 | 说明 |
|
||||
|---|---|---|
|
||||
| 消息 Pin | `+messages-set-pin` / `+messages-unset-pin` | 作用于一条消息 |
|
||||
| 消息 Top | `+messages-set-top` / `+messages-unset-top` | 作用于会话内一条消息 |
|
||||
| Favorite | `+flag-create` / `+flag-cancel` | 当前用户收藏 |
|
||||
| 会话 Top | `+conversation-set-top` | 作用于整个会话,不属于本文件 |
|
||||
|
||||
用户要求确认 Pin 已生效时,使用 `+messages-list-pin` 检查真实结果中的 `messageId`;取消 Pin
|
||||
后仅在用户要求确认取消结果时再次查询。典型短链为:`+messages-set-pin` →
|
||||
`+messages-list-pin` → `+messages-unset-pin`。
|
||||
|
||||
需要原子 fallback 时,消息 Pin 对应 `message set-pin-msg/unset-pin-msg`,消息 Top 对应
|
||||
`message set-top-msg/unset-top-msg`,Favorite 对应 `message add-favorite/remove-favorite`。
|
||||
四种对象不能互换,即使用户都使用“收藏、钉住、置顶”等自然语言。
|
||||
|
||||
## 表情回应
|
||||
|
||||
优先在 [chat-emoji-list.md](../chat-emoji-list.md) 按表情名称查默认 emoji,不必全文理解表格。
|
||||
|
||||
| 场景 | 入口 |
|
||||
|---|---|
|
||||
| 添加/移除默认 emoji | `+messages-add-emoji` / `+messages-remove-emoji` |
|
||||
| 默认表情无合适项时创建文字表情 | `+messages-create-text-emotion` |
|
||||
| 添加/移除文字表情 | `+messages-add-text-emotion` / `+messages-remove-text-emotion` |
|
||||
| 替换文字表情 | `message update-text-emotion` |
|
||||
|
||||
reaction 查询属于 [message-query.md](message-query.md),不要为了查看回应执行写命令。
|
||||
|
||||
## 流式卡片与文本工具
|
||||
|
||||
流式卡片使用根 Skill 直接链接的 [card/create.md](../card/create.md)、
|
||||
[card/update.md](../card/update.md) 和 [card/schema.md](../card/schema.md);本文件不复制卡片参数。
|
||||
|
||||
纯文本翻译使用:
|
||||
|
||||
```bash
|
||||
dws chat text translate --query "你好世界" --to en_US
|
||||
```
|
||||
|
||||
用户只要求翻译文本时不要误走消息发送。
|
||||
|
||||
## 完成与错误
|
||||
|
||||
- 写操作检查任务级结果、投递状态和失败项,不只看退出码。
|
||||
- 投递状态 unknown 时保留幂等键,不自动换目标重发。
|
||||
- `unknown flag` 时读取精确 leaf Help,最多修正一次。
|
||||
- 目标消息不存在、会话不匹配或发送者上下文缺失时停止,不猜 ID。
|
||||
- 已知稳定消息 ID 的单一动作及其紧邻验证均在本文件完成。
|
||||
@@ -1,76 +0,0 @@
|
||||
# message-media:特殊消息与资源下载
|
||||
|
||||
> 返回入口:[DingTalk Chat Skill](../../SKILL.md)
|
||||
|
||||
只用于位置、联系人名片、底层 mediaId/fileId 和消息资源下载。普通文本、Markdown、文件、
|
||||
图片、音频和视频发送继续按根 Skill 使用 `+dm`、`+send-to-group` 或 `+messages-send --file`,
|
||||
不读取本文件。
|
||||
|
||||
## 默认边界
|
||||
|
||||
- 用户身份普通文件/音视频:`dws chat +messages-send --as user --file <相对路径>`。
|
||||
- 已知资源引用单独下载:`dws chat +messages-resource-download`。
|
||||
- 从消息中定位并下载资源:在定位消息的 `+chat-messages`、`+search-msg` 或
|
||||
`+messages-mget` 同一次调用中加 `--download-resources`。
|
||||
- 只有 Shortcut 尚未发布的位置、联系人名片或真实底层媒体字段,才使用原子 fallback。
|
||||
|
||||
<!-- dws-intent: chat.send.advanced -->`dws chat +messages-send` 的 user 文件能力不能外推给 Bot/Webhook;
|
||||
机器人富媒体边界读取 [chat-bot.md](chat-bot.md),不得静默改成当前用户身份。
|
||||
|
||||
## 位置与联系人名片
|
||||
|
||||
位置消息必须确认纬度、经度、地址名称和地图缩略图 mediaId:
|
||||
|
||||
```bash
|
||||
dws chat message send --group <openConversationId> --msg-type location \
|
||||
--latitude <纬度> --longitude <经度> --location-name <地址名称> \
|
||||
--map-thumbnail-url "@mediaId"
|
||||
```
|
||||
|
||||
联系人名片的 `--contact-id` 必须是联系人 `openDingTalkId`,不能把 userId 直接代入:
|
||||
|
||||
```bash
|
||||
dws chat message send --group <openConversationId> \
|
||||
--msg-type profile --contact-id <openDingTalkId>
|
||||
```
|
||||
|
||||
用户要求真实发送结果时,保留发送返回的 `openTaskId`,再执行:
|
||||
|
||||
```bash
|
||||
dws chat message query-send-status --open-task-id <openTaskId> --format json
|
||||
```
|
||||
|
||||
检查真实 `sendStatus`、`openMessageId` 和 `openConversationId`。
|
||||
|
||||
原子 `message send` 只在 Shortcut 缺少真实必需字段时使用。群聊目标用 `--group`;单聊目标
|
||||
用 `--user` 或 `--open-dingtalk-id`,三者通常互斥。发送前核对接收对象、消息类型和资源来源。
|
||||
|
||||
## 资源下载
|
||||
|
||||
公开 `+messages-resource-download` 使用工作目录内安全相对路径,默认不覆盖;完整文件先写入
|
||||
临时落盘再原子发布。覆盖必须由用户显式传 `--overwrite`,读取和下载不需要 `--yes`。
|
||||
|
||||
任务要求从某条消息中定位并下载资源时,优先在限定会话、消息或时间范围的查询中加
|
||||
`--download-resources --output-dir <目录>`,并检查下载 ledger。`+messages-resource-download`
|
||||
只用于已经持有完整、真实且属于当前组织/profile 的独立资源引用、无需再定位消息的场景。
|
||||
若 `fileId` 返回 `RESOURCE_NOT_FOUND`,不得把同一个 ID 改称 `mediaId` 重试,也不得原样
|
||||
重复调用;应回到消息查询并使用 `--download-resources`。
|
||||
|
||||
底层 fallback:
|
||||
|
||||
```bash
|
||||
dws chat message download-media --type mediaId --resource-id <mediaId> \
|
||||
--message-id <openMessageId> --open-conversation-id <openConversationId> \
|
||||
--output ./downloads/
|
||||
```
|
||||
|
||||
`resource-id`、`message-id` 和会话 ID 必须来自同一 profile 下的真实消息查询结果。
|
||||
当前没有 Range/断点续传;失败时保留 ledger 或错误,显式重试整个文件,不拼接残片。
|
||||
|
||||
## 完成与错误
|
||||
|
||||
- 查询并下载时同时检查消息完整性和每项下载 ledger;单项失败不抹掉已取得消息。
|
||||
- 文件/音视频发送失败先确认工作目录内相对路径可读,不恢复独立上传再提取 mediaId 的旧默认链路。
|
||||
- 位置参数不完整时先向用户确认,不猜经纬度或缩略图。
|
||||
- 名片发送失败时确认 `--contact-id` 是 openDingTalkId。
|
||||
- 下载目标存在时默认停止;只有用户明确允许覆盖时才传 `--overwrite`。
|
||||
@@ -1,132 +0,0 @@
|
||||
# message-query:消息读取、搜索与查询
|
||||
|
||||
> 返回入口:[DingTalk Chat Skill](../../SKILL.md)
|
||||
|
||||
用于浏览或导出指定会话、按条件搜索消息、按消息 ID 读取详情、查看 @我、话题回复、
|
||||
Favorite、Pin 和 reaction。只读任务优先使用 Shortcut;只有 Shortcut 未发布所需底层字段、
|
||||
原始响应或手工 continuation 时才读取精确原子 leaf Schema。
|
||||
|
||||
## 入口选择
|
||||
|
||||
| 用户终点 | 唯一推荐入口 |
|
||||
|---|---|
|
||||
| <!-- dws-intent: chat.read.conversation -->浏览或导出一个指定群聊/单聊 | `dws chat +chat-messages` |
|
||||
| <!-- dws-intent: chat.search.filtered -->发送者、关键词、@对象或消息类型是主要条件 | `dws chat +search-msg` |
|
||||
| 已知消息 IDs 读取详情 | `dws chat +messages-mget` |
|
||||
| 查看 @我的消息 | `dws chat +at-me` |
|
||||
| 查看 Favorite | `dws chat +flag-list` |
|
||||
| 已知话题主消息或 thread/topic ID 读取回复 | `dws chat +thread-replies` |
|
||||
|
||||
`+chat-messages` 是指定会话的粗粒度读取;`+search-msg` 是目标条件明确的单/跨会话检索。
|
||||
不要先读完整会话再补跑搜索,也不要把群名或姓名直接填入只接受稳定 ID 的参数。
|
||||
|
||||
## 指定会话读取
|
||||
|
||||
群聊 `--group` 可传群名或 `openConversationId`;也可用 `--chat-query` 显式解析群名、
|
||||
用 `--conversation-id` 显式传稳定 ID。单聊使用 `--user` 或 `--open-dingtalk-id`。
|
||||
|
||||
```bash
|
||||
dws chat +chat-messages --group <群名或openConversationId> --format json
|
||||
dws chat +chat-messages --group <openConversationId> --page-all --page-limit 50 --format json
|
||||
```
|
||||
|
||||
可附带非必填的 `--sender-query <姓名>`:未传时返回全部消息;未解析出稳定 ID 时保留
|
||||
全部消息并记录失败;唯一解析出 userId/openDingTalkId 后,按消息 `senderId` 筛选同一次
|
||||
读取结果,覆盖最终 `messages/count` 并返回 `resolvedFilters`。不得用展示名字符串比较否定
|
||||
已经成功解析的稳定发送者身份。
|
||||
|
||||
```bash
|
||||
dws chat +chat-messages --group "项目群" --sender-query "测试用户甲" --page-all --format json
|
||||
```
|
||||
|
||||
时间范围使用公开可选的 `--start`、`--end`、`--order asc|desc`,兼容别名为
|
||||
`--start-time/--end-time/--sort`。范围为 `[start,end)`;仅开始时间表示到本次执行当前时间;
|
||||
仅结束时间只支持 `desc`,`asc` 必须提供开始时间。旧 `--time/--direction` 只用于兼容的
|
||||
单边界模式,不能与范围模式混用。
|
||||
|
||||
```bash
|
||||
dws chat +chat-messages --group <openConversationId> \
|
||||
--start "2026-08-01T00:00:00+08:00" --end "2026-08-02T00:00:00+08:00" \
|
||||
--order asc --page-all --format json
|
||||
```
|
||||
|
||||
完整读取后只需消息字段可判断的子集时,在同一次调用中使用全局 `--jq`,保留根信封并
|
||||
同步改写 `messages/count`;不得丢失 `complete`、`hasMore`、`failures` 等 ledger。
|
||||
发送者姓名仍使用 `--sender-query` 解析稳定身份,不用 `--jq` 比较展示名。
|
||||
|
||||
```bash
|
||||
dws chat +chat-messages --group "项目群" --page-all --format json \
|
||||
--jq '. as $root | [.messages[] | select((.reactions // []) | length > 0)] as $matched | $root | .messages = $matched | .count = ($matched | length)'
|
||||
```
|
||||
|
||||
要求导出时用 `--output <工作目录内相对.json>` 原子写入;需要资源时在读取命令上加
|
||||
`--download-resources`,不要让 Agent 先输出全量 JSON 再手工遍历资源引用。
|
||||
|
||||
## 多维度搜索
|
||||
|
||||
- 关键词使用公开 `--query`。
|
||||
- 已知稳定会话 ID 使用 `--group` / `--groups`;稳定发送者 ID 使用 `--senders`。
|
||||
- 只有群名时使用 `--chat-query`,由 CLI 唯一解析会话。
|
||||
- 只有发送者姓名时使用 `--sender-query`,由 CLI 唯一解析人员。
|
||||
- 不传会话过滤时搜索全部会话;默认时间范围为最近 7 天。
|
||||
- `--page-all` 只翻完当前时间范围内的游标页;精确范围使用成对的 `--start/--end`。
|
||||
- `--order` 只稳定排列已经取得的结果;未全量或 `complete=false` 时不得称为完整范围全局排序。
|
||||
|
||||
```bash
|
||||
dws chat +search-msg --chat-query "项目群" --sender-query "测试用户甲" --page-all --format json
|
||||
dws chat +search-msg --chat-query "项目群" --query "发布计划" --page-all --format json
|
||||
dws chat +search-msg --sender-query "测试用户甲" --page-all --format json
|
||||
```
|
||||
|
||||
需要 Shortcut 未发布的原始过滤字段或响应时,才评估 `message search-advanced`。它支持
|
||||
发送者、@对象、多个会话、消息类型、会话类型、机器人消息和时间范围,但不是默认入口。
|
||||
至少提供一种真实过滤条件,完整遍历只有 `--page-all` 会触发。
|
||||
|
||||
## 其他查询
|
||||
|
||||
### 已知消息、@我与话题回复
|
||||
|
||||
- `+messages-mget --msg-ids <id...>`:最多 50 条;结果可直接用于回复、转发、撤回和资源下载。
|
||||
- `+at-me [--group <群名或ID>] --page-all`:群内或跨全部会话查看 @我的消息。
|
||||
- `+thread-replies --message-id <rootMessageId>`:自动只读解析 conversation/thread。
|
||||
- `+thread-replies --group <cid> --thread-id <threadId>`:显式稳定上下文。
|
||||
|
||||
话题回复默认 `desc`;`asc` 必须与 `--page-all` 一起使用。自动续页使用下层毫秒级
|
||||
`nextCursor`,不得使用只有秒精度的展示时间手工拼 continuation。检查 `complete`、
|
||||
`hasMore`、`stopReason` 和 `failures`。
|
||||
|
||||
### Favorite、Pin 与 reaction 查询
|
||||
|
||||
| 任务 | 入口 |
|
||||
|---|---|
|
||||
| Favorite 列表 | `+flag-list`;要求全部时加 `--page-all`,页大小 1–30 |
|
||||
| 消息 Pin 列表 | `message list-pin-msg --open-conversation-id <cid>` |
|
||||
| 批量 reaction/文字回应 | `message list-emotion-replies --msg-ids <id...>` |
|
||||
| 已读/未读状态 | `message read-status --group <cid> --message-id <id>` |
|
||||
|
||||
Favorite、消息 Pin、消息 Top 和会话 Top 是不同对象。写入或取消这些状态读取
|
||||
[message-actions.md](message-actions.md),这里只负责查询。
|
||||
|
||||
## 原子 fallback
|
||||
|
||||
| 原子命令 | 仅用于 |
|
||||
|---|---|
|
||||
| `message list` | 指定会话原始响应或显式手工 continuation |
|
||||
| `message list-all` | 时间范围内全部会话的原始分页响应 |
|
||||
| `message list-by-sender` | 已有稳定发送者 ID 且需要底层原始响应 |
|
||||
| `message list-mentions` / `list-focused` | @我或特别关注的原始列表 |
|
||||
| `message search` / `search-advanced` | Shortcut 未发布的真实过滤字段 |
|
||||
| `message list-topic-replies` | 已知 conversation/thread 的原始话题回复 |
|
||||
| `message list-by-ids` | 已知消息 ID 的原始详情响应 |
|
||||
|
||||
Typed `chat message` 自动翻页只由 `--page-all` 触发;只传 `--page-limit`、`--max-items`
|
||||
或 `--page-delay` 仍是单页。非第一页失败时保留 partial 结果、失败页和 continuation,不能
|
||||
把 partial result 表述成完整成功。
|
||||
|
||||
## 完成与错误
|
||||
|
||||
- 查询必须检查 `complete`、`hasMore`、`stopReason`、`failures` 和下载 ledger。
|
||||
- 发送者/群名零命中或多候选时停止,不选择第一项。
|
||||
- `unknown flag` 时读取精确 leaf Help,修正后最多重试一次。
|
||||
- 子消息优先使用自己的 `messageId`;只在缺会话 ID 时继承父消息的 `conversationId`。
|
||||
- 查到真实消息后需要写操作时,使用 [message-actions.md](message-actions.md) 中的稳定 ID 规则。
|
||||
@@ -22,12 +22,12 @@
|
||||
|
||||
| 用户终点 | 对象 | Reference |
|
||||
|---|---|---|
|
||||
| 收藏或取消收藏 | 当前用户的 Favorite | [message-actions.md](chat/message-actions.md) |
|
||||
| Pin/Unpin 一条消息 | 消息 Pin | [message-actions.md](chat/message-actions.md) |
|
||||
| 置顶/取消置顶一条消息 | 消息 Top | [message-actions.md](chat/message-actions.md) |
|
||||
| 收藏或取消收藏 | 当前用户的 Favorite | [chat-message.md](chat/chat-message.md) |
|
||||
| Pin/Unpin 一条消息 | 消息 Pin | [chat-message.md](chat/chat-message.md) |
|
||||
| 置顶/取消置顶一条消息 | 消息 Top | [chat-message.md](chat/chat-message.md) |
|
||||
| 置顶/取消置顶整个会话 | 会话 Top | [chat-conversation.md](chat/chat-conversation.md) |
|
||||
| 查看置顶会话 | 会话列表 | `+conversation-list-top` |
|
||||
| 标记消息已读 | 消息读取状态 | [message-actions.md](chat/message-actions.md) |
|
||||
| 标记消息已读 | 消息读取状态 | [chat-message.md](chat/chat-message.md) |
|
||||
| 清红点、标记会话未读 | 会话状态 | [chat-conversation.md](chat/chat-conversation.md) |
|
||||
|
||||
Favorite、消息 Pin、消息 Top 和会话 Top 不能互换,即使用户都说“收藏/钉住/置顶”。
|
||||
@@ -37,8 +37,7 @@ Favorite、消息 Pin、消息 Top 和会话 Top 不能互换,即使用户都
|
||||
| 用户终点 | 选择 |
|
||||
|---|---|
|
||||
| 已有成员 IDs 创建群 | `+chat-create` |
|
||||
| 查群、查看成员、邀请链接 | [group-discovery.md](chat/group-discovery.md) |
|
||||
| 加人、踢人、管理员、群公告、群设置 | [group-admin.md](chat/group-admin.md) |
|
||||
| 加人、踢人、管理员、群公告、群设置 | [chat-group.md](chat/chat-group.md) |
|
||||
| 找可用机器人并取得单聊 ID | `chat bot find`,不是只查自己创建机器人的 `bot search` |
|
||||
| 已知 robotCode 发送 | `+messages-send --as bot` |
|
||||
| 机器人入群、移除、批量群发或撤回 | [chat-bot.md](chat/chat-bot.md) |
|
||||
|
||||
@@ -61,12 +61,6 @@ Flags:
|
||||
- 输出形态:带过滤时输出从单页透传变为聚合形态 `{items, maxDepth, truncated, errors}`。
|
||||
- 已知代价:大目录(>2000 条)触顶截断时 `truncated=true`(退出码 0,结果每条都正确但没扫完);
|
||||
建议用 `--folder` 指定子目录缩小扫描范围;带关键词的过滤场景改用 `dws drive search`。
|
||||
- 与 `--latest` 组合时上一条不适用:排序基不完整的 Top-N 不是全局最新,故触顶截断**或**递归途中
|
||||
目录读取失败都拒绝产出并报错(`LATEST_SCAN_TRUNCATED` / `LATEST_SCAN_INCOMPLETE`),不会以
|
||||
退出码 0 交出结果;错误消息里带首个失败目录的 folder/depth/reason,以及一条复现原候选集
|
||||
(查询域 + `--folder` + `--pattern`/`--type`/`--start`/`--end`)的恢复命令。Windows 构建下,
|
||||
若原值含 shell 元字符则命令里只给占位符、原值另起一行以数据形式列出(cmd.exe 与 PowerShell
|
||||
没有共同安全的引用形式),照抄时需手动替换。
|
||||
|
||||
### 获取钉盘空间列表
|
||||
|
||||
|
||||
@@ -129,245 +129,6 @@ func Value() string {
|
||||
}
|
||||
}
|
||||
|
||||
// shardChangedPackagesShards mirrors the shard names scripts/ci/test-packages.sh
|
||||
// asserts complete coverage for in its verify mode. Keeping the list here in
|
||||
// full is deliberate: the union assertion below fails if a shard is added
|
||||
// upstream without being selected, and an unknown-shard error fails if one is
|
||||
// removed, so shard-plan drift cannot silently shrink focused-test coverage.
|
||||
var shardChangedPackagesShards = []string{
|
||||
"app",
|
||||
"generators",
|
||||
"helpers",
|
||||
"cli",
|
||||
"smoke",
|
||||
"remaining",
|
||||
"release-scripts",
|
||||
}
|
||||
|
||||
func TestChangedTestPackagesShardSelectionCoversEveryImpactedPackageExactlyOnce(t *testing.T) {
|
||||
repository := newShardChangedPackagesRepository(t)
|
||||
baseRef := commitChangedTestPackagesFixture(t, repository, "initial shard fixture")
|
||||
|
||||
writeChangedTestPackagesFixture(t, repository, "core/core.go", `package core
|
||||
|
||||
func Value() string {
|
||||
return "changed"
|
||||
}
|
||||
`)
|
||||
headRef := commitChangedTestPackagesFixture(t, repository, "change core")
|
||||
|
||||
impacted := runChangedTestPackages(t, repository, "list", baseRef, headRef)
|
||||
if len(impacted) == 0 {
|
||||
t.Fatal("shard fixture produced no impacted packages")
|
||||
}
|
||||
|
||||
owner := map[string]string{}
|
||||
union := make([]string, 0, len(impacted))
|
||||
for _, shard := range shardChangedPackagesShards {
|
||||
for _, pkg := range runChangedTestPackagesShard(t, repository, shard, baseRef, headRef) {
|
||||
if previous, ok := owner[pkg]; ok {
|
||||
t.Fatalf("package %q selected by both shard %q and %q", pkg, previous, shard)
|
||||
}
|
||||
owner[pkg] = shard
|
||||
union = append(union, pkg)
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(union)
|
||||
if !slices.Equal(union, impacted) {
|
||||
t.Fatalf("shard selection union = %q, want every impacted package %q", union, impacted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangedTestPackagesShardSelectionFailsClosedOnUnknownShard(t *testing.T) {
|
||||
repository := newShardChangedPackagesRepository(t)
|
||||
baseRef := commitChangedTestPackagesFixture(t, repository, "initial shard fixture")
|
||||
|
||||
writeChangedTestPackagesFixture(t, repository, "core/core.go", `package core
|
||||
|
||||
func Value() string {
|
||||
return "changed"
|
||||
}
|
||||
`)
|
||||
headRef := commitChangedTestPackagesFixture(t, repository, "change core")
|
||||
|
||||
script := filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh")
|
||||
command := exec.Command(script, "list-shard", "nonexistent", baseRef, headRef)
|
||||
command.Dir = repository
|
||||
output, err := command.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatalf("unknown shard unexpectedly succeeded:\n%s", output)
|
||||
}
|
||||
// An unknown shard must abort rather than report an empty selection: a
|
||||
// silently empty shard would let a mistyped CI shard name skip every test
|
||||
// while still reporting success.
|
||||
if !strings.Contains(string(output), "unknown test package shard") {
|
||||
t.Fatalf("unknown shard failure = %q, want fail-closed diagnostic", output)
|
||||
}
|
||||
if strings.TrimSpace(string(output)) == "" {
|
||||
t.Fatal("unknown shard produced empty output instead of a diagnostic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangedTestPackagesShardSelectionIsEmptyForUnaffectedShard(t *testing.T) {
|
||||
repository := newShardChangedPackagesRepository(t)
|
||||
baseRef := commitChangedTestPackagesFixture(t, repository, "initial shard fixture")
|
||||
|
||||
writeChangedTestPackagesFixture(t, repository, "internal/helpers/helpers.go", `package helpers
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " helpers changed"
|
||||
}
|
||||
`)
|
||||
headRef := commitChangedTestPackagesFixture(t, repository, "change helpers only")
|
||||
|
||||
if selected := runChangedTestPackagesShard(t, repository, "helpers", baseRef, headRef); len(selected) == 0 {
|
||||
t.Fatal("helpers shard selected nothing for a helpers-only change")
|
||||
}
|
||||
// An unaffected shard is a normal outcome and must exit zero with no
|
||||
// output so the CI step can no-op instead of failing the job.
|
||||
if selected := runChangedTestPackagesShard(t, repository, "app", baseRef, headRef); len(selected) != 0 {
|
||||
t.Fatalf("app shard = %q, want empty for a helpers-only change", selected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangedTestPackagesShardSelectionRejectsMissingShardArgument(t *testing.T) {
|
||||
repository := newShardChangedPackagesRepository(t)
|
||||
baseRef := commitChangedTestPackagesFixture(t, repository, "initial shard fixture")
|
||||
|
||||
script := filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh")
|
||||
command := exec.Command(script, "list-shard", baseRef, baseRef)
|
||||
command.Dir = repository
|
||||
output, err := command.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatalf("list-shard without a shard argument unexpectedly succeeded:\n%s", output)
|
||||
}
|
||||
if !strings.Contains(string(output), "usage:") {
|
||||
t.Fatalf("missing shard argument failure = %q, want usage diagnostic", output)
|
||||
}
|
||||
}
|
||||
|
||||
func runChangedTestPackagesShard(t *testing.T, repository, shard, baseRef, headRef string) []string {
|
||||
t.Helper()
|
||||
|
||||
output := runChangedTestPackagesCommand(
|
||||
t,
|
||||
repository,
|
||||
filepath.Join(repository, "scripts", "ci", "changed-test-packages.sh"),
|
||||
"list-shard",
|
||||
shard,
|
||||
baseRef,
|
||||
headRef,
|
||||
)
|
||||
return strings.Fields(output)
|
||||
}
|
||||
|
||||
// newShardChangedPackagesRepository builds a fixture whose directory layout
|
||||
// matches every shard scripts/ci/test-packages.sh knows about, so shard
|
||||
// selection can be exercised without depending on the real repository's working
|
||||
// tree state. Every package imports core, letting a single core edit mark the
|
||||
// whole module impacted.
|
||||
func newShardChangedPackagesRepository(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
repository := t.TempDir()
|
||||
for path, contents := range map[string]string{
|
||||
"go.mod": `module example.com/shardfixture
|
||||
|
||||
go 1.22
|
||||
`,
|
||||
"core/core.go": `package core
|
||||
|
||||
func Value() string {
|
||||
return "core"
|
||||
}
|
||||
`,
|
||||
"internal/app/app.go": `package app
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " app"
|
||||
}
|
||||
`,
|
||||
"internal/generator/gen/gen.go": `package gen
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " gen"
|
||||
}
|
||||
`,
|
||||
"internal/helpers/helpers.go": `package helpers
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " helpers"
|
||||
}
|
||||
`,
|
||||
"internal/cli/cli.go": `package cli
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " cli"
|
||||
}
|
||||
`,
|
||||
"test/smoke/smoke.go": `package smoke
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " smoke"
|
||||
}
|
||||
`,
|
||||
"test/scripts/scripts.go": `package scripts
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " scripts"
|
||||
}
|
||||
`,
|
||||
"pkg/extra/extra.go": `package extra
|
||||
|
||||
import "example.com/shardfixture/core"
|
||||
|
||||
func Value() string {
|
||||
return core.Value() + " extra"
|
||||
}
|
||||
`,
|
||||
} {
|
||||
writeChangedTestPackagesFixture(t, repository, path, contents)
|
||||
}
|
||||
|
||||
projectRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("resolve project root: %v", err)
|
||||
}
|
||||
for _, name := range []string{"changed-test-packages.sh", "test-packages.sh"} {
|
||||
script, err := os.ReadFile(filepath.Join(projectRoot, "scripts", "ci", name))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
scriptPath := filepath.Join(repository, "scripts", "ci", name)
|
||||
if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil {
|
||||
t.Fatalf("create script directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(scriptPath, script, 0o755); err != nil {
|
||||
t.Fatalf("copy %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
runChangedTestPackagesCommand(t, repository, "git", "init", "-q", "-b", "main")
|
||||
runChangedTestPackagesCommand(t, repository, "git", "config", "user.name", "DWS CI")
|
||||
runChangedTestPackagesCommand(t, repository, "git", "config", "user.email", "dws-ci@example.invalid")
|
||||
return repository
|
||||
}
|
||||
|
||||
func newChangedTestPackagesRepository(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user