Compare commits

..
Author SHA1 Message Date
Dennis 1d3a2976cf fix(wiki): document search parameter adapter 2026-08-15 00:04:54 +08:00
338 changed files with 4417 additions and 24055 deletions
@@ -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,8 +0,0 @@
---
category: Changed
---
- **Faster Schema Catalog assembly** — projects typed values into payload JSON
without re-running a validation scan over documents `json.Marshal` has just
produced, cutting roughly a third of the projection work across the full tool
set. Untrusted JSON input keeps its existing validation.
@@ -1,11 +0,0 @@
---
category: Fixed
---
- **Aitable pagination and Minutes unshare verification** (#1006) — keeps
record queries on the service's 20-record page boundary so multi-page reads
and mutation readbacks no longer report false retryable failures, preserves
`totalCount` when supplied, validates `--dry-run` plans before transport,
follows active deletion readback continuations before proving absence, and
rejects Minutes unshare success until the listening note exists and the
service acknowledges the exact task and member targets.
@@ -1,5 +0,0 @@
---
category: Added
---
- **Robot group reference replies** (#928) — `chat message send-by-bot` supports paired `--reply` and `--ref-sender` flags for Markdown replies that quote an existing group message.
@@ -1,5 +0,0 @@
---
category: Fixed
---
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback. Document reverts and media inserts now require explicit readback evidence and report partial success when the server cannot prove the requested result.
@@ -1,5 +0,0 @@
---
category: Changed
---
- **AI Table parameter aliases** — accepts reviewed equivalent spellings for Base, table, workflow, search, pagination, and description parameters while keeping role-changing or semantically ambiguous inputs blocked.
@@ -1,9 +0,0 @@
---
category: Added
---
- **AI Table server-side statistics** — adds `dws aitable record stats` for
ungrouped record-set metrics through `query_records_stats`, plus `dws aitable
record group-stats` for grouped, distinct, and advanced aggregation through
`query_stats`; both commands validate their JSON aggregation contracts before
dispatch.
@@ -1,5 +0,0 @@
---
category: Added
---
- **Calendar event share-info** (#980) — adds `dws calendar event share-info` to fetch a calendar event's share info (title, organizer, location, join info) for sharing with others; supports `--calendar-id` and `--language`.
@@ -1,11 +0,0 @@
---
category: Added
---
- **Calendar and To-do Shortcut workflows** — aligns 47 public task-oriented
entries with lark-cli where the DingTalk backend supports equivalent
semantics, rejects malformed or missing collections instead of returning
false empty success, preserves truthful pagination, and requires stable
identifiers plus read-back or explicit terminal receipts for writes. Adds
deterministic contract coverage, a PII-safe live E2E runner, and a sanitized
capability review with documented platform boundaries.
@@ -1,5 +0,0 @@
---
category: Fixed
---
- **Chat sender identity guards** — preserves unverified mixed sender inputs after exact message `senderId` matches and aligns `--sender-query` Skill guidance with fail-closed Runtime behavior.
@@ -1,5 +0,0 @@
---
category: Changed
---
- **Doc/drive description scope** — restates the `dingtalk-doc` description as document-entity-and-content operations with an explicit exclusion list, and narrows `dingtalk-drive` to file-level management of DingTalk documents, so first-round Agent selection separates content work from file management without changing CLI behavior.
@@ -1,10 +0,0 @@
---
category: Added
---
- **Doc and Sheet comment lifecycle commands** — adds `comment batch-query`,
`comment resolve`, `comment restore`, and the lightweight
`comment react-reply` to both `dws doc` and `dws sheet`. The two domains share
the same `doc-comment` MCP capabilities; batch queries preserve input order
for repeated `topicId:commentKey` references, while reaction replies require
DingTalk reaction names such as `憨笑` or `鼓掌` rather than raw Unicode emoji.
@@ -1,6 +0,0 @@
---
category: Added
---
- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported.
- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads.
@@ -1,5 +0,0 @@
---
category: Fixed
---
- **Windows event bus lifecycle** — start event consumers without unsupported inherited file descriptors, stop buses through local IPC with a termination fallback, and preserve subscription cleanup when startup fails.
+25 -141
View File
@@ -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,38 +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.
# internal/app is carried by one shard per bounded partition rather than a
# single app shard: the partitions used to run end to end inside one job,
# where the Schema partition alone owned most of the wall clock. The
# app-<partition> names are pinned to the helper's partition set by
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
# its job silently.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard:
- app-schema
- app-a-b
- app-c
- app-d-r
- app-s-z-example-fuzz
- generators
- helpers
- cli
- smoke
- remaining
- release-scripts
steps:
- name: Check out repository
uses: actions/checkout@v4
@@ -573,110 +542,37 @@ jobs:
with:
go-version-file: go.mod
- name: Select impacted packages for shard
id: select
shell: bash
env:
TEST_SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
# Every app partition shard tests the same single internal/app
# package, so the impacted-package query uses the base shard name and
# the partition only selects which tests run.
package_shard="$TEST_SHARD"
case "$TEST_SHARD" in
app-*) package_shard=app ;;
esac
package_output="$(
./scripts/ci/changed-test-packages.sh \
list-shard "$package_shard" "$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." \
>> "$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' }}
- name: Test changed packages and reverse dependencies
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
case "$TEST_SHARD" in
app-*)
# A single long-lived app test process retains every constructed
# command tree in framework registries. Each partition is its own
# job, so that state is released when the process exits and the
# partitions run concurrently instead of end to end. The helper
# still verifies that the partition patterns cover every top-level
# test exactly once before running the one it was asked for.
test "${#packages[@]}" -eq 1
./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"
exit 0
;;
esac
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[@]}"
package_output="$(
./scripts/ci/changed-test-packages.sh \
list "$TEST_BASE_REF" "$TEST_HEAD_REF"
)"
if [ -z "$package_output" ]; then
echo "No buildable Go package is affected by this revision." \
>> "$GITHUB_STEP_SUMMARY"
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 }})"
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
# internal/app is split across one shard per bounded partition so the
# partitions run concurrently and each releases its framework registries
# when the process exits; cli/smoke need headroom beyond go test -timeout for
# setup + assembly. The app-<partition> names are pinned to the helper's
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
# app runs several independently bounded processes; cli/smoke need headroom
# beyond go test -timeout for setup + assembly.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard:
- app-schema
- app-a-b
- app-c
- app-d-r
- app-s-z-example-fuzz
- app
- generators
- helpers
- cli
@@ -702,30 +598,18 @@ jobs:
TEST_SHARD: ${{ matrix.shard }}
run: |
set -euo pipefail
# Every app partition shard tests the same single internal/app
# package, so the package query uses the base shard name and the
# partition only selects which tests run.
package_shard="$TEST_SHARD"
case "$TEST_SHARD" in
app-*) package_shard=app ;;
esac
package_output="$(./scripts/ci/test-packages.sh list "$package_shard")"
package_output="$(./scripts/ci/test-packages.sh list "$TEST_SHARD")"
test -n "$package_output"
mapfile -t packages <<< "$package_output"
test "${#packages[@]}" -gt 0
case "$TEST_SHARD" in
app-*)
# A single long-lived app test process retains every constructed
# command tree in framework registries. Each partition is its own
# job, so that state is released when the process exits and the
# partitions run concurrently instead of end to end. The helper
# still verifies that the partition patterns cover every top-level
# test exactly once before running the one it was asked for.
test "${#packages[@]}" -eq 1
./scripts/ci/run-app-race-tests.sh run "${packages[0]}" "${TEST_SHARD#app-}"
exit 0
;;
esac
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
# cli/smoke own heavy NewRootCommand / Schema assembly under -race;
# give them a dedicated package timeout on slower hosted runners.
timeout_budget=12m
@@ -827,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" \
@@ -856,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"
-7
View File
@@ -20,10 +20,6 @@ test/cli_compat/testdata/
.gitignore
.worktrees/
.qoder/
_logs/
_docs/
_output/
vendor/
# Secrets & credentials
.env
@@ -73,6 +69,3 @@ coverage-*.txt
# stray compiled generator binary (source lives in internal/generator/cmd_param_aliases/)
/cmd_param_aliases
# Local product-skill design materials (not for repository pushes)
/design-dws-product-skills/
-86
View File
@@ -6,92 +6,6 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.59-beta.3] - 2026-08-19
### Added
- **Robot group reference replies** (#928) — `chat message send-by-bot` supports paired `--reply` and `--ref-sender` flags for Markdown replies that quote an existing group message.
- **AI Table server-side statistics** — adds `dws aitable record stats` for
ungrouped record-set metrics through `query_records_stats`, plus `dws aitable
record group-stats` for grouped, distinct, and advanced aggregation through
`query_stats`; both commands validate their JSON aggregation contracts before
dispatch.
- **Calendar event share-info** (#980) — adds `dws calendar event share-info` to fetch a calendar event's share info (title, organizer, location, join info) for sharing with others; supports `--calendar-id` and `--language`.
- **Calendar and To-do Shortcut workflows** — aligns 47 public task-oriented
entries with lark-cli where the DingTalk backend supports equivalent
semantics, rejects malformed or missing collections instead of returning
false empty success, preserves truthful pagination, and requires stable
identifiers plus read-back or explicit terminal receipts for writes. Adds
deterministic contract coverage, a PII-safe live E2E runner, and a sanitized
capability review with documented platform boundaries.
- **Doc and Sheet comment lifecycle commands** — adds `comment batch-query`,
`comment resolve`, `comment restore`, and the lightweight
`comment react-reply` to both `dws doc` and `dws sheet`. The two domains share
the same `doc-comment` MCP capabilities; batch queries preserve input order
for repeated `topicId:commentKey` references, while reaction replies require
DingTalk reaction names such as `憨笑` or `鼓掌` rather than raw Unicode emoji.
- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported.
- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads.
### Changed
- **AI Table parameter aliases** — accepts reviewed equivalent spellings for Base, table, workflow, search, pagination, and description parameters while keeping role-changing or semantically ambiguous inputs blocked.
- **Doc/drive description scope** — restates the `dingtalk-doc` description as document-entity-and-content operations with an explicit exclusion list, and narrows `dingtalk-drive` to file-level management of DingTalk documents, so first-round Agent selection separates content work from file management without changing CLI behavior.
### Fixed
- **Aitable pagination and Minutes unshare verification** (#1006) — keeps
record queries on the service's 20-record page boundary so multi-page reads
and mutation readbacks no longer report false retryable failures, preserves
`totalCount` when supplied, validates `--dry-run` plans before transport,
follows active deletion readback continuations before proving absence, and
rejects Minutes unshare success until the listening note exists and the
service acknowledges the exact task and member targets.
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback. Document reverts and media inserts now require explicit readback evidence and report partial success when the server cannot prove the requested result.
- **Chat sender identity guards** — preserves unverified mixed sender inputs after exact message `senderId` matches and aligns `--sender-query` Skill guidance with fail-closed Runtime behavior.
- **Windows event bus lifecycle** — start event consumers without unsupported inherited file descriptors, stop buses through local IPC with a termination fallback, and preserve subscription cleanup when startup fails.
## [1.0.59-beta.2] - 2026-08-17
### 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.
- **Feedback survey entry in root help** (#1019) — `dws --help` now closes with a Feedback section linking the user-experience survey form.
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
activity shortcuts with strict collection validation, cursor handling,
write-terminal evidence, safe read-backs where the backend supports them,
task-oriented routing, and documented backend
boundaries.
### 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.
- **Faster Schema Catalog assembly** — projects typed values into payload JSON
without re-running a validation scan over documents `json.Marshal` has just
produced, cutting roughly a third of the projection work across the full tool
set. Untrusted JSON input keeps its existing validation.
### 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.0.59-beta.1] - 2026-08-14
### Added
+11 -11
View File
@@ -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.3"
version "1.0.59-beta.1"
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.3/dws-darwin-arm64.tar.gz"
sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-arm64.tar.gz"
sha256 "36a30f3496e0f759c15c0b09f67dbd23b8ecdfff2eebe572f88125b26485830f"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz"
sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-amd64.tar.gz"
sha256 "e7a04906380efd8da88cd112e6a512bb6470a3956dc370150037ed6e314db445"
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.3/dws-linux-arm64.tar.gz"
sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-arm64.tar.gz"
sha256 "f59ab055f3e841e4cebc964ae3ef969668475548abaaf8bede44afdca9a3e28d"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz"
sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-amd64.tar.gz"
sha256 "2c8f919489d958c7d49262615e81faac70a9fbcae2d589ab54a0bb3c5700a057"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-skills.zip"
sha256 "25f4a7e1d01fa4d771d79201b34b11ee8a24182bdcdc94bfb98d2bd5845bed3b"
end
def install
+2 -66
View File
@@ -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
View File
@@ -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)
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
```
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单登记了 IM ID rename 的 `pending` 记录;`pending` 只记录已评审计划,候选与 merge-base 仍必须精确匹配 `before`,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
## 两阶段迁移与回执清理
-1
View File
@@ -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 编排 / 执行字段(不算声明)
+1 -47
View File
@@ -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`;
+65 -338
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-08-18T17:38:50.904696",
"count": 436,
"generated_at": "2026-08-14T13:50:36.324505",
"count": 418,
"results": [
{
"suite": "semantic",
@@ -489,7 +489,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 ID、筛选、关键词、字段投影和分页查询;fieldIds 只返回用户要求的列,并投影稳定的 records/count/cursor 结果。",
"semantic_delta": "统一 ID、筛选、关键词和分页查询,并投影稳定的 records/count/cursor 结果。",
"availability": "available"
},
{
@@ -692,16 +692,6 @@
"semantic_delta": "按当前父目录语义调整文件夹展示顺序。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
"command": "+table-bootstrap",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "在已有 Base 中声明式创建一张表和字段,字段自动分片,逐层读回验证并发布可恢复检查点。",
"availability": "available"
},
{
"suite": "semantic",
"service": "aitable",
@@ -1066,274 +1056,144 @@
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+agenda",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证 result.events 与分页证据,稳定投影 id 为 eventId;显式空数组才是空日程。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+attendee-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证参会人数组并投影显示名、响应状态和 self;真实后端不返回稳定参会人 userId,因此不伪造身份字段。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "write",
"service": "calendar",
"command": "+book",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按参会人姓名解析后创建日程,并在多步骤路径提供失败回滚与详情读回。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+book-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证日历本数组并稳定投影 calendarId,缺失数组不再误报为空。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+book-search",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按日历本名称搜索并严格区分零命中与协议错误。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "write",
"service": "calendar",
"command": "+cancel-event",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "删除前读取并展示目标,经高风险确认后取消指定日程。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+conflicts",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "在指定范围分析重叠日程并输出冲突对,避免人工逐项比对。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "calendar",
"command": "+create",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "创建响应必须给出 eventId/id,且标题和起止时间通过详情读回后才成功。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+free",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名解析参与者后查询闲忙,省去手工 userId 解析。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+free-slots",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "从已有日程计算工作时段内空档,并显式输出满足时长的候选区间。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+freebusy",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一按用户或会议室 ID 查询指定范围闲忙,并声明至少一类目标约束。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "calendar",
"command": "+get",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "严格读取日程对象并把稳定 id 规范化为 eventId,拒绝空响应和对象漂移。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "write",
"service": "calendar",
"command": "+invite",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名唯一解析用户后邀请到已有日程。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+my-free",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "以当前用户身份查询一个时间段是否空闲。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+next-event",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "在未来时间窗内定位最近的下一场日程。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "write",
"service": "calendar",
"command": "+reschedule",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "修改前读取目标日程,再更新完整起止时间。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "calendar",
"command": "+room-find",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按单个未来时间段严格查询可用会议室,公开真实 page/pageSize/hasMore 证据和后端筛选边界。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+room-groups",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格读取 result.groupList,避免会议室分组被响应 wrapper 漂移静默清空。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+room-search",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按名称查询会议室但不声称检查时间可用性;缺失会议室数组直接失败。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "calendar",
"command": "+rsvp",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "把直白 RSVP 动作映射为 responseStatus,拒绝空 ack,并在写后读取日程终态。",
"availability": "available"
},
{
"suite": "semantic",
"service": "calendar",
"command": "+search-event",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "在服务端日程当前页按标题、描述和地点过滤,同时保留 hasMore/nextCursor,绝不把单页零命中当全局未找到。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+suggest-time",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名逐个唯一解析参与者,再查询大家共同可用的建议时间。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "calendar",
"command": "+suggestion",
"risk": "read",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "直接按 userId 查询建议时段,严格区分显式空 recommendEventTimes 与缺失或畸形响应。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+today",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "查询并整理今天的日程视图。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+tomorrow",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "查询并整理明天的日程视图。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "calendar",
"command": "+update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "组合字段与参会人分阶段更新,明确非事务步骤并以详情和参会人列表读回验证。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "calendar",
"command": "+week",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "查询并按日期整理本周日程。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
@@ -3722,214 +3582,81 @@
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "write",
"service": "todo",
"command": "+assign",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "保留历史按姓名解析后创建并指派的新任务语义,避免同名破坏性变更。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "write",
"service": "todo",
"command": "+assign-multi",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "全部姓名唯一解析后才创建一次,并以单一统一结果输出稳定 taskId。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "todo",
"command": "+comment",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "新增评论必须取得稳定 commentId,并分页读回验证内容。",
"availability": "available"
},
{
"suite": "semantic",
"service": "todo",
"command": "+complete",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先读当前状态避免重复写,必要时完成后再读回核验。",
"availability": "available"
},
{
"suite": "semantic",
"service": "todo",
"command": "+create",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "创建后要求稳定 taskId,并读取 todoDetailModel 验证标题。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "todo",
"command": "+created-todos",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "遍历创建者角色的全部分页后投影。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "todo",
"command": "+due-today",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按本地日历日构造服务端截止窗口并遍历全部分页。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "todo",
"command": "+get",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格绑定 todoDetailModel.taskId,缺失或错绑均失败。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "todo",
"command": "+get-my-tasks",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格识别 todoCards/hasMore/page/size;显式空数组才是空结果,并支持有界全量分页。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "todo",
"command": "+get-related-tasks",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "遍历与我相关三种角色的全部分页并按 taskId 去重。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "read",
"service": "todo",
"command": "+list-attachment",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格区分显式空附件与缺失容器,并要求每项稳定附件 ID。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "todo",
"command": "+list-comment",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格区分显式空评论与缺失容器,并要求每项稳定 commentId。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "todo",
"command": "+list-sub",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格校验子待办容器、对象元素和稳定 taskId。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"suite": "read",
"service": "todo",
"command": "+overdue",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "遍历执行者角色的全部分页并按当前时间筛选逾期项。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "todo",
"command": "+related-tasks",
"risk": "read",
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留旧命令拼写,新的 Agent 路由统一使用 +get-related-tasks。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "write",
"service": "todo",
"command": "+remind",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "解析当前用户后创建给自己的待办;该命令不是独立提醒规则。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
"service": "todo",
"command": "+reminder",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "严格验证提醒参数和 success=true 终端回执;上游不能读回规则,固定 verified=false。",
"availability": "available"
},
{
"suite": "semantic",
"service": "todo",
"command": "+reopen",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先读当前状态避免重复写,必要时重开后再读回核验。",
"availability": "available"
},
{
"suite": "semantic",
"service": "todo",
"command": "+search",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "遍历全部 hasMore 分页并按标题匹配;达到页上限仍未耗尽时失败。",
"availability": "available"
},
{
"suite": "semantic",
"suite": "write",
"service": "todo",
"command": "+todo-done",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "跨全部分页按标题唯一定位后完成任务。",
"availability": "available"
},
{
"suite": "semantic",
"service": "todo",
"command": "+update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "更新指定字段后读取详情逐字段核验。",
"availability": "available"
"status": "real-ok"
},
{
"suite": "semantic",
+1 -1
View File
@@ -102,7 +102,7 @@
<tr><td><code>minutes +latest-minutes</code></td><td>列妙记→取最新一条详情</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>minutes +action-items</code></td><td>列妙记→取最新→取其待办</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>wiki +wiki-new-doc --space &lt;名&gt;</code></td><td>按名搜知识空间→建文档(跨 doc server 路由)</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>doc +doc-append --doc --content</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>doc +doc-append --doc --text</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>doc +share-doc --to &lt;名&gt; --url</code></td><td>解析人→把文档链接私信 TA(跨服务)</td><td class="c ok">编译/挂载</td></tr>
</tbody>
</table>
+4 -4
View File
@@ -56,13 +56,13 @@
| shortcut | 多步/智能逻辑 | 验证 |
|----------|--------------|------|
| `chat +dm --to <姓名> --content` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
| `chat +dm --to <姓名> --text` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
| `contact +lookup --name <姓名>` | 搜人→解析 userId→取完整资料 | ✅ **真机端到端** |
| `todo +assign --to <姓名> --task` | 解析人→建待办并把 TA 设为执行人 | ✅ dry-run 真机 |
| `chat +send-to-group --group <群名> --content` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
| `chat +send-to-group --group <群名> --text` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
| `calendar +book --title --start --end [--with <姓名CSV>]` | 建日程→按名加参与者→**失败回滚删日程**(对标 lark `calendar +create`) | ✅ dry-run 真机 |
| `calendar +free --who <姓名> --start --end` | 解析人→查其时段忙闲 | ✅ **真机端到端**(解析 202397→查忙闲) |
| `chat +broadcast --to <姓名CSV> --content` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
| `chat +broadcast --to <姓名CSV> --text` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
| `minutes +latest-minutes` | 列妙记→取最新一条详情 | ✅ 编译/挂载 |
| `chat +group-members --group <群名>` | 按群名搜群→列群成员 | ✅ 编译/挂载 |
| `contact +org --name <姓名>` | 解析人→取详情拿 deptId→查部门详情 | ✅ **真机端到端**(3 步:董鑫阳→模型算法/16人) |
@@ -76,7 +76,7 @@
| `todo +todo-done --task <关键词>` | 列我的待办→按标题匹配→标记完成 | ✅ 编译/挂载 |
| `calendar +reschedule --event <id>` | 查日程详情→改时间(查→改机械多步) | ✅ 编译/挂载 |
| `wiki +wiki-new-doc --space <名>` | 按名搜知识空间→在其下建文档(跨 doc server 路由) | ✅ 编译/挂载 |
| `doc +doc-append --doc --content` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
| `doc +doc-append --doc --text` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
| `minutes +action-items` | 列妙记→取最新→取其待办事项 | ✅ 编译/挂载 |
| `minutes +detail --id <taskUuid>` | 一条命令聚合听记 basic/summary/keywords/transcript/todos,partial-failure 容错 | ✅ 全量测试 |
| `minutes +replace-batch --id --pair "原文=>替换"…` | 多组批量替换文字,去重校验+逐组结果聚合 | ✅ 全量测试 |
@@ -1,55 +0,0 @@
# OA Attachment Download URL Output Design
## Goal
Keep the existing command and MCP request unchanged while making the returned
OSS signed URL directly copyable from JSON output:
```text
dws oa approval attachment download-url
```
## Scope
Only `oa approval attachment download-url` changes. The other OA attachment
commands and the global JSON formatter retain their current behavior.
## Design
The command continues to invoke MCP server `oa`, tool
`get_attachment_download_url`, with the same arguments. Its leaf declaration
provides a command-specific `Call` callback that invokes the existing MCP
dispatcher with HTML escaping disabled when the selected output format is
JSON. This preserves literal `&` separators in `result.downloadUri` instead of
rendering them as `\u0026`.
For `raw`, `table`, and other non-JSON formats, the callback uses the existing
escaped dispatcher behavior so their current rendering remains unchanged.
The change does not alter the URL, decode or re-sign it, download the file, or
change global JSON serialization.
## Error Handling
Authentication, MCP transport, gateway, PAT, and business errors continue
through the existing dispatcher and retain their current classification and
output behavior.
## Verification
Add a `TestCrossPlatformCoverage*` regression test that executes the real Cobra
leaf in explicit JSON mode with a fake MCP result containing a signed URL. It
must verify:
- the request still targets `oa/get_attachment_download_url`;
- the exact request arguments remain unchanged, including omission of the
optional boolean when the flag was not supplied;
- stdout contains literal `&OSSAccessKeyId=` and `&Signature=`;
- stdout contains no `\u0026` escape.
The fake caller must report JSON format (or the command must be executed with
`--format json`) so the test fails against the current escaped JSON path rather
than accidentally exercising raw MCP text output.
Run the focused OA attachment tests, format modified Go files, and rebuild the
CLI. No commit is created.
-4
View File
@@ -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
@@ -18,9 +16,7 @@ require (
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
github.com/spf13/cobra v1.10.2
github.com/yuin/goldmark v1.8.5
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
-2
View File
@@ -105,8 +105,6 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
+1 -2
View File
@@ -1220,8 +1220,7 @@ func newEventStopCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
editionName := editionNameOrDefault()
clientIDHash := dwsevent.ClientIDHash(clientID)
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
if errors.Is(err, busctl.ErrNotRunning) {
fmt.Fprintln(c.OutOrStdout(), "bus is not running")
return nil
+1 -1
View File
@@ -1220,7 +1220,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
}
busState := "personal bus stopped"
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
if errors.Is(err, busctl.ErrNotRunning) {
busState = "personal bus is not running"
} else {
+1 -113
View File
@@ -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") }
+1 -5
View File
@@ -52,8 +52,6 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
switch tool {
case "list_calendar_events":
return `{"result":{"events":[]}}`
case "query_records":
return `{"success":true,"status":"success","error":{},"data":{}}`
case "search_mail_users":
return `{"users":[{"name":"Fixture User","email":"fixture@example.com","id":"fixture-user"}]}`
case "search_dept_by_keyword":
@@ -65,11 +63,9 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
case "list_doc_versions":
return `{"result":{"items":[{"version":3}]}}`
case "revert_doc_version":
return `{"revertedToVersion":3}`
return `{"version":3}`
case "search_doc_templates":
return `{"result":[{"templateId":"fixture-template-id"}]}`
case "list_workflows":
return `{"workflows":[]}`
case "create_document":
return `{"nodeId":"fixture-node"}`
case "list_files":
@@ -27,23 +27,12 @@ const (
// its spelling while holding every other input constant.
var paramAliasCompleteCommands = map[string][]string{
"aitable +base-search": {"aitable", "+base-search", "--query", "fixture"},
"aitable +export-data": {"aitable", "+export-data", "--base-id", "base-1", "--scope", "all", "--format", "excel"},
"aitable +field-get": {"aitable", "+field-get", "--base-id", "base-1", "--table-id", "table-1"},
"aitable +find-record": {"aitable", "+find-record", "--base", "base-1", "--table", "table-1", "--query", "fixture"},
"aitable +list-tables": {"aitable", "+list-tables", "--base", "base-1"},
"aitable +record-query": {"aitable", "+record-query", "--base-id", "base-1", "--table-id", "table-1", "--query", "fixture"},
"aitable +record-share-links": {"aitable", "+record-share-links", "--base", "base-1", "--table", "table-1", "--record-ids", "record-1"},
"aitable +record-share-url": {"aitable", "+record-share-url", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1"},
"aitable +table-get": {"aitable", "+table-get", "--base-id", "base-1"},
"aitable +workflow-list": {"aitable", "+workflow-list", "--base-id", "base-1", "--limit", "7"},
"aitable attachment upload": {"aitable", "attachment", "upload", "--base-id", "base-1", "--file-name", "fixture.txt", "--size", "7"},
"aitable base list": {"aitable", "base", "list", "--cursor", "cursor-1", "--limit", "7"},
"aitable base update": {"aitable", "base", "update", "--base-id", "base-1", "--name", "Fixture Base", "--desc", "fixture description"},
"aitable field search-options": {"aitable", "field", "search-options", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--keyword", "fixture", "--limit", "7"},
"aitable record query": {"aitable", "record", "query", "--base-id", "base-1", "--table-id", "table-1", "--limit", "7"},
"aitable workflow get": {"aitable", "workflow", "get", "--base-id", "base-1", "--workflow-id", "workflow-1"},
"aitable workflow history": {"aitable", "workflow", "history", "--base-id", "base-1", "--workflow-id", "workflow-1", "--after-time", "1000", "--before-time", "2000", "--page", "2", "--size", "25"},
"aitable workflow run": {"aitable", "workflow", "run", "--base-id", "base-1", "--workflow-id", "workflow-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
@@ -73,12 +62,12 @@ var paramAliasCompleteCommands = map[string][]string{
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat +messages-reply": {"chat", "+messages-reply", "--group", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--content", "hello fixture", "--yes"},
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--content", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--content", "hello fixture", "--yes"},
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
"chat bot search": {"chat", "bot", "search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
@@ -104,11 +93,11 @@ var paramAliasCompleteCommands = map[string][]string{
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
"chat message reply": {"chat", "message", "reply", "--group", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--content", "hello fixture", "--yes"},
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--content", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"},
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"},
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--content", "fixture", "--at-users", "user-1,user-2", "--yes"},
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
"contact +list-sub-depts": {"contact", "+list-sub-depts", "--dept", "1"},
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
@@ -127,7 +116,7 @@ var paramAliasCompleteCommands = map[string][]string{
"doc +copy": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
"doc +create": {"doc", "+create", "--name", "Fixture Document", "--content", "fixture body", "--doc-format", "markdown"},
"doc +create-from-template": {"doc", "+create-from-template", "--query", "fixture template", "--name", "Fixture From Template", "--folder", "folder-1", "--workspace", "workspace-1"},
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--content", "fixture appendix", "--yes"},
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--text", "fixture appendix", "--yes"},
"doc +export-submit": {"doc", "+export-submit", "--node", "node-1", "--export-format", "docx"},
"doc +fetch": {"doc", "+fetch", "--node", "node-1", "--scope", "section", "--start-block-id", "block-1"},
"doc +find-doc": {"doc", "+find-doc", "--query", "fixture", "--limit", "7"},
@@ -143,8 +132,8 @@ var paramAliasCompleteCommands = map[string][]string{
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
"doc +export": {"doc", "+export", "--node", "node-1", "--export-format", "docx", "--output", "exports/fixture.docx"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--content", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--content", "fixture paragraph", "--yes"},
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
"doc comment create-inline": {"doc", "comment", "create-inline", "--node", "node-1", "--block-id", "block-1", "--start", "0", "--end", "7", "--content", "fixture comment", "--yes"},
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
@@ -207,7 +196,7 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
},
"doc block insert": {
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--content", "fixture paragraph", "--yes"},
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
},
"doc +inspect": {
"include-permissions": {"doc", "+inspect", "--node", "node-1", "--include-permissions"},
@@ -230,8 +219,8 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", appFixtureCurrentDOpenID, "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
},
"chat message send": {
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--content", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"},
"file": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"},
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"},
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"},
},
"chat +conversation-set-top": {
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
@@ -271,6 +260,7 @@ var paramAliasNewIMCases = []struct {
{command: "chat message list-favorites", emitted: "limit", canonical: "size"},
{command: "chat message list-unread-conversations", emitted: "limit", canonical: "count"},
{command: "chat message list-unread-conversations", emitted: "size", canonical: "count"},
{command: "chat message send", emitted: "file", canonical: "file-path"},
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
{command: "chat +chat-update", emitted: "chat-id", canonical: "group"},
@@ -288,7 +278,7 @@ var paramAliasNewIMCases = []struct {
{command: "chat +chat-members-get", emitted: "chat", canonical: "id"},
{command: "chat +messages-list", emitted: "start", canonical: "time"},
{command: "chat +messages-reply", emitted: "msg-id", canonical: "ref-msg-id"},
{command: "chat +messages-reply", emitted: "chat", canonical: "group"},
{command: "chat +messages-reply", emitted: "chat", canonical: "conversation-id"},
{command: "chat +flag-cancel", emitted: "group", canonical: "conversation-id"},
{command: "chat +flag-cancel", emitted: "chat", canonical: "conversation-id"},
{command: "chat +flag-create", emitted: "group", canonical: "conversation-id"},
@@ -382,113 +372,16 @@ var paramAliasNewDriveCases = []struct {
{command: "drive +upload", emitted: "file-id", canonical: "node"},
}
// paramAliasAITableDeleteDisableCompleteCommands contains complete invocations
// for every AITable delete/disable command whose confirmation boundary is
// reached by aliases introduced in the AITable expansion. These templates are
// intentionally separate from paramAliasCompleteCommands: that map mirrors
// the reviewed validation fixture one-for-one, while this matrix exhaustively
// proves the safety boundary for generated aliases beyond the fixture sample.
var paramAliasAITableDeleteDisableCompleteCommands = map[string][]string{
"aitable +advperm-disable": {"aitable", "+advperm-disable", "--base-id", "base-1", "--yes"},
"aitable +base-delete": {"aitable", "+base-delete", "--base-id", "base-1", "--yes"},
"aitable +chart-delete": {"aitable", "+chart-delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--chart-id", "chart-1", "--yes"},
"aitable +dashboard-delete": {"aitable", "+dashboard-delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--yes"},
"aitable +field-delete": {"aitable", "+field-delete", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--yes"},
"aitable +form-delete": {"aitable", "+form-delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
"aitable +record-delete": {"aitable", "+record-delete", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
"aitable +role-delete": {"aitable", "+role-delete", "--base-id", "base-1", "--role-id", "role-1", "--yes"},
"aitable +section-delete": {"aitable", "+section-delete", "--base-id", "base-1", "--section-id", "section-1", "--yes"},
"aitable +table-delete": {"aitable", "+table-delete", "--base-id", "base-1", "--table-id", "table-1", "--yes"},
"aitable +view-delete": {"aitable", "+view-delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
"aitable +workflow-disable": {"aitable", "+workflow-disable", "--base-id", "base-1", "--workflow-id", "workflow-1", "--yes"},
"aitable advperm disable": {"aitable", "advperm", "disable", "--base-id", "base-1", "--yes"},
"aitable advperm role-delete": {"aitable", "advperm", "role-delete", "--base-id", "base-1", "--role-id", "role-1", "--yes"},
"aitable base delete": {"aitable", "base", "delete", "--base-id", "base-1", "--yes"},
"aitable chart delete": {"aitable", "chart", "delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--chart-id", "chart-1", "--yes"},
"aitable dashboard delete": {"aitable", "dashboard", "delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--yes"},
"aitable field delete": {"aitable", "field", "delete", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--yes"},
"aitable form delete": {"aitable", "form", "delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
"aitable form questions delete": {"aitable", "form", "questions", "delete", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--yes"},
"aitable record delete": {"aitable", "record", "delete", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
"aitable table delete": {"aitable", "table", "delete", "--base-id", "base-1", "--table-id", "table-1", "--yes"},
"aitable view delete": {"aitable", "view", "delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
"aitable workflow disable": {"aitable", "workflow", "disable", "--base-id", "base-1", "--workflow-id", "workflow-1", "--yes"},
}
// paramAliasNewAITableDeleteDisableCases is the exhaustive set of alias
// tuples newly introduced by this change on AITable delete/disable commands
// that require confirmation. Every tuple must remain on both sides of the
// confirmation gate: rejected with zero calls before --yes, and exactly
// payload-equivalent to its canonical spelling after --yes.
var paramAliasNewAITableDeleteDisableCases = []struct {
// paramAliasNewDriveConfirmationCases selects one newly reviewed alias for
// every Drive command in the expansion whose declared runtime safety requires
// confirmation. The full matrix below proves all spellings preserve the
// confirmed payload; this smaller matrix proves aliases cannot cross the
// confirmation boundary before any transport call is made.
var paramAliasNewDriveConfirmationCases = []struct {
command string
emitted string
canonical string
}{
{command: "aitable +advperm-disable", emitted: "base", canonical: "base-id"},
{command: "aitable +advperm-disable", emitted: "base-token", canonical: "base-id"},
{command: "aitable +base-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +base-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +chart-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +chart-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +dashboard-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +dashboard-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +field-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +field-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +field-delete", emitted: "table", canonical: "table-id"},
{command: "aitable +form-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +form-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +form-delete", emitted: "table", canonical: "table-id"},
{command: "aitable +record-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +record-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +record-delete", emitted: "table", canonical: "table-id"},
{command: "aitable +role-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +role-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +section-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +section-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +table-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +table-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +table-delete", emitted: "table", canonical: "table-id"},
{command: "aitable +view-delete", emitted: "base", canonical: "base-id"},
{command: "aitable +view-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable +view-delete", emitted: "table", canonical: "table-id"},
{command: "aitable +workflow-disable", emitted: "base", canonical: "base-id"},
{command: "aitable +workflow-disable", emitted: "base-token", canonical: "base-id"},
{command: "aitable +workflow-disable", emitted: "flow-id", canonical: "workflow-id"},
{command: "aitable advperm disable", emitted: "base-token", canonical: "base-id"},
{command: "aitable advperm role-delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable base delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable chart delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable dashboard delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable field delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable field delete", emitted: "table", canonical: "table-id"},
{command: "aitable form delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable form delete", emitted: "table", canonical: "table-id"},
{command: "aitable form questions delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable form questions delete", emitted: "table", canonical: "table-id"},
{command: "aitable record delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable record delete", emitted: "table", canonical: "table-id"},
{command: "aitable table delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable table delete", emitted: "table", canonical: "table-id"},
{command: "aitable view delete", emitted: "base-token", canonical: "base-id"},
{command: "aitable view delete", emitted: "table", canonical: "table-id"},
{command: "aitable workflow disable", emitted: "base-token", canonical: "base-id"},
{command: "aitable workflow disable", emitted: "flow-id", canonical: "workflow-id"},
}
// paramAliasNewConfirmationCases selects newly reviewed aliases for commands
// whose declared runtime safety requires confirmation. The full matrix below
// proves all spellings preserve the confirmed payload; this smaller matrix
// proves aliases cannot cross the confirmation boundary before any transport
// call is made.
var paramAliasNewConfirmationCases = []struct {
command string
emitted string
canonical string
}{
{command: "aitable workflow run", emitted: "base-token", canonical: "base-id"},
{command: "aitable workflow run", emitted: "flow-id", canonical: "workflow-id"},
{command: "aitable workflow run", emitted: "table", canonical: "table-id"},
{command: "drive +delete", emitted: "file-id", canonical: "node"},
{command: "drive +publish-unset", emitted: "document-url", canonical: "node"},
{command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"},
@@ -509,27 +402,7 @@ var paramAliasNewConfirmationCases = []struct {
// That duplicated command construction was enough to push the pre-existing
// macOS app suite beyond its package-level 10-minute timeout.
var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("aitable +export-data", "export-format"): true, // shortcut-local export format keeps the final payload
paramAliasPayloadCaseKey("aitable +find-record", "base-id"): true, // shortcut Base ID compatibility
paramAliasPayloadCaseKey("aitable +find-record", "table-id"): true, // shortcut Table ID compatibility
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
paramAliasPayloadCaseKey("aitable +record-share-links", "base-id"): true, // observed experiment Base ID spelling
paramAliasPayloadCaseKey("aitable +record-share-links", "table-id"): true, // observed experiment Table ID spelling
paramAliasPayloadCaseKey("aitable +workflow-list", "max-results"): true, // shortcut pagination-size alias
paramAliasPayloadCaseKey("aitable attachment upload", "file-size"): true, // byte-size command override
paramAliasPayloadCaseKey("aitable base list", "next-cursor"): true, // cursor concept alias
paramAliasPayloadCaseKey("aitable base update", "description"): true, // plain description alias on a write command
paramAliasPayloadCaseKey("aitable field search-options", "query"): true, // search keyword concept alias
paramAliasPayloadCaseKey("aitable workflow get", "flow-id"): true, // workflow ID concept alias
paramAliasPayloadCaseKey("aitable workflow history", "base-token"): true, // Base ID concept alias
paramAliasPayloadCaseKey("aitable workflow history", "end-time"): true, // upper time-bound override
paramAliasPayloadCaseKey("aitable workflow history", "flow-id"): true, // workflow ID concept alias
paramAliasPayloadCaseKey("aitable workflow history", "page-index"): true, // zero-based page override
paramAliasPayloadCaseKey("aitable workflow history", "page-size"): true, // page-size concept alias
paramAliasPayloadCaseKey("aitable workflow history", "start-time"): true, // lower time-bound override
paramAliasPayloadCaseKey("aitable workflow run", "base-token"): true, // Base ID concept alias on a confirmed write
paramAliasPayloadCaseKey("aitable workflow run", "flow-id"): true, // workflow ID concept alias on a confirmed write
paramAliasPayloadCaseKey("aitable workflow run", "table"): true, // Table ID concept alias on a confirmed write
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
@@ -553,11 +426,7 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
paramAliasPayloadCaseKey("doc +access-grant", "doc-id"): true, // permission write keeps document identity
paramAliasPayloadCaseKey("doc +version-revert", "version-number"): true, // high-write version role with canonical confirmation
paramAliasPayloadCaseKey("chat +messages-reply", "conversation-id"): true, // renamed conversation Primary keeps final reply payload
paramAliasPayloadCaseKey("chat message send", "file-path"): true, // renamed local-file Primary reaches the same final payload
paramAliasPayloadCaseKey("doc +doc-append", "text"): true, // shortcut content rename keeps append payload
paramAliasPayloadCaseKey("doc block insert", "text"): true, // block write content compatibility alias
paramAliasPayloadCaseKey("doc block update", "text"): true, // update uses the same typed compatibility path
paramAliasPayloadCaseKey("doc block insert", "content"): true, // block write content alias
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
@@ -760,116 +629,13 @@ func TestCrossPlatformCoverageNewDriveParamAliasesReachCanonicalEquivalentFinalP
}
}
func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmationAndPayload(t *testing.T) {
coveredCommands := make(map[string]bool)
reviewedAliases := make(map[string]string, len(paramAliasNewAITableDeleteDisableCases))
for _, test := range paramAliasNewAITableDeleteDisableCases {
test := test
caseKey := paramAliasPayloadCaseKey(test.command, test.emitted)
if previous, duplicate := reviewedAliases[caseKey]; duplicate {
t.Fatalf("duplicate AITable delete/disable alias case %q: --%s and --%s", caseKey, previous, test.canonical)
}
reviewedAliases[caseKey] = test.canonical
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasAITableDeleteDisableCompleteCommands[test.command]
if !ok {
t.Fatal("reviewed AITable delete/disable alias has no complete safety template")
}
coveredCommands[test.command] = true
canonicalArgs := append([]string(nil), complete...)
aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted)
if replacements != 1 {
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs)
}
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
if removals != 1 {
t.Fatalf("safety template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
}
entry, exists := cli.LookupParamAlias(test.command)
target, active := entry.ResolveAlias(test.emitted)
if !exists || !active || target != test.canonical {
t.Fatalf("reviewed AITable alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
}
unconfirmedCaller := &paramAliasCaptureCaller{}
ctx, unconfirmedErr := executeParamAliasPayloadE2E(t, unconfirmedCaller, unconfirmedArgs...)
if ctx == nil {
t.Fatal("unconfirmed AITable alias command skipped PreParse")
}
var appErr *apperrors.Error
if !errors.As(unconfirmedErr, &appErr) || appErr.Reason != "confirmation_required" {
t.Fatalf("unconfirmed AITable alias command error = %#v, want confirmation_required\nargs=%v", unconfirmedErr, unconfirmedArgs)
}
if len(unconfirmedCaller.calls) != 0 {
t.Fatalf("unconfirmed AITable alias crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, unconfirmedCaller.calls)
}
canonicalCaller := &paramAliasCaptureCaller{}
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
if canonicalErr != nil {
t.Fatalf("confirmed canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
}
if len(canonicalCaller.calls) == 0 {
t.Fatalf("confirmed canonical command reached no final transport payload: args=%v", canonicalArgs)
}
aliasCaller := &paramAliasCaptureCaller{}
aliasCtx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
if aliasErr != nil {
t.Fatalf("confirmed alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
}
if aliasCtx == nil {
t.Fatal("confirmed AITable alias command skipped PreParse")
}
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
t.Fatalf("confirmed final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
}
})
}
for command := range paramAliasAITableDeleteDisableCompleteCommands {
if !coveredCommands[command] {
t.Errorf("AITable delete/disable safety template %q has no reviewed alias case", command)
}
}
if len(coveredCommands) != len(paramAliasAITableDeleteDisableCompleteCommands) {
t.Fatalf("AITable delete/disable safety coverage = %d commands, want %d", len(coveredCommands), len(paramAliasAITableDeleteDisableCompleteCommands))
}
activeAliases := 0
for command := range paramAliasAITableDeleteDisableCompleteCommands {
entry, exists := cli.LookupParamAlias(command)
if !exists {
t.Errorf("AITable delete/disable safety command %q has no generated alias entry", command)
continue
}
for emitted, canonical := range entry.Aliases {
activeAliases++
caseKey := paramAliasPayloadCaseKey(command, emitted)
reviewedCanonical, reviewed := reviewedAliases[caseKey]
if !reviewed {
t.Errorf("active AITable delete/disable alias %q --%s -> --%s has no confirmation/payload case", command, emitted, canonical)
continue
}
if reviewedCanonical != canonical {
t.Errorf("reviewed AITable delete/disable alias %q --%s target = --%s, generated --%s", command, emitted, reviewedCanonical, canonical)
}
}
}
if activeAliases != len(reviewedAliases) {
t.Fatalf("AITable delete/disable generated alias coverage = %d, want %d reviewed cases", activeAliases, len(reviewedAliases))
}
}
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
for _, test := range paramAliasNewConfirmationCases {
func TestCrossPlatformCoverageNewDriveParamAliasesCannotBypassConfirmation(t *testing.T) {
for _, test := range paramAliasNewDriveConfirmationCases {
test := test
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
if !ok {
t.Fatal("reviewed confirmation alias has no complete-command E2E template")
t.Fatal("reviewed Drive confirmation alias has no complete-command E2E template")
}
aliasArgs, replacements := replaceLongFlag(complete, test.canonical, test.emitted)
if replacements != 1 {
@@ -883,7 +649,7 @@ func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing
entry, exists := cli.LookupParamAlias(test.command)
target, active := entry.ResolveAlias(test.emitted)
if !exists || !active || target != test.canonical {
t.Fatalf("reviewed confirmation alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
t.Fatalf("reviewed Drive alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
}
caller := &paramAliasCaptureCaller{}
-26
View File
@@ -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 })
+17 -101
View File
@@ -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
@@ -1024,36 +959,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() {
+10 -45
View File
@@ -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)
}
}
-30
View File
@@ -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) {
+2 -92
View File
@@ -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
@@ -264,7 +221,7 @@ func TestRootChatMediaUploadWithoutAppCredentialsReturnsMigrationValidation(t *t
}
got := output.String() + "\n" + err.Error()
for _, want := range []string{"已下线", "chat message send --msg-type file --file"} {
for _, want := range []string{"已下线", "chat message send --msg-type file --file-path"} {
if !strings.Contains(got, want) {
t.Fatalf("chat media upload migration output missing %q:\n%s", want, got)
}
@@ -439,7 +396,7 @@ func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) {
t.Fatalf("chat file upload error = nil, want downline error\n%s", got)
}
got = got + "\n" + err.Error()
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file"} {
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file-path"} {
if !strings.Contains(got, want) {
t.Fatalf("chat file upload output missing %q:\n%s", want, got)
}
@@ -462,53 +419,6 @@ func TestCalendarEventListDryRunPreviewsOnly(t *testing.T) {
}
}
func TestCalendarEventShareInfoDryRunPreviewsOnly(t *testing.T) {
got, err := executeRootCaptureStdout(t, []string{
"--dry-run", "calendar", "event", "share-info",
"--id", "EVT_001",
"--language", "zh-CN",
"--calendar-id", "primary",
})
if err != nil {
t.Fatalf("calendar event share-info --dry-run error = %v\n%s", err, got)
}
for _, want := range []string{"get_event_share_info", "eventId", "EVT_001", "zh-CN", "primary"} {
if !strings.Contains(got, want) {
t.Fatalf("calendar event share-info dry-run output missing %q:\n%s", want, got)
}
}
}
func TestCalendarEventShareInfoRequiresEventID(t *testing.T) {
got, err := executeRootCaptureStdout(t, []string{
"--dry-run", "calendar", "event", "share-info",
})
if err == nil {
t.Fatalf("calendar event share-info without --id: expected error, got nil\n%s", got)
}
if strings.Contains(got, "\"executed\": true") {
t.Fatalf("share-info without --id must not execute:\n%s", got)
}
}
func TestCalendarEventShareInfoOmitsOptionalArgs(t *testing.T) {
got, err := executeRootCaptureStdout(t, []string{
"--dry-run", "calendar", "event", "share-info",
"--id", "EVT_001",
})
if err != nil {
t.Fatalf("calendar event share-info --dry-run with only --id error = %v\n%s", err, got)
}
if !strings.Contains(got, "\"eventId\"") {
t.Fatalf("calendar event share-info dry-run output missing eventId:\n%s", got)
}
for _, unwanted := range []string{"\"calendarId\"", "\"language\""} {
if strings.Contains(got, unwanted) {
t.Fatalf("calendar event share-info dry-run with only --id should not contain %q:\n%s", unwanted, got)
}
}
}
func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
root := NewRootCommand()
@@ -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
+3 -10
View File
@@ -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)
}
+1 -1
View File
@@ -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
@@ -1,49 +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.
package app
import "testing"
func TestCrossPlatformCoverageCalendarAgendaFinalSchemaPreservesCompositeProperties(t *testing.T) {
snapshot := fullSchemaSnapshotForTest(t)
tool := snapshot.Tools["calendar.shortcut_agenda"]
if tool == nil {
t.Fatal("calendar.shortcut_agenda is missing from final Schema")
}
parameters := schemaContractMap(tool["parameters"])
for flag, want := range map[string]string{
"start": "start",
"end": "end",
} {
parameter := parameters[flag]
if parameter == nil {
t.Fatalf("calendar.shortcut_agenda --%s is missing from final Schema", flag)
}
if got := schemaContractString(parameter["property"]); got != want {
t.Errorf("calendar.shortcut_agenda --%s property=%q, want %q", flag, got, want)
}
}
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
t.Fatalf("calendar.shortcut_agenda interface_mode=%q, want composite", got)
}
result := schemaContractMap(tool["result"])
dataSchema := schemaContractMap(result["data_schema"])
properties := schemaContractMap(dataSchema["properties"])
for _, field := range []string{"hasMore", "nextCursor"} {
if _, exists := properties[field]; exists {
t.Fatalf("calendar.shortcut_agenda Result data_schema leaked pagination field %q", field)
}
}
if properties["complete"] == nil {
t.Fatal("calendar.shortcut_agenda Result data_schema is missing complete")
}
pagination, ok := tool["pagination"].(map[string]any)
if !ok {
t.Fatalf("calendar.shortcut_agenda pagination=%T, want object", tool["pagination"])
}
if got := schemaContractString(pagination["meta_path"]); got != "meta.pagination" {
t.Fatalf("calendar.shortcut_agenda pagination meta_path=%q, want meta.pagination", got)
}
}
-14
View File
@@ -400,7 +400,6 @@ func schemaContractPayloadForBoundCanonicals(t *testing.T, root *cobra.Command,
func TestChatSchemaSeparatesSendAndReply(t *testing.T) {
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
"chat.send_personal_message",
"chat.send_robot_message",
"chat.reply_personal_message",
)
@@ -419,19 +418,6 @@ func TestChatSchemaSeparatesSendAndReply(t *testing.T) {
if _, exists := snapshot.Tools["chat.upload_conversation_file"]; exists {
t.Fatal("downlined chat file upload must not be advertised in Schema")
}
botReply := snapshot.Tools["chat.send_robot_message"]
botParams := schemaContractMap(botReply["parameters"])
if got := schemaContractString(botParams["reply"]["property"]); got != "referenceOpenMessageId" {
t.Fatalf("bot --reply property = %q", got)
}
if got := schemaContractString(botParams["ref-sender"]["property"]); got != "srcMsgSendOpenDingTalkId" {
t.Fatalf("bot --ref-sender property = %q", got)
}
if got, ok := botParams["title"]["required"].(bool); !ok || got {
t.Fatalf("bot --title required = %#v, want false for conditional Markdown input", botParams["title"]["required"])
}
assertSchemaContractConstraintGroup(t, botReply, "require_together", []string{"reply", "ref-sender"})
}
func TestCalendarAttendeeDeleteSchemaMatchesRuntimeGate(t *testing.T) {
@@ -1,166 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"encoding/json"
"reflect"
"strings"
"testing"
)
func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t *testing.T) {
tests := []struct {
cliPath string
canonical string
rpc string
description string
effect string
resultType string
resultFields map[string]string
sensitivePaths []string
}{
{
cliPath: "oa approval attachment download-url",
canonical: "oa.get_attachment_download_url",
rpc: "get_attachment_download_url",
description: "获取审批附件下载授权并生成临时下载链接",
effect: "read",
resultType: "object",
resultFields: map[string]string{
"spaceId": "integer", "agentId": "integer", "downloadUri": "string",
"class": "string", "fileId": "string",
},
sensitivePaths: []string{"downloadUri"},
},
{
cliPath: "oa approval attachment authorize-download",
canonical: "oa.auth_download_file",
rpc: "auth_download_file",
description: "批量授权当前用户下载指定的审批钉盘文件",
effect: "write",
resultType: "boolean",
},
{
cliPath: "oa approval attachment authorize-preview",
canonical: "oa.auth_preview_attachment",
rpc: "auth_preview_attachment",
description: "批量授权当前用户预览审批单中的附件",
effect: "write",
resultType: "object",
resultFields: map[string]string{
"spaceId": "integer", "agentId": "integer", "class": "string",
},
},
}
for _, test := range tests {
t.Run(test.rpc, func(t *testing.T) {
root := NewRootCommand()
command := exactCommandForTest(root, test.cliPath)
if command == nil {
t.Fatalf("executable command %q is missing", test.cliPath)
}
var stdout, stderr bytes.Buffer
root.SetOut(&stdout)
root.SetErr(&stderr)
root.SetArgs([]string{"schema", test.cliPath, "--format", "json"})
if err := root.Execute(); err != nil {
t.Fatalf("execute delivery schema leaf: %v; stderr=%s", err, stderr.String())
}
var tool map[string]any
if err := json.Unmarshal(stdout.Bytes(), &tool); err != nil {
t.Fatalf("decode delivery schema leaf: %v", err)
}
if got := schemaContractString(tool["canonical_path"]); got != test.canonical {
t.Fatalf("canonical_path = %q, want %q", got, test.canonical)
}
if got := schemaContractString(tool["primary_cli_path"]); got != test.cliPath {
t.Fatalf("primary_cli_path = %q, want %q", got, test.cliPath)
}
if got := schemaContractString(tool["description"]); !strings.HasPrefix(got, test.description) {
t.Fatalf("description = %q, want prefix %q", got, test.description)
}
if got := schemaContractString(tool["interface_mode"]); got != "mcp" {
t.Fatalf("interface_mode = %q, want mcp", got)
}
if got := schemaContractString(tool["availability"]); got != "available" {
t.Fatalf("availability = %q, want available", got)
}
interfaceRef := schemaInterfaceObject(tool["interface_ref"])
if got := schemaContractString(interfaceRef["product_id"]); got != "oa" {
t.Fatalf("interface_ref.product_id = %q, want oa", got)
}
if got := schemaContractString(interfaceRef["rpc_name"]); got != test.rpc {
t.Fatalf("interface_ref.rpc_name = %q, want %q", got, test.rpc)
}
if got := schemaContractString(tool["effect"]); got != test.effect {
t.Fatalf("effect = %q, want %q", got, test.effect)
}
if got := schemaContractString(tool["risk"]); got != "low" {
t.Fatalf("risk = %q, want low", got)
}
if got := schemaContractString(tool["confirmation"]); got != "not_required" {
t.Fatalf("confirmation = %q, want not_required", got)
}
if got := schemaContractString(tool["idempotency"]); got != "idempotent" {
t.Fatalf("idempotency = %q, want idempotent", got)
}
fullResult := oaAttachmentResultContract(t, tool, test.resultType, test.resultFields, test.sensitivePaths)
stdout.Reset()
stderr.Reset()
root.SetArgs([]string{"schema", test.cliPath, "--compact", "--format", "json"})
if err := root.Execute(); err != nil {
t.Fatalf("execute compact delivery schema leaf: %v; stderr=%s", err, stderr.String())
}
var compactTool map[string]any
if err := json.Unmarshal(stdout.Bytes(), &compactTool); err != nil {
t.Fatalf("decode compact delivery schema leaf: %v", err)
}
compactResult, ok := compactTool["result"].(map[string]any)
if !ok {
t.Fatalf("compact result = %#v, want object", compactTool["result"])
}
if !reflect.DeepEqual(compactResult, fullResult) {
t.Fatalf("compact/full result projection differs\ncompact: %#v\nfull: %#v", compactResult, fullResult)
}
if problem := schemaHelpFlagCompletenessProblem(test.canonical, test.cliPath, command, tool); problem != "" {
t.Fatal(problem)
}
})
}
}
func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any {
t.Helper()
result, ok := tool["result"].(map[string]any)
if !ok {
t.Fatalf("full result = %#v, want object", tool["result"])
}
if got, want := schemaContractStringSlice(result["outcomes"]), []string{"success", "failure"}; !reflect.DeepEqual(got, want) {
t.Fatalf("result.outcomes = %#v, want %#v", got, want)
}
if got := schemaContractStringSlice(result["sensitive_paths"]); !reflect.DeepEqual(got, sensitivePaths) {
t.Fatalf("result.sensitive_paths = %#v, want %#v", got, sensitivePaths)
}
dataSchema, ok := result["data_schema"].(map[string]any)
if !ok || schemaContractString(dataSchema["type"]) != resultType {
t.Fatalf("result.data_schema = %#v, want type %q", result["data_schema"], resultType)
}
properties, _ := dataSchema["properties"].(map[string]any)
if len(properties) != len(fields) {
t.Fatalf("result.data_schema.properties = %#v, want fields %#v", properties, fields)
}
for name, fieldType := range fields {
property, ok := properties[name].(map[string]any)
if !ok || schemaContractString(property["type"]) != fieldType {
t.Fatalf("result.data_schema.properties.%s = %#v, want type %q", name, properties[name], fieldType)
}
}
return result
}
+3 -22
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 435
publicShortcutCount = 418
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including the hidden historical minutes.shortcut_minutes_search contract.
schemaPublishedShortcutCount = 438
schemaPublishedShortcutCount = 420
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 435
publiclyDeliveredShortcutCount = 418
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -140,25 +140,6 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
assertChatCatalogCompleteLeafContracts(t)
}
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
result, _ := leaf["result"].(map[string]any)
if got, want := schemaContractStringSlice(result["outcomes"]), []string{"success", "failure"}; !schemaContractJSONEqual(got, want) {
t.Fatalf("aitable +table-bootstrap outcomes = %#v, want %#v", got, want)
}
dataSchema, _ := result["data_schema"].(map[string]any)
properties := schemaContractMap(dataSchema["properties"])
status := properties["status"]
if got, want := schemaContractStringSlice(status["enum"]), []string{"success", "planned", "partial_success", "unknown"}; !schemaContractJSONEqual(got, want) {
t.Fatalf("aitable +table-bootstrap status enum = %#v, want %#v", got, want)
}
for _, property := range []string{"contractVersion", "operation", "executed", "retryable", "plan", "completedSteps", "verification", "checkpoint", "knownSideEffects", "result"} {
if properties[property] == nil {
t.Errorf("aitable +table-bootstrap final Result data_schema is missing %q", property)
}
}
}
func TestDeliveryWikiSpaceSearchDeclaresCompatibilityAdapter(t *testing.T) {
leaf := executeShortcutSchemaQuery(t, "--cli-path", "wiki +space-search")
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
+3 -3
View File
@@ -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) {
-143
View File
@@ -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]) + "..."
}
-99
View File
@@ -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)
}
}
-86
View File
@@ -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, "")
}
-147
View File
@@ -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)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -1,242 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package cli
import "testing"
func TestPrimaryParamMigrationKeepsConceptDirectionCommandScoped(t *testing.T) {
concepts, err := LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
if got := concepts.ByConcept["content_text"].CanonicalHint; got != "text" {
t.Fatalf("content_text canonical hint = %q, want unchanged global hint text", got)
}
if got := concepts.ByConcept["open_conversation_id"].CanonicalHint; got != "conversation-id" {
t.Fatalf("open_conversation_id canonical hint = %q, want unchanged global hint conversation-id", got)
}
content := concepts.ByConcept["content_text"]
docNode := concepts.ByConcept["doc_node_id"]
docAppend, problems := reduceLeafParamAliases(
"doc +doc-append",
realMap(
realFlag{name: "content"},
realFlag{name: "text", hidden: true},
realFlag{name: "doc"},
),
[]Concept{content, docNode},
CommandOverride{},
)
if len(problems) != 0 {
t.Fatalf("doc +doc-append reduction problems = %v", problems)
}
assertPrimaryParamAlias(t, docAppend, "body", "content")
assertPrimaryParamAlias(t, docAppend, "node", "doc")
insertOverride := primaryParamOverride(t, concepts, "doc block insert")
docInsert, problems := reduceLeafParamAliases(
"doc block insert",
realMap(
realFlag{name: "content"},
realFlag{name: "text", hidden: true},
realFlag{name: "parent-block"},
realFlag{name: "ref-block"},
),
[]Concept{content},
insertOverride,
)
if len(problems) != 0 {
t.Fatalf("doc block insert reduction problems = %v", problems)
}
assertPrimaryParamAlias(t, docInsert, "body", "content")
docUpdate, problems := reduceLeafParamAliases(
"doc block update",
realMap(realFlag{name: "content"}, realFlag{name: "text", hidden: true}),
[]Concept{content},
CommandOverride{},
)
if len(problems) != 0 {
t.Fatalf("doc block update reduction problems = %v", problems)
}
assertPrimaryParamAlias(t, docUpdate, "body", "content")
replyOverride := primaryParamOverride(t, concepts, "chat +messages-reply")
chatReply, problems := reduceLeafParamAliases(
"chat +messages-reply",
realMap(
realFlag{name: "group"},
realFlag{name: "conversation-id", hidden: true},
realFlag{name: "ref-msg-id"},
),
[]Concept{concepts.ByConcept["open_conversation_id"]},
replyOverride,
)
if len(problems) != 0 {
t.Fatalf("chat +messages-reply reduction problems = %v", problems)
}
for _, emitted := range []string{"chat", "chat-id", "open-conversation-id"} {
assertPrimaryParamAlias(t, chatReply, emitted, "group")
}
assertPrimaryParamAlias(t, chatReply, "msg-id", "ref-msg-id")
if chatReply.IsBlocked("group") {
t.Fatal("chat +messages-reply canonical --group must not remain blocked")
}
}
func TestPrimaryParamMigrationUpdatesReviewedFixturesAndMappingKeys(t *testing.T) {
concepts, err := LoadParamConcepts()
if err != nil {
t.Fatalf("LoadParamConcepts() error = %v", err)
}
fixtures := []ParamFixtureCase{
{Command: "doc +doc-append", Emitted: "text", Expect: "content", Via: "native:reviewed-compatibility-alias"},
{Command: "doc +doc-append", Emitted: "node", Expect: "doc", Via: "concept:doc_node_id"},
{Command: "doc block insert", Emitted: "text", Expect: "content", Via: "native:reviewed-compatibility-alias", Occ: 2},
{Command: "doc block update", Emitted: "text", Expect: "content", Via: "native:reviewed-compatibility-alias", Occ: 6},
{Command: "chat message send", Emitted: "file-path", Expect: "file", Via: "native:reviewed-compatibility-alias"},
{Command: "chat +messages-reply", Emitted: "conversation-id", Expect: "group", Via: "native:reviewed-compatibility-alias"},
{Command: "chat +messages-reply", Emitted: "chat", Expect: "group", Via: "concept:open_conversation_id"},
{Command: "chat +messages-reply", Emitted: "open-conversation-id", Expect: "group", Via: "concept:open_conversation_id"},
}
for _, want := range fixtures {
found := false
for _, got := range concepts.Fixture {
if got == want {
found = true
break
}
}
if !found {
t.Errorf("reviewed fixture missing %#v", want)
}
}
tests := []struct {
oldKey string
newKey string
reason string
}{
{"chat.reply_personal_message --text", "chat.reply_personal_message --content", "serialized into the aggregate content JSON string"},
{"chat.send_personal_message --file-path", "chat.send_personal_message --file", "local upload/preprocessing input"},
{"chat.send_personal_message --text", "chat.send_personal_message --content", "serialized into the aggregate content payload"},
{"doc.insert_document_block --text", "doc.insert_document_block --content", "aggregate convenience input used to build element"},
{"doc.update_document_block --text", "doc.update_document_block --content", "aggregate convenience input used to build element"},
{"todo.add_todo_attachment --file-path", "todo.add_todo_attachment --file", "local upload input used to construct attachmentList"},
}
for _, test := range tests {
if _, exists := reviewedSchemaParameterMappingExclusions[test.oldKey]; exists {
t.Errorf("stale mapping exclusion remains at %q", test.oldKey)
}
if got := reviewedSchemaParameterMappingExclusions[test.newKey]; got != test.reason {
t.Errorf("mapping exclusion %q reason = %q, want %q", test.newKey, got, test.reason)
}
}
}
func TestPrimaryParamMigrationPreservesDeliverySchemaSignatures(t *testing.T) {
tests := []struct {
path string
primary string
legacy string
required bool
cliRequired bool
property string
propertyFrom string
format string
requiredWhen string
requiredWhenFrom string
}{
{path: "aisearch person", primary: "query", legacy: "keyword", required: true, property: "keyword", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat message reply", primary: "content", legacy: "text", required: true, cliRequired: true, propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
{path: "chat message send", primary: "content", legacy: "text", propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
{path: "chat message send", primary: "file", legacy: "file-path", propertyFrom: "reviewed_mapping_exclusion", format: "file-path", requiredWhen: "msg-type is file or audio or video", requiredWhenFrom: "typed_parameter_metadata"},
{path: "chat message send-by-webhook", primary: "content", legacy: "text", required: true, cliRequired: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +broadcast", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +dm", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +send-to-group", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +messages-send-by-bot", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +messages-batch-send-by-bot", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +messages-send-by-webhook", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +messages-reply", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "chat +messages-reply", primary: "group", legacy: "conversation-id", required: true, property: "conversationId", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "doc +doc-append", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
{path: "doc block insert", primary: "content", legacy: "text", propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
{path: "doc block update", primary: "content", legacy: "text", propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
{path: "todo task add-attachment", primary: "file", legacy: "file-path", required: true, propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
}
for _, test := range tests {
t.Run(test.path+"/"+test.primary, func(t *testing.T) {
leaf, err := queryDeliverySchemaPayload([]string{test.path})
if err != nil {
t.Fatal(err)
}
parameters := schemaMap(leaf["parameters"])
parameter, exists := parameters[test.primary]
if !exists {
t.Fatalf("%s missing visible Primary --%s", test.path, test.primary)
}
if _, exists := parameters[test.legacy]; exists {
t.Fatalf("%s unexpectedly publishes hidden legacy --%s", test.path, test.legacy)
}
if got := parameter["type"]; got != "string" {
t.Errorf("%s --%s type = %#v, want string", test.path, test.primary, got)
}
if got := parameter["required"]; got != test.required {
t.Errorf("%s --%s required = %#v, want %v", test.path, test.primary, got, test.required)
}
if got, _ := parameter["cli_required"].(bool); got != test.cliRequired {
t.Errorf("%s --%s cli_required = %#v, want %v", test.path, test.primary, parameter["cli_required"], test.cliRequired)
}
if got, _ := parameter["property"].(string); got != test.property {
t.Errorf("%s --%s property = %q, want %q", test.path, test.primary, got, test.property)
}
if got := parameter["interface_type"]; got != nil && got != "" {
t.Errorf("%s --%s interface_type = %#v, want omitted/empty", test.path, test.primary, got)
}
if got, _ := parameter["format"].(string); got != test.format {
t.Errorf("%s --%s format = %q, want %q", test.path, test.primary, got, test.format)
}
if got, _ := parameter["required_when"].(string); got != test.requiredWhen {
t.Errorf("%s --%s required_when = %q, want %q", test.path, test.primary, got, test.requiredWhen)
}
provenance := schemaMap(parameter["field_provenance"])
propertyProvenance := provenance["property"]
if got := propertyProvenance["source"]; got != test.propertyFrom {
t.Errorf("%s --%s property source = %#v, want %s", test.path, test.primary, got, test.propertyFrom)
}
typeProvenance := provenance["type"]
if got := typeProvenance["source"]; got != "cobra_flag_type" {
t.Errorf("%s --%s type source = %#v, want cobra_flag_type", test.path, test.primary, got)
}
requiredWhenProvenance := provenance["required_when"]
if got := requiredWhenProvenance["source"]; got != test.requiredWhenFrom {
t.Errorf("%s --%s required_when source = %#v, want %s", test.path, test.primary, got, test.requiredWhenFrom)
}
})
}
}
func primaryParamOverride(t *testing.T, concepts ParamConcepts, path string) CommandOverride {
t.Helper()
for _, override := range concepts.Overrides {
if override.CommandPath == path {
return override
}
}
t.Fatalf("command override %q not found", path)
return CommandOverride{}
}
func assertPrimaryParamAlias(t *testing.T, entry *ParamAliasEntry, emitted, want string) {
t.Helper()
if entry == nil {
t.Fatalf("alias entry is nil; want %s -> %s", emitted, want)
}
if got, ok := entry.ResolveAlias(emitted); !ok || got != want {
t.Fatalf("ResolveAlias(%q) = %q (ok=%v), want %q", emitted, got, ok, want)
}
}
+2 -2
View File
@@ -68,9 +68,9 @@ func init() {
registerRequireOneOf("devdoc.search_open_platform_docs_rag", "query", "keyword")
registerRequireOneOf("event.consume", "event_key", "subscribe-id")
registerExclusiveOneOf("event.stop", "all", "subscribe_id")
registerRequireOneOf("doc.insert_document_block", "content", "heading", "element")
registerRequireOneOf("doc.insert_document_block", "text", "heading", "element")
registerExclusiveOneOf("doc.update_document", "content", "content-file")
registerRequireOneOf("doc.update_document_block", "content", "heading", "element")
registerRequireOneOf("doc.update_document_block", "text", "heading", "element")
registerRequireOneOf("pat.batch_grant", "scope", "product", "products", "domain", "domains", "recommend")
registerRequireOneOf("mail.search_mail_users", "keyword", "employee-no")
// --body is a hidden compatibility alias for the public --content flag.
+2 -18
View File
@@ -1127,37 +1127,21 @@ func putRawJSON(payload map[string]any, key string, raw json.RawMessage) error {
return nil
}
// rawJSONValue decodes a JSON value that may come from an untrusted source, so
// it validates before decoding. Callers holding output that json.Marshal just
// produced should use typedJSONValue instead of paying the validation scan.
func rawJSONValue(raw json.RawMessage) (any, error) {
if !json.Valid(raw) {
return nil, fmt.Errorf("invalid JSON value")
}
return decodeValidJSONValue(raw), nil
}
// decodeValidJSONValue decodes JSON whose validity the caller has already
// established, either by json.Valid or by having just marshaled it. Decode
// errors are unreachable under that precondition and are therefore discarded,
// exactly as this path behaved when the decode was inlined into rawJSONValue.
func decodeValidJSONValue(raw json.RawMessage) any {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
_ = decoder.Decode(&value)
return value
return value, nil
}
// typedJSONValue projects a typed value into the generic JSON shape the payload
// renderers consume. json.Marshal output is valid by construction, so this path
// decodes it directly: routing through rawJSONValue re-scanned every marshaled
// document with json.Valid, which measured ~34% of Schema Catalog assembly time
// across the 1121-tool set (26.0s -> 17.2s for the internal/app schema suite).
func typedJSONValue(value any) (any, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
return decodeValidJSONValue(data), nil
return rawJSONValue(data)
}
@@ -269,20 +269,20 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"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 --content": "serialized into the aggregate content JSON string",
"chat.reply_personal_message --text": "serialized into the aggregate content JSON string",
"chat.search_groups --cursor": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
"chat.search_groups --exclude-muted": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --exclude-muted is a CLI wrapper input and does not publish a direct interface property.",
"chat.search_groups --limit": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
"chat.search_messages --user": "conditional wrapper: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
"chat.search_messages --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
"chat.send_personal_message --contact-id": "serialized into the aggregate content payload",
"chat.send_personal_message --file": "local upload/preprocessing input",
"chat.send_personal_message --file-path": "local upload/preprocessing input",
"chat.send_personal_message --latitude": "serialized into the aggregate content payload",
"chat.send_personal_message --location-name": "serialized into the aggregate content payload",
"chat.send_personal_message --longitude": "serialized into the aggregate content payload",
"chat.send_personal_message --map-thumbnail-url": "serialized into the aggregate content payload",
"chat.send_personal_message --media-id": "serialized into the aggregate content payload",
"chat.send_personal_message --content": "serialized into the aggregate content payload",
"chat.send_personal_message --text": "serialized into the aggregate content payload",
"chat.send_personal_message --title": "serialized into the aggregate content payload",
"chat.send_personal_message --user": "resolves a userId to receiverOpenDingTalkId when possible and otherwise uses the unpinned receiverUserId compatibility property",
"chat.set_group_member_mute_list --user": "conditional wrapper/alias of --users: userIds are remotely resolved to openDingTalkIds outside dry-run and otherwise may be sent as uids; openDingTalkId inputs are sent as openDingTalkIds, so one exact property would misdescribe execution",
@@ -406,7 +406,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"doc.insert_document_block --fix-jsonml": "local JSONML normalization control",
"doc.insert_document_block --heading": "aggregate convenience input used to build element",
"doc.insert_document_block --level": "aggregate convenience input used to build element",
"doc.insert_document_block --content": "aggregate convenience input used to build element",
"doc.insert_document_block --text": "aggregate convenience input used to build element",
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
@@ -438,7 +438,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"doc.update_document_block --fix-jsonml": "local JSONML normalization control",
"doc.update_document_block --heading": "aggregate convenience input used to build element",
"doc.update_document_block --level": "aggregate convenience input used to build element",
"doc.update_document_block --content": "aggregate convenience input used to build element",
"doc.update_document_block --text": "aggregate convenience input used to build element",
"doc.version_list --cursor": "Reviewed unpinned adapter: doc.version_list has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
"doc.version_list --limit": "Reviewed unpinned adapter: doc.version_list has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
"doc.version_list --node": "Reviewed unpinned adapter: doc.version_list has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
@@ -659,7 +659,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
"sheet.write_image --file": "local upload input used to obtain resourceId/resourceUrl",
"sheet.write_image --mime-type": "local upload metadata",
"sheet.write_image --name": "local upload metadata",
"todo.add_todo_attachment --file": "local upload input used to construct attachmentList",
"todo.add_todo_attachment --file-path": "local upload input used to construct attachmentList",
"todo.get_user_todos_in_current_org --query-all": "Local route selector: the default path calls get_user_todos_in_current_org, while --query-all switches to get_user_todos; it is not a property of the pinned default RPC.",
"todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.",
"wiki.create_wikiSpace --icon": "runtime extension sends icon, which is absent from the pinned create_wikiSpace metadata",
+3 -3
View File
@@ -77,7 +77,7 @@ func cloneRuntimeSchemaStringMap(source map[string]string) map[string]string {
func init() {
RegisterRuntimeSchemaParameterMetadata("aisearch.enterprise_person_search", RuntimeSchemaParameterMetadata{
Required: []string{"query"},
Required: []string{"keyword"},
})
RegisterRuntimeSchemaParameterMetadata("aitable.export_data", RuntimeSchemaParameterMetadata{
RequiredWhen: map[string]string{
@@ -146,8 +146,8 @@ func init() {
})
RegisterRuntimeSchemaParameterMetadata("chat.send_personal_message", RuntimeSchemaParameterMetadata{
RequiredWhen: map[string]string{
"media-id": "msg-type is image",
"file": "msg-type is file or audio or video",
"media-id": "msg-type is image",
"file-path": "msg-type is file or audio or video",
},
Examples: map[string]string{"media-id": "@lADP_schema_smoke"},
})
-15
View File
@@ -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
}
-191
View File
@@ -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
}
-334
View File
@@ -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 },
})
})
}
}
+3 -8
View File
@@ -361,18 +361,13 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) {
}
return nil, net.ErrClosed
}}
d := &daemon{listener: l, log: logger, hub: NewHub(1), idleStop: make(chan struct{}), stopReq: make(chan string, 1)}
d := &daemon{listener: l, log: logger, hub: NewHub(1), idleStop: make(chan struct{})}
d.acceptLoop(context.Background())
d.shuttingDown.Store(true)
d.acceptLoop(context.Background())
d.triggerShutdown("test")
select {
case reason := <-d.stopReq:
if reason != "test" {
t.Fatalf("stop reason = %q", reason)
}
default:
t.Fatal("trigger did not notify lifecycle loop")
if !l.closed {
t.Fatal("trigger did not close listener")
}
d = &daemon{listener: &scriptedListener{accept: func() (net.Conn, error) { return nil, net.ErrClosed }}, log: logger, hub: NewHub(1), idleStop: make(chan struct{})}
+7 -14
View File
@@ -186,7 +186,6 @@ func Run(ctx context.Context, cfg Config) error {
dedup: dd,
started: time.Now().UTC(),
idleStop: make(chan struct{}),
stopReq: make(chan string, 1),
}
// 4. Signal ready BEFORE accepting consumers (avoids a slow-fork
@@ -264,9 +263,6 @@ func Run(ctx context.Context, cfg Config) error {
}
case <-d.idleStop:
log.Info("bus: idle timeout reached, shutting down")
case reason := <-d.stopReq:
shutdownReason = reason
log.Info("bus: shutdown requested via IPC", "reason", reason)
}
// 7. Graceful shutdown — cancel runCtx first so all background
@@ -296,7 +292,6 @@ type daemon struct {
shutdownMu sync.Mutex
shuttingDown atomic.Bool
idleStop chan struct{}
stopReq chan string
credentialHandoffMu sync.Mutex
terminalMu sync.RWMutex
@@ -754,17 +749,15 @@ func (d *daemon) idleWatch(ctx context.Context) {
}
}
// triggerShutdown is called from RPC handlers that want to end the bus. The
// buffered request wakes Run's lifecycle select, which then cancels the cloud
// source and performs the same graceful shutdown used for signals and idle
// expiry. It is intentionally non-blocking so repeated stop requests cannot
// strand connection handlers.
// triggerShutdown is called from RPC handlers that want to end the bus.
// It works by closing the listener (which unblocks Run's select via the
// source error path, indirectly). For v1 a full ctx-cancellation hook is
// out of scope; busctl/stop also sends SIGTERM which is the authoritative
// shutdown path.
func (d *daemon) triggerShutdown(reason string) {
d.log.Info("bus: shutdown triggered via IPC", "reason", reason)
select {
case d.stopReq <- reason:
default:
}
_ = d.listener.Close() // unblocks Accept(), but doesn't kill Source
// Best-effort: a future version wires a context.CancelFunc here.
}
// shutdown performs the graceful tear-down sequence:
-61
View File
@@ -141,67 +141,6 @@ func TestDaemon_RunStartsAndShutsDownCleanly(t *testing.T) {
}
}
func TestCrossPlatformCoverageDaemonIPCStopShutsDownCleanly(t *testing.T) {
workDir := shortTempDir(t)
identityHash := dwsevent.IdentityHash(workDir)
endpoint := dwsevent.IPCEndpoint(workDir, "open", dwsevent.SourceKindAppStream, identityHash)
runDone := make(chan error, 1)
go func() {
runDone <- Run(context.Background(), Config{
WorkDir: workDir,
IPCEndpoint: endpoint,
ClientID: "ding_ipc_stop_test",
Edition: "open",
SourceKind: dwsevent.SourceKindAppStream,
IdentityHash: identityHash,
Source: &fakeSource{},
})
}()
var conn net.Conn
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
var err error
conn, err = transport.Dial(endpoint)
if err == nil {
break
}
time.Sleep(10 * time.Millisecond)
}
if conn == nil {
t.Fatal("bus IPC endpoint did not become ready")
}
w := transport.NewWriter(conn)
r := transport.NewReader(conn)
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
ConsumerPID: os.Getpid(),
Role: transport.HelloRoleStop,
}); err != nil {
t.Fatalf("write stop hello: %v", err)
}
var bye transport.Bye
if err := r.ReadJSON(&bye); err != nil {
t.Fatalf("read stop response: %v", err)
}
_ = conn.Close()
if bye.Type != transport.FrameTypeBye || bye.Reason != "stop_request" {
t.Fatalf("stop response = %#v", bye)
}
select {
case err := <-runDone:
if err != nil {
t.Fatalf("Run returned after IPC stop: %v", err)
}
case <-time.After(3 * time.Second):
t.Fatal("Run did not return after IPC stop")
}
if pid := ReadHolderPID(filepath.Join(workDir, LockFileName)); pid != 0 {
t.Fatalf("bus lock retained pid %d after IPC stop", pid)
}
}
func TestDaemon_ConsumerReceivesEvents(t *testing.T) {
skipOnWindows(t, "uses Unix socket dial")
workDir := shortTempDir(t)
-41
View File
@@ -114,47 +114,6 @@ func ReadHolderPID(path string) int {
return parsePID(b)
}
// ValidateHolderOwnership reports whether pid still owns the exclusive lock at
// path. If the lock can be acquired, no daemon owns it: the PID payload is
// stale and is cleared while the probe holds the lock. The PID is read again
// after a busy result so a daemon replacement cannot be mistaken for the
// original owner.
//
// Callers use this immediately before sending a process-level termination
// fallback. A live PID by itself is not proof of ownership because operating
// systems may reuse PIDs after an unclean daemon exit.
func ValidateHolderOwnership(path string, pid int) (bool, error) {
if pid <= 0 || ReadHolderPID(path) != pid {
return false, nil
}
probe, err := busTryAcquire(path)
if err != nil {
if errors.Is(err, ErrBusy) {
return ReadHolderPID(path) == pid, nil
}
return false, fmt.Errorf("bus: verify lock owner: %w", err)
}
defer probe.Close()
// We own the lock, so the recorded PID cannot own it. Clear only the PID
// we validated above; a changed payload is left untouched defensively.
f := probe.File()
if _, err := busSeek(f, 0, io.SeekStart); err != nil {
return false, fmt.Errorf("bus: seek stale lock: %w", err)
}
current, err := busReadAll(f)
if err != nil {
return false, fmt.Errorf("bus: read stale lock: %w", err)
}
if parsePID(current) == pid {
if err := truncateAndWritePID(f, 0); err != nil {
return false, fmt.Errorf("bus: clear stale PID: %w", err)
}
}
return false, nil
}
// Close releases the flock and best-effort blanks the PID body so a stale
// reader (e.g. `event status` racing our shutdown) does not see our
// long-dead PID and try to signal it. The lock file itself is NOT removed
-107
View File
@@ -16,13 +16,9 @@ package bus
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"testing"
eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestAcquire_WritesOurPID(t *testing.T) {
@@ -116,109 +112,6 @@ func TestReadHolderPID_MalformedReturnsZero(t *testing.T) {
}
}
func TestCrossPlatformCoverageValidateHolderOwnershipHeldLock(t *testing.T) {
path := filepath.Join(t.TempDir(), LockFileName)
held, err := busTryAcquire(path)
if err != nil {
t.Fatalf("hold lock: %v", err)
}
defer held.Close()
if err := truncateAndWritePID(held.File(), os.Getpid()); err != nil {
t.Fatalf("write holder PID: %v", err)
}
owned, err := ValidateHolderOwnership(path, os.Getpid())
if err != nil {
t.Fatalf("ValidateHolderOwnership: %v", err)
}
if !owned {
t.Fatal("held lock was not attributed to its recorded PID")
}
}
func TestCrossPlatformCoverageValidateHolderOwnershipClearsReusedPID(t *testing.T) {
path := filepath.Join(t.TempDir(), LockFileName)
// The current PID is alive but deliberately does not hold this lock. This
// models a stale daemon PID that the OS has reassigned to another process.
if err := os.WriteFile(path, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil {
t.Fatalf("write stale PID: %v", err)
}
owned, err := ValidateHolderOwnership(path, os.Getpid())
if err != nil {
t.Fatalf("ValidateHolderOwnership: %v", err)
}
if owned {
t.Fatal("live reused PID without the bus lock was accepted as owner")
}
if got := ReadHolderPID(path); got != 0 {
t.Fatalf("stale PID after validation = %d, want cleared", got)
}
}
func TestCrossPlatformCoverageValidateHolderOwnershipRejectsMismatch(t *testing.T) {
path := filepath.Join(t.TempDir(), LockFileName)
if err := os.WriteFile(path, []byte("123\n"), 0o600); err != nil {
t.Fatal(err)
}
owned, err := ValidateHolderOwnership(path, 456)
if err != nil || owned {
t.Fatalf("ValidateHolderOwnership mismatch = %v, %v", owned, err)
}
}
func TestCrossPlatformCoverageValidateHolderOwnershipErrors(t *testing.T) {
errInjected := errors.New("injected ownership validation failure")
writeLivePID := func(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), LockFileName)
if err := os.WriteFile(path, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil {
t.Fatal(err)
}
return path
}
t.Run("probe", func(t *testing.T) {
path := writeLivePID(t)
testseam.Swap(t, &busTryAcquire, func(string) (*eventlock.File, error) {
return nil, errInjected
})
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
t.Fatalf("probe error = %v", err)
}
})
t.Run("seek", func(t *testing.T) {
path := writeLivePID(t)
testseam.Swap(t, &busSeek, func(*os.File, int64, int) (int64, error) {
return 0, errInjected
})
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
t.Fatalf("seek error = %v", err)
}
})
t.Run("read", func(t *testing.T) {
path := writeLivePID(t)
testseam.Swap(t, &busReadAll, func(io.Reader) ([]byte, error) {
return nil, errInjected
})
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
t.Fatalf("read error = %v", err)
}
})
t.Run("clear", func(t *testing.T) {
path := writeLivePID(t)
testseam.Swap(t, &busTruncate, func(*os.File, int64) error {
return errInjected
})
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
t.Fatalf("clear error = %v", err)
}
})
}
func TestAcquire_AfterReleaseReclaimable(t *testing.T) {
path := filepath.Join(t.TempDir(), LockFileName)
l1, err := Acquire(path)
@@ -284,13 +284,11 @@ func TestCrossPlatformCoverageQueryStatusAndEntryEdges(t *testing.T) {
func TestCrossPlatformCoverageStopInjectedEdges(t *testing.T) {
origRead := stopReadHolderPID
origValidateOwner := stopValidateHolderOwner
origAlive := stopAlive
origFind := stopFindProcess
origSignal := stopSignalProcess
t.Cleanup(func() {
stopReadHolderPID = origRead
stopValidateHolderOwner = origValidateOwner
stopAlive = origAlive
stopFindProcess = origFind
stopSignalProcess = origSignal
@@ -309,7 +307,6 @@ func TestCrossPlatformCoverageStopInjectedEdges(t *testing.T) {
t.Fatal(err)
}
stopFindProcess = func(int) (*os.Process, error) { return proc, nil }
stopValidateHolderOwner = func(string, int) (bool, error) { return true, nil }
stopSignalProcess = func(*os.Process, os.Signal) error { return os.ErrProcessDone }
if err := Stop(StopConfig{WorkDir: "x"}); err != nil {
t.Fatalf("done signal = %v", err)
+2 -3
View File
@@ -112,9 +112,8 @@ func Discover(cfg DiscoverConfig) (net.Conn, error) {
return nil, fmt.Errorf("busctl: mkdir workdir: %w", err)
}
_, spawnErr := cfg.Spawn(SpawnConfig{
ClientID: cfg.ClientID,
IPCEndpoint: cfg.IPCEndpoint,
ExtraArgs: cfg.SpawnExtraArgs,
ClientID: cfg.ClientID,
ExtraArgs: cfg.SpawnExtraArgs,
})
if spawnErr != nil && !errors.Is(spawnErr, ErrSpawnFailed) {
// Hard error (couldn't even exec the child). Stop here — no bus
+1 -4
View File
@@ -122,10 +122,7 @@ func TestDiscover_NoBus_SpawnSucceeds(t *testing.T) {
closer()
}
})
fakeSpawn := func(cfg SpawnConfig) (int, error) {
if cfg.IPCEndpoint != sock {
t.Fatalf("spawn IPC endpoint = %q, want %q", cfg.IPCEndpoint, sock)
}
fakeSpawn := func(SpawnConfig) (int, error) {
closer = startStubBus(t, sock)
return 12345, nil
}
+14 -15
View File
@@ -43,14 +43,18 @@ var (
spawnPipe = os.Pipe
)
// ErrSpawnFailed is returned when the child reports startup failure through
// the Unix ready pipe or exits before binding the Windows named pipe.
var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure")
// ErrSpawnFailed is returned when the child reports startup failure via
// the ready pipe ('E' byte). The child's exit error / log file holds the
// actual cause; this sentinel just lets the caller distinguish "ready
// pipe said no" from "ready pipe timed out / closed early".
var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure on ready pipe")
// ErrSpawnTimeout is returned when ReadyTimeout elapses without any signal.
var ErrSpawnTimeout = errors.New("busctl: bus child did not signal readiness within deadline")
// SpawnConfig describes one spawn attempt.
// SpawnConfig describes one spawn attempt. ClientID is the only field
// inspected by the child; the rest govern process attributes the parent
// applies before exec.
type SpawnConfig struct {
// ExecPath is the dws binary to exec. Default os.Executable().
ExecPath string
@@ -58,23 +62,18 @@ type SpawnConfig struct {
// ClientID is passed as `--client-id` to `dws event _bus`. Required.
ClientID string
// IPCEndpoint is the bus endpoint the child binds. Windows uses the
// existing named pipe as its readiness handshake because os/exec does not
// support ExtraFiles there. Unix keeps the inherited ready-pipe protocol.
IPCEndpoint string
// ExtraArgs are appended after `--client-id`. Empty for normal use; tests
// pass `--extra-flag-for-test` etc.
ExtraArgs []string
// Env to pass to the child. Defaults to os.Environ(). Unix appends the
// ReadyFDEnv entry automatically; Windows removes any inherited copy.
// Env to pass to the child. Defaults to os.Environ(). The ReadyFDEnv
// entry is appended automatically.
Env []string
}
// spawnWithReadyPipe is the Unix implementation behind Spawn. It forks a
// detached `dws event _bus --client-id <id>` child and waits for the inherited
// ready pipe. Windows has a named-pipe implementation in spawn_windows.go.
// Spawn forks a detached `dws event _bus --client-id <id>` child process and
// waits for it to signal readiness via the ready pipe. Returns the child's
// PID on success — the caller can then dial the bus IPC endpoint.
//
// stdio detach (plan invariant #7):
// - cmd.Stdout / cmd.Stderr set to nil so the child's own writes don't
@@ -90,7 +89,7 @@ type SpawnConfig struct {
// Parent (this function):
// - Holds the read end open until either 1 byte is read or ReadyTimeout
// - Returns ErrSpawnFailed for 'E', ErrSpawnTimeout otherwise
func spawnWithReadyPipe(cfg SpawnConfig) (pid int, err error) {
func Spawn(cfg SpawnConfig) (pid int, err error) {
if cfg.ClientID == "" {
return 0, errors.New("busctl: SpawnConfig.ClientID is required")
}
+1 -24
View File
@@ -22,8 +22,6 @@ import (
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
// Test child mode: when DWS_BUSCTL_TEST_CHILD is set, this test binary
@@ -37,8 +35,7 @@ import (
// env-marker pattern is what Go's own os/exec tests use and stays
// confined to this file.
const (
childEnvMarker = "DWS_BUSCTL_TEST_CHILD"
childEndpointEnv = "DWS_BUSCTL_TEST_ENDPOINT"
childEnvMarker = "DWS_BUSCTL_TEST_CHILD"
// values:
// "ready" — write 'R' then sleep 30s (parent should see ready)
// "fail" — write 'E' then exit (parent should see ErrSpawnFailed)
@@ -73,26 +70,6 @@ func TestMain(m *testing.M) {
writeReady('R')
time.Sleep(30 * time.Second)
os.Exit(0)
case "windows-ready":
listener, err := transport.Listen(os.Getenv(childEndpointEnv))
if err != nil {
os.Exit(3)
}
defer listener.Close()
conn, err := listener.Accept()
if err != nil {
os.Exit(4)
}
_ = conn.Close()
time.Sleep(30 * time.Second)
os.Exit(0)
case "windows-fail":
os.Exit(5)
case "windows-exit":
os.Exit(0)
case "windows-stall":
time.Sleep(30 * time.Second)
os.Exit(0)
}
os.Exit(m.Run())
}
-7
View File
@@ -20,13 +20,6 @@ import (
"syscall"
)
// Spawn starts the detached bus and waits for its inherited ready pipe.
// ExtraFiles is intentionally confined to Unix: Go does not support it on
// Windows.
func Spawn(cfg SpawnConfig) (pid int, err error) {
return spawnWithReadyPipe(cfg)
}
// applyDetach configures the child to live past parent death and not share
// the parent's controlling terminal. Setsid puts the child in a new
// session, so SIGHUP on the parent's controlling tty (e.g. SSH disconnect)
-87
View File
@@ -16,101 +16,14 @@
package busctl
import (
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
// CREATE_NEW_PROCESS_GROUP (0x00000200) prevents the child from receiving
// the parent's Ctrl+C signal, similar in spirit to Setsid on Unix.
const createNewProcessGroup = 0x00000200
var (
spawnWindowsDial = transport.Dial
spawnWindowsPollInterval = 25 * time.Millisecond
)
// Spawn starts a detached Windows bus without cmd.ExtraFiles (unsupported by
// Go on Windows). Readiness is confirmed by dialing the bus's existing named
// pipe. The child is reaped in the background after readiness, while an early
// process exit is surfaced as ErrSpawnFailed.
func Spawn(cfg SpawnConfig) (pid int, err error) {
if strings.TrimSpace(cfg.ClientID) == "" {
return 0, fmt.Errorf("busctl: SpawnConfig.ClientID is required")
}
if strings.TrimSpace(cfg.IPCEndpoint) == "" {
return 0, fmt.Errorf("busctl: SpawnConfig.IPCEndpoint is required on Windows")
}
if cfg.ExecPath == "" {
execPath, err := spawnExecutable()
if err != nil {
return 0, fmt.Errorf("busctl: locate executable: %w", err)
}
cfg.ExecPath = execPath
}
if cfg.Env == nil {
cfg.Env = os.Environ()
}
args := append([]string{"event", "_bus", "--client-id", cfg.ClientID}, cfg.ExtraArgs...)
cmd := exec.Command(cfg.ExecPath, args...)
cmd.Env = withoutReadyFDEnv(cfg.Env)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
applyDetach(cmd)
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("busctl: start %s: %w", cfg.ExecPath, err)
}
pid = cmd.Process.Pid
waitDone := make(chan error, 1)
go func() { waitDone <- cmd.Wait() }()
timer := time.NewTimer(ReadyTimeout)
defer timer.Stop()
ticker := time.NewTicker(spawnWindowsPollInterval)
defer ticker.Stop()
for {
if conn, dialErr := spawnWindowsDial(cfg.IPCEndpoint); dialErr == nil {
_ = conn.Close()
return pid, nil
}
select {
case waitErr := <-waitDone:
if waitErr == nil {
return pid, fmt.Errorf("%w: child exited before binding named pipe", ErrSpawnFailed)
}
return pid, fmt.Errorf("%w: %v", ErrSpawnFailed, waitErr)
case <-timer.C:
_ = cmd.Process.Kill()
select {
case <-waitDone:
case <-time.After(time.Second):
}
return pid, ErrSpawnTimeout
case <-ticker.C:
}
}
}
func withoutReadyFDEnv(env []string) []string {
prefix := strings.ToUpper(ReadyFDEnv) + "="
out := make([]string, 0, len(env))
for _, entry := range env {
if strings.HasPrefix(strings.ToUpper(entry), prefix) {
continue
}
out = append(out, entry)
}
return out
}
func applyDetach(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: createNewProcessGroup,
-132
View File
@@ -1,132 +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.
//go:build windows
package busctl
import (
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestCrossPlatformCoverageWindowsSpawnUsesNamedPipeReadiness(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-test-%d-%d`, os.Getpid(), time.Now().UnixNano())
pid, err := spawnWithMarker(t, "windows-ready", func(cfg *SpawnConfig) {
cfg.IPCEndpoint = endpoint
cfg.Env = append(cfg.Env, childEndpointEnv+"="+endpoint)
})
if err != nil {
t.Fatalf("Spawn() = %v", err)
}
if pid <= 0 {
t.Fatalf("Spawn() pid = %d", pid)
}
proc, err := os.FindProcess(pid)
if err != nil {
t.Fatal(err)
}
_ = proc.Kill()
}
func TestCrossPlatformCoverageWindowsSpawnReportsEarlyExit(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-fail-%d-%d`, os.Getpid(), time.Now().UnixNano())
_, err := spawnWithMarker(t, "windows-fail", func(cfg *SpawnConfig) {
cfg.IPCEndpoint = endpoint
})
if !errors.Is(err, ErrSpawnFailed) {
t.Fatalf("Spawn() error = %v, want ErrSpawnFailed", err)
}
}
func TestCrossPlatformCoverageWindowsSpawnReportsCleanEarlyExit(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-exit-%d-%d`, os.Getpid(), time.Now().UnixNano())
_, err := spawnWithMarker(t, "windows-exit", func(cfg *SpawnConfig) {
cfg.IPCEndpoint = endpoint
})
if !errors.Is(err, ErrSpawnFailed) {
t.Fatalf("Spawn() error = %v, want ErrSpawnFailed", err)
}
}
func TestCrossPlatformCoverageWindowsSpawnResolvesExecutable(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-resolve-%d-%d`, os.Getpid(), time.Now().UnixNano())
testseam.Swap(t, &spawnExecutable, func() (string, error) { return os.Args[0], nil })
pid, err := spawnWithMarker(t, "windows-ready", func(cfg *SpawnConfig) {
cfg.ExecPath = ""
cfg.IPCEndpoint = endpoint
cfg.Env = append(cfg.Env, childEndpointEnv+"="+endpoint)
})
if err != nil {
t.Fatalf("Spawn() = %v", err)
}
proc, err := os.FindProcess(pid)
if err != nil {
t.Fatal(err)
}
_ = proc.Kill()
}
func TestCrossPlatformCoverageWindowsSpawnValidationAndStartErrors(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-errors-%d-%d`, os.Getpid(), time.Now().UnixNano())
if _, err := Spawn(SpawnConfig{ClientID: "client"}); err == nil {
t.Fatal("missing endpoint unexpectedly succeeded")
}
testseam.Swap(t, &spawnExecutable, func() (string, error) {
return "", errors.New("executable unavailable")
})
if _, err := Spawn(SpawnConfig{ClientID: "client", IPCEndpoint: endpoint}); err == nil {
t.Fatal("executable lookup unexpectedly succeeded")
}
if _, err := Spawn(SpawnConfig{
ClientID: "client",
IPCEndpoint: endpoint,
ExecPath: `C:\\definitely-missing-dws.exe`,
}); err == nil {
t.Fatal("missing executable unexpectedly started")
}
}
func TestCrossPlatformCoverageWindowsSpawnReadyTimeoutTerminatesChild(t *testing.T) {
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-stall-%d-%d`, os.Getpid(), time.Now().UnixNano())
testseam.Swap(t, &ReadyTimeout, 100*time.Millisecond)
_, err := spawnWithMarker(t, "windows-stall", func(cfg *SpawnConfig) {
cfg.IPCEndpoint = endpoint
})
if !errors.Is(err, ErrSpawnTimeout) {
t.Fatalf("Spawn() error = %v, want ErrSpawnTimeout", err)
}
}
func TestCrossPlatformCoverageWindowsSpawnRemovesInheritedReadyFD(t *testing.T) {
env := withoutReadyFDEnv([]string{
"PATH=C:\\Windows",
ReadyFDEnv + "=3",
strings.ToLower(ReadyFDEnv) + "=4",
})
if len(env) != 1 || !strings.HasPrefix(env[0], "PATH=") {
t.Fatalf("withoutReadyFDEnv() = %#v", env)
}
}
func TestCrossPlatformCoverageWindowsUnixReadyPipeValidation(t *testing.T) {
if _, err := spawnWithReadyPipe(SpawnConfig{}); err == nil {
t.Fatal("spawnWithReadyPipe() unexpectedly accepted an empty client ID")
}
}
+21 -92
View File
@@ -17,17 +17,15 @@ import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
// DefaultStopTimeout is the wall-clock budget Stop waits for each graceful or
// fallback termination phase. 5s covers the bus's own tear-down (broadcast
// Bye → consumer goroutines drain → cleanup) with margin.
// DefaultStopTimeout is the wall-clock budget Stop waits for the bus to
// exit after the signal is sent. 5s covers the bus's own graceful tear-down
// (broadcast Bye → consumer goroutines drain → cleanup) with margin.
const DefaultStopTimeout = 5 * time.Second
// ErrNotRunning indicates bus.lock either does not exist or its recorded
@@ -35,32 +33,20 @@ const DefaultStopTimeout = 5 * time.Second
// distinguish "nothing to stop" from "failed to stop".
var ErrNotRunning = errors.New("busctl: bus is not running")
// ErrOwnerUnverified indicates the PID from bus.lock is alive but no longer
// owns that lock. Stop refuses to send a process-level signal in this state
// because the operating system may have reused the stale PID.
var ErrOwnerUnverified = errors.New("busctl: bus process ownership could not be verified")
var (
stopReadHolderPID = bus.ReadHolderPID
stopValidateHolderOwner = bus.ValidateHolderOwnership
stopAlive = process.Alive
stopFindProcess = os.FindProcess
stopSignalProcess = func(proc *os.Process, signal os.Signal) error { return proc.Signal(signal) }
stopDial = transport.Dial
stopRequest = requestBusStop
stopWaitForBusExit = waitForBusExit
stopReadHolderPID = bus.ReadHolderPID
stopAlive = process.Alive
stopFindProcess = os.FindProcess
stopSignalProcess = func(proc *os.Process, signal os.Signal) error { return proc.Signal(signal) }
)
// StopConfig identifies the target bus and tunes timing.
type StopConfig struct {
// WorkDir holds bus.lock; Stop reads the PID from there.
WorkDir string
// IPCEndpoint enables the cross-platform graceful stop RPC. Callers that
// know the bus identity should always provide it. Empty preserves the
// legacy signal-only fallback for compatibility and focused tests.
IPCEndpoint string
// Timeout is the wall-clock budget for graceful exit and, if required,
// the subsequent platform termination fallback.
// Timeout is the total wall-clock budget for graceful exit. After this,
// Stop returns an error; it does NOT escalate to SIGKILL — leave that
// to the operator.
Timeout time.Duration
}
@@ -68,12 +54,13 @@ type StopConfig struct {
// for the process to actually die. Returns ErrNotRunning if no bus is
// running for that work dir.
//
// Stop first asks the bus to shut down through its owner-only IPC endpoint.
// This is the normal path on every platform and lets the daemon cancel its
// cloud source, notify consumers, and release its lock. If the endpoint is
// unavailable (for example an older bus) or graceful shutdown times out, the
// platform signal is used as a compatibility fallback: SIGTERM on Unix and
// TerminateProcess via os.Kill on Windows.
// Implementation note: on Unix we send SIGTERM. The bus daemon's Run loop
// watches its parent ctx for cancellation; the cobra `event _bus` command
// wires signal.NotifyContext so SIGTERM triggers ctx.Done() → graceful
// shutdown path. On Windows we use os.Process.Signal(os.Interrupt) which
// the Go runtime maps to TerminateProcess for processes outside our
// console group; for v1 that's acceptable (Windows graceful shutdown is
// future work — plan §16 v2).
func Stop(cfg StopConfig) error {
if cfg.WorkDir == "" {
return errors.New("busctl: StopConfig.WorkDir is required")
@@ -94,32 +81,6 @@ func Stop(cfg StopConfig) error {
if err != nil {
return fmt.Errorf("busctl: find process %d: %w", pid, err)
}
if strings.TrimSpace(cfg.IPCEndpoint) != "" {
if err := stopRequest(cfg.IPCEndpoint); err == nil && stopWaitForBusExit(pid, cfg.Timeout) {
return nil
}
}
// The bus may finish shutting down immediately after the graceful wait
// reaches its deadline (or after a failed IPC request). Recheck before
// inspecting ownership so a completed stop is not reported as stale.
if !stopAlive(pid) {
return nil
}
owner, err := stopValidateHolderOwner(LockPath(cfg.WorkDir), pid)
if err != nil {
return fmt.Errorf("busctl: verify bus pid=%d ownership: %w", pid, err)
}
if !owner {
// ValidateHolderOwnership observes a released lock both for a stale,
// reused PID and for a bus that exits during the ownership probe. Only
// the still-alive case is unsafe to signal.
if !stopAlive(pid) {
return nil
}
return fmt.Errorf("%w: pid=%d does not own %s; stale PID was not signalled", ErrOwnerUnverified, pid, LockPath(cfg.WorkDir))
}
if err := stopSignalProcess(proc, stopSignal()); err != nil {
// On many Unix platforms Signal returns "process already finished"
// when the bus has just exited on its own — treat that as success.
@@ -129,45 +90,13 @@ func Stop(cfg StopConfig) error {
return fmt.Errorf("busctl: signal bus pid=%d: %w", pid, err)
}
if stopWaitForBusExit(pid, cfg.Timeout) {
return nil
}
return fmt.Errorf("busctl: bus pid=%d did not exit within %s", pid, cfg.Timeout)
}
func requestBusStop(endpoint string) error {
conn, err := stopDial(endpoint)
if err != nil {
return fmt.Errorf("busctl: dial bus for stop: %w", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(DefaultStatusRPCTimeout))
w := transport.NewWriter(conn)
r := transport.NewReader(conn)
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
ConsumerPID: os.Getpid(),
Role: transport.HelloRoleStop,
}); err != nil {
return fmt.Errorf("busctl: write stop hello: %w", err)
}
var bye transport.Bye
if err := r.ReadJSON(&bye); err != nil {
return fmt.Errorf("busctl: read stop response: %w", err)
}
if bye.Type != transport.FrameTypeBye || bye.Reason != "stop_request" {
return fmt.Errorf("busctl: unexpected stop response type=%q reason=%q", bye.Type, bye.Reason)
}
return nil
}
func waitForBusExit(pid int, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
// Poll for actual exit.
deadline := time.Now().Add(cfg.Timeout)
for time.Now().Before(deadline) {
if !stopAlive(pid) {
return true
return nil
}
time.Sleep(50 * time.Millisecond)
}
return !stopAlive(pid)
return fmt.Errorf("busctl: bus pid=%d did not exit within %s", pid, cfg.Timeout)
}
+1 -270
View File
@@ -16,7 +16,6 @@ package busctl
import (
"context"
"errors"
"net"
"os"
"os/exec"
"path/filepath"
@@ -24,10 +23,7 @@ import (
"testing"
"time"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
func TestStop_NotRunningWhenLockMissing(t *testing.T) {
@@ -38,7 +34,7 @@ func TestStop_NotRunningWhenLockMissing(t *testing.T) {
}
}
func TestCrossPlatformCoverageStopNotRunningWhenPIDDead(t *testing.T) {
func TestStop_NotRunningWhenPIDDead(t *testing.T) {
dir := shortTempDir(t)
// Write a definitely-dead PID into bus.lock.
if err := os.WriteFile(LockPath(dir), []byte("2147483646\n"), 0o600); err != nil {
@@ -50,269 +46,6 @@ func TestCrossPlatformCoverageStopNotRunningWhenPIDDead(t *testing.T) {
}
}
func TestCrossPlatformCoverageStopPrefersGracefulIPC(t *testing.T) {
const pid = 4242
stopped := false
signals := 0
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
testseam.Swap(t, &stopAlive, func(int) bool { return !stopped })
proc, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
testseam.Swap(t, &stopRequest, func(endpoint string) error {
if endpoint != "test-endpoint" {
t.Fatalf("stop endpoint = %q", endpoint)
}
stopped = true
return nil
})
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
signals++
return nil
})
if err := Stop(StopConfig{
WorkDir: "test-workdir",
IPCEndpoint: "test-endpoint",
Timeout: time.Second,
}); err != nil {
t.Fatalf("Stop() = %v", err)
}
if signals != 0 {
t.Fatalf("graceful IPC stop used %d process signals", signals)
}
}
func TestCrossPlatformCoverageStopDoesNotSignalUnverifiedReusedPID(t *testing.T) {
const pid = 4242
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
testseam.Swap(t, &stopAlive, func(int) bool { return true })
proc, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
testseam.Swap(t, &stopRequest, func(string) error { return errors.New("stale endpoint") })
testseam.Swap(t, &stopValidateHolderOwner, func(path string, gotPID int) (bool, error) {
if path != LockPath("test-workdir") || gotPID != pid {
t.Fatalf("ownership check path=%q pid=%d", path, gotPID)
}
return false, nil
})
signals := 0
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
signals++
return nil
})
err = Stop(StopConfig{
WorkDir: "test-workdir",
IPCEndpoint: "stale-endpoint",
Timeout: time.Millisecond,
})
if !errors.Is(err, ErrOwnerUnverified) {
t.Fatalf("Stop() error = %v, want ErrOwnerUnverified", err)
}
if signals != 0 {
t.Fatalf("unverified reused PID received %d signals", signals)
}
}
func TestCrossPlatformCoverageStopAcceptsExitAtGracefulTimeoutBoundary(t *testing.T) {
const pid = 4242
t.Run("before ownership validation", func(t *testing.T) {
exited := false
validated := false
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
testseam.Swap(t, &stopAlive, func(int) bool { return !exited })
proc, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
testseam.Swap(t, &stopRequest, func(string) error { return nil })
testseam.Swap(t, &stopWaitForBusExit, func(int, time.Duration) bool {
exited = true
return false
})
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) {
validated = true
return false, nil
})
if err := Stop(StopConfig{WorkDir: "test-workdir", IPCEndpoint: "test-endpoint"}); err != nil {
t.Fatalf("Stop() = %v, want success after bus exit", err)
}
if validated {
t.Fatal("ownership was validated after the bus had already exited")
}
})
t.Run("during ownership validation", func(t *testing.T) {
exited := false
signals := 0
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
testseam.Swap(t, &stopAlive, func(int) bool { return !exited })
proc, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
testseam.Swap(t, &stopRequest, func(string) error { return nil })
testseam.Swap(t, &stopWaitForBusExit, func(int, time.Duration) bool { return false })
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) {
exited = true
return false, nil
})
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
signals++
return nil
})
if err := Stop(StopConfig{WorkDir: "test-workdir", IPCEndpoint: "test-endpoint"}); err != nil {
t.Fatalf("Stop() = %v, want success after exit during ownership validation", err)
}
if signals != 0 {
t.Fatalf("exited bus received %d fallback signals", signals)
}
})
}
func TestCrossPlatformCoverageStopReportsOwnershipValidationError(t *testing.T) {
const pid = 4242
errInjected := errors.New("ownership validation failed")
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
testseam.Swap(t, &stopAlive, func(int) bool { return true })
proc, err := os.FindProcess(os.Getpid())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) {
return false, errInjected
})
signals := 0
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
signals++
return nil
})
err = Stop(StopConfig{WorkDir: "test-workdir"})
if !errors.Is(err, errInjected) {
t.Fatalf("Stop() error = %v, want injected ownership error", err)
}
if signals != 0 {
t.Fatalf("ownership validation error sent %d signals", signals)
}
}
func TestCrossPlatformCoverageRequestBusStopProtocol(t *testing.T) {
dir := shortTempDir(t)
endpoint := dwsevent.IPCEndpoint(
dir,
"open",
dwsevent.SourceKindAppStream,
dwsevent.IdentityHash(dir),
)
listener, err := transport.Listen(endpoint)
if err != nil {
t.Fatalf("Listen() = %v", err)
}
defer listener.Close()
serverDone := make(chan error, 1)
go func() {
conn, err := listener.Accept()
if err != nil {
serverDone <- err
return
}
defer conn.Close()
r := transport.NewReader(conn)
w := transport.NewWriter(conn)
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
serverDone <- err
return
}
if hello.Type != transport.FrameTypeHello || hello.Role != transport.HelloRoleStop {
serverDone <- errors.New("unexpected stop hello")
return
}
serverDone <- w.WriteJSON(transport.Bye{
Type: transport.FrameTypeBye,
Reason: "stop_request",
})
}()
if err := requestBusStop(endpoint); err != nil {
t.Fatalf("requestBusStop() = %v", err)
}
if err := <-serverDone; err != nil {
t.Fatalf("stop protocol server = %v", err)
}
}
func TestCrossPlatformCoverageRequestBusStopErrors(t *testing.T) {
t.Run("dial", func(t *testing.T) {
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
return nil, errors.New("dial failed")
})
if err := requestBusStop("test-endpoint"); err == nil {
t.Fatal("requestBusStop() unexpectedly succeeded")
}
})
t.Run("write", func(t *testing.T) {
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
return &queryErrorConn{failAt: 1}, nil
})
if err := requestBusStop("test-endpoint"); err == nil {
t.Fatal("requestBusStop() unexpectedly succeeded")
}
})
t.Run("read", func(t *testing.T) {
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
return &queryErrorConn{}, nil
})
if err := requestBusStop("test-endpoint"); err == nil {
t.Fatal("requestBusStop() unexpectedly succeeded")
}
})
t.Run("unexpected response", func(t *testing.T) {
client, server := net.Pipe()
t.Cleanup(func() {
_ = client.Close()
_ = server.Close()
})
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
return client, nil
})
serverDone := make(chan error, 1)
go func() {
var hello transport.Hello
if err := transport.NewReader(server).ReadJSON(&hello); err != nil {
serverDone <- err
return
}
serverDone <- transport.NewWriter(server).WriteJSON(transport.Bye{
Type: transport.FrameTypeBye,
Reason: "unexpected",
})
}()
if err := requestBusStop("test-endpoint"); err == nil {
t.Fatal("requestBusStop() unexpectedly succeeded")
}
if err := <-serverDone; err != nil {
t.Fatalf("stop protocol server = %v", err)
}
})
}
func TestStop_SignalsLiveProcess(t *testing.T) {
skipOnWindows(t)
dir := shortTempDir(t)
@@ -330,7 +63,6 @@ func TestStop_SignalsLiveProcess(t *testing.T) {
}
}()
pid := cmd.Process.Pid
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) { return true, nil })
// Reap the child in background so Wait doesn't leave a zombie.
waited := make(chan error, 1)
@@ -379,7 +111,6 @@ func TestStop_TimeoutWhenChildIgnoresSignal(t *testing.T) {
_, _ = cmd.Process.Wait()
}()
pid := cmd.Process.Pid
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) { return true, nil })
if err := os.WriteFile(LockPath(dir), []byte(strconv.Itoa(pid)+"\n"), 0o600); err != nil {
t.Fatal(err)
+5 -4
View File
@@ -17,7 +17,8 @@ package busctl
import "os"
// stopSignal is only the fallback after the graceful IPC stop path fails or
// times out. Go maps os.Kill to TerminateProcess on Windows; os.Interrupt is
// unsupported and returns syscall.EWINDOWS.
func stopSignal() os.Signal { return os.Kill }
// stopSignal returns the graceful-shutdown signal for Windows. The Go
// runtime maps os.Interrupt to TerminateProcess for non-console-group
// processes — not truly graceful, but acceptable for v1 (Windows graceful
// shutdown via Ctrl+Break is in the v2 backlog, plan §16).
func stopSignal() os.Signal { return os.Interrupt }
@@ -1,27 +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.
//go:build windows
package busctl
import (
"os"
"testing"
)
func TestCrossPlatformCoverageWindowsStopFallbackUsesKill(t *testing.T) {
if stopSignal() != os.Kill {
t.Fatalf("stopSignal() = %v, want os.Kill", stopSignal())
}
}
+31 -47
View File
@@ -3,21 +3,18 @@ package helpers
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// boolPtr returns a pointer to v, for ParamDecl.Required declarations.
func boolPtr(v bool) *bool { return &v }
// 这些是原有的宽松兜底名,不属于本次 Primary 迁移;保持原生 hidden
// 注册,不给它们新增 alias_of/origin 元数据。
var aisearchPersonGuessFlags = []string{"name", "q", "text"}
// 历史解析优先级必须独立于新旧 Primary:双传时旧 --keyword 始终获胜。
var aisearchQueryResolutionOrder = []string{"keyword", "name", "q", "query", "text"}
// aisearchKeywordAliases 是 --keyword flag 的同义瞎猜兜底列表。
// 模型可能写 --name / --q / --query / --text,这些都被识别为 keyword。
var aisearchKeywordAliases = []string{"name", "q", "query", "text"}
// flagValue 安全地读取 flag 值:先查 local,再查自身 PersistentFlags,
// 再查 parents 的 PersistentFlags。比 cmd.Flags().GetString 更鲁棒,
@@ -61,11 +58,14 @@ func aisearchFlagOrDefault(cmd *cobra.Command, primary, def string, aliases ...s
return def
}
// resolveAisearchKeyword 按历史顺序解析人员搜索词。虽然公开 Primary 已改为
// --query,但兼容期内显式旧 --keyword 的值仍优先。
// resolveAisearchKeyword 从命令的 flag 中解析 keyword:优先 --keyword,
// 否则 fallback 到 aisearchKeywordAliases 中的任一同义 flag。
func resolveAisearchKeyword(cmd *cobra.Command) string {
for _, name := range aisearchQueryResolutionOrder {
if v := flagValue(cmd, name); v != "" {
if v := flagValue(cmd, "keyword"); v != "" {
return v
}
for _, alias := range aisearchKeywordAliases {
if v := flagValue(cmd, alias); v != "" {
return v
}
}
@@ -73,23 +73,9 @@ func resolveAisearchKeyword(cmd *cobra.Command) string {
}
func addAisearchPersonFlags(cmd *cobra.Command) {
// Alias evidence must be emitted by corecmd.FlagSpec.Aliases. Build the pair
// on a temporary declaration command so the legacy alias can retain its
// historical -w shorthand without extending the global alias mechanism or
// assigning that shorthand to the new --query Primary.
declaration := &cobra.Command{Use: "aisearch-query-flags"}
corecmd.RegisterFlags(declaration, []corecmd.FlagSpec{{
Name: "query",
Usage: "搜索关键词 (必填,如人名、技能关键词等)",
Aliases: []string{"keyword"},
}})
queryFlag := declaration.Flags().Lookup("query")
keywordFlag := declaration.Flags().Lookup("keyword")
keywordFlag.Shorthand = "w"
cmd.Flags().AddFlag(queryFlag)
cmd.Flags().AddFlag(keywordFlag)
cmd.Flags().StringP("keyword", "w", "", "搜索关键词 (必填,如人名、技能关键词等)")
cmd.Flags().StringP("dimension", "d", "all", "查询维度: all/name/department/position/duty/supervisor/subordinate/phone/jobNumber,多个用逗号分隔")
for _, alias := range aisearchPersonGuessFlags {
for _, alias := range aisearchKeywordAliases {
cmd.Flags().String(alias, "", "")
_ = cmd.Flags().MarkHidden(alias)
}
@@ -112,7 +98,8 @@ func runAisearchPerson(cmd *cobra.Command, _ []string) error {
}
keyword := resolveAisearchKeyword(cmd)
if keyword == "" {
return validateRequiredFlags(cmd, "query")
// 复用原有报错文案("keyword is required")
return validateRequiredFlags(cmd, "keyword")
}
dimensions := parseDimensions(flagValue(cmd, "dimension"))
return callMCPTool("enterprise_person_search", map[string]any{
@@ -201,8 +188,8 @@ func newAisearchCommand() *cobra.Command {
Use: "aisearch",
Short: "AI 搜问",
Long: `AI 搜问:搜索企业人员信息、企业内部知识内容与企业内部行为记录。`,
// 智能 root:模型常漏 person 子命令直接 dws aisearch --query xxx,
// 检测到 query 就自动等价于 person;否则退回 group help。
// 智能 root:模型常漏 person 子命令直接 dws aisearch --keyword xxx,
// 检测到 keyword 就自动等价于 person;否则退回 group help。
RunE: func(cmd *cobra.Command, args []string) error {
if resolveAisearchKeyword(cmd) != "" {
return runAisearchPerson(cmd, args)
@@ -212,9 +199,9 @@ func newAisearchCommand() *cobra.Command {
}
// root 和 person 各自定义同一组本地 flag,这样:
// - dws aisearch --query xxx ← root 自己能解析
// - dws aisearch person --query xxx ← person 本地 flag
// - dws aisearch search --query xxx ← search 是 person 的 alias
// - dws aisearch --keyword xxx ← root 自己能解析
// - dws aisearch person --keyword xxx ← person 本地 flag
// - dws aisearch search --keyword xxx ← search 是 person 的 alias
// 不能放在 product PersistentFlags:否则 enterprise/behavior 会公开接受
// 它们不拥有的 person-only dimension,破坏 Help ↔ Schema 完整性。
addAisearchPersonFlags(root)
@@ -232,7 +219,7 @@ func newAisearchCommand() *cobra.Command {
"query", "people", "ask", "find", "lookup",
"contact",
},
// 显式声明允许任意位置参数:模型可能写 dws aisearch person search --query xxx,
// 显式声明允许任意位置参数:模型可能写 dws aisearch person search --keyword xxx,
// 此时 "search" 会作为 positional arg 被忽略,不报错。
Args: cobra.ArbitraryArgs,
Short: "搜索企业人员",
@@ -250,13 +237,13 @@ func newAisearchCommand() *cobra.Command {
jobNumber 工号
多个维度用逗号分隔。`,
Example: ` dws aisearch person --query "张三" --dimension department
dws aisearch person --query "产品部" --dimension department
dws aisearch person --query "五道" --dimension supervisor
dws aisearch person --query "AI搜问" --dimension duty
dws aisearch person --query "李四" --dimension name,department
dws aisearch person --query "13800138000" --dimension phone
dws aisearch person --query "W12345" --dimension jobNumber`,
Example: ` dws aisearch person --keyword "张三" --dimension department
dws aisearch person --keyword "产品部" --dimension department
dws aisearch person --keyword "五道" --dimension supervisor
dws aisearch person --keyword "AI搜问" --dimension duty
dws aisearch person --keyword "李四" --dimension name,department
dws aisearch person --keyword "13800138000" --dimension phone
dws aisearch person --keyword "W12345" --dimension jobNumber`,
RunE: runAisearchPerson,
}
DeclareLeafMetadata(personCmd, LeafSpec{
@@ -290,13 +277,10 @@ func newAisearchCommand() *cobra.Command {
"搜企业知识内容时用 aisearch enterprise;搜行为记录时用 aisearch behavior",
},
Examples: []string{
"dws aisearch person --query \"张三\" --dimension name --format json",
"dws aisearch person --query \"五道\" --dimension supervisor --format json",
"dws aisearch person --keyword \"张三\" --dimension name --format json",
"dws aisearch person --keyword \"五道\" --dimension supervisor --format json",
},
},
Parameters: []contract.ParamDecl{
{Name: "query", Property: "keyword", Required: boolPtr(true)},
},
},
})
addAisearchPersonFlags(personCmd)
+2 -64
View File
@@ -4,12 +4,7 @@
package helpers
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
)
import "testing"
func TestAisearchPersonFlagsDoNotLeakIntoContentCommands(t *testing.T) {
root := newAisearchCommand()
@@ -17,34 +12,12 @@ func TestAisearchPersonFlagsDoNotLeakIntoContentCommands(t *testing.T) {
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"query", "dimension"} {
for _, name := range []string{"keyword", "dimension"} {
flag := person.Flags().Lookup(name)
if flag == nil || flag.Hidden {
t.Fatalf("person --%s = %#v, want visible local flag", name, flag)
}
}
if person.Flags().Lookup("query").Shorthand != "" {
t.Fatal("new Primary --query must not acquire a shorthand")
}
keyword := person.Flags().Lookup("keyword")
if keyword == nil || !keyword.Hidden || keyword.Shorthand != "w" {
t.Fatalf("person --keyword = %#v, want hidden legacy flag with -w", keyword)
}
if got := keyword.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "query" {
t.Fatalf("person --keyword alias_of = %#v, want query", got)
}
if got := keyword.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
t.Fatalf("person --keyword alias_origin = %#v, want %q", got, runtimeannotate.FlagAliasOriginCorecmdV1)
}
for _, name := range []string{"name", "q", "text"} {
flag := person.Flags().Lookup(name)
if flag == nil || !flag.Hidden {
t.Fatalf("person --%s = %#v, want preserved hidden guess flag", name, flag)
}
if got := flag.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 0 {
t.Fatalf("person --%s unexpectedly gained alias_of metadata: %#v", name, got)
}
}
for _, path := range []string{"enterprise", "behavior"} {
cmd, _, err := root.Find([]string{path})
@@ -62,38 +35,3 @@ func TestAisearchPersonFlagsDoNotLeakIntoContentCommands(t *testing.T) {
}
}
}
func TestAisearchPersonQueryPrimaryCompatibility(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "root new", args: []string{"--query", "new"}, want: "new"},
{name: "root old", args: []string{"--keyword", "old"}, want: "old"},
{name: "root old shorthand", args: []string{"-w", "old-short"}, want: "old-short"},
{name: "root both old wins", args: []string{"--query", "new", "--keyword", "old"}, want: "old"},
{name: "person new", args: []string{"person", "--query", "new"}, want: "new"},
{name: "person old", args: []string{"person", "--keyword", "old"}, want: "old"},
{name: "person old shorthand", args: []string{"person", "-w", "old-short"}, want: "old-short"},
{name: "person both old wins", args: []string{"person", "--query", "new", "--keyword", "old"}, want: "old"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
installScriptedCaller(t, caller)
if err := executeFilterCoverage(t, newAisearchCommand(), tc.args...); err != nil {
t.Fatal(err)
}
if caller.tool != "enterprise_person_search" || caller.args["keyword"] != tc.want {
t.Fatalf("call = %s %#v, want backend keyword %q", caller.tool, caller.args, tc.want)
}
})
}
installScriptedCaller(t, &scriptedToolCaller{})
err := executeFilterCoverage(t, newAisearchCommand(), "person")
if err == nil || !strings.Contains(err.Error(), "--query") || strings.Contains(err.Error(), "--keyword") {
t.Fatalf("missing query error = %v, want only new Primary", err)
}
}
+5 -325
View File
@@ -520,102 +520,6 @@ func normalizeFilters(parsed any) any {
}
}
type aitableStatsItem struct {
FieldID string `json:"fieldId"`
StatsType string `json:"statsType"`
}
func aitableStatsValidationf(format string, args ...any) error {
return apperrors.NewValidation(fmt.Sprintf(format, args...))
}
func parseAitableStatsItems(raw string, uppercase, uniqueFields bool, maxItems int) ([]map[string]any, error) {
var items []aitableStatsItem
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil, aitableStatsValidationf("--stats 必须是 JSON 数组: %v", err)
}
var strictItems []aitableStatsItem
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&strictItems); err != nil {
return nil, aitableStatsValidationf("--stats 包含不支持的字段: %v", err)
}
items = strictItems
if len(items) == 0 {
return nil, aitableStatsValidationf("--stats 至少需要一个统计项")
}
if maxItems > 0 && len(items) > maxItems {
return nil, aitableStatsValidationf("--stats 单次最多支持 %d 个统计项,got %d", maxItems, len(items))
}
seenFields := make(map[string]struct{}, len(items))
out := make([]map[string]any, 0, len(items))
for index, item := range items {
item.FieldID = strings.TrimSpace(item.FieldID)
item.StatsType = strings.TrimSpace(item.StatsType)
if item.FieldID == "" || item.StatsType == "" {
return nil, aitableStatsValidationf("--stats[%d] 的 fieldId 和 statsType 均不能为空", index)
}
if uniqueFields {
if _, exists := seenFields[item.FieldID]; exists {
return nil, aitableStatsValidationf("--stats 中 fieldId %q 重复;query_records_stats 要求同一字段的多个统计类型拆成多次调用", item.FieldID)
}
seenFields[item.FieldID] = struct{}{}
}
if uppercase && item.StatsType != strings.ToUpper(item.StatsType) {
return nil, aitableStatsValidationf("--stats[%d].statsType 必须使用大写枚举值,got %q", index, item.StatsType)
}
if !uppercase && item.StatsType != strings.ToLower(item.StatsType) {
return nil, aitableStatsValidationf("--stats[%d].statsType 必须使用小写值,got %q", index, item.StatsType)
}
out = append(out, map[string]any{"fieldId": item.FieldID, "statsType": item.StatsType})
}
return out, nil
}
func parseAitableObjectFlag(name, raw string) (map[string]any, error) {
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.UseNumber()
var value map[string]any
if err := decoder.Decode(&value); err != nil {
return nil, aitableStatsValidationf("--%s 必须是 JSON 对象: %v", name, err)
}
if value == nil {
return nil, aitableStatsValidationf("--%s 必须是 JSON 对象,不能是 null", name)
}
var trailing any
if err := decoder.Decode(&trailing); err != io.EOF {
if err == nil {
return nil, aitableStatsValidationf("--%s 只能包含一个 JSON 对象", name)
}
return nil, aitableStatsValidationf("--%s 包含无效的尾随内容: %v", name, err)
}
return value, nil
}
func validateAitableStatsFilters(value map[string]any, rawJSON string) error {
if err := validateFiltersStructure(value, rawJSON); err != nil {
return aitableStatsValidationf("%v", err)
}
return nil
}
func validateAitableArrayDSL(name, raw string) error {
var value []any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return aitableStatsValidationf("--%s 必须是 JSON 数组字符串: %v", name, err)
}
if len(value) == 0 {
return aitableStatsValidationf("--%s 至少需要一个条目", name)
}
for index, item := range value {
if _, ok := item.(map[string]any); !ok {
return aitableStatsValidationf("--%s[%d] 必须是 JSON 对象,got %T", name, index, item)
}
}
return nil
}
// normalizeViewConfigFilter 将 view config 中的 filter 字段规范化为服务端要求的数组格式。
// 服务端 POJO 要求 config.filter 为 []FilterRule(JSON array)。
// 常见错误格式:
@@ -791,29 +695,15 @@ const aitableMaxRetries = 3
// callAitableTool 是 aitable 专用的 MCP 调用入口,带自动重试。
// 替代直接调用 callMCPTool,对网络抖动和服务端瞬态错误进行透明重试。
func callAitableTool(toolName string, args map[string]any) error {
return callAitableToolContext(context.Background(), toolName, args)
}
func callAitableToolContext(ctx context.Context, toolName string, args map[string]any) error {
if ctx == nil {
ctx = context.Background()
}
var lastErr error
for attempt := 0; attempt <= aitableMaxRetries; attempt++ {
if err := ctx.Err(); err != nil {
return err
}
if attempt > 0 {
backoff := time.Duration(1<<(attempt-1)) * time.Second // 1s, 2s, 4s
fmt.Fprintf(os.Stderr, "[aitable retry %d/%d] %s after %v...\n", attempt, aitableMaxRetries, toolName, backoff)
select {
case <-ctx.Done():
return ctx.Err()
case <-helperAfter(backoff):
}
helperSleep(backoff)
}
err := callMCPToolContext(ctx, toolName, args)
err := callMCPTool(toolName, args)
if err == nil {
return nil
}
@@ -1208,7 +1098,7 @@ func newAitableCommand() *cobra.Command {
dws aitable base [list|search|get|get-primary-doc-id|create|update|delete|copy] Base 管理
dws aitable table [get|create|update|delete] 数据表管理
dws aitable field [get|create|update|delete|search-options] 字段管理
dws aitable record [query|stats|group-stats|create|update|delete] 记录管理
dws aitable record [query|create|update|delete] 记录管理
dws aitable view [get|create|update|delete] 视图管理
dws aitable form [list|delete|update] 表单管理
dws aitable form field [list|update|hide] 表单字段管理
@@ -1891,7 +1781,7 @@ config 结构参考:
if v, _ := cmd.Flags().GetString("field-ids"); v != "" {
toolArgs["fieldIds"] = parseCSVValues(v)
}
return callAitableToolContext(cmd.Context(), "get_fields", toolArgs)
return callAitableTool("get_fields", toolArgs)
},
}
DeclareLeafMetadata(fieldGetCmd, LeafSpec{
@@ -2355,199 +2245,6 @@ newFieldName、config、aiConfig 至少传入一项。
},
})
recordStatsCmd := &cobra.Command{
Use: "stats",
Short: "整表或过滤后的字段聚合统计",
Long: `对单张数据表执行不分组的服务端聚合,底层调用 query_records_stats。
适用于记录计数、求和、平均值、最大值、最小值、中位数、去重数、完整率等标量统计。
--stats 是 JSON 数组,单次最多 20 项;每项必须包含 fieldId 和大写 statsType。
同一个 fieldId 不能在一次请求中重复,如需对同一字段计算多个指标,请拆成多次调用。
常用 statsType:COUNT、COUNT_COLUMN、SUM、AVG、MAX、MIN、MEDIAN、STANDARD_DEVIATION、RANGE、
DISTINCT、DISTINCT_RATIO、EXISTS、UN_EXISTS、EXIST_RATIO、UN_EXIST_RATIO、CHECKED、UN_CHECKED、
CHECKED_RATIO、UN_CHECKED_RATIO、LATEST_DATE、EARLIEST_DATE、DATE_RANGE、DATE_RANGE_MONTH。
需要统计全部匹配记录时不要传 --limit;limit 会改变参与统计的记录范围。
lt/gt/lte/gte 的过滤值必须使用 JSON 数字;单选/多选过滤建议使用 field get 返回的选项 ID。`,
Example: ` dws aitable record stats --base-id BASE_ID --table-id TABLE_ID --stats '[{"fieldId":"fldAmount","statsType":"SUM"}]'
dws aitable record stats --base-id BASE_ID --table-id TABLE_ID --filters '{"operator":"and","operands":[{"operator":"gt","operands":["fldAmount",0]}]}' --stats '[{"fieldId":"fldTitle","statsType":"COUNT"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id", "stats"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
stats, err := parseAitableStatsItems(mustGetFlag(cmd, "stats"), true, true, 20)
if err != nil {
return err
}
toolArgs := map[string]any{
"baseId": baseID,
"tableId": mustGetFlag(cmd, "table-id"),
"stats": stats,
}
if raw, _ := cmd.Flags().GetString("filters"); raw != "" {
filters, err := parseAitableObjectFlag("filters", raw)
if err != nil {
return err
}
if err := validateAitableStatsFilters(filters, raw); err != nil {
return err
}
toolArgs["filters"] = normalizeFilters(filters)
}
if raw, _ := cmd.Flags().GetString("sort"); raw != "" {
if err := validateAitableArrayDSL("sort", raw); err != nil {
return err
}
// query_records_stats 的 MCP Schema 将 sort 定义为 JSON 数组编码后的字符串,
// 与 query_records 使用的数组类型不同。
toolArgs["sort"] = raw
}
if value, _ := cmd.Flags().GetInt("limit"); cmd.Flags().Changed("limit") {
if value <= 0 {
return aitableStatsValidationf("--limit 必须大于 0;统计全部匹配记录时请省略该参数")
}
toolArgs["limit"] = value
}
if value, _ := cmd.Flags().GetString("keyword"); value != "" {
toolArgs["keyword"] = value
}
if value, _ := cmd.Flags().GetString("search-field-ids"); value != "" {
toolArgs["searchFieldIds"] = parseCSVValues(value)
}
if value, _ := cmd.Flags().GetString("data-version"); value != "" {
toolArgs["dataVersion"] = value
}
return callAitableTool("query_records_stats", toolArgs)
},
}
DeclareLeafMetadata(recordStatsCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "query_records_stats",
CanonicalPath: "aitable.query_records_stats",
CLIPath: "aitable record stats",
PrimaryCLIPath: "aitable record stats",
},
Description: "对整表或过滤后的记录执行不分组字段聚合。",
Interface: aitableMCPInterface("query_records_stats"),
Selection: contract.SelectionSpec{
AgentSummary: "对整表或过滤后的记录执行不分组字段聚合。",
UseWhen: []string{"需要记录总数、SUM/AVG/MAX/MIN、中位数、去重数、完整率等标量统计时,优先服务端聚合"},
AvoidWhen: []string{"需要按字段分组或使用 query_stats 高级聚合时用 record group-stats;需要行级明细时用 record query"},
Examples: []string{`dws aitable record stats --base-id <BASE_ID> --table-id <TABLE_ID> --stats '[{"fieldId":"<FIELD_ID>","statsType":"COUNT"}]'`},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
{Name: "stats", Property: "stats", Required: boolPtr(true), InterfaceType: "array"},
{Name: "filters", Property: "filters", InterfaceType: "object"},
{Name: "search-field-ids", Property: "searchFieldIds", InterfaceType: "array"},
{Name: "data-version", Property: "dataVersion"},
},
Result: aitableRecordsStatsResultSpec(),
},
})
recordGroupStatsCmd := &cobra.Command{
Use: "group-stats",
Short: "分组、去重及高级聚合统计",
Long: `对单张数据表执行分组或高级服务端聚合,底层调用 query_stats,最多返回 1000 个分组。
适用于按一个或多个字段分组、条件唯一实体计数,以及 distinct、distinct_ratio 等高级统计。
--stats 是 JSON 数组,每项包含 fieldId 和小写 statsType。基础类型为 sum、avg、count、max、min;
部分后端也支持 median、distinct、distinct_ratio 等高级类型,不支持时服务端会返回明确错误。
--group 是 JSON 数组编码后的字符串,不分组的 DISTINCT 标量统计可以省略。
不要依赖服务端 limit 选择 Top N;应在不超过 1000 个分组时获取完整结果后再排序。`,
Example: ` dws aitable record group-stats --base-id BASE_ID --table-id TABLE_ID --group '[{"fieldId":"fldCategory","direction":"ASC","fieldConfig":null,"arraySplitMode":true}]' --stats '[{"fieldId":"fldAmount","statsType":"sum"}]'
dws aitable record group-stats --base-id BASE_ID --table-id TABLE_ID --stats '[{"fieldId":"fldStore","statsType":"distinct"}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "table-id", "stats"); err != nil {
return err
}
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
if err != nil {
return err
}
stats, err := parseAitableStatsItems(mustGetFlag(cmd, "stats"), false, false, 0)
if err != nil {
return err
}
toolArgs := map[string]any{
"baseId": baseID,
"tableId": mustGetFlag(cmd, "table-id"),
"stats": stats,
}
if raw, _ := cmd.Flags().GetString("filters"); raw != "" {
filters, err := parseAitableObjectFlag("filters", raw)
if err != nil {
return err
}
if err := validateAitableStatsFilters(filters, raw); err != nil {
return err
}
toolArgs["filters"] = normalizeFilters(filters)
}
if raw, _ := cmd.Flags().GetString("group"); raw != "" {
if err := validateAitableArrayDSL("group", raw); err != nil {
return err
}
toolArgs["group"] = raw
}
if raw, _ := cmd.Flags().GetString("sort"); raw != "" {
if err := validateAitableArrayDSL("sort", raw); err != nil {
return err
}
toolArgs["sortDsl"] = raw
}
if value, _ := cmd.Flags().GetInt("limit"); cmd.Flags().Changed("limit") {
if value < 1 || value > 1000 {
return aitableStatsValidationf("--limit 必须在 [1, 1000] 范围内,got %d", value)
}
toolArgs["limit"] = value
}
if value, _ := cmd.Flags().GetString("data-version"); value != "" {
toolArgs["dataVersion"] = value
}
return callAitableTool("query_stats", toolArgs)
},
}
DeclareLeafMetadata(recordGroupStatsCmd, LeafSpec{
Safety: aitableSafetyRead(),
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "aitable",
Name: "query_stats",
CanonicalPath: "aitable.query_stats",
CLIPath: "aitable record group-stats",
PrimaryCLIPath: "aitable record group-stats",
},
Description: "按字段分组,或执行去重等高级聚合统计。",
Interface: aitableMCPInterface("query_stats"),
Selection: contract.SelectionSpec{
AgentSummary: "按字段分组,或执行去重等高级聚合统计。",
UseWhen: []string{"需要各分类/实体的分组统计,或对满足条件的门店、客户、商品等执行 DISTINCT 唯一计数时"},
AvoidWhen: []string{"普通不分组 COUNT/SUM/AVG/MAX/MIN 优先用 record stats;原始记录和 Top N 明细用 record query"},
Examples: []string{`dws aitable record group-stats --base-id <BASE_ID> --table-id <TABLE_ID> --stats '[{"fieldId":"<FIELD_ID>","statsType":"distinct"}]'`},
},
Parameters: []contract.ParamDecl{
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
{Name: "stats", Property: "stats", Required: boolPtr(true), InterfaceType: "array"},
{Name: "filters", Property: "filters", InterfaceType: "object"},
{Name: "group", Property: "group"},
{Name: "sort", Property: "sortDsl"},
{Name: "data-version", Property: "dataVersion"},
},
Result: aitableGroupedStatsResultSpec(),
},
})
recordCreateCmd := &cobra.Command{
Use: "create",
Short: "新增记录",
@@ -7533,23 +7230,6 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(约 5000 条);设为 0 表示显式不限页数;超限时错误详情保留已取记录和续传 cursor")
recordQueryCmd.Flags().String("view-id", "", "视图 ID(record query 不支持按视图过滤,此参数会被忽略并给出提示)")
_ = recordQueryCmd.Flags().MarkHidden("view-id")
recordStatsCmd.Flags().String("base-id", "", "Base ID(通过 base get 确认目标)(必填)")
recordStatsCmd.Flags().String("table-id", "", "Table ID(通过 table get 获取)(必填)")
recordStatsCmd.Flags().String("stats", "", `统计项 JSON 数组(必填),例如 [{"fieldId":"fldAmount","statsType":"SUM"}];statsType 必须大写,最多 20 项且 fieldId 不得重复`)
recordStatsCmd.Flags().String("filters", "", "结构化过滤条件 JSON 对象;数值比较值必须是 JSON 数字")
recordStatsCmd.Flags().String("sort", "", `参与统计记录的排序 DSL(JSON 数组字符串),例如 [{"fieldId":"fldDate","direction":"ASC"}]`)
recordStatsCmd.Flags().Int("limit", 0, "参与统计的最大记录数;省略表示统计全部匹配记录")
recordStatsCmd.Flags().String("keyword", "", "全文关键词,仅匹配的记录参与统计")
recordStatsCmd.Flags().String("search-field-ids", "", "关键词搜索字段 ID 列表,逗号分隔;仅 --keyword 非空时生效")
recordStatsCmd.Flags().String("data-version", "", "可选数据版本;通常省略以使用最新版本")
recordGroupStatsCmd.Flags().String("base-id", "", "Base ID(通过 base get 确认目标)(必填)")
recordGroupStatsCmd.Flags().String("table-id", "", "Table ID(通过 table get 获取)(必填)")
recordGroupStatsCmd.Flags().String("stats", "", `统计项 JSON 数组(必填),例如 [{"fieldId":"fldAmount","statsType":"sum"}];statsType 必须小写`)
recordGroupStatsCmd.Flags().String("filters", "", "结构化过滤条件 JSON 对象;数值比较值必须是 JSON 数字")
recordGroupStatsCmd.Flags().String("group", "", `分组字段 DSL(JSON 数组字符串);不分组的 distinct 统计可省略`)
recordGroupStatsCmd.Flags().String("sort", "", "统计结果排序 DSL(JSON 数组字符串),映射到 MCP sortDsl")
recordGroupStatsCmd.Flags().Int("limit", 0, "返回的分组结果数,范围 1-1000;省略表示不额外限制")
recordGroupStatsCmd.Flags().String("data-version", "", "可选数据版本;通常省略以使用最新版本")
recordCreateCmd.Flags().String("base-id", "", "Base ID,可通过 base list 或 base search 获取 (必填)")
recordCreateCmd.Flags().String("table-id", "", "Table ID,可通过 base get 获取 (必填)")
recordCreateCmd.Flags().String("records", "", "待创建的记录列表 JSON 数组,单次最多 100 条 (必填)")
@@ -7614,7 +7294,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
recordPrimaryDocCreateCmd.Flags().String("record-id", "", "目标 Record ID (必填)")
recordCmd.AddCommand(
recordQueryCmd, recordStatsCmd, recordGroupStatsCmd, recordCreateCmd,
recordQueryCmd, recordCreateCmd,
recordUpdateCmd, recordDeleteCmd,
recordBatchUpdateCmd,
recordHistoryListCmd,
@@ -9,7 +9,6 @@ import (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -19,22 +18,6 @@ type aitableCommandCoverageCaller struct {
response map[string]string
}
type aitableCommandContextKey struct{}
type aitableCommandContextCaller struct {
value any
}
func (c *aitableCommandContextCaller) CallTool(ctx context.Context, _, _ string, _ map[string]any) (*edition.ToolResult, error) {
c.value = ctx.Value(aitableCommandContextKey{})
return nil, context.Canceled
}
func (*aitableCommandContextCaller) Format() string { return "json" }
func (*aitableCommandContextCaller) DryRun() bool { return false }
func (*aitableCommandContextCaller) Fields() string { return "" }
func (*aitableCommandContextCaller) JQ() string { return "" }
func (c *aitableCommandCoverageCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) {
if c.err != nil {
return nil, c.err
@@ -84,11 +67,6 @@ func TestCrossPlatformCoverageAitableRetryWrappersExhaustAndRecover(t *testing.T
oldDeps, oldSleep := deps, helperSleep
t.Cleanup(func() { deps, helperSleep = oldDeps, oldSleep })
helperSleep = func(time.Duration) {}
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
ready := make(chan time.Time, 1)
ready <- time.Time{}
return ready
})
retryable := fmt.Errorf("timeout: retryable: true")
caller := &aitableTestCaller{errors: []error{retryable, retryable, retryable, retryable}}
@@ -108,48 +86,6 @@ func TestCrossPlatformCoverageAitableRetryWrappersExhaustAndRecover(t *testing.T
if err := callAitableHelperTool("retry", nil); err == nil {
t.Fatal("exhausted helper retries returned nil")
}
caller = &aitableTestCaller{}
installAitableDeps(t, caller)
if err := callAitableToolContext(nil, "nil-context", nil); err != nil {
t.Fatalf("nil context was not normalized: %v", err)
}
caller = &aitableTestCaller{errors: []error{retryable}}
installAitableDeps(t, caller)
ctx, cancel := context.WithCancel(context.Background())
backoffPending := make(chan time.Time)
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
cancel()
return backoffPending
})
if err := callAitableToolContext(ctx, "cancel-during-backoff", nil); err != context.Canceled {
t.Fatalf("cancel during retry backoff = %v, want %v", err, context.Canceled)
}
}
func TestCrossPlatformCoverageAitableFieldListPreservesCommandContext(t *testing.T) {
old := deps
t.Cleanup(func() { deps = old })
caller := &aitableCommandContextCaller{}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newAitableCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"field", "list", "--base-id=b", "--table-id=t"})
ctx := context.WithValue(context.Background(), aitableCommandContextKey{}, "field-list-context")
if err := root.ExecuteContext(ctx); err == nil {
t.Fatal("field list context probe unexpectedly succeeded")
}
if caller.value != "field-list-context" {
t.Fatalf("field list caller context value = %#v", caller.value)
}
}
func TestCrossPlatformCoverageAitableCommandValidationEdges(t *testing.T) {
-101
View File
@@ -14,8 +14,6 @@
package helpers
import (
"encoding/json"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
@@ -74,102 +72,3 @@ func aitableCompositeInterface(reason string) *contract.InterfaceSpec {
Reason: reason,
}
}
func aitableRecordsStatsResultSpec() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{
contract.ResultOutcomeSuccess,
contract.ResultOutcomeFailure,
},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"不分组的字段聚合结果",
"properties":{
"results":{
"type":"array",
"description":"按数据版本返回的聚合结果批次",
"items":{
"type":"object",
"properties":{
"dataVersion":{"description":"参与统计的数据版本"},
"deltaVersion":{"description":"聚合结果的增量版本"},
"results":{
"type":"array",
"description":"请求中各统计项的结果",
"items":{
"type":"object",
"properties":{
"fieldId":{"type":"string","description":"被统计的字段 ID"},
"statsType":{"type":"string","description":"实际执行的统计类型"},
"value":{"description":"统计值;具体 JSON 类型取决于统计类型和字段类型"}
},
"required":["fieldId","statsType","value"],
"additionalProperties":true
}
}
},
"required":["results"],
"additionalProperties":true
}
}
},
"required":["results"],
"additionalProperties":true
}`),
}
}
func aitableGroupedStatsResultSpec() *contract.ResultSpec {
return &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{
contract.ResultOutcomeSuccess,
contract.ResultOutcomeFailure,
},
DataSchema: json.RawMessage(`{
"type":"object",
"description":"分组或高级字段聚合结果",
"properties":{
"dataVersion":{"description":"参与统计的数据版本"},
"results":{
"type":"array",
"description":"每个分组一条结果;未分组时通常只有一条",
"items":{
"type":"object",
"properties":{
"groupKeys":{
"type":"array",
"description":"当前结果的分组键",
"items":{
"type":"object",
"properties":{
"fieldId":{"type":"string","description":"分组字段 ID"},
"value":{"description":"分组值;编码取决于字段类型"},
"recordCount":{"type":"integer","description":"该分组包含的记录数"}
},
"additionalProperties":true
}
},
"fieldStatsMap":{
"type":"object",
"description":"按字段 ID 索引的聚合值",
"additionalProperties":{
"type":"object",
"properties":{
"action":{"type":"string","description":"实际执行的统计动作"},
"value":{"description":"统计值;具体 JSON 类型取决于统计动作和字段类型"}
},
"required":["action","value"],
"additionalProperties":true
}
}
},
"required":["fieldStatsMap"],
"additionalProperties":true
}
}
},
"required":["results"],
"additionalProperties":true
}`),
}
}
-278
View File
@@ -1,278 +0,0 @@
// Copyright 2026 Alibaba Group
// SPDX-License-Identifier: Apache-2.0
package helpers
import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func runAitableStatsCLI(t *testing.T, args ...string) (*aitableTestCaller, error) {
t.Helper()
caller := &aitableTestCaller{}
installAitableDeps(t, caller)
command := newAitableCommand()
command.SilenceErrors = true
command.SilenceUsage = true
command.SetArgs(args)
return caller, command.Execute()
}
func TestCrossPlatformCoverageAitableRecordsStatsForwardsValidatedContract(t *testing.T) {
caller, err := runAitableStatsCLI(t,
"record", "stats",
"--base-id", "base-stats",
"--table-id", "table-stats",
"--stats", `[{"fieldId":"fldCount","statsType":"COUNT"},{"fieldId":"fldAmount","statsType":"SUM"}]`,
"--filters", `{"operator":"and","operands":[{"operator":"gt","operands":["fldAmount",0]}]}`,
"--sort", `[{"fieldId":"fldAmount","direction":"DESC"}]`,
"--limit", "500",
"--keyword", "华东",
"--search-field-ids", "fldName,fldRegion",
"--data-version", "42",
)
if err != nil {
t.Fatalf("record stats returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool calls = %d, want 1", len(caller.calls))
}
call := caller.calls[0]
if call.server != "aitable" || call.tool != "query_records_stats" {
t.Fatalf("tool = %s/%s, want aitable/query_records_stats", call.server, call.tool)
}
wantStats := []map[string]any{
{"fieldId": "fldCount", "statsType": "COUNT"},
{"fieldId": "fldAmount", "statsType": "SUM"},
}
if !reflect.DeepEqual(call.args["stats"], wantStats) {
t.Fatalf("stats = %#v, want %#v", call.args["stats"], wantStats)
}
filters := call.args["filters"].(map[string]any)
condition := filters["operands"].([]any)[0].(map[string]any)
if got := condition["operands"].([]any)[1]; got != json.Number("0") {
t.Fatalf("numeric filter value = %#v (%T), want json.Number(0)", got, got)
}
if call.args["sort"] != `[{"fieldId":"fldAmount","direction":"DESC"}]` ||
call.args["limit"] != 500 || call.args["keyword"] != "华东" || call.args["dataVersion"] != "42" {
t.Fatalf("forwarded args = %#v", call.args)
}
if !reflect.DeepEqual(call.args["searchFieldIds"], []string{"fldName", "fldRegion"}) {
t.Fatalf("searchFieldIds = %#v", call.args["searchFieldIds"])
}
}
func TestCrossPlatformCoverageAitableGroupStatsForwardsStringDSL(t *testing.T) {
group := `[{"fieldId":"fldRegion","direction":"ASC","fieldConfig":null,"arraySplitMode":true}]`
sortDSL := `[{"fieldId":"fldAmount","direction":"DESC"}]`
caller, err := runAitableStatsCLI(t,
"record", "group-stats",
"--base-id", "base-stats",
"--table-id", "table-stats",
"--stats", `[{"fieldId":"fldStore","statsType":"distinct"},{"fieldId":"fldAmount","statsType":"avg"}]`,
"--filters", `{"operator":"OR","operands":[{"operator":"not_before","operands":["fldDate","2026-01-01"]}]}`,
"--group", group,
"--sort", sortDSL,
"--limit", "1000",
"--data-version", "43",
)
if err != nil {
t.Fatalf("record group-stats returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool calls = %d, want 1", len(caller.calls))
}
call := caller.calls[0]
if call.server != "aitable" || call.tool != "query_stats" {
t.Fatalf("tool = %s/%s, want aitable/query_stats", call.server, call.tool)
}
if call.args["group"] != group || call.args["sortDsl"] != sortDSL || call.args["limit"] != 1000 || call.args["dataVersion"] != "43" {
t.Fatalf("forwarded args = %#v", call.args)
}
stats := call.args["stats"].([]map[string]any)
if len(stats) != 2 || stats[0]["statsType"] != "distinct" || stats[1]["statsType"] != "avg" {
t.Fatalf("stats = %#v", stats)
}
}
func TestCrossPlatformCoverageAitableStatsRejectsInvalidInputsBeforeCallingMCP(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "records stats require uppercase",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`},
wantErr: "大写",
},
{
name: "records stats require at least one item",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[]`},
wantErr: "至少需要一个统计项",
},
{
name: "records stats enforce item cap",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[
{"fieldId":"f01","statsType":"COUNT"},{"fieldId":"f02","statsType":"COUNT"},
{"fieldId":"f03","statsType":"COUNT"},{"fieldId":"f04","statsType":"COUNT"},
{"fieldId":"f05","statsType":"COUNT"},{"fieldId":"f06","statsType":"COUNT"},
{"fieldId":"f07","statsType":"COUNT"},{"fieldId":"f08","statsType":"COUNT"},
{"fieldId":"f09","statsType":"COUNT"},{"fieldId":"f10","statsType":"COUNT"},
{"fieldId":"f11","statsType":"COUNT"},{"fieldId":"f12","statsType":"COUNT"},
{"fieldId":"f13","statsType":"COUNT"},{"fieldId":"f14","statsType":"COUNT"},
{"fieldId":"f15","statsType":"COUNT"},{"fieldId":"f16","statsType":"COUNT"},
{"fieldId":"f17","statsType":"COUNT"},{"fieldId":"f18","statsType":"COUNT"},
{"fieldId":"f19","statsType":"COUNT"},{"fieldId":"f20","statsType":"COUNT"},
{"fieldId":"f21","statsType":"COUNT"}
]`},
wantErr: "单次最多支持 20 个统计项",
},
{
name: "records stats require item fields",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":" ","statsType":"COUNT"}]`},
wantErr: "均不能为空",
},
{
name: "records stats reject unknown item fields",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT","unknownKey":123}]`},
wantErr: "不支持的字段",
},
{
name: "records stats reject duplicate field",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"},{"fieldId":"f","statsType":"SUM"}]`},
wantErr: "重复",
},
{
name: "group stats require lowercase",
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`},
wantErr: "小写",
},
{
name: "group must be array DSL",
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--group", `{}`},
wantErr: "JSON 数组",
},
{
name: "group limit capped",
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--limit", "1001"},
wantErr: "[1, 1000]",
},
{
name: "filters require logical root",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"eq","operands":[]}`},
wantErr: `root "operator" must be "and" or "or"`,
},
{
name: "records filters reject malformed JSON",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{`},
wantErr: "必须是 JSON 对象",
},
{
name: "records filters reject null",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `null`},
wantErr: "不能是 null",
},
{
name: "records filters reject a second JSON value",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{} {}`},
wantErr: "只能包含一个 JSON 对象",
},
{
name: "records filters reject invalid trailing content",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{} trailing`},
wantErr: "无效的尾随内容",
},
{
name: "records filters require operand array",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"and","operands":{}}`},
wantErr: `"operands" must be an array`,
},
{
name: "records filters reject silently unsupported operators",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"and","operands":[{"operator":"date_between","operands":["fldDate",[1,2]]}]}`},
wantErr: `unsupported filter operator "date_between"`,
},
{
name: "records sort requires an item",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--sort", `[]`},
wantErr: "至少需要一个条目",
},
{
name: "records sort items must be objects",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--sort", `[1]`},
wantErr: "必须是 JSON 对象",
},
{
name: "records limit must be positive",
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--limit", "0"},
wantErr: "必须大于 0",
},
{
name: "group filters reject malformed JSON",
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{`},
wantErr: "必须是 JSON 对象",
},
{
name: "group filters require logical root",
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{"operator":"eq","operands":[]}`},
wantErr: `root "operator" must be "and" or "or"`,
},
{
name: "group filters reject silently unsupported operators",
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{"operator":"and","operands":[{"operator":"date_between","operands":["fldDate",[1,2]]}]}`},
wantErr: `unsupported filter operator "date_between"`,
},
{
name: "group sort rejects invalid DSL",
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--sort", `{}`},
wantErr: "JSON 数组",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller, err := runAitableStatsCLI(t, test.args...)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want containing %q", err, test.wantErr)
}
var structured *apperrors.Error
if !errors.As(err, &structured) || structured.Category != apperrors.CategoryValidation {
t.Fatalf("error = %#v, want structured validation error", err)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %d, want 0", len(caller.calls))
}
})
}
}
func TestCrossPlatformCoverageAitableStatsPublishInterfaceAndResultContracts(t *testing.T) {
root := newAitableCommand()
tests := []struct {
path string
rpc string
}{
{path: "aitable record stats", rpc: "query_records_stats"},
{path: "aitable record group-stats", rpc: "query_stats"},
}
for _, test := range tests {
leaf := findCLIPath(root, test.path)
if leaf == nil {
t.Fatalf("missing leaf %q", test.path)
}
final, ok := contractfinal.RuntimeContractFinal(leaf)
if !ok || final.Interface == nil || final.Interface.Ref == nil || final.Interface.Ref.RPCName != test.rpc {
t.Fatalf("%s interface = %#v", test.path, final.Interface)
}
if final.Result == nil || len(final.Result.Outcomes) == 0 || len(final.Result.DataSchema) == 0 {
t.Fatalf("%s result = %#v", test.path, final.Result)
}
}
}
+2 -82
View File
@@ -230,7 +230,7 @@ func newCalendarCommand() *cobra.Command {
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
命令结构:
dws calendar event [list|get|create|update|delete|suggest|respond|instances|share-info] 日程管理
dws calendar event [list|get|create|update|delete|suggest|respond|instances] 日程管理
dws calendar attendee [list|add|delete] 参会人管理
dws calendar room [search|add|delete|list-groups] 会议室管理
dws calendar busy search 闲忙查询 (可查人、查会议室)
@@ -2259,87 +2259,7 @@ func newCalendarCommand() *cobra.Command {
eventInstancesCmd.Flags().Int("count", 0, "")
_ = eventInstancesCmd.Flags().MarkHidden("count")
eventShareInfoCmd := &cobra.Command{
Use: "share-info",
Aliases: []string{"share_info"},
Short: "获取日程的分享信息",
Long: `根据日程 ID 获取日程的分享信息,展示日程主题、组织人、地点、入会信息等,用于向他人分享日程。eventId 可通过 dws calendar event list 获取。`,
Example: ` dws calendar event share-info --id EVENT_ID
dws calendar event share-info --id EVENT_ID --language zh-CN
dws calendar event share-info --id EVENT_ID --calendar-id primary`,
RunE: func(cmd *cobra.Command, args []string) error {
eventID, err := mustFlagOrFallback(cmd, "id", "event", "event-id", "eventId")
if err != nil {
return err
}
toolArgs := map[string]any{"eventId": eventID}
if v := flagOrFallback(cmd, "calendar-id", "calendarId", "calendar"); v != "" {
toolArgs["calendarId"] = v
}
if v := flagOrFallback(cmd, "language", "lang"); v != "" {
toolArgs["language"] = v
}
return callMCPTool("get_event_share_info", toolArgs)
},
}
DeclareLeafMetadata(eventShareInfoCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "calendar",
Name: "get_event_share_info",
CanonicalPath: "calendar.get_event_share_info",
CLIPath: "calendar event share-info",
PrimaryCLIPath: "calendar event share-info",
Aliases: []string{"calendar event share_info"},
},
Description: "获取日程的分享信息",
Interface: &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: "calendar", RPCName: "get_event_share_info"},
},
Selection: contract.SelectionSpec{
AgentSummary: "获取日程的分享信息(主题、组织人、地点、入会信息等)",
UseWhen: []string{"已知 eventId,需要获取日程分享信息或入会信息用于分享给他人时"},
AvoidWhen: []string{
"要查看日程详情改用 dws calendar event get",
"未知 eventId 时先 dws calendar event list",
},
Examples: []string{
"dws calendar event share-info --id <EVENT_ID>",
"dws calendar event share-info --id <EVENT_ID> --language zh-CN",
},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "eventId", Required: boolPtr(true)},
{Name: "calendar-id", Property: "calendarId"},
{Name: "language", Property: "language"},
},
},
})
// EventShareInfo flags
eventShareInfoCmd.Flags().String("id", "", "日程 ID (必填)")
eventShareInfoCmd.Flags().String("event", "", "")
_ = eventShareInfoCmd.Flags().MarkHidden("event")
eventShareInfoCmd.Flags().String("event-id", "", "")
_ = eventShareInfoCmd.Flags().MarkHidden("event-id")
eventShareInfoCmd.Flags().String("eventId", "", "")
_ = eventShareInfoCmd.Flags().MarkHidden("eventId")
eventShareInfoCmd.Flags().String("calendar-id", "", "日历 ID (默认 primary 主日历)")
eventShareInfoCmd.Flags().String("calendarId", "", "")
_ = eventShareInfoCmd.Flags().MarkHidden("calendarId")
eventShareInfoCmd.Flags().String("calendar", "", "")
_ = eventShareInfoCmd.Flags().MarkHidden("calendar")
eventShareInfoCmd.Flags().String("language", "", "语言代码 (可选,如 zh-CN)")
eventShareInfoCmd.Flags().String("lang", "", "")
_ = eventShareInfoCmd.Flags().MarkHidden("lang")
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd, eventShareInfoCmd)
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd)
// participant
participantCmd.PersistentFlags().String("event", "", "日程 ID (必填)")
+67 -160
View File
@@ -19,28 +19,14 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
// promoteLegacyChatString copies an explicitly supplied legacy flag into the
// new canonical flag. Cobra validates MarkFlagRequired after PreRunE, and the
// overwrite also preserves the migration rule that a legacy value wins when
// both spellings are present.
func promoteLegacyChatString(cmd *cobra.Command, canonical, legacy string) error {
if !cmd.Flags().Changed(legacy) {
return nil
}
value, err := cmd.Flags().GetString(legacy)
if err != nil {
return err
}
return cmd.Flags().Set(canonical, value)
}
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
func callProjectedChatMessages(cmd *cobra.Command, toolName string, args map[string]any, search bool) error {
if deps.Caller.DryRun() {
@@ -3158,7 +3144,7 @@ func newChatCommand() *cobra.Command {
--open-dingtalk-id 单聊接收人 openDingTalkId
纯文本 / Markdown 消息(默认):
无需指定 --msg-type,直接传消息内容即可。推荐使用 --content flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。
无需指定 --msg-type,直接传消息内容即可。推荐使用 --text flag 传递内容(尤其当内容含换行、引号等特殊字符时),也支持位置参数。可选 --title 作为消息标题。
图文混排时,公网图片 URL 需要写成 Markdown 图片语法:![图片标题](https://example.com/image.png),才会以内联图片展示。
如果省略开头的 !,例如 [图片标题](https://example.com/image.png),将按链接/URL 展示,不会渲染为图片。
@@ -3168,24 +3154,24 @@ func newChatCommand() *cobra.Command {
获取 openMessageId 和 openConversationId,无需再按消息内容反查。
本地图片 / 文件消息:
统一使用 --msg-type file --file <本地路径>。CLI 会完成上传并按 file 消息发送;
统一使用 --msg-type file --file-path <本地路径>。CLI 会完成上传并按 file 消息发送;
.png/.jpg 也会显示为可下载的文件附件,不会生成 mediaId 或渲染成内联图片。
旧版内联图片消息:
仅当上游已经提供有效 mediaId 时,使用 --msg-type image --media-id。
当前 CLI 不提供本地文件到 mediaId 的上传能力。`,
Example: ` dws chat message send --conversation-id <openconversation_id> --content "hello"
dws chat message send --user <userId> --content "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> --content "请查收"
dws chat message send --conversation-id <openconversation_id> --title "周报提醒" --content "请大家本周五前提交周报"
Example: ` dws chat message send --conversation-id <openconversation_id> "hello"
dws chat message send --user <userId> "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> "请查收"
dws chat message send --conversation-id <openconversation_id> --title "周报提醒" "请大家本周五前提交周报"
# 图文混排 Markdown:公网图片 URL 需要写成 ![图片标题](URL) 才会以内联图片展示
dws chat message send --conversation-id <openconversation_id> --content $'这是图文说明\n\n![这个是展示图片标题](https://down.dingtalk.com/media/lQLPM5jiBEiBNjswMLAKd_CTzm8eowpEWPT_7-cA_48_48.png)'
dws chat message send --conversation-id <openconversation_id> --text $'这是图文说明\n\n![这个是展示图片标题](https://down.dingtalk.com/media/lQLPM5jiBEiBNjswMLAKd_CTzm8eowpEWPT_7-cA_48_48.png)'
# 发送本地图片或文件(图片会作为可下载的 file 附件发送)
dws chat message send --conversation-id <openconversation_id> --msg-type file --file ./screenshot.png
dws chat message send --conversation-id <openconversation_id> --msg-type file --file ./report.pdf
dws chat message send --conversation-id <openconversation_id> --msg-type file --file-path ./screenshot.png
dws chat message send --conversation-id <openconversation_id> --msg-type file --file-path ./report.pdf
# 发送本地音频/视频(audio/video 是 file 的语义别名)
dws chat message send --conversation-id <openconversation_id> --msg-type audio --file ./recording.mp3
dws chat message send --conversation-id <openconversation_id> --msg-type video --file ./demo.mp4
dws chat message send --conversation-id <openconversation_id> --msg-type audio --file-path ./recording.mp3
dws chat message send --conversation-id <openconversation_id> --msg-type video --file-path ./demo.mp4
# 旧版内联图片:仅当上游已经持有有效 mediaId 时使用
dws chat message send --conversation-id <openconversation_id> --msg-type image --media-id <mediaId>
# 查询群 ID: dws chat search --query "群名"
@@ -3264,7 +3250,7 @@ func newChatCommand() *cobra.Command {
contentJSON = fmt.Sprintf(`{"mediaId":"%s"}`, mediaId)
case "file", "audio", "video":
serviceMsgType = "file"
filePath := flagOrFallback(cmd, "file-path", "file")
filePath, _ := cmd.Flags().GetString("file-path")
dentryId, _ := cmd.Flags().GetInt64("dentry-id")
spaceId, _ := cmd.Flags().GetInt64("space-id")
if (dentryId == 0) != (spaceId == 0) {
@@ -3295,7 +3281,7 @@ func newChatCommand() *cobra.Command {
}
contentJSON, _ = buildConversationFileContent(dentryId, spaceId, meta)
} else if dentryId == 0 || spaceId == 0 {
return fmt.Errorf("--file must be a readable local file, or pass legacy --dentry-id and --space-id: %w", err)
return fmt.Errorf("--file-path must be a readable local file, or pass legacy --dentry-id and --space-id: %w", err)
}
}
if contentJSON == "" {
@@ -3303,7 +3289,7 @@ func newChatCommand() *cobra.Command {
fileType, _ := cmd.Flags().GetString("file-type")
fileSize, _ := cmd.Flags().GetInt64("file-size")
if dentryId == 0 || spaceId == 0 || fileName == "" {
return fmt.Errorf("readable local --file is required for msgType=file; legacy flags --dentry-id, --space-id, --file-name are still supported")
return fmt.Errorf("readable local --file-path is required for msgType=file; legacy flags --dentry-id, --space-id, --file-name are still supported")
}
contentJSON = fmt.Sprintf(`{"dentryId":%d,"spaceId":%d,"fileName":"%s","fileType":"%s","filePath":"%s","fileSize":%d}`,
dentryId, spaceId, fileName, fileType, filePath, fileSize)
@@ -3349,7 +3335,7 @@ func newChatCommand() *cobra.Command {
text = args[0]
}
if text == "" {
return fmt.Errorf("message content required (use --content or positional arg, or --media-id for image)")
return fmt.Errorf("message content required (use --text or positional arg, or --media-id for image)")
}
title, _ := cmd.Flags().GetString("title")
if title == "" {
@@ -3409,7 +3395,7 @@ func newChatCommand() *cobra.Command {
AgentSummary: "以当前用户身份发送群聊或单聊消息",
UseWhen: []string{"用户明确要以个人身份发送文本或媒体消息时;响应返回 openTaskId 后用 chat message query-send-status 确认投递并取得后续操作所需的消息 ID"},
AvoidWhen: []string{"机器人身份或 Webhook 发送应使用对应命令"},
Examples: []string{"dws chat message send --group <openConversationId> --content \"项目已更新\""},
Examples: []string{"dws chat message send --group <openConversationId> \"项目已更新\""},
},
Parameters: []contract.ParamDecl{
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
@@ -3427,11 +3413,10 @@ func newChatCommand() *cobra.Command {
chatMessageSendByBotCmd := &cobra.Command{
Use: "send-by-bot",
Short: "机器人发送消息(--conversation-id 群聊 / --users 单聊)",
Long: `群聊:传 --conversation-id 指定群;单聊:传 --users 或 --open-dingtalk-ids 指定用户列表,与 --conversation-id 只能选其一,不能同时指定。省略 --msg-type 时发送 Markdown;图片使用 --msg-type image --image-url;本地文件使用 --msg-type file --file-path,CLI 完成上传后发送。机器人引用回复仅支持群聊 Markdown,同时传 --reply(原消息 openMessageId)和 --ref-sender(原消息发送者 openDingTalkId);回复可省略 --title,CLI 会从正文生成标题。
Long: `群聊:传 --conversation-id 指定群;单聊:传 --users 或 --open-dingtalk-ids 指定用户列表,与 --conversation-id 只能选其一,不能同时指定。省略 --msg-type 时发送 Markdown;图片使用 --msg-type image --image-url;本地文件使用 --msg-type file --file-path,CLI 完成上传后发送。
⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。`,
Example: ` dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --title "日报" --text "## 今日完成..."
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --reply <openMessageId> --ref-sender <openDingTalkId> --text "收到"
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --msg-type image --image-url "https://example.com/image.png"
dws chat message send-by-bot --robot-code <robot-code> --conversation-id <openconversation_id> --msg-type file --file-path ./report.pdf
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
@@ -3448,17 +3433,10 @@ func newChatCommand() *cobra.Command {
text := mustGetFlag(cmd, "text")
imageURL := strings.TrimSpace(mustGetFlag(cmd, "image-url"))
filePath := mustGetFlag(cmd, "file-path")
referenceOpenMessageID := strings.TrimSpace(mustGetFlag(cmd, "reply"))
refSender := strings.TrimSpace(mustGetFlag(cmd, "ref-sender"))
replyChanged := cmd.Flags().Changed("reply")
refSenderChanged := cmd.Flags().Changed("ref-sender")
if err := validateRequiredFlags(cmd, "robot-code"); err != nil {
return err
}
if replyChanged != refSenderChanged || (replyChanged && (referenceOpenMessageID == "" || refSender == "")) {
return fmt.Errorf("--reply and --ref-sender must be specified together")
}
if msgType == "" {
if imageURL != "" {
return fmt.Errorf("--msg-type image is required when using --image-url")
@@ -3470,17 +3448,8 @@ func newChatCommand() *cobra.Command {
}
switch msgType {
case "markdown":
if referenceOpenMessageID != "" {
if err := validateRequiredFlags(cmd, "text"); err != nil {
return err
}
if title == "" {
title = sanitizeTitleFromText(text)
}
} else {
if err := validateRequiredFlags(cmd, "title", "text"); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "title", "text"); err != nil {
return err
}
case "image":
if imageURL == "" {
@@ -3506,22 +3475,6 @@ func newChatCommand() *cobra.Command {
return fmt.Errorf("--conversation-id or --users/--open-dingtalk-ids is required")
}
if referenceOpenMessageID != "" {
if chatID == "" {
return fmt.Errorf("--reply and --ref-sender are only supported with --conversation-id")
}
if msgType != "markdown" {
return fmt.Errorf("--reply and --ref-sender only support Markdown group messages")
}
if !isOpenDingTalkID(refSender) {
resolved, err := resolveOpenDingTalkID(cmd.Context(), refSender)
if err != nil {
return err
}
refSender = resolved
}
}
userIDs := splitCommaList(usersStr)
openDingTalkIDs := splitCommaList(openDingtalkIdsStr)
fileURL := ""
@@ -3587,10 +3540,6 @@ func newChatCommand() *cobra.Command {
toolArgs["msgKey"] = "sampleMarkdownDX"
}
toolArgs["openConversationId"] = chatID
if referenceOpenMessageID != "" {
toolArgs["referenceOpenMessageId"] = referenceOpenMessageID
toolArgs["srcMsgSendOpenDingTalkId"] = refSender
}
if len(atUserIds) > 0 {
toolArgs["atUserIds"] = atUserIds
}
@@ -3637,20 +3586,17 @@ func newChatCommand() *cobra.Command {
CLIPath: "chat message send-by-bot",
PrimaryCLIPath: "chat message send-by-bot",
},
Description: "以应用机器人身份发送 Markdown、图片或文件群消息、群聊引用回复或批量单聊",
Description: "以应用机器人身份发送 Markdown、图片或文件群消息或批量单聊",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "以应用机器人身份发送 Markdown、图片或文件群消息、群聊引用回复或批量单聊",
UseWhen: []string{"已有 robotCode 且需要机器人身份投递 Markdown、图片或文件时", "需要机器人在群聊中引用回复一条已有消息时"},
AgentSummary: "以应用机器人身份发送 Markdown、图片或文件群消息或批量单聊",
UseWhen: []string{"已有 robotCode 且需要机器人身份投递 Markdown、图片或文件时"},
AvoidWhen: []string{"个人身份发送或自定义 Webhook 告警不要使用"},
Examples: []string{
"dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title \"日报\" --text \"今日进展\"",
"dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --reply <openMessageId> --ref-sender <openDingTalkId> --text \"收到\"",
},
Examples: []string{"dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title \"日报\" --text \"今日进展\""},
},
// Keep title/text required_when out of Schema: the runtime switch above
// enforces Markdown inputs, while adding it breaks merge-base compatibility.
@@ -3659,8 +3605,6 @@ func newChatCommand() *cobra.Command {
{Name: "msg-type", RequiredWhen: "image-url or file-path is provided", Enum: []string{"markdown", "image", "file"}},
{Name: "image-url", RequiredWhen: "msg-type is image"},
{Name: "file-path", RequiredWhen: "msg-type is file"},
{Name: "reply", Property: "referenceOpenMessageId", RequiredWhen: "ref-sender is provided", InterfaceType: "string"},
{Name: "ref-sender", Property: "srcMsgSendOpenDingTalkId", RequiredWhen: "reply is provided", InterfaceType: "string"},
},
},
})
@@ -3730,25 +3674,19 @@ func newChatCommand() *cobra.Command {
chatMessageSendByWebhookCmd := &cobra.Command{
Use: "send-by-webhook",
Short: "自定义机器人 Webhook 发送群消息",
Long: `通过自定义机器人 Webhook 发送群消息。@ 人时需在 --content 中包含 @userId 或 @手机号,否则 @ 不生效。
Long: `通过自定义机器人 Webhook 发送群消息。@ 人时需在 --text 中包含 @userId 或 @手机号,否则 @ 不生效。
⚠️ 重要:该接口会真实发送消息到目标群聊,不可用于测试或试探性调用。调用前必须确认消息内容无误。`,
Example: ` dws chat message send-by-webhook --token <webhook-token> --title "告警" --content "CPU 超 90%" --at-all
dws chat message send-by-webhook --token <webhook-token> --title "test" --content "hi @118785" --at-users 118785`,
PreRunE: func(cmd *cobra.Command, args []string) error {
return promoteLegacyChatString(cmd, "content", "text")
},
Example: ` dws chat message send-by-webhook --token <webhook-token> --title "告警" --text "CPU 超 90%" --at-all
dws chat message send-by-webhook --token <webhook-token> --title "test" --text "hi @118785" --at-users 118785`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := promoteLegacyChatString(cmd, "content", "text"); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "token", "title", "content"); err != nil {
if err := validateRequiredFlags(cmd, "token", "title", "text"); err != nil {
return err
}
toolArgs := map[string]any{
"robotToken": mustGetFlag(cmd, "token"),
"title": mustGetFlag(cmd, "title"),
"text": mustGetFlag(cmd, "content"),
"text": mustGetFlag(cmd, "text"),
}
if v, _ := cmd.Flags().GetBool("at-all"); v {
toolArgs["isAtAll"] = true
@@ -3823,12 +3761,11 @@ func newChatCommand() *cobra.Command {
"使用企业机器人 robot-code 发消息时用 chat message send-by-bot",
"以个人身份发消息时用 chat message send",
},
Examples: []string{"dws chat message send-by-webhook --token <webhook-token> --title \"告警\" --content \"CPU 超 90%\" --at-all"},
Examples: []string{"dws chat message send-by-webhook --token <webhook-token> --title \"告警\" --text \"CPU 超 90%\" --at-all"},
},
Parameters: []contract.ParamDecl{
{Name: "at-all", Property: "isAtAll"},
{Name: "at-users", Property: "atUserIds"},
{Name: "content", Property: "text"},
{Name: "token", Property: "robotToken"},
},
},
@@ -4377,7 +4314,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
Example: ` dws chat message recall --conversation-id <openConversationId> --message-id <openMessageId>
# 发送后撤回:send -> query-send-status -> recall
dws chat message send --conversation-id <openConversationId> --content "待撤回的内容"
dws chat message send --conversation-id <openConversationId> --text "待撤回的内容"
dws chat message query-send-status --open-task-id <上一步返回的openTaskId>
dws chat message recall --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId>`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -4438,7 +4375,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
Example: ` dws chat message edit --conversation-id <openConversationId> --message-id <openMessageId> --text "更新后的内容"
# 发送后编辑:send -> query-send-status -> edit
dws chat message send --conversation-id <openConversationId> --content "原始内容"
dws chat message send --conversation-id <openConversationId> --text "原始内容"
dws chat message query-send-status --open-task-id <上一步返回的openTaskId>
dws chat message edit --conversation-id <上一步返回的openConversationId> --message-id <上一步返回的openMessageId> --text "更新后的内容"
@@ -4774,19 +4711,21 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
chatMessageSendCmd.Flags().String("user", "", "单聊接收人 userId(单聊时与 --open-dingtalk-id 二选一)")
chatMessageSendCmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId(单聊时与 --user 二选一)")
chatMessageSendCmd.Flags().String("title", "", "消息标题,显示在消息列表(可选,未指定时使用消息内容)")
corecmd.RegisterFlags(chatMessageSendCmd, []corecmd.FlagSpec{{
Name: "content",
Usage: "消息内容(推荐方式,也可用位置参数传递。内容含换行/特殊字符时必须使用此 flag)",
Aliases: []string{"text"},
}})
for _, alias := range []string{"body", "message", "markdown"} {
chatMessageSendCmd.Flags().String(alias, "", "--content 的兼容别名")
_ = chatMessageSendCmd.Flags().MarkHidden(alias)
}
// 别名注册: --text/--content/--body/--message/--markdown → 位置参数
chatMessageSendCmd.Flags().String("text", "", "消息内容(推荐方式,也可用位置参数传递。内容含换行/特殊字符时必须使用此 flag)")
chatMessageSendCmd.Flags().String("content", "", "--text 的别名")
chatMessageSendCmd.Flags().String("body", "", "--text 的别名")
chatMessageSendCmd.Flags().String("message", "", "--text 的别名")
chatMessageSendCmd.Flags().String("markdown", "", "--text 的别名")
// --text 不再隐藏,作为首选传参方式展示在 help 中
_ = chatMessageSendCmd.Flags().MarkHidden("content")
_ = chatMessageSendCmd.Flags().MarkHidden("body")
_ = chatMessageSendCmd.Flags().MarkHidden("message")
_ = chatMessageSendCmd.Flags().MarkHidden("markdown")
chatMessageSendCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效,可选),设置时,消息内容中一定要包含对应的占位符<@all>")
chatMessageSendCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效,可选),设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2>")
chatMessageSendCmd.Flags().String("media-id", "", "上游已提供的图片 mediaId(仅旧版 msgType=image;CLI 不提供本地上传到 mediaId)")
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video/location/profile(本地图片/文件推荐 file --file;image 仅接受已有 mediaId)")
chatMessageSendCmd.Flags().String("msg-type", "", "富媒体消息类型: image/file/audio/video/location/profile(本地图片/文件推荐 file --file-path;image 仅接受已有 mediaId)")
chatMessageSendCmd.Flags().String("latitude", "", "位置消息纬度(msgType=location 时必填)")
chatMessageSendCmd.Flags().String("longitude", "", "位置消息经度(msgType=location 时必填)")
chatMessageSendCmd.Flags().String("location-name", "", "位置消息地址名称(msgType=location 时必填)")
@@ -4796,11 +4735,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
chatMessageSendCmd.Flags().Int64("space-id", 0, "空间 ID(与 --dentry-id 成对传入时跳过自动上传)")
chatMessageSendCmd.Flags().String("file-name", "", "文件名")
chatMessageSendCmd.Flags().String("file-type", "", "文件类型/扩展名")
corecmd.RegisterFlags(chatMessageSendCmd, []corecmd.FlagSpec{{
Name: "file",
Usage: "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)",
Aliases: []string{"file-path"},
}})
chatMessageSendCmd.Flags().String("file-path", "", "本地文件路径(msgType=file/audio/video 时直接上传并按 file 消息发送)")
chatMessageSendCmd.Flags().Int64("file-size", 0, "文件大小,单位字节")
_ = chatMessageSendCmd.Flags().MarkHidden("dentry-id")
_ = chatMessageSendCmd.Flags().MarkHidden("space-id")
@@ -4821,19 +4756,19 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
cli.AnnotateRuntimePositionals(chatMessageSendCmd, contract.RuntimeSchemaPositional{
Name: "content",
Type: "string",
Description: "消息内容(也可使用 --content;富媒体消息可省略)",
Description: "消息内容(也可使用 --text;富媒体消息可省略)",
Required: false,
Index: 0,
})
cli.AnnotateRuntimeFlagEnum(chatMessageSendCmd, "msg-type", "image", "file", "audio", "video")
cli.AnnotateRuntimeFlagFormat(chatMessageSendCmd, "file", "file-path")
cli.AnnotateRuntimeFlagFormat(chatMessageSendCmd, "file-path", "file-path")
chatMessageSendByBotCmd.Flags().String("robot-code", "", "机器人 Code (必填)")
_ = chatMessageSendByBotCmd.MarkFlagRequired("robot-code")
chatMessageSendByBotCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时必填)")
chatMessageSendByBotCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔,最多20个(单聊时必填)")
chatMessageSendByBotCmd.Flags().String("msg-type", "", "消息类型: markdown/image/file(省略时为 markdown;图片使用 image --image-url;本地文件使用 file --file-path)")
chatMessageSendByBotCmd.Flags().String("title", "", "Markdown 消息标题(发送普通 Markdown 时必填;引用回复省略时从正文生成)")
chatMessageSendByBotCmd.Flags().String("title", "", "Markdown 消息标题(发送 Markdown 时必填)")
chatMessageSendByBotCmd.Flags().String("text", "", "Markdown 消息内容(发送 Markdown 时必填;稳定换行用空行,转义形式写 \\n\\n,不要只写 \\n)")
chatMessageSendByBotCmd.Flags().String("image-url", "", "公网图片 URL(msgType=image 时必填)")
chatMessageSendByBotCmd.Flags().String("file-path", "", "本地文件路径(msgType=file 时直接上传并按 file 消息发送)")
@@ -4841,13 +4776,9 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
chatMessageSendByBotCmd.Flags().String("open-dingtalk-ids", "", "用户 openDingtalkId 列表,逗号分隔(单聊时可替代 --users,可选)")
chatMessageSendByBotCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingtalkId 列表,逗号分隔(仅群聊时生效,可选)")
chatMessageSendByBotCmd.Flags().Bool("at-all", false, "@所有人(可选),服务端接收字符串 true/false")
chatMessageSendByBotCmd.Flags().String("reply", "", "被引用消息的 openMessageId(仅群聊 Markdown;必须与 --ref-sender 同时使用)")
chatMessageSendByBotCmd.Flags().String("ref-sender", "", "被引用消息发送者的 openDingTalkId(仅群聊 Markdown;必须与 --reply 同时使用)")
chatMessageSendByBotCmd.MarkFlagsRequiredTogether("reply", "ref-sender")
cli.AnnotateRuntimeConstraints(chatMessageSendByBotCmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"group", "users"}},
RequireOneOf: [][]string{{"group", "users"}},
RequireTogether: [][]string{{"reply", "ref-sender"}},
})
cli.AnnotateRuntimeFlagFormat(chatMessageSendByBotCmd, "file-path", "file-path")
@@ -4861,12 +4792,8 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("token")
chatMessageSendByWebhookCmd.Flags().String("title", "", "消息标题 (必填)")
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("title")
corecmd.RegisterFlags(chatMessageSendByWebhookCmd, []corecmd.FlagSpec{{
Name: "content",
Usage: "消息内容 (必填)",
Aliases: []string{"text"},
}})
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("content")
chatMessageSendByWebhookCmd.Flags().String("text", "", "消息内容 (必填)")
_ = chatMessageSendByWebhookCmd.MarkFlagRequired("text")
chatMessageSendByWebhookCmd.Flags().Bool("at-all", false, "@ 所有人")
chatMessageSendByWebhookCmd.Flags().String("at-mobiles", "", "@ 指定手机号,逗号分隔")
chatMessageSendByWebhookCmd.Flags().String("at-users", "", "@ 指定用户,逗号分隔")
@@ -5076,7 +5003,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
Use: "conversation-info",
Short: "获取会话基础信息",
Long: `获取指定会话的基础信息。
发送本地文件消息请优先使用 dws chat message send --msg-type file --file <本地文件>,CLI 不再要求调用方获取或传递 spaceId。`,
发送本地文件消息请优先使用 dws chat message send --msg-type file --file-path <本地文件>,CLI 不再要求调用方获取或传递 spaceId。`,
Example: ` dws chat conversation-info --conversation-id <openConversationId> --format json
dws chat conversation-info --user <userId> --format json
dws chat conversation-info --open-dingtalk-id <openDingTalkId> --format json`,
@@ -5191,11 +5118,11 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id
Hidden: true,
Long: `chat file upload 已下线,不再调用 chat/upload_conversation_file_by_url。
发送本地文件消息请改用 chat message send --msg-type file --file;该路径仍然可用,CLI 内部会完成本地文件上传和消息发送。`,
Example: ` dws chat message send --conversation-id <openConversationId> --msg-type file --file ./report.pdf --format json
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file ./report.pdf --format json`,
发送本地文件消息请改用 chat message send --msg-type file --file-path;该路径仍然可用,CLI 内部会完成本地文件上传和消息发送。`,
Example: ` dws chat message send --conversation-id <openConversationId> --msg-type file --file-path ./report.pdf --format json
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./report.pdf --format json`,
RunE: func(cmd *cobra.Command, args []string) error {
return fmt.Errorf("chat file upload 已下线;chat/upload_conversation_file_by_url 当前不可用。发送本地文件请改用: dws chat message send --msg-type file --file <本地路径>")
return fmt.Errorf("chat file upload 已下线;chat/upload_conversation_file_by_url 当前不可用。发送本地文件请改用: dws chat message send --msg-type file --file-path <本地路径>")
},
}
chatFileUploadCmd.Flags().String("conversation-id", "", "群聊 openConversationId(群聊时使用)")
@@ -6961,22 +6888,10 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
- 群聊:dws chat search --query "群名"
- 单聊:dws chat conversation-info --open-dingtalk-id <openDingTalkId>
(人员信息可通过 dws contact user search --keyword "姓名" --format json 获取)`,
Example: ` dws chat message reply --group <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --content "收到,马上处理"
dws chat message reply --group <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --content "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
PreRunE: func(cmd *cobra.Command, args []string) error {
if err := promoteLegacyChatString(cmd, "group", "conversation-id"); err != nil {
return err
}
return promoteLegacyChatString(cmd, "content", "text")
},
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := promoteLegacyChatString(cmd, "group", "conversation-id"); err != nil {
return err
}
if err := promoteLegacyChatString(cmd, "content", "text"); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "group", "ref-msg-id", "ref-sender", "content"); err != nil {
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
return err
}
refSender := mustGetFlag(cmd, "ref-sender")
@@ -6993,7 +6908,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
clawType = edition.ClawType()
}
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"openConversationId": mustGetFlag(cmd, "conversation-id"),
"msgType": "reply",
"clawType": clawType,
}
@@ -7001,7 +6916,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
atOpenIDs := mustGetFlag(cmd, "at-open-dingtalk-ids")
replyText := applyCurrentUserGroupMentions(
toolArgs,
mustGetFlag(cmd, "content"),
mustGetFlag(cmd, "text"),
atOpenIDs,
atAll,
)
@@ -7043,32 +6958,24 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
AgentSummary: "引用指定消息发送个人回复",
UseWhen: []string{"用户要针对某条已有消息进行引用回复时"},
AvoidWhen: []string{"无需引用上下文的普通消息使用 chat message send"},
Examples: []string{"dws chat message reply --group <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --content \"收到\""},
Examples: []string{"dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text \"收到\""},
},
Parameters: []contract.ParamDecl{
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
{Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"},
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"},
{Name: "group", Property: "openConversationId"},
{Name: "conversation-id", Property: "openConversationId"},
},
},
})
corecmd.RegisterFlags(chatMessageReplyCmd, []corecmd.FlagSpec{{
Name: "group",
Usage: "会话 openConversationId (必填,支持单聊/群聊)",
Aliases: []string{"conversation-id"},
}})
_ = chatMessageReplyCmd.MarkFlagRequired("group")
chatMessageReplyCmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)")
_ = chatMessageReplyCmd.MarkFlagRequired("conversation-id")
chatMessageReplyCmd.Flags().String("ref-msg-id", "", "被引用的消息 openMessageId (必填)")
_ = chatMessageReplyCmd.MarkFlagRequired("ref-msg-id")
chatMessageReplyCmd.Flags().String("ref-sender", "", "被引用消息的发送者 openDingTalkId (必填)")
_ = chatMessageReplyCmd.MarkFlagRequired("ref-sender")
corecmd.RegisterFlags(chatMessageReplyCmd, []corecmd.FlagSpec{{
Name: "content",
Usage: "回复内容 (必填)",
Aliases: []string{"text"},
}})
_ = chatMessageReplyCmd.MarkFlagRequired("content")
chatMessageReplyCmd.Flags().String("text", "", "回复内容 (必填)")
_ = chatMessageReplyCmd.MarkFlagRequired("text")
chatMessageReplyCmd.Flags().String("uuid", "", "幂等键(可选)")
chatMessageReplyCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)")
chatMessageReplyCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐)")
@@ -446,100 +446,6 @@ func TestCrossPlatformCoverageChatBotRichMediaCoverage(t *testing.T) {
if caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleMarkdownDX" {
t.Fatalf("direct markdown call = %s %#v", caller.tool, caller.args)
}
if _, exists := caller.args["referenceOpenMessageId"]; exists {
t.Fatalf("ordinary direct message unexpectedly contains reply fields: %#v", caller.args)
}
})
t.Run("group Markdown reference reply", func(t *testing.T) {
const senderOpenDingTalkID = "DAAAAAAAAAAAiE"
caller := &scriptedToolCaller{}
if err := runChatCoverageCommand(t, caller,
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
"--reply", "message-id", "--ref-sender", senderOpenDingTalkID,
"--text", "received",
); err != nil {
t.Fatal(err)
}
if caller.tool != "send_robot_group_message" ||
caller.args["referenceOpenMessageId"] != "message-id" ||
caller.args["srcMsgSendOpenDingTalkId"] != senderOpenDingTalkID ||
caller.args["title"] != "received" {
t.Fatalf("group reference reply call = %s %#v", caller.tool, caller.args)
}
})
t.Run("group reference reply resolves sender user ID", func(t *testing.T) {
caller := &scriptedToolCaller{steps: []scriptedToolStep{
{text: `{"result":[{"userId":"sender-user","openDingTalkId":"D-sender"}]}`},
{text: `{}`},
}}
if err := runChatCoverageCommand(t, caller,
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
"--reply", "message-id", "--ref-sender", "sender-user",
"--title", "reply", "--text", "received",
); err != nil {
t.Fatal(err)
}
if caller.tool != "send_robot_group_message" || caller.args["srcMsgSendOpenDingTalkId"] != "D-sender" {
t.Fatalf("resolved group reference reply = %s %#v", caller.tool, caller.args)
}
})
t.Run("direct RunE enforces paired reply flags", func(t *testing.T) {
err := runChatCoverageDirect(t, []string{"message", "send-by-bot"}, map[string]string{
"robot-code": "robot",
"conversation-id": "group",
"reply": "message-id",
"title": "reply",
"text": "received",
})
if err == nil || !strings.Contains(err.Error(), "must be specified together") {
t.Fatalf("direct RunE paired reply flags error = %v", err)
}
})
for _, tc := range []struct {
name string
args []string
want string
}{
{name: "reply requires sender", args: []string{"--reply", "message-id"}, want: "ref-sender"},
{name: "sender requires reply", args: []string{"--ref-sender", "D-sender"}, want: "reply"},
{name: "paired reply flags reject empty values", args: []string{"--reply=", "--ref-sender="}, want: "specified together"},
} {
t.Run(tc.name, func(t *testing.T) {
args := []string{
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
"--title", "reply", "--text", "received",
}
err := runChatCoverageCommand(t, &scriptedToolCaller{}, append(args, tc.args...)...)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("paired reply flags error = %v, want %q", err, tc.want)
}
})
}
t.Run("reference reply is group only", func(t *testing.T) {
err := runChatCoverageCommand(t, &scriptedToolCaller{},
"message", "send-by-bot", "--robot-code", "robot", "--users", "user",
"--reply", "message-id", "--ref-sender", "D-sender",
"--title", "reply", "--text", "received",
)
if err == nil || !strings.Contains(err.Error(), "only supported with --conversation-id") {
t.Fatalf("direct reference reply error = %v", err)
}
})
t.Run("reference reply rejects rich media", func(t *testing.T) {
err := runChatCoverageCommand(t, &scriptedToolCaller{},
"message", "send-by-bot", "--robot-code", "robot", "--conversation-id", "group",
"--reply", "message-id", "--ref-sender", "D-sender",
"--msg-type", "image", "--image-url", "https://example.test/image.png",
)
if err == nil || !strings.Contains(err.Error(), "only support Markdown") {
t.Fatalf("rich-media reference reply error = %v", err)
}
})
t.Run("direct image", func(t *testing.T) {
+5 -5
View File
@@ -18,7 +18,7 @@ import (
"github.com/spf13/cobra"
)
const chatMediaUploadReplacement = "dws chat message send --msg-type file --file <本地路径>"
const chatMediaUploadReplacement = "dws chat message send --msg-type file --file-path <本地路径>"
func newChatMediaGroup() *cobra.Command {
media := &cobra.Command{
@@ -43,11 +43,11 @@ func newChatMediaUploadCommand() *cobra.Command {
Deprecated: "请改用 " + chatMediaUploadReplacement,
Long: `此命令仅为 1.x 命令行兼容保留,不再读取应用凭证或调用旧版媒体上传接口。
发送本地图片或文件时,请使用 chat message send --msg-type file --file。
发送本地图片或文件时,请使用 chat message send --msg-type file --file-path。
该路径会把图片作为可下载的 file 消息发送,不会生成 mediaId,也不会渲染成内联 image 消息。
如果上游已经提供 mediaId,仍可使用 chat message send --msg-type image --media-id。`,
Example: " dws chat message send --conversation-id <openConversationId> --msg-type file --file ./screenshot.png\n" +
" dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file ./report.pdf",
Example: " dws chat message send --group <openConversationId> --msg-type file --file-path ./screenshot.png\n" +
" dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./report.pdf",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(*cobra.Command, []string) error {
@@ -56,7 +56,7 @@ func newChatMediaUploadCommand() *cobra.Command {
}
// Keep the historical flags so existing argv remains parseable while the
// 1.x compatibility command returns an actionable migration error.
cmd.Flags().String("file", "", "旧版兼容参数;本地文件请改用 chat message send --file")
cmd.Flags().String("file", "", "旧版兼容参数;本地文件请改用 chat message send --file-path")
cmd.Flags().String("type", "image", "旧版兼容参数;不再执行媒体上传")
return cmd
}
@@ -28,7 +28,7 @@ func TestCrossPlatformCoverageChatMediaUploadIsDeprecatedCompatibilityStub(t *te
if err == nil {
t.Fatal("deprecated media upload returned nil error")
}
for _, want := range []string{"已下线", "chat message send", "--msg-type file", "--file", "--media-id"} {
for _, want := range []string{"已下线", "chat message send", "--msg-type file", "--file-path", "--media-id"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("media upload migration error missing %q: %v", want, err)
}
-328
View File
@@ -1,328 +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 helpers
import (
"encoding/json"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/spf13/cobra"
)
const commentServer = "doc-comment"
var commentBatchResultSchema = json.RawMessage(`{
"type":"object",
"description":"按请求顺序返回的评论详情",
"properties":{
"commentList":{
"type":"array",
"description":"与请求 comments 顺序一致的评论详情列表",
"items":{
"type":"object",
"description":"单条评论查询结果;不存在时 found=false",
"properties":{
"topicId":{"type":"string","description":"评论主题 ID"},
"commentKey":{"type":"string","description":"评论唯一标识"},
"found":{"type":"boolean","description":"该 topicId/commentKey 是否存在"},
"content":{"type":["string","null"],"description":"纯文本评论内容"},
"quote":{"type":["string","null"],"description":"划词评论引用内容"},
"creatorId":{"type":["string","null"],"description":"创建者用户 ID"},
"createTime":{"type":["integer","null"],"description":"创建时间,毫秒时间戳"},
"updateTime":{"type":["integer","null"],"description":"更新时间,毫秒时间戳"},
"isSolved":{"type":["boolean","null"],"description":"是否已解决"},
"isEmoji":{"type":["boolean","null"],"description":"是否为表情回复"},
"replyCommentKey":{"type":["string","null"],"description":"被回复评论的标识"}
},
"required":["topicId","commentKey","found"],
"additionalProperties":true
}
}
},
"required":["commentList"],
"additionalProperties":true
}`)
var commentStatusResultSchema = json.RawMessage(`{
"type":"object",
"description":"评论解决状态更新结果",
"properties":{
"commentKey":{"type":"string","description":"评论唯一标识"},
"resolved":{"type":"boolean","description":"更新后的解决状态"},
"message":{"type":"string","description":"操作结果消息"}
},
"required":["commentKey","resolved"],
"additionalProperties":true
}`)
var commentReactionResultSchema = json.RawMessage(`{
"type":"object",
"description":"表情回复创建结果",
"properties":{
"commentKey":{"type":"string","description":"新建表情回复的评论标识"},
"message":{"type":"string","description":"操作结果消息"}
},
"required":["commentKey"],
"additionalProperties":true
}`)
// newCommentBaseCommands returns the base comment commands shared by Doc and Sheet.
// Both resource domains call the same comment MCP tools; only CLI identity and
// guidance differ.
func newCommentBaseCommands(surface string) []*cobra.Command {
batchCmd := newCommentBatchQueryCommand(surface)
resolveCmd := newCommentStatusCommand(surface, true)
restoreCmd := newCommentStatusCommand(surface, false)
reactCmd := newCommentReactionCommand(surface)
commands := []*cobra.Command{batchCmd, resolveCmd, restoreCmd, reactCmd}
for _, cmd := range commands {
addCommentNodeAliases(cmd)
}
return commands
}
func newCommentBatchQueryCommand(surface string) *cobra.Command {
resourceName := commentResourceName(surface)
cmd := &cobra.Command{
Use: "batch-query",
Aliases: []string{"batch-query-comments", "batch"},
Short: "按 topicId + commentKey 批量查询评论详情",
Long: `批量查询同一文档或表格中的评论详情,单次最多 100 条。
--comment-ref 可重复传入,格式为 topicId:commentKey。topicId 和 commentKey
均可从 comment list 的返回结果中获得。结果严格保持输入顺序;不存在的评论
会返回 found=false 的占位项。`,
Example: fmt.Sprintf(" dws %s comment batch-query --node <NODE_ID> --comment-ref global:<COMMENT_KEY> --comment-ref <TOPIC_ID>:<COMMENT_KEY> --format json", surface),
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
rawRefs, _ := cmd.Flags().GetStringSlice("comment-ref")
refs, err := parseCommentRefs(rawRefs)
if err != nil {
return err
}
return callMCPToolOnServer(commentServer, "batch_query_comments", map[string]any{
"nodeId": nodeID,
"comments": refs,
})
},
}
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: commentIdentity(surface, "batch_query_comments", "batch-query"),
Description: "按 topicId + commentKey 批量查询评论详情,保持输入顺序并显式标记缺失项",
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: commentBatchResultSchema,
},
Interface: commentInterface("batch_query_comments"),
Selection: contract.SelectionSpec{
AgentSummary: "已知多组 topicId/commentKey 时,一次获取完整评论详情",
UseWhen: []string{fmt.Sprintf("需要回查%s comment list 结果中的多条评论详情,或批量核对评论是否仍存在时", resourceName)},
AvoidWhen: []string{fmt.Sprintf("尚未取得%s评论的 topicId/commentKey 时先使用 comment list", resourceName)},
Examples: []string{fmt.Sprintf("dws %s comment batch-query --node <NODE_ID> --comment-ref <TOPIC_ID>:<COMMENT_KEY> --format json", surface)},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
{Name: "comment-ref", Property: "comments", InterfaceType: "array"},
},
},
})
cmd.Flags().String("node", "", "文档或表格 ID / URL (必填)")
cmd.Flags().StringSlice("comment-ref", nil, "评论引用 topicId:commentKey,可重复;最多 100 条 (必填)")
return cmd
}
func newCommentStatusCommand(surface string, resolve bool) *cobra.Command {
resourceName := commentResourceName(surface)
use := "restore"
rpc := "restore_comment"
short := "将已解决评论恢复为未解决"
resolved := false
if resolve {
use = "resolve"
rpc = "resolve_comment"
short = "将评论标记为已解决"
resolved = true
}
cmd := &cobra.Command{
Use: use,
Aliases: []string{use + "-comment"},
Short: short,
Example: fmt.Sprintf(" dws %s comment %s --node <NODE_ID> --comment-key <COMMENT_KEY> --format json", surface, use),
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "comment-key"); err != nil {
return err
}
return callMCPToolOnServer(commentServer, rpc, map[string]any{
"nodeId": nodeID,
"commentKey": mustGetFlag(cmd, "comment-key"),
})
},
}
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: commentIdentity(surface, rpc, use),
Description: short,
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: commentStatusResultSchema,
},
Interface: commentInterface(rpc),
Selection: contract.SelectionSpec{
AgentSummary: short,
UseWhen: []string{fmt.Sprintf("用户明确要求把%s中的某条评论标记为%s时", resourceName, map[bool]string{true: "已解决", false: "未解决"}[resolved])},
AvoidWhen: []string{fmt.Sprintf("永久删除%s评论使用 comment delete;修改文字使用 comment update", resourceName)},
Examples: []string{fmt.Sprintf("dws %s comment %s --node <NODE_ID> --comment-key <COMMENT_KEY> --format json", surface, use)},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
{Name: "comment-key", Property: "commentKey"},
},
},
})
cmd.Flags().String("node", "", "文档或表格 ID / URL (必填)")
cmd.Flags().String("comment-key", "", "目标评论的 commentKey (必填)")
return cmd
}
func newCommentReactionCommand(surface string) *cobra.Command {
resourceName := commentResourceName(surface)
cmd := &cobra.Command{
Use: "react-reply",
Short: "创建一条表情回复",
Long: `创建表情回复的便捷命令。底层复用 reply_comment,并固定传入 emoji=true。
与 comment reply --emoji 一致,--reaction 必须填写钉钉表情名称,不要直接传
Unicode Emoji。例如用户要求 😄 时传“憨笑”,要求 👏 时传“鼓掌”。
当前轻量版仅支持创建,不包含删除或聚合能力。`,
Example: fmt.Sprintf(" dws %s comment react-reply --node <NODE_ID> --comment-key <COMMENT_KEY> --reaction \"憨笑\" --format json", surface),
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
if err := validateRequiredFlags(cmd, "comment-key", "reaction"); err != nil {
return err
}
return callMCPToolOnServer(commentServer, "reply_comment", map[string]any{
"nodeId": nodeID,
"replyCommentKey": mustGetFlag(cmd, "comment-key"),
"content": mustGetFlag(cmd, "reaction"),
"emoji": true,
})
},
}
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: commentIdentity(surface, "react_reply", "react-reply"),
Description: "为指定评论创建表情回复(轻量版)",
Result: &contract.ResultSpec{
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
DataSchema: commentReactionResultSchema,
},
Interface: commentInterface("reply_comment"),
Selection: contract.SelectionSpec{
AgentSummary: "使用钉钉表情名称为指定评论创建表情回复",
UseWhen: []string{fmt.Sprintf("用户要用表情回应%s中的某条评论时;先将 Unicode Emoji 转为钉钉表情名称,例如 😄 转为憨笑、👏 转为鼓掌", resourceName)},
AvoidWhen: []string{fmt.Sprintf("%s评论的普通文字回复使用 comment reply;当前轻量版不支持删除 reaction", resourceName)},
Examples: []string{fmt.Sprintf("dws %s comment react-reply --node <NODE_ID> --comment-key <COMMENT_KEY> --reaction \"憨笑\" --format json", surface)},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId"},
{Name: "comment-key", Property: "replyCommentKey"},
{Name: "reaction", Property: "content"},
},
},
})
cmd.Flags().String("node", "", "文档或表格 ID / URL (必填)")
cmd.Flags().String("comment-key", "", "被回应评论的 commentKey (必填)")
cmd.Flags().String("reaction", "", "钉钉表情名称,不是 Unicode Emoji;例如 😄=憨笑、👏=鼓掌 (必填)")
return cmd
}
func parseCommentRefs(rawRefs []string) ([]map[string]any, error) {
if len(rawRefs) == 0 {
return nil, fmt.Errorf("missing required flag(s): --comment-ref")
}
if len(rawRefs) > 100 {
return nil, fmt.Errorf("--comment-ref 最多可传 100 条,当前为 %d 条", len(rawRefs))
}
refs := make([]map[string]any, 0, len(rawRefs))
for index, raw := range rawRefs {
parts := strings.SplitN(strings.TrimSpace(raw), ":", 2)
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" {
return nil, fmt.Errorf("invalid --comment-ref[%d] %q: expected topicId:commentKey", index, raw)
}
refs = append(refs, map[string]any{
"topicId": strings.TrimSpace(parts[0]),
"commentKey": strings.TrimSpace(parts[1]),
})
}
return refs, nil
}
func addCommentNodeAliases(cmd *cobra.Command) {
cmd.Flags().String("url", "", "")
cmd.Flags().String("id", "", "")
cmd.Flags().String("node-id", "", "")
cmd.Flags().String("doc-id", "", "")
cmd.Flags().String("file-id", "", "")
for _, name := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
_ = cmd.Flags().MarkHidden(name)
}
}
func commentIdentity(surface, name, cliLeaf string) contract.ToolIdentitySpec {
return contract.ToolIdentitySpec{
ProductID: surface,
Name: name,
CanonicalPath: surface + "." + name,
CLIPath: surface + " comment " + cliLeaf,
PrimaryCLIPath: surface + " comment " + cliLeaf,
}
}
func commentResourceName(surface string) string {
if surface == "sheet" {
return "表格"
}
return "文档"
}
func commentInterface(rpc string) *contract.InterfaceSpec {
return &contract.InterfaceSpec{
Mode: "mcp",
Availability: "available",
Ref: &contract.InterfaceRefSpec{ProductID: commentServer, RPCName: rpc},
}
}
-174
View File
@@ -1,174 +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 helpers
import (
"io"
"os"
"reflect"
"strings"
"testing"
"github.com/spf13/cobra"
)
func executeCommentBaseCommand(t *testing.T, caller *docCommentMutationCaller, surface string, args ...string) error {
t.Helper()
previousDeps := deps
previousArgs := os.Args
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
})
InitDeps(caller)
deps.Out.w = io.Discard
os.Args = []string{"dws", surface}
var cmd *cobra.Command
if surface == "sheet" {
cmd = newSheetCommand()
} else {
cmd = newDocCommand()
}
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(args)
return cmd.Execute()
}
func TestCrossPlatformCoverageCommentBaseCommandsRegisteredForDocAndSheet(t *testing.T) {
for _, surface := range []struct {
name string
cmd *cobra.Command
}{
{name: "doc", cmd: newDocCommand()},
{name: "sheet", cmd: newSheetCommand()},
} {
for _, leaf := range []string{"batch-query", "resolve", "restore", "react-reply"} {
cmd, remaining, err := surface.cmd.Find([]string{"comment", leaf})
if err != nil || len(remaining) != 0 {
t.Fatalf("dws %s comment %s not registered: cmd=%v remaining=%v err=%v",
surface.name, leaf, cmd, remaining, err)
}
}
}
}
func TestCrossPlatformCoverageDocCommentBatchQueryMapsStructuredRefs(t *testing.T) {
caller := &docCommentMutationCaller{}
err := executeCommentBaseCommand(t, caller, "doc",
"comment", "batch-query", "--node", "doc-1",
"--comment-ref", "global:comment-1", "--comment-ref", "topic-2:comment-2")
if err != nil {
t.Fatal(err)
}
want := docCommentMutationCall{
productID: commentServer,
toolName: "batch_query_comments",
args: map[string]any{
"nodeId": "doc-1",
"comments": []map[string]any{
{"topicId": "global", "commentKey": "comment-1"},
{"topicId": "topic-2", "commentKey": "comment-2"},
},
},
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
}
}
func TestCrossPlatformCoverageSheetCommentResolveAndRestoreUseSharedRPCs(t *testing.T) {
caller := &docCommentMutationCaller{}
if err := executeCommentBaseCommand(t, caller, "sheet",
"comment", "resolve", "--node", "sheet-1", "--comment-key", "comment-1"); err != nil {
t.Fatal(err)
}
if err := executeCommentBaseCommand(t, caller, "sheet",
"comment", "restore", "--node", "sheet-1", "--comment-key", "comment-1"); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].toolName != "resolve_comment" || caller.calls[1].toolName != "restore_comment" {
t.Fatalf("tools = %q, %q", caller.calls[0].toolName, caller.calls[1].toolName)
}
wantArgs := map[string]any{"nodeId": "sheet-1", "commentKey": "comment-1"}
if !reflect.DeepEqual(caller.calls[0].args, wantArgs) || !reflect.DeepEqual(caller.calls[1].args, wantArgs) {
t.Fatalf("calls = %#v, want args %#v", caller.calls, wantArgs)
}
}
func TestCrossPlatformCoverageCommentReactReplyForcesEmojiTrue(t *testing.T) {
caller := &docCommentMutationCaller{}
err := executeCommentBaseCommand(t, caller, "doc",
"comment", "react-reply", "--file-id", "doc-1",
"--comment-key", "comment-1", "--reaction", "鼓掌")
if err != nil {
t.Fatal(err)
}
want := docCommentMutationCall{
productID: commentServer,
toolName: "reply_comment",
args: map[string]any{
"nodeId": "doc-1",
"replyCommentKey": "comment-1",
"content": "鼓掌",
"emoji": true,
},
}
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
}
}
func TestCrossPlatformCoverageCommentReactReplyGuidesDingTalkEmojiNames(t *testing.T) {
for _, surface := range []struct {
name string
cmd *cobra.Command
}{
{name: "doc", cmd: newDocCommand()},
{name: "sheet", cmd: newSheetCommand()},
} {
cmd, remaining, err := surface.cmd.Find([]string{"comment", "react-reply"})
if err != nil || len(remaining) != 0 {
t.Fatalf("dws %s comment react-reply lookup: remaining=%v err=%v", surface.name, remaining, err)
}
reaction := cmd.Flags().Lookup("reaction")
if reaction == nil || !strings.Contains(reaction.Usage, "不是 Unicode Emoji") || !strings.Contains(reaction.Usage, "😄=憨笑") {
t.Fatalf("dws %s reaction guidance = %#v", surface.name, reaction)
}
if !strings.Contains(cmd.Long, "不要直接传") || !strings.Contains(cmd.Example, `--reaction "憨笑"`) {
t.Fatalf("dws %s react-reply help missing DingTalk emoji-name guidance", surface.name)
}
}
}
func TestCrossPlatformCoverageParseCommentRefsRejectsInvalidShapeAndLimit(t *testing.T) {
if _, err := parseCommentRefs(nil); err == nil {
t.Fatal("missing refs returned nil error")
}
if _, err := parseCommentRefs([]string{"missing-separator"}); err == nil {
t.Fatal("invalid ref returned nil error")
}
tooMany := make([]string, 101)
for index := range tooMany {
tooMany[index] = "global:comment"
}
if _, err := parseCommentRefs(tooMany); err == nil {
t.Fatal("101 refs returned nil error")
}
}
+1 -1
View File
@@ -96,7 +96,7 @@ func TestCallbackInboundMediaPreservesFutureRichTextAttachment(t *testing.T) {
// TestExtractCallbackText covers the markdown / richText fallback path used
// when SDK data.Text.Content is empty. This is the recovery path for
// `dws chat message send --conversation-id ... --content ...` (defaults to msgType=markdown)
// `dws chat message send --group ... --text ...` (defaults to msgType=markdown)
// which otherwise gets silently dropped by the connector.
func TestExtractCallbackText(t *testing.T) {
cases := []struct {
+1 -1
View File
@@ -1259,7 +1259,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
}
// Structured-text fallback: DingTalk leaves data.Text.Content blank on
// markdown / richText callbacks (the body ships in data.Content). Without
// this, `dws chat message send --conversation-id ... --content ...` — which defaults
// this, `dws chat message send --group ... --text ...` — which defaults
// to msgType=markdown — hits the drop branch below and the bot looks
// dead to the sender.
if text == "" {
+21 -305
View File
@@ -9,16 +9,15 @@ import (
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
// ──────────────────────────────────────────────────────────
@@ -493,11 +492,10 @@ func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]stri
return nil
}
// runMediaInsert implements the four-step flow for inserting an attachment into a document:
// runMediaInsert implements the three-step flow for inserting an attachment into a document:
// 1. get_doc_attachment_upload_info → obtain uploadUrl + resourceId
// 2. HTTP PUT file content to OSS
// 3. insert_document_block with attachment element
// 4. list_document_blocks → prove the uploaded resource is visible in the document
func runMediaInsert(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -553,7 +551,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
// Step 1: get upload credentials (uploadUrl + resourceId)
deps.Out.PrintInfo(fmt.Sprintf("[1/4] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
credText, err := callMCPToolReturnText(ctx, "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
@@ -571,7 +569,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
}
// Step 2: HTTP PUT file to OSS
deps.Out.PrintInfo("[2/4] 上传文件到 OSS...")
deps.Out.PrintInfo("[2/3] 上传文件到 OSS...")
ossHeaders := map[string]string{
"Content-Type": mimeType,
@@ -594,7 +592,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
}
// Step 3: insert block into document
deps.Out.PrintInfo("[3/4] 插入块到文档...")
deps.Out.PrintInfo("[3/3] 插入块到文档...")
const maxInlineImageSize = 20 * 1024 * 1024 // 20MB
@@ -646,8 +644,7 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
insertArgs["referenceBlockId"] = v
}
insertText, err := callMCPToolReturnText(ctx, "insert_document_block", insertArgs)
if err != nil {
if err := callMCPTool("insert_document_block", insertArgs); err != nil {
return apperrors.NewAPI(
"附件已上传,但正文 block 插入结果未知;请先检查媒体列表,不要重复上传或插入",
apperrors.WithOperation("doc.media_insert"),
@@ -668,23 +665,6 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
apperrors.WithCause(err),
)
}
insertResult := map[string]any{}
if strings.TrimSpace(insertText) != "" {
if err := json.Unmarshal([]byte(insertText), &insertResult); err != nil {
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName,
fmt.Errorf("解析 insert_document_block 响应失败: %w", err))
}
}
insertedBlockID := insertedDocBlockID(insertResult)
deps.Out.PrintInfo("[4/4] 回读验证媒体块...")
verifiedBlockID, verifyErr := verifyInsertedDocMedia(ctx, nodeID, insertedBlockID, resourceID, resourceURL)
if verifyErr != nil {
return docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName, verifyErr)
}
if insertedBlockID == "" {
insertedBlockID = verifiedBlockID
}
return deps.Out.PrintJSON(map[string]any{
"contractVersion": "doc.operation.v1",
@@ -694,271 +674,16 @@ func runMediaInsert(cmd *cobra.Command, _ []string) error {
"operation": "doc.media_insert",
"data": map[string]any{
"nodeId": nodeID, "resourceId": resourceID, "resourceUrl": resourceURL,
"blockId": insertedBlockID, "fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize,
"inserted": true, "verified": true,
"fileName": fileName, "mimeType": mimeType, "sizeBytes": fileSize, "inserted": true,
},
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "success"},
{"name": "verify", "status": "success"},
},
})
}
func docMediaInsertVerificationError(nodeID, resourceID, resourceURL, fileName string, cause error) error {
return apperrors.NewAPI(
"附件已上传且插块请求已执行,但回读未能证明媒体块落库;不要直接重试上传或插入",
apperrors.WithOperation("doc.media_insert"),
apperrors.WithReason("doc_media_insert_verification_failed"),
apperrors.WithFailureStage("verify"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("运行 dws doc +media-list 检查 resourceId", "确认媒体不存在后再决定是否重新执行"),
apperrors.WithDetails(map[string]any{
"contractVersion": "doc.operation.v1", "status": "partial_success", "nodeId": nodeID,
"resourceId": resourceID, "resourceUrl": resourceURL, "fileName": fileName, "verified": false,
"steps": []map[string]any{
{"name": "resolve_upload", "status": "success"},
{"name": "upload_oss", "status": "success"},
{"name": "insert_block", "status": "success"},
{"name": "verify", "status": "failed"},
},
}),
apperrors.WithCause(cause),
)
}
var docMediaVerifyWait = waitForDocVerification
func verifyInsertedDocMedia(ctx context.Context, nodeID, blockID, resourceID, resourceURL string) (string, error) {
delays := []time.Duration{250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second, 8 * time.Second}
var lastErr error
for attempt := 0; attempt <= len(delays); attempt++ {
blocks, err := readAllDocBlocksForVerification(ctx, nodeID)
if err != nil {
lastErr = err
} else {
lastErr = nil
if found := findVerifiedMediaBlock(blocks, blockID, resourceID, resourceURL); found != "" {
return found, nil
}
}
if attempt < len(delays) {
if err := docMediaVerifyWait(ctx, delays[attempt]); err != nil {
return "", err
}
}
}
if lastErr != nil {
return "", fmt.Errorf("媒体资源在有界回读窗口内仍无法读取: %w", lastErr)
}
return "", fmt.Errorf("媒体资源在有界回读窗口内仍不可见")
}
func waitForDocVerification(ctx context.Context, delay time.Duration) error {
if ctx == nil {
ctx = context.Background()
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func readAllDocBlocksForVerification(ctx context.Context, nodeID string) ([]any, error) {
const pageSize = 50
const maxItems = 5000
all := make([]any, 0, pageSize)
seenPageIdentities := map[string]bool{}
for start := 0; start < maxItems; start += pageSize {
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
"nodeId": nodeID, "format": "jsonml", "startIndex": start, "endIndex": start + pageSize - 1,
})
if err != nil {
return nil, err
}
var payload map[string]any
if err := json.Unmarshal([]byte(text), &payload); err != nil {
return nil, fmt.Errorf("解析 list_document_blocks 回读失败: %w", err)
}
payload = nestedDocMap(payload)
blocks, ok := payload["blocks"].([]any)
if !ok {
return nil, fmt.Errorf("list_document_blocks 回读缺少 blocks 数组")
}
pageIdentity := docBlockPageIdentity(blocks)
if pageIdentity != "" && seenPageIdentities[pageIdentity] {
return nil, fmt.Errorf("list_document_blocks 分页停滞")
}
if pageIdentity != "" {
seenPageIdentities[pageIdentity] = true
}
all = append(all, blocks...)
hasMore, hasMoreKnown := payload["hasMore"].(bool)
if hasMoreKnown && !hasMore {
return all, nil
}
if !hasMoreKnown {
if total, ok := docNumberAsInt(payload["totalCount"]); ok && len(all) >= total {
return all, nil
}
}
if !hasMoreKnown && len(blocks) < pageSize {
return all, nil
}
if len(blocks) == 0 {
return nil, fmt.Errorf("list_document_blocks 声明仍有下一页但当前页为空")
}
}
return nil, fmt.Errorf("文档块超过安全回读上限")
}
func docBlockPageIdentity(blocks []any) string {
if len(blocks) == 0 {
return ""
}
ids := make([]string, 0, len(blocks))
for _, value := range blocks {
id := ""
switch block := value.(type) {
case map[string]any:
id = directDocBlockIdentity(block)
if id == "" {
if element, ok := block["element"].(map[string]any); ok {
id = directDocBlockIdentity(element)
}
}
case []any:
if len(block) > 1 {
if attributes, ok := block[1].(map[string]any); ok {
id = directDocBlockIdentity(attributes)
}
}
}
if id == "" {
return ""
}
ids = append(ids, id)
}
encoded, _ := json.Marshal(ids)
return string(encoded)
}
func directDocBlockIdentity(block map[string]any) string {
for _, key := range []string{"blockId", "id", "uuid", "elementId"} {
if text, ok := block[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
return ""
}
func nestedDocMap(data map[string]any) map[string]any {
for _, key := range []string{"result", "data"} {
if nested, ok := data[key].(map[string]any); ok {
return nestedDocMap(nested)
}
}
return data
}
func nestedDocString(value any, keys ...string) string {
switch typed := value.(type) {
case map[string]any:
for _, key := range keys {
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
orderedKeys := make([]string, 0, len(typed))
for key := range typed {
orderedKeys = append(orderedKeys, key)
}
sort.Strings(orderedKeys)
for _, key := range orderedKeys {
if text := nestedDocString(typed[key], keys...); text != "" {
return text
}
}
case []any:
for _, child := range typed {
if text := nestedDocString(child, keys...); text != "" {
return text
}
}
}
return ""
}
// insertedDocBlockID only accepts explicit block IDs from the insert result or
// known response wrappers. Arbitrary IDs may belong to the document, operator,
// or request and must not become a hard constraint for the media readback.
func insertedDocBlockID(data map[string]any) string {
for _, key := range []string{"blockId", "elementId"} {
if text, ok := data[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
for _, wrapper := range []string{"result", "data", "content"} {
if inner, ok := data[wrapper].(map[string]any); ok {
if text := insertedDocBlockID(inner); text != "" {
return text
}
}
}
return ""
}
func findVerifiedMediaBlock(blocks []any, blockID, resourceID, resourceURL string) string {
for _, value := range blocks {
candidateID := nestedDocString(value, "blockId", "id", "uuid")
if candidateID == "" || (blockID != "" && candidateID != blockID) {
continue
}
mediaValue := docMediaReadbackValue(value)
if resourceID != "" && nestedDocString(mediaValue, "resourceId") == resourceID {
return candidateID
}
if resourceURL != "" && nestedDocString(mediaValue, "resourceUrl", "src") == resourceURL {
return candidateID
}
}
return ""
}
func docMediaReadbackValue(value any) any {
block, ok := value.(map[string]any)
if !ok {
return value
}
encoded, ok := block["jsonml"].(string)
if !ok || strings.TrimSpace(encoded) == "" {
return value
}
var decoded any
if json.Unmarshal([]byte(encoded), &decoded) != nil {
return value
}
return decoded
}
func docNumberAsInt(value any) (int, bool) {
switch typed := value.(type) {
case float64:
if typed >= 0 && typed == float64(int(typed)) {
return int(typed), true
}
case int:
return typed, typed >= 0
}
return 0, false
}
// parseAttachmentUploadInfo extracts uploadUrl, resourceId and resourceUrl from the MCP tool response.
func parseAttachmentUploadInfo(text string) (uploadURL, resourceID, resourceURL string, err error) {
var data map[string]any
@@ -1018,7 +743,7 @@ func inferMimeType(fileName string) string {
}
// buildBlockElement 从 flags 构建块元素 JSON 对象。
// 优先级: --element (原始 JSON) > --heading > --text (旧名) > --content
// 优先级: --element (原始 JSON) > --heading > --text
func buildBlockElement(cmd *cobra.Command) (any, error) {
if raw, _ := cmd.Flags().GetString("element"); raw != "" {
var obj any
@@ -1037,13 +762,13 @@ func buildBlockElement(cmd *cobra.Command) (any, error) {
"heading": map[string]any{"text": h, "level": level},
}, nil
}
if t := flagOrFallback(cmd, "text", "content"); t != "" {
if t, _ := cmd.Flags().GetString("text"); t != "" {
return map[string]any{
"blockType": "paragraph",
"paragraph": map[string]any{"text": t},
}, nil
}
return nil, fmt.Errorf("block content required: --content / --heading / --element")
return nil, fmt.Errorf("block content required: --text / --heading / --element")
}
// buildNodeTransferRunE creates a RunE handler for copy/move commands.
@@ -2127,12 +1852,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
Use: "insert",
Short: "插入块元素",
Long: `向文档插入块元素。通过 --element 传入 JSON 格式的块元素。
可用 --content 快速插入段落,--heading + --level 快速插入标题。
可用 --text 快速插入段落,--heading + --level 快速插入标题。
块类型: paragraph, heading, blockquote, callout, columns,
orderedList, unorderedList, table, sheet, attachment, slot`,
Example: ` # 快捷插入段落
dws doc block insert --node DOC_ID --content "这是一段文字"
dws doc block insert --node DOC_ID --text "这是一段文字"
# 快捷插入标题
dws doc block insert --node DOC_ID --heading "二级标题" --level 2
@@ -2155,7 +1880,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
dws doc block insert --node DOC_ID --element '{"blockType":"unorderedList","unorderedList":{"list":{"listId":"list-2"}},"children":[{"text":"第二项"}]}'
# 在指定位置之前插入
dws doc block insert --node DOC_ID --content "插入内容" --ref-block BLOCK_ID --where before`,
dws doc block insert --node DOC_ID --text "插入内容" --ref-block BLOCK_ID --where before`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
@@ -2241,14 +1966,14 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
},
Selection: contract.SelectionSpec{
AgentSummary: "向文档插入块元素",
UseWhen: []string{"在文档中插入新块(段落/标题等);简单场景用 --content/--heading,复杂块用 --element JSON"},
UseWhen: []string{"在文档中插入新块(段落/标题等);简单场景用 --text/--heading,复杂块用 --element JSON"},
AvoidWhen: []string{
"整篇追加 Markdown 优先 doc update --mode append",
"插入本地文件附件优先 doc media insert",
"删块用 block delete;改已有块用 block update",
},
Examples: []string{
"dws doc block insert --node <DOC_ID> --content \"这是一段文字\" --format json",
"dws doc block insert --node <DOC_ID> --text \"这是一段文字\" --format json",
"dws doc block insert --node <DOC_ID> --heading \"二级标题\" --level 2 --format json",
},
},
@@ -2265,8 +1990,8 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
Use: "update",
Short: "更新块元素",
Long: `更新文档中指定块的内容或样式。需提供 --block-id 和块元素内容。
可用 --content 快速更新为段落,--heading + --level 快速更新为标题。`,
Example: ` dws doc block update --node DOC_ID --block-id BLOCK_ID --content "新内容" # 查询 nodeId: dws doc search --query "..." 或 dws doc list # 查询 blockId: dws doc block list --node <nodeId>
可用 --text 快速更新为段落,--heading + --level 快速更新为标题。`,
Example: ` dws doc block update --node DOC_ID --block-id BLOCK_ID --text "新内容" # 查询 nodeId: dws doc search --query "..." 或 dws doc list # 查询 blockId: dws doc block list --node <nodeId>
dws doc block update --node DOC_ID --block-id BLOCK_ID --element '{"blockType":"heading","heading":{"text":"新标题","level":1}}'`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "block-id"); err != nil {
@@ -2338,7 +2063,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
"插入新块用 block insert;删除用 block delete",
"改文档显示名用 rename;整篇覆盖用 update overwrite",
},
Examples: []string{"dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content \"新内容\" --format json"},
Examples: []string{"dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text \"新内容\" --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "content-format", Property: "format"},
@@ -2748,6 +2473,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
// block insert
blockInsertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
blockInsertCmd.Flags().String("text", "", "快捷: 段落文本内容")
blockInsertCmd.Flags().String("heading", "", "快捷: 标题文本")
blockInsertCmd.Flags().Int("level", 1, "标题级别 1-6 (配合 --heading)")
blockInsertCmd.Flags().String("element", "", "块元素 JSON (高级)")
@@ -2761,6 +2487,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
// block update
blockUpdateCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
blockUpdateCmd.Flags().String("block-id", "", "目标块 ID (必填)")
blockUpdateCmd.Flags().String("text", "", "快捷: 段落文本内容")
blockUpdateCmd.Flags().String("heading", "", "快捷: 标题文本")
blockUpdateCmd.Flags().Int("level", 1, "标题级别 1-6 (配合 --heading)")
blockUpdateCmd.Flags().String("element", "", "块元素 JSON (高级)")
@@ -3515,7 +3242,6 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
}
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd, commentCreateInlineCmd)
commentCmd.AddCommand(newCommentBaseCommands("doc")...)
// ── permission (文档协作权限) ────────────────────────────
permissionCmd := &cobra.Command{
@@ -4642,16 +4368,6 @@ CLI 内部自动完成全部流程:
RegisterCrossProductAliases(child)
}
}
// Register the block content Primary after the cross-product pass. Registering
// --content earlier would make the global content/markdown group expand a new
// --markdown alias onto these commands, which is outside this migration.
for _, cmd := range []*cobra.Command{blockInsertCmd, blockUpdateCmd} {
corecmd.RegisterFlags(cmd, []corecmd.FlagSpec{{
Name: "content",
Usage: "快捷: 段落文本内容",
Aliases: []string{"text"},
}})
}
// ── deprecated 标记:文件管理命令已迁移到 drive ──
deprecatedDocToDrive := map[*cobra.Command]string{

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