Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0137a1950b | ||
|
|
ed0ec3103a | ||
|
|
8f0bb91ddb | ||
|
|
b5be003330 | ||
|
|
dd29805063 | ||
|
|
497b627080 | ||
|
|
4233105963 | ||
|
|
2541081f97 | ||
|
|
3a6e5fb4b3 | ||
|
|
83783972a3 | ||
|
|
919b9d698f | ||
|
|
ea77270030 | ||
|
|
1a62ef3616 | ||
|
|
7d37c15a46 | ||
|
|
9fa763e213 | ||
|
|
ee0e629c72 | ||
|
|
2c8f970703 | ||
|
|
20d0eaa489 | ||
|
|
67c88828f0 | ||
|
|
b015c27034 | ||
|
|
604ec5f50a | ||
|
|
27296ec426 | ||
|
|
6a38a168dd | ||
|
|
9771053d81 | ||
|
|
10c0c5083e | ||
|
|
a0187b5297 | ||
|
|
81991f1c07 | ||
|
|
836670ef50 | ||
|
|
53ce0a8303 | ||
|
|
78867f3601 | ||
|
|
37438659e6 | ||
|
|
3c12c835a3 | ||
|
|
389f83241f | ||
|
|
3714adc2db | ||
|
|
f37d0569a1 | ||
|
|
798b58bf3c | ||
|
|
d926bed3cc | ||
|
|
5ac180d3dd | ||
|
|
35e60407d3 | ||
|
|
ea46132cf6 | ||
|
|
478dc155e8 | ||
|
|
08ecb38a42 |
@@ -61,46 +61,6 @@ jobs:
|
||||
- name: Test with Race Detection
|
||||
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/...
|
||||
|
||||
- name: Test release scripts
|
||||
run: go test -v -count=1 -timeout=5m ./test/scripts
|
||||
|
||||
test-darwin:
|
||||
name: Test (macOS auth/keychain)
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Test macOS auth and Keychain paths with Race Detection
|
||||
run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth ./internal/app
|
||||
|
||||
test-windows:
|
||||
name: Test (Windows)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build Windows CLI
|
||||
run: go build -o dws.exe ./cmd
|
||||
|
||||
- name: Test Windows auth and DPAPI paths
|
||||
run: |
|
||||
go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth
|
||||
go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)'
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -18,7 +18,10 @@ jobs:
|
||||
release:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
# 60 (not 30): mirroring every release asset to Gitee is slow; 30 min cut the
|
||||
# Gitee step off mid-upload on the v1.0.42 release. The Gitee step is now also
|
||||
# idempotent (re-runs only upload missing assets).
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
@@ -37,50 +40,17 @@ jobs:
|
||||
- name: Multi Profile E2E
|
||||
run: bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
- name: Install rcodesign (sign darwin binaries from Linux)
|
||||
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RCS_VERSION="0.29.0"
|
||||
RCS_ARCHIVE_SHA256="dbe85cedd8ee4217b64e9a0e4c2aef92ab8bcaaa41f20bde99781ff02e600002"
|
||||
set -eu
|
||||
RCS_VERSION="0.27.0"
|
||||
curl -fsSL -o /tmp/rcodesign.tar.gz \
|
||||
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
printf '%s %s\n' "$RCS_ARCHIVE_SHA256" /tmp/rcodesign.tar.gz \
|
||||
| sha256sum --check --strict -
|
||||
mkdir -p /tmp/rcodesign
|
||||
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
|
||||
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
|
||||
rcodesign --version
|
||||
|
||||
- name: Prepare Apple Developer ID certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${APPLE_CERTIFICATE_P12_BASE64:-}" ] || [ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ]; then
|
||||
if [ "$GITHUB_REPOSITORY_OWNER" = "DingTalk-Real-AI" ]; then
|
||||
echo "APPLE_CERTIFICATE_P12_BASE64 and APPLE_CERTIFICATE_PASSWORD are required for official releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Developer ID secrets are unavailable; fork release will use ad-hoc signing."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
umask 077
|
||||
certificate_path="$RUNNER_TEMP/dws-developer-id.p12"
|
||||
password_path="$RUNNER_TEMP/dws-developer-id-password"
|
||||
printf '%s' "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$certificate_path"
|
||||
printf '%s' "$APPLE_CERTIFICATE_PASSWORD" > "$password_path"
|
||||
|
||||
# Fail before packaging if the secret is corrupt or the password is wrong.
|
||||
# The exported P12 may use legacy PKCS#12 ciphers; OpenSSL 3 requires
|
||||
# -legacy to validate those containers even though rcodesign can read them.
|
||||
openssl pkcs12 -legacy -in "$certificate_path" -passin "file:$password_path" -noout
|
||||
|
||||
echo "DWS_APPLE_CERTIFICATE_P12=$certificate_path" >> "$GITHUB_ENV"
|
||||
echo "DWS_APPLE_CERTIFICATE_PASSWORD_FILE=$password_path" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
@@ -93,88 +63,12 @@ jobs:
|
||||
run: ./scripts/release/post-goreleaser.sh
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: ${{ github.ref_name }}
|
||||
DWS_REQUIRE_DEVELOPER_ID_SIGNING: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
|
||||
- name: Remove Apple Developer ID certificate
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
rm -f "$RUNNER_TEMP/dws-developer-id.p12"
|
||||
rm -f "$RUNNER_TEMP/dws-developer-id-password"
|
||||
|
||||
# GoReleaser uploads the original archives to a Draft before
|
||||
# post-goreleaser.sh replaces the Darwin binaries. Re-upload every changed
|
||||
# file, verify the Draft digests, and keep it private for Apple validation.
|
||||
- name: Upload finalized signed assets to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DWS_PUBLISH_RELEASE: "false"
|
||||
run: ./scripts/release/finalize-github-release.sh
|
||||
|
||||
- name: Preserve finalized distribution files
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: finalized-release-dist
|
||||
path: dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
verify-darwin-signatures:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
|
||||
needs: release
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Download finalized Darwin assets from Draft release
|
||||
- name: Upload dws-skills.zip to release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
gh release download "$GITHUB_REF_NAME" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--dir dist \
|
||||
--pattern 'dws-darwin-amd64.tar.gz' \
|
||||
--pattern 'dws-darwin-arm64.tar.gz' \
|
||||
--clobber
|
||||
|
||||
- name: Verify finalized Darwin signatures with Apple codesign
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for arch in amd64 arm64; do
|
||||
archive="dist/dws-darwin-${arch}.tar.gz"
|
||||
stage="$RUNNER_TEMP/verify-darwin-${arch}"
|
||||
mkdir -p "$stage"
|
||||
tar -xzf "$archive" -C "$stage"
|
||||
test -f "$stage/dws"
|
||||
codesign --verify --strict --verbose=4 "$stage/dws"
|
||||
codesign -dvvv "$stage/dws"
|
||||
done
|
||||
|
||||
publish-release:
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
|
||||
needs:
|
||||
- release
|
||||
- verify-darwin-signatures
|
||||
runs-on: ubuntu-latest
|
||||
# Mirroring every release asset to Gitee can be slow; 30 minutes previously
|
||||
# cut the fallback upload off mid-run.
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore finalized distribution files
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: finalized-release-dist
|
||||
path: dist
|
||||
|
||||
- name: Publish verified Draft release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false
|
||||
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
|
||||
|
||||
- name: Sync release to China OSS mirror
|
||||
# 自动同步到国内镜像,供 install.sh 的 DWS_RELEASE_BASE 开关消费。
|
||||
|
||||
@@ -42,8 +42,5 @@ dws.zip
|
||||
# 功能测试运行产物
|
||||
results.jsonl
|
||||
test/dev_functional/results.jsonl
|
||||
/auto-test/
|
||||
/eval-runs/
|
||||
/.qoder/
|
||||
.vercel
|
||||
.env*
|
||||
|
||||
+1
-3
@@ -67,9 +67,7 @@ release:
|
||||
# 用当前运行 CI 的仓库 owner: fork CI 发到 fork, 官方 CI 发到官方, 两边都对
|
||||
owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
|
||||
name: dingtalk-workspace-cli
|
||||
# Keep the release private until post-processing has replaced the Darwin
|
||||
# archives and verified every finalized asset digest.
|
||||
draft: true
|
||||
draft: false
|
||||
prerelease: auto
|
||||
name_template: "v{{.Version}}"
|
||||
mode: replace
|
||||
|
||||
@@ -8,41 +8,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
### Changed
|
||||
|
||||
- **`event consume` AI-subprocess contract** — aligns personal event streaming with the contract an orchestrator can drive without guessing: a fixed stderr ready line `[event] ready event_key=<key> bus_pid=<pid>` (block on it, don't `sleep`); a final `[event] exited — received N event(s) in Xs (reason: limit|timeout|signal|bus_shutdown)` line with exit code 0 on controlled exit and non-zero (no `exited` line) on failure; stdin-EOF as a graceful shutdown signal, armed only for a parent-controlled pipe stdin on an unbounded run (an interactive TTY and `< /dev/null` never trigger it), with a self-explaining diagnostic when it fires; and ownership-based subscription cleanup — a subscription this run created is unsubscribed on any clean exit while a `--subscribe-id`-reused one is left intact (`--ephemeral` still forces cleanup), so `kill -9` is the only way to leak a server-side subscription. Skill docs (mono + `dingtalk-event`) document the contract; design notes in `docs/event-subprocess-contract.md`.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws schema` for registered local commands** — `dws schema "event consume"` (or `event.consume`) now returns a machine-readable input schema synthesized from the command's cobra flags, in the same flat shape helper subtrees emit: `{description, path, source, parameters{<flag>:{type, required, description, default?}}}` plus an `arguments` array for positional inputs, with `source: "cobra"` distinguishing flag-synthesized schema from MCP-fetched (`mcp:<server>`). Intermediate nodes (`dws schema event`) list their subcommands. The mechanism is a reusable registry (`cobraSchemaRoots`); `event` is the first consumer and more command trees can opt in without further wiring. Inherited global flags and hidden internal flags are excluded so the schema describes just that command.
|
||||
- **Safe macOS Keychain → file-DEK migration** — `dws auth migrate-keychain --to file-dek` preflights every legacy/profile auth entry before rewriting, ignores unrelated application secrets, supports side-effect-free `--dry-run`, requires explicit `--yes`, and lets sandboxed and normal processes share an existing login without exposing tokens.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`dev connect` forwards complex message payloads and recoverable attachments** — messages such as forwarded chat records (`chatRecord`) are no longer discarded when they do not match the CLI's built-in text, image, or file extractors; their message type and raw JSON payload are passed to every Stream-bridge backend. Attachment discovery is based on locator capabilities (`downloadCode`, `mediaId`, `fileId`, or `dentryId` + `spaceId`) rather than a message-type allowlist, so new and unknown outer, rich-text, and nested record types retain their original bytes. Nested pictures, audio, video, and files are downloaded completely, including entries degraded by DingTalk to locator-free `unknownMsgType`: the connector acknowledges the Stream callback first, then resolves their original `mediaId` / `fileId` through the message APIs. Original bytes use each backend's native attachment path where available (OpenCode file parts, Gemini inline/Files API, Codex local images, Qoder attachments) or a narrowly allowed read-only local path for CLI/custom agents. For OpenCode, large videos are converted to a full-duration 12-frame storyboard before the model call, avoiding in-process base64 expansion and out-of-memory failures while preserving the fully downloaded original for the duration of the turn.
|
||||
- **Cross-platform auth regression coverage** — dedicated macOS CI now runs the Darwin-only auth/keychain regression suite with race detection, Windows CI builds and tests the native DPAPI path, and recovery guidance prefers safe migration or per-profile cleanup over destructive global reset.
|
||||
|
||||
## [1.0.51] - 2026-07-10
|
||||
|
||||
This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material.
|
||||
|
||||
### Added
|
||||
|
||||
- **Agoal product commands** (#585) — adds `dws agoal` strategy, contract, scorecard, user-objective, report, and objective-template command groups, together with static routing and the bundled mono/multi Agoal skills.
|
||||
- **Wukong chat command parity** (#585) — adds `chat group notice create|edit|get|list`, `group share-invite`, `text translate`, `category create-smart`, and `message list-emotion-replies`.
|
||||
- **Wukong document import commands** (#585) — adds `doc import` for starting imports and `doc import get` for querying import tasks.
|
||||
- **Wukong mail command parity** (#585) — adds mailbox profile, message batch-get, sent-message recall and recall-detail, auto-reply update, plus allow-list and block-list management.
|
||||
- **Wukong sheet grouping commands** (#585) — adds `sheet group-dimension` and `sheet ungroup-dimension` for whole-row or whole-column ranges.
|
||||
- **Keychain health diagnostics** (#578) — `dws doctor` now includes a keychain check, while `dws auth status` distinguishes ordinary logged-out state from `keychain_unavailable` and `dek_missing` failures and returns remediation hints in table and JSON output.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`dws pat chmod` defaults to permanent grants** (#584) — running `dws pat chmod <scope>` without `--grant-type` now requests a `permanent` grant instead of `session`, aligning the direct CLI path with the recommend-authorization helper. Session grants remain available by passing `--grant-type session --session-id <id>`.
|
||||
- **The `dev connect --channel gemini` path now uses the Gemini `generateContent` API** (#587) — configure it with `GEMINI_API_KEY` or `GOOGLE_API_KEY`, optionally override the compatible endpoint with `GEMINI_API_BASE_URL` or `GOOGLE_GEMINI_API_BASE_URL`, and select a model with `--agent-model` or `GEMINI_MODEL`; a local `gemini` executable is no longer required.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Non-blocking `dev connect` turn scheduling** (#587) — stream and `@`-poll callbacks no longer wait for the active turn to finish. Turns stay serialized per conversation, messages received mid-turn are coalesced into one pending follow-up, and different conversations can continue in parallel.
|
||||
- **Connect agent recovery and headless execution** (#587) — stale addressable sessions retry once with a fresh session, unsupported Qoder control requests receive an immediate response instead of hanging, OpenCode and bypass-mode channels receive non-interactive permission settings, and backend/API failures are no longer posted as successful assistant replies.
|
||||
- **Side-effect-free credential reads** (#578) — keychain reads inspect encrypted credential data before looking up the DEK and never generate a replacement key on a read path. Missing DEKs and unavailable macOS Keychains are surfaced as explicit diagnostic failures instead of silently mutating credential state.
|
||||
|
||||
## [1.0.50] - 2026-07-08
|
||||
|
||||
|
||||
@@ -258,16 +258,6 @@ dws --profile <name|corpId> contact user search --query "..." # run one comman
|
||||
|
||||
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list the profiles, run the query per org with `--profile`, then merge. Writes default to the current org only — confirm the target org before writing across orgs.
|
||||
|
||||
On macOS, an unreadable registered token slot blocks a new OAuth login rather than risking a mixed Keychain/file-DEK state. If normal terminal commands can still read the login while a sandbox using `DWS_DISABLE_KEYCHAIN=1` cannot, migrate the legacy and profile auth entries without exposing tokens:
|
||||
|
||||
```bash
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
|
||||
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
|
||||
```
|
||||
|
||||
The migration validates every selected auth ciphertext before writing, ignores unrelated application secrets, and can be rerun after an interrupted commit. If validation identifies genuinely damaged ciphertext, remove only the affected profile with `dws auth logout --profile <name|corpId>`, then log in again. Use `dws auth reset` only when you intend to discard every local profile.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -255,16 +255,6 @@ dws --profile <名称|corpId> contact user search --query "..." # 单次对指
|
||||
|
||||
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
|
||||
|
||||
macOS 下,如果已登记的 token slot 无法解密,为避免把系统 Keychain 和 file-DEK 写成混合状态,新的 OAuth 登录会直接拒绝。如果普通终端仍能读取登录态、只有设置 `DWS_DISABLE_KEYCHAIN=1` 的沙箱读不到,可在不暴露 token 的情况下迁移 legacy 与各 profile 的认证条目:
|
||||
|
||||
```bash
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
|
||||
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
|
||||
```
|
||||
|
||||
迁移会先验证全部认证密文再写入、忽略无关的应用密钥;提交中断后可安全重跑。如果预检确认是密文本身损坏,报错会给出对应 `corpId`;只清理这个组织可执行 `dws auth logout --profile <名称|corpId>`,再重新登录。只有确认要丢弃全部本地 profile 时才用 `dws auth reset`。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# cli_to_mcp smoke tests
|
||||
|
||||
This directory contains lightweight command-to-tool contract tests for hardcoded
|
||||
DWS commands synced from `dws-wukong`.
|
||||
|
||||
The tests do not call live DingTalk APIs. They exercise command help, validation,
|
||||
and `--dry-run` output so command paths and MCP argument mappings stay stable.
|
||||
|
||||
Run with an already built binary:
|
||||
|
||||
```bash
|
||||
DWS_BIN=/path/to/dws pytest auto-test/cli_to_mcp/testcases
|
||||
```
|
||||
|
||||
If `DWS_BIN` is not set, the runner falls back to `go run ./cmd` from the repo
|
||||
root.
|
||||
@@ -0,0 +1,193 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_agoal_strategy_and_contract_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"list",
|
||||
"--scope-type",
|
||||
"PERSONAL",
|
||||
"--scope-id",
|
||||
"user123",
|
||||
"--request-id",
|
||||
"req-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_strategy_decodings" in output
|
||||
assert dry_run_args(output) == {
|
||||
"scopeType": "PERSONAL",
|
||||
"openId": "user123",
|
||||
"requestId": "req-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"update",
|
||||
"--profile-id",
|
||||
"profile123",
|
||||
"--content",
|
||||
'[{"id":"e1","title":{"title":"new"}}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_strategy_decoding" in output
|
||||
assert dry_run_args(output) == {
|
||||
"profileId": "profile123",
|
||||
"content": [{"id": "e1", "title": {"title": "new"}}],
|
||||
}
|
||||
|
||||
output = assert_ok(dws.run_raw("agoal", "contract", "fields", "--dry-run"))
|
||||
assert "list_op_contract_fields" in output
|
||||
assert dry_run_args(output) == {}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"contract",
|
||||
"update",
|
||||
"--contract-id",
|
||||
"contract123",
|
||||
"--dimensions",
|
||||
'[{"id":"dim1","title":"metric"}]',
|
||||
"--audit-config",
|
||||
'{"needAudit":true}',
|
||||
"--objective-template",
|
||||
'{"id":"tpl1"}',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_op_contract" in output
|
||||
assert dry_run_args(output) == {
|
||||
"contractId": "contract123",
|
||||
"dimensions": [{"id": "dim1", "title": "metric"}],
|
||||
"auditConfig": '{"needAudit":true}',
|
||||
"objectiveTemplate": '{"id":"tpl1"}',
|
||||
}
|
||||
|
||||
|
||||
def test_agoal_scorecard_user_report_template_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"detail",
|
||||
"--selected-time",
|
||||
"2026-01-01T00:00:00+08:00",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_score_card_detail" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["deptId"] == "dept123"
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"update",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--selected-time",
|
||||
"2026-01-01",
|
||||
"--id",
|
||||
"sc123",
|
||||
"--tracking-period-type",
|
||||
"MONTHLY",
|
||||
"--content",
|
||||
'[{"id":"dim1","items":[]}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_score_card" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
assert args["content"] == [{"id": "dim1", "items": []}]
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"user",
|
||||
"objectives",
|
||||
"--user-id",
|
||||
"user123",
|
||||
"--rule-id",
|
||||
"rule123",
|
||||
"--period-ids",
|
||||
"p1,p2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_user_objectives" in output
|
||||
assert dry_run_args(output) == {
|
||||
"dingUserId": "user123",
|
||||
"objectiveRuleId": "rule123",
|
||||
"periodIds": ["p1", "p2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"report",
|
||||
"submit-detail",
|
||||
"--template-id",
|
||||
"tpl123",
|
||||
"--submit-state",
|
||||
"LATE",
|
||||
"--query-date",
|
||||
"2026-06-18T00:00:00+08:00",
|
||||
"--page",
|
||||
"1",
|
||||
"--page-size",
|
||||
"20",
|
||||
"--keyword",
|
||||
"alice",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_submit_detail" in output
|
||||
assert dry_run_args(output) == {
|
||||
"templateId": "tpl123",
|
||||
"submitState": "LATE",
|
||||
"queryDate": "2026-06-18",
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"keyword": "alice",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"obj-template",
|
||||
"create-or-update",
|
||||
"--title",
|
||||
"tpl",
|
||||
"--dimensions",
|
||||
'[{"title":"dim"}]',
|
||||
"--objective-weight",
|
||||
"--dimension-weight",
|
||||
"--compute-by-weight",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_or_update_obj_template" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "tpl",
|
||||
"dimensions": '[{"title":"dim"}]',
|
||||
"objectiveWeight": True,
|
||||
"dimensionWeight": True,
|
||||
"computeByWeight": True,
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_notice_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"create",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--content",
|
||||
"maintenance tonight",
|
||||
"--sticky",
|
||||
"--send-ding",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"content": "maintenance tonight",
|
||||
"sticky": True,
|
||||
"sendDing": True,
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"edit",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--content",
|
||||
"updated",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "edit_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
"content": "updated",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"get",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"list",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--limit",
|
||||
"20",
|
||||
"--cursor",
|
||||
"next",
|
||||
"--scheduled",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_group_notices" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"limit": 20,
|
||||
"cursor": "next",
|
||||
"scheduled": True,
|
||||
}
|
||||
|
||||
|
||||
def test_chat_misc_new_commands_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"share-invite",
|
||||
"--source",
|
||||
"sourceCid",
|
||||
"--target",
|
||||
"targetCid",
|
||||
"--expires-seconds",
|
||||
"3600",
|
||||
"--uuid",
|
||||
"uuid-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "share_group_invite_url" in output
|
||||
assert dry_run_args(output) == {
|
||||
"sourceOpenConversationId": "sourceCid",
|
||||
"targetOpenConversationId": "targetCid",
|
||||
"expiresSeconds": 3600,
|
||||
"uuid": "uuid-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("chat", "text", "translate", "--query", "hello", "--to", "zh_CN", "--dry-run")
|
||||
)
|
||||
assert "translate" in output
|
||||
assert dry_run_args(output) == {"query": "hello", "to": "zh_CN"}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"category",
|
||||
"create-smart",
|
||||
"--name",
|
||||
"priority",
|
||||
"--keywords",
|
||||
"alpha,beta",
|
||||
"--members",
|
||||
"uid1,uid2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_smart_conv_category" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "priority",
|
||||
"keywords": ["alpha", "beta"],
|
||||
"memberOpenDingTalkIds": ["uid1", "uid2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"message",
|
||||
"list-emotion-replies",
|
||||
"--msg-ids",
|
||||
"msg1,msg2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_message_emotion_replies" in output
|
||||
assert dry_run_args(output) == {"openMessageIds": ["msg1", "msg2"]}
|
||||
@@ -0,0 +1,8 @@
|
||||
import pytest
|
||||
|
||||
from test_utils import DWSRunner
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def dws():
|
||||
return DWSRunner()
|
||||
@@ -0,0 +1,54 @@
|
||||
from test_utils import combined_output
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_doc_import_help_and_validation(dws, tmp_path):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "--help"))
|
||||
assert "dws doc import" in output
|
||||
assert "--file string" in output
|
||||
assert "--workspace string" in output
|
||||
assert "--name string" in output
|
||||
|
||||
result = dws.run_raw("doc", "import", "--file", str(tmp_path / "missing.md"), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "cannot read file" in output
|
||||
|
||||
bad = tmp_path / "bad.exe"
|
||||
bad.write_text("bad", encoding="utf-8")
|
||||
result = dws.run_raw("doc", "import", "--file", str(bad), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "unsupported file format" in output
|
||||
|
||||
|
||||
def test_doc_import_dry_run(dws, tmp_path):
|
||||
source = tmp_path / "sample.md"
|
||||
source.write_text("# Sample\n\nhello\n", encoding="utf-8")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"doc",
|
||||
"import",
|
||||
"--file",
|
||||
str(source),
|
||||
"--name",
|
||||
"Imported Sample",
|
||||
"--workspace",
|
||||
"workspace123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "Imported Sample" in output
|
||||
assert "sample.md" in output
|
||||
assert "md" in output
|
||||
|
||||
|
||||
def test_doc_import_get_dry_run(dws):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "get", "--task-id", "task123", "--dry-run"))
|
||||
assert "task123" in output
|
||||
@@ -0,0 +1,143 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def assert_fails(result, expected: str):
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0, output
|
||||
assert expected in output
|
||||
|
||||
|
||||
def test_mailbox_profile_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "mailbox", "profile", "--help"))
|
||||
assert "dws mail mailbox profile" in output
|
||||
assert "--email string" in output
|
||||
|
||||
assert_fails(dws.run_raw("mail", "mailbox", "profile"), "email")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("mail", "mailbox", "profile", "--email", mail_email(), "--dry-run")
|
||||
)
|
||||
assert "get_mailbox_profile" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
|
||||
def test_message_batch_get_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "message", "batch-get", "--help"))
|
||||
assert "dws mail message batch-get" in output
|
||||
assert "--email string" in output
|
||||
assert "--ids string" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw("mail", "message", "batch-get", "--email", mail_email()),
|
||||
"ids",
|
||||
)
|
||||
|
||||
too_many_ids = ",".join(f"msg_{i:02d}" for i in range(21))
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
too_many_ids,
|
||||
"--dry-run",
|
||||
),
|
||||
"20",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
"msg_001,msg_002",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_email_by_message_id" in output
|
||||
assert "msg_001" in output
|
||||
assert "msg_002" in output
|
||||
|
||||
|
||||
def test_sent_message_recall_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall", "--help"))
|
||||
assert "dws mail sent-message recall" in output
|
||||
assert "--subject string" in output
|
||||
assert "--yes" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
),
|
||||
"--yes",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "recall_sent_message" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"id": "msg_001",
|
||||
"subject": "subject",
|
||||
}
|
||||
|
||||
|
||||
def test_sent_message_recall_detail_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall-detail", "--help"))
|
||||
assert "dws mail sent-message recall-detail" in output
|
||||
assert "--email string" in output
|
||||
assert "--id string" in output
|
||||
assert "FINISHED" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall-detail",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"task_001",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_recall_detail" in output
|
||||
assert dry_run_args(output) == {"email": mail_email(), "id": "task_001"}
|
||||
@@ -0,0 +1,133 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_auto_reply_update_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "auto-reply", "update", "--help"))
|
||||
for flag in ("--email string", "--enabled string", "--start string", "--end string", "--scope string", "--content string"):
|
||||
assert flag in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"auto-reply",
|
||||
"update",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--enabled",
|
||||
"true",
|
||||
"--start",
|
||||
"2026/07/01 09:00:00 +0800",
|
||||
"--end",
|
||||
"2026/07/07 18:00:00 +0800",
|
||||
"--scope",
|
||||
"all",
|
||||
"--content",
|
||||
"out of office",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_auto_reply" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"enabled": True,
|
||||
"startTime": "2026/07/01 09:00:00 +0800",
|
||||
"endTime": "2026/07/07 18:00:00 +0800",
|
||||
"scope": "all",
|
||||
"content": "out of office",
|
||||
}
|
||||
|
||||
|
||||
def test_allow_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "allow-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com,@example.org",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com", "@example.org"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com"],
|
||||
}
|
||||
|
||||
|
||||
def test_block_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "block-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com,@junk.example",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com", "@junk.example"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com"],
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "group-dimension", "--help"))
|
||||
assert "dws sheet group-dimension" in output
|
||||
assert "--group-state string" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"fold",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "group_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "3:7",
|
||||
"groupState": "fold",
|
||||
}
|
||||
|
||||
|
||||
def test_ungroup_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "ungroup-dimension", "--help"))
|
||||
assert "dws sheet ungroup-dimension" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"ungroup-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"C:F",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "ungroup_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "C:F",
|
||||
}
|
||||
|
||||
|
||||
def test_group_dimension_rejects_invalid_state(dws):
|
||||
result = dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"invalid",
|
||||
"--dry-run",
|
||||
)
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "group-state" in output
|
||||
@@ -0,0 +1,58 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root(start_file: str) -> Path:
|
||||
current = Path(start_file).resolve()
|
||||
for parent in [current, *current.parents]:
|
||||
if (parent / "go.mod").exists():
|
||||
return parent
|
||||
raise RuntimeError(f"cannot locate repo root from {start_file}")
|
||||
|
||||
|
||||
def resolve_dws_cmd(start_file: str) -> list[str]:
|
||||
root = repo_root(start_file)
|
||||
if env_bin := os.environ.get("DWS_BIN"):
|
||||
return shlex.split(env_bin)
|
||||
|
||||
for rel in ("dws", "build/dws", "bin/dws", "dingtalk-workspace-cli"):
|
||||
candidate = root / rel
|
||||
if candidate.exists() and os.access(candidate, os.X_OK):
|
||||
return [str(candidate)]
|
||||
|
||||
return ["go", "run", "./cmd"]
|
||||
|
||||
|
||||
def combined_output(result: subprocess.CompletedProcess) -> str:
|
||||
return (result.stdout or "") + (result.stderr or "")
|
||||
|
||||
|
||||
def dry_run_args(output: str) -> dict:
|
||||
match = re.search(r"Arguments:\s*(\{.*\})", output, re.S)
|
||||
assert match, f"dry-run output does not contain Arguments JSON: {output}"
|
||||
return json.loads(match.group(1))
|
||||
|
||||
|
||||
class DWSRunner:
|
||||
def __init__(self):
|
||||
self.root = repo_root(__file__)
|
||||
self.cmd = resolve_dws_cmd(__file__)
|
||||
|
||||
def run_raw(self, *args: str, timeout: int = 45) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[*self.cmd, *args],
|
||||
cwd=self.root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def run(self, *args: str, timeout: int = 45):
|
||||
result = self.run_raw(*args, timeout=timeout)
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return json.loads(result.stdout)
|
||||
@@ -1,115 +0,0 @@
|
||||
# Event consume — AI subprocess contract
|
||||
|
||||
Aligns `dws event consume` with the "AI subprocess contract" that
|
||||
`lark-cli event consume` exposes, so any orchestrator (Claude Code's
|
||||
Monitor, a bash bridge, systemd, an agent plugin) can drive it with zero
|
||||
ambiguity: know when it is ready, stop it cleanly, and machine-read why it
|
||||
exited.
|
||||
|
||||
Scope of this branch: the four **contract** items below. Reconnect
|
||||
resilience (keeping the stream alive across a transient upstream drop) is
|
||||
tracked separately and intentionally out of scope here.
|
||||
|
||||
## Baseline (already present, no work)
|
||||
|
||||
- `--max-events N` — stop after N events (exit 0).
|
||||
- `--duration D` — wall-clock budget (exit 0). Kept as `--duration`, NOT
|
||||
aliased to `--timeout`: the global `--timeout` is the HTTP request
|
||||
timeout (int seconds) and would collide (different type and meaning).
|
||||
Docs note the lark-cli name difference.
|
||||
- Bus idle-shutdown fires only with **zero** consumers, so a connected
|
||||
consumer is never idle-killed.
|
||||
- SIGINT/SIGTERM already cancel the run context and return cleanly.
|
||||
|
||||
## Improvements
|
||||
|
||||
### 1. Ready marker (standardized)
|
||||
|
||||
On connect, emit a fixed stderr line **before** any stdout event:
|
||||
|
||||
```
|
||||
[event] ready event_key=<key> bus_pid=<pid>
|
||||
```
|
||||
|
||||
Parents block on stderr until this line, then read stdout. Suppressed
|
||||
under `--quiet`. Replaces the ad-hoc `connected bus pid=...` line (which
|
||||
omits `event_key`).
|
||||
|
||||
**Verification**
|
||||
- T1a: stderr contains a line matching `^\[event\] ready event_key=<key>`.
|
||||
- T1b: that line appears before the first stdout event (ordering).
|
||||
- T1c: with `--quiet`, the line is absent.
|
||||
|
||||
### 2. stdin EOF = graceful exit
|
||||
|
||||
`consume` watches stdin; closing stdin is a shutdown signal (wired for AI
|
||||
subprocess callers). To stay resident, feed a never-EOF stdin
|
||||
(`< <(tail -f /dev/null)`) or run bounded (`--max-events` / `--duration`).
|
||||
|
||||
**Verification**
|
||||
- T2a: `printf '' | dws event consume <key>` exits ≤2s, code 0, final
|
||||
line `reason: signal` (stdin-eof classified as signal).
|
||||
- T2b: `dws event consume <key> < <(tail -f /dev/null)` still alive after
|
||||
5s, connection intact.
|
||||
- T2c (unit): a controllable stdin reader hitting EOF makes Run return nil
|
||||
via the cleanup path.
|
||||
|
||||
### 3. Exit reason contract + exit codes
|
||||
|
||||
On exit, final stderr line:
|
||||
|
||||
```
|
||||
[event] exited — received N event(s) in Xs (reason: <limit|timeout|signal|bus_shutdown>)
|
||||
```
|
||||
|
||||
Exit codes: controlled exit (limit/timeout/signal/stdin-eof) = 0; startup
|
||||
or runtime failure (permissions, network, params) = non-zero, with no
|
||||
`exited` line and an `Error:` line instead.
|
||||
|
||||
**Verification**
|
||||
- T3a: `--max-events 1` + 1 event → exit 0, reason=`limit`, N=1.
|
||||
- T3b: `--duration 2s`, no events → exit 0, reason=`timeout`.
|
||||
- T3c: SIGTERM mid-run → exit 0, reason=`signal`.
|
||||
- T3d: bad params / permission failure → exit≠0, no `exited` line, has `Error:`.
|
||||
- Unit tests assert (reason string, exit code) for each path.
|
||||
|
||||
### 4. Cleanup on exit (no `kill -9`)
|
||||
|
||||
Ownership-based, matching lark-cli:
|
||||
- If this run **created** the subscription (no `--subscribe-id`), a clean
|
||||
exit (SIGTERM / SIGINT / stdin-EOF / limit / timeout) **unsubscribes**
|
||||
it server-side and sends Bye.
|
||||
- If `--subscribe-id` was passed (reusing an existing subscription), the
|
||||
subscription is **left intact** — the caller owns its lifecycle.
|
||||
- `--ephemeral` remains as an explicit "always unsubscribe" override.
|
||||
- Help/docs warn: avoid `kill -9` (skips the unsubscribe → leaked
|
||||
server-side subscription: "subscription already exists" on restart,
|
||||
duplicate delivery). Prefer SIGTERM or closing stdin.
|
||||
|
||||
**Verification**
|
||||
- T4a: start consume (self-created subscription), record subscribe_id;
|
||||
SIGTERM; afterwards `dws event status` no longer lists that subscribe_id
|
||||
and the server-side subscription is gone.
|
||||
- T4b: start consume with `--subscribe-id <existing>`; SIGTERM; the
|
||||
subscription is still present (reuse case preserved).
|
||||
- T4c (control): `kill -9` leaves subscribe_id lingering (documented risk;
|
||||
we only guarantee SIGTERM is clean, we do not fix kill -9 itself).
|
||||
|
||||
## Out of scope (next branch)
|
||||
|
||||
**Reconnect resilience** — today `personal source` retries only
|
||||
`retryable` errors (1–30s backoff); a non-retryable error tears the bus
|
||||
down and takes consume with it (the likely cause of the observed silent
|
||||
drop). Making more drops retryable, keeping the bus alive across a
|
||||
reconnect, and emitting `reason: source_lost` only after exhausting the
|
||||
budget — tracked on its own branch, since it needs error-classification
|
||||
judgement and real flaky-network testing, and would otherwise couple clean
|
||||
contract work with resilience work.
|
||||
|
||||
## Test surface
|
||||
|
||||
- Unit: extend `internal/event/consume/*_test.go` with fake bus conn /
|
||||
stdin / stderr sink for T1c, T2c, T3 (all paths), T4 ownership branch.
|
||||
- Integration/e2e: `--foreground` + mock source (or a short real run) for
|
||||
T1a/b, T2a/b, T3a–d, T4a/b/c — assert the stderr contract lines and exit
|
||||
codes.
|
||||
@@ -13,7 +13,7 @@ require (
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
golang.org/x/crypto v0.49.0
|
||||
|
||||
@@ -88,8 +88,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
|
||||
@@ -82,7 +82,6 @@ func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
cmd.AddCommand(
|
||||
newAuthLogoutCommand(),
|
||||
newAuthStatusCommand(),
|
||||
newAuthMigrateKeychainCommand(),
|
||||
newAuthExportCommand(),
|
||||
newAuthImportCommand(),
|
||||
newAuthExchangeCommand(),
|
||||
@@ -284,7 +283,6 @@ var (
|
||||
loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
|
||||
loginRecommendProductSelector = selectLoginRecommendProducts
|
||||
authLoginInteractiveTerminal = isInteractiveTerminal
|
||||
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
|
||||
)
|
||||
|
||||
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
|
||||
@@ -523,65 +521,6 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newAuthMigrateKeychainCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "migrate-keychain",
|
||||
Short: "将 macOS 系统 Keychain 登录态安全迁移到 file-DEK",
|
||||
Long: `将 dws-cli 的 legacy 与 profile 登录 token 统一重加密为 file-DEK,使 Codex 等沙箱进程与普通终端共享同一登录态。
|
||||
|
||||
迁移必须从仍可读取原登录态的系统 Keychain 模式运行。命令会先验证全部认证密文;任何认证条目不可解密时均不会写入。应用密钥等无关条目不在迁移范围内。
|
||||
先用 --dry-run 预检,确认后加 --yes 执行。`,
|
||||
Example: ` env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
target, err := cmd.Flags().GetString("to")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --to")
|
||||
}
|
||||
if strings.TrimSpace(target) != "file-dek" {
|
||||
return apperrors.NewValidation("--to 当前仅支持 file-dek")
|
||||
}
|
||||
if os.Getenv(keychain.DisableKeychainEnv) != "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"迁移必须从系统 Keychain 模式运行;请使用 `env -u %s dws auth migrate-keychain --to file-dek ...`",
|
||||
keychain.DisableKeychainEnv,
|
||||
))
|
||||
}
|
||||
|
||||
dryRun, _ := cmd.Root().PersistentFlags().GetBool("dry-run")
|
||||
yes, _ := cmd.Root().PersistentFlags().GetBool("yes")
|
||||
if !dryRun && !yes {
|
||||
return apperrors.NewValidation("迁移会重加密全部本地登录 token;请先使用 --dry-run 预检,确认后加 --yes 执行")
|
||||
}
|
||||
count, err := migrateKeychainToFileDEK(defaultConfigDir(), dryRun)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("keychain migration failed: %v", err))
|
||||
}
|
||||
|
||||
result := struct {
|
||||
Success bool `json:"success"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Target string `json:"target"`
|
||||
Entries int `json:"entries"`
|
||||
}{Success: true, DryRun: dryRun, Target: "file-dek", Entries: count}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(result)
|
||||
}
|
||||
if dryRun {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "预检通过:%d 个本地认证条目可迁移到 file-DEK\n", count)
|
||||
} else {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "迁移完成:%d 个本地认证条目已统一使用 file-DEK\n", count)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("to", "file-dek", "目标密钥后端(当前仅支持 file-dek)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
|
||||
if _, err := authpkg.ResolveProfile(configDir, selector); err != nil {
|
||||
return apperrors.NewValidation(err.Error())
|
||||
@@ -657,7 +596,7 @@ func newAuthExportCommand() *cobra.Command {
|
||||
}
|
||||
if !authpkg.PortableExportSupported() {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录",
|
||||
"macOS 默认将 DEK 存在系统 Keychain,导出的包无法在其它机器解密;请设置 %s=1 后重新登录再导出",
|
||||
keychain.DisableKeychainEnv,
|
||||
))
|
||||
}
|
||||
@@ -1290,18 +1229,11 @@ func authStatusDiagnosticFromError(err error) *authStatusDiagnostic {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if keychain.IsCiphertextKeyMismatch(err) {
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "ciphertext_key_mismatch",
|
||||
Message: "本地登录态与可用登录密钥不匹配,已拒绝覆盖现有凭证",
|
||||
Hint: "macOS 请先在系统 Keychain 模式运行 `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`,预检通过后加 --yes 迁移;只有密文损坏且确认无法恢复时才按 profile 退出或执行 auth reset。",
|
||||
}
|
||||
}
|
||||
if keychain.IsDEKMissing(err) {
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "dek_missing",
|
||||
Message: "本地登录密钥缺失,无法解密已保存的登录态",
|
||||
Hint: "请先恢复或统一原登录密钥;确认旧登录态不可恢复后,执行 dws auth reset,再重新登录。",
|
||||
Hint: "重新登录以生成新的本地登录密钥;如仍异常,可先清理本地登录态后再登录。",
|
||||
}
|
||||
}
|
||||
if !keychain.IsUnavailable(err) {
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -233,22 +232,6 @@ func TestAuthStatusJSONReportsDEKMissing(t *testing.T) {
|
||||
if !strings.Contains(resp.Hint, "重新登录") {
|
||||
t.Fatalf("hint should mention 重新登录; response=%+v", resp)
|
||||
}
|
||||
if !strings.Contains(resp.Hint, "dws auth reset") {
|
||||
t.Fatalf("hint should mention dws auth reset; response=%+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusDiagnosticReportsCiphertextKeyMismatch(t *testing.T) {
|
||||
diagnostic := authStatusDiagnosticFromError(fmt.Errorf("load token: %w", keychain.ErrCiphertextKeyMismatch))
|
||||
if diagnostic == nil {
|
||||
t.Fatal("authStatusDiagnosticFromError() = nil")
|
||||
}
|
||||
if diagnostic.Reason != "ciphertext_key_mismatch" {
|
||||
t.Fatalf("reason = %q, want ciphertext_key_mismatch", diagnostic.Reason)
|
||||
}
|
||||
if !strings.Contains(diagnostic.Hint, keychain.DisableKeychainEnv) {
|
||||
t.Fatalf("hint should mention %s: %q", keychain.DisableKeychainEnv, diagnostic.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
@@ -352,91 +335,6 @@ func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMigrateKeychainDryRunAndConfirmedExecution(t *testing.T) {
|
||||
t.Setenv(keychain.DisableKeychainEnv, "")
|
||||
oldMigrate := migrateKeychainToFileDEK
|
||||
t.Cleanup(func() { migrateKeychainToFileDEK = oldMigrate })
|
||||
|
||||
calls := 0
|
||||
migrateKeychainToFileDEK = func(_ string, dryRun bool) (int, error) {
|
||||
calls++
|
||||
if calls == 1 && !dryRun {
|
||||
t.Fatal("first migration call should be dry-run")
|
||||
}
|
||||
if calls == 2 && dryRun {
|
||||
t.Fatal("second migration call should execute")
|
||||
}
|
||||
return 4, nil
|
||||
}
|
||||
|
||||
newRoot := func() (*cobra.Command, *bytes.Buffer) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(newAuthMigrateKeychainCommand())
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
return root, &out
|
||||
}
|
||||
|
||||
root, out := newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("migrate-keychain --dry-run error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"dry_run":true`) || !strings.Contains(out.String(), `"entries":4`) {
|
||||
t.Fatalf("dry-run output = %q", out.String())
|
||||
}
|
||||
|
||||
root, out = newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("migrate-keychain --yes error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), `"dry_run":false`) || !strings.Contains(out.String(), `"entries":4`) {
|
||||
t.Fatalf("migration output = %q", out.String())
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("migration calls = %d, want 2", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMigrateKeychainRequiresConfirmationAndSystemMode(t *testing.T) {
|
||||
oldMigrate := migrateKeychainToFileDEK
|
||||
t.Cleanup(func() { migrateKeychainToFileDEK = oldMigrate })
|
||||
migrateKeychainToFileDEK = func(_ string, _ bool) (int, error) {
|
||||
t.Fatal("migration backend should not be called")
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
newRoot := func() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(newAuthMigrateKeychainCommand())
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
return root
|
||||
}
|
||||
|
||||
t.Setenv(keychain.DisableKeychainEnv, "")
|
||||
root := newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("unconfirmed migration error = %v, want --yes guidance", err)
|
||||
}
|
||||
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
root = newRoot()
|
||||
root.SetArgs([]string{"migrate-keychain", "--dry-run"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "env -u") {
|
||||
t.Fatalf("file-DEK mode migration error = %v, want system-mode guidance", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLogoutDefaultDeletesAllProfilesAndPreservesAppConfig(t *testing.T) {
|
||||
configDir := setupAuthLogoutProfiles(t,
|
||||
authLogoutTestToken("corp_primary"),
|
||||
|
||||
@@ -101,9 +101,7 @@ func newEventConsumeCommand() *cobra.Command {
|
||||
raw 仅 SDK 原始 payload,无外层封装
|
||||
compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)
|
||||
|
||||
默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加
|
||||
--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用
|
||||
SIGTERM、关 stdin,或 dws event stop <subscribe_id>,不要 kill -9。
|
||||
默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接。
|
||||
--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费
|
||||
通常不需要设置。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
@@ -216,11 +214,6 @@ SIGTERM、关 stdin,或 dws event stop <subscribe_id>,不要 kill -9。
|
||||
DryRun: dryRun,
|
||||
SpawnExtraArgs: streamOpts.spawnArgs(),
|
||||
}
|
||||
// Arm the stdin-EOF shutdown watcher only for a pipe-style,
|
||||
// unbounded run (see shouldWatchStdinEOF).
|
||||
if shouldWatchStdinEOF(maxEvents, duration) {
|
||||
cfg.Stdin = c.InOrStdin()
|
||||
}
|
||||
|
||||
// Step 5: validation (flag-only rules).
|
||||
if err := consume.ValidateConfig(cfg); err != nil {
|
||||
@@ -267,7 +260,7 @@ SIGTERM、关 stdin,或 dws event stop <subscribe_id>,不要 kill -9。
|
||||
f.BoolVar(&dryRun, "dry-run", false,
|
||||
"仅打印解析后的配置,不连接 bus / 云端")
|
||||
f.BoolVar(&foreground, "foreground", false,
|
||||
"当前进程直接跑 bus 服务、不 fork、不打印事件(给 systemd/k8s 托管用);读事件不要用它")
|
||||
"不 fork daemon,当前进程跑 bus (systemd/k8s/launchd 友好)")
|
||||
f.StringVar(&personalOpts.SubscribeID, "subscribe-id", "",
|
||||
"个人事件订阅 ID;传入后复用已有订阅")
|
||||
f.StringVar(&personalOpts.Rule, "rule", "",
|
||||
@@ -281,10 +274,7 @@ SIGTERM、关 stdin,或 dws event stop <subscribe_id>,不要 kill -9。
|
||||
f.DurationVar(&personalOpts.TTL, "ttl", 0,
|
||||
"个人订阅 TTL (Go duration,如 24h;0 表示不过期)")
|
||||
f.BoolVar(&personalOpts.Ephemeral, "ephemeral", false,
|
||||
"强制退出时取消个人订阅。默认已按归属清理:本次新建的订阅退出即取消,"+
|
||||
"用 --subscribe-id 复用的订阅保留。优雅停可用 SIGTERM、关闭 stdin,"+
|
||||
"或从外部 dws event stop <subscribe_id>(会一并退订);"+
|
||||
"请勿 kill -9(会跳过退订、泄漏服务端订阅)")
|
||||
"consume 退出时自动取消个人订阅")
|
||||
f.StringVar(&personalOpts.UserID, "user", "",
|
||||
"个人单聊对端 userId")
|
||||
f.StringVar(&personalOpts.GroupID, "group", "",
|
||||
@@ -467,13 +457,7 @@ func newEventBusCommand() *cobra.Command {
|
||||
readyPipe := busctl.ReadyFDFromEnv()
|
||||
failEarly := func(err error) error {
|
||||
if readyPipe != nil {
|
||||
// 'E' signals failure; the trailing text lets the parent
|
||||
// (busctl.waitReady) surface the real startup error to the
|
||||
// user instead of an opaque "startup failure on ready pipe".
|
||||
_, _ = readyPipe.Write([]byte{'E'})
|
||||
if err != nil {
|
||||
_, _ = io.WriteString(readyPipe, err.Error())
|
||||
}
|
||||
_ = readyPipe.Close()
|
||||
}
|
||||
return err
|
||||
@@ -1109,26 +1093,6 @@ func firstArg(args []string) string {
|
||||
return args[0]
|
||||
}
|
||||
|
||||
// shouldWatchStdinEOF gates the stdin-EOF shutdown watcher (AI-subprocess
|
||||
// contract). It arms only for a parent-controlled, pipe-style stdin on an
|
||||
// unbounded run:
|
||||
// - bounded runs (--max-events / --duration) already have their own
|
||||
// lifecycle, so stdin is irrelevant;
|
||||
// - char devices (an interactive TTY, or /dev/null) are excluded, so a
|
||||
// terminal Ctrl-D and the common `< /dev/null` launch do NOT trigger a
|
||||
// surprise shutdown. Only a pipe / regular file — an stdin a parent
|
||||
// holds and can close to stop us — arms the watcher.
|
||||
func shouldWatchStdinEOF(maxEvents int, duration time.Duration) bool {
|
||||
if maxEvents > 0 || duration > 0 {
|
||||
return false
|
||||
}
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fi.Mode()&os.ModeCharDevice == 0
|
||||
}
|
||||
|
||||
// eventTypesWithDefault picks the catch-all list from registry when the
|
||||
// user did not pass --event-types.
|
||||
func eventTypesWithDefault(types []string) []string {
|
||||
|
||||
@@ -204,7 +204,6 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: opts.EventKey,
|
||||
Format: normalised,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
@@ -240,14 +239,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
_ = client.DeleteSubscription(context.Background(), sub.SubscribeID)
|
||||
_ = personal.RemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
}
|
||||
// Ownership-based cleanup (AI-subprocess contract, aligned with
|
||||
// lark-cli): a subscription this run CREATED is unsubscribed on exit
|
||||
// (any exit — SIGTERM / stdin-EOF / limit / timeout / error), so nothing
|
||||
// leaks server-side. A subscription REUSED via --subscribe-id is left
|
||||
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
|
||||
// either way.
|
||||
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
|
||||
if opts.Ephemeral || selfCreated {
|
||||
if opts.Ephemeral {
|
||||
defer cleanup()
|
||||
}
|
||||
|
||||
@@ -259,7 +251,6 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: eventKey,
|
||||
Format: normalised,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
@@ -269,11 +260,6 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
}
|
||||
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
|
||||
// run (see shouldWatchStdinEOF).
|
||||
if shouldWatchStdinEOF(opts.Common.MaxEvents, opts.Common.Duration) {
|
||||
cfg.Stdin = c.InOrStdin()
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
|
||||
if opts.DebugRawEvents && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
|
||||
@@ -770,16 +756,6 @@ func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL stri
|
||||
"--source-kind", string(dwsevent.SourceKindPersonalStream),
|
||||
"--stream-source-id", identity.SourceID,
|
||||
}
|
||||
// Forward the organization so the detached _bus child resolves
|
||||
// credentials for the SAME profile the parent used. Without this the
|
||||
// child falls back to the default profile's token slot and fails to
|
||||
// authenticate the personal stream for a non-default `--profile`
|
||||
// (symptom: "bus child reported startup failure on ready pipe", no
|
||||
// bus.log). --profile accepts a corpId; the root pre-parses it into the
|
||||
// runtime profile before the _bus handler resolves the identity.
|
||||
if cid := strings.TrimSpace(identity.CorpID); cid != "" {
|
||||
args = append(args, "--profile", cid)
|
||||
}
|
||||
if strings.TrimSpace(ticketMode) != "" {
|
||||
args = append(args, "--stream-ticket-mode", ticketMode)
|
||||
}
|
||||
|
||||
@@ -1,64 +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 (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
)
|
||||
|
||||
// A bounded run never arms the stdin-EOF watcher, regardless of stdin
|
||||
// shape: --max-events / --duration are the lifecycle control.
|
||||
func TestShouldWatchStdinEOF_BoundedIsNeverArmed(t *testing.T) {
|
||||
if shouldWatchStdinEOF(1, 0) {
|
||||
t.Error("--max-events set should not arm stdin watcher")
|
||||
}
|
||||
if shouldWatchStdinEOF(0, 5*time.Second) {
|
||||
t.Error("--duration set should not arm stdin watcher")
|
||||
}
|
||||
if shouldWatchStdinEOF(3, 2*time.Second) {
|
||||
t.Error("both bounds set should not arm stdin watcher")
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: the detached _bus child must receive --profile so it resolves
|
||||
// credentials for the same organization as the parent. Missing it made a
|
||||
// non-default `--profile` consume fail with "bus child reported startup
|
||||
// failure on ready pipe" (no bus.log).
|
||||
func TestPersonalBusSpawnArgs_ForwardsProfile(t *testing.T) {
|
||||
args := personalBusSpawnArgs(personal.Identity{
|
||||
CorpID: "dinga626d60c1128d449",
|
||||
SourceID: "open",
|
||||
}, "", "")
|
||||
found := false
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == "--profile" && args[i+1] == "dinga626d60c1128d449" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("spawn args must forward --profile <corpId>; got %v", args)
|
||||
}
|
||||
|
||||
// No CorpID → no --profile appended (avoid an empty flag value).
|
||||
bare := personalBusSpawnArgs(personal.Identity{SourceID: "open"}, "", "")
|
||||
for _, a := range bare {
|
||||
if a == "--profile" {
|
||||
t.Errorf("must not append --profile when CorpID is empty; got %v", bare)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,10 +163,6 @@ func (p *DeviceFlowProvider) resetCredentialState() {
|
||||
}
|
||||
|
||||
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
|
||||
if err := preflightTokenPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
|
||||
p.clientID = runtimeClientID
|
||||
clientMu.Lock()
|
||||
|
||||
@@ -1,28 +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 "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
|
||||
// MigrateKeychainToFileDEK serializes migration with profile/token updates so
|
||||
// refresh and login cannot rewrite an entry while its DEK backend is changing.
|
||||
func MigrateKeychainToFileDEK(configDir string, dryRun bool) (int, error) {
|
||||
var migrated int
|
||||
err := withProfilesLock(configDir, func() error {
|
||||
var err error
|
||||
migrated, err = keychain.MigrateToFileDEK(keychain.Service, dryRun)
|
||||
return err
|
||||
})
|
||||
return migrated, err
|
||||
}
|
||||
@@ -15,14 +15,12 @@ package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -30,9 +28,6 @@ var (
|
||||
migrationDone bool
|
||||
)
|
||||
|
||||
// ErrTokenDataNotFound means the requested keychain slot does not exist.
|
||||
var ErrTokenDataNotFound = errors.New("token data not found")
|
||||
|
||||
// SaveTokenDataKeychain saves TokenData to the platform keychain.
|
||||
// This is the new secure storage method using random master key.
|
||||
func SaveTokenDataKeychain(data *TokenData) error {
|
||||
@@ -91,7 +86,7 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
|
||||
return nil, fmt.Errorf("load from keychain: %w", err)
|
||||
}
|
||||
if jsonStr == "" {
|
||||
return nil, fmt.Errorf("%w in keychain account %q", ErrTokenDataNotFound, account)
|
||||
return nil, fmt.Errorf("no token data in keychain account %q", account)
|
||||
}
|
||||
|
||||
var data TokenData
|
||||
@@ -101,72 +96,6 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// preflightTokenPersistence verifies that every registered token slot can be
|
||||
// read before an OAuth login or exchange can target any profile.
|
||||
// A missing slot is safe (first login or a legacy fallback); any other error
|
||||
// stops the remote operation when existing ciphertext is already known to be
|
||||
// unreadable and therefore unsafe to update.
|
||||
func preflightTokenPersistence(configDir string) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
|
||||
}
|
||||
|
||||
cfg, err := LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load token profiles: %w", err)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(cfg.Profiles))
|
||||
for _, profile := range cfg.Profiles {
|
||||
corpID := strings.TrimSpace(profile.CorpID)
|
||||
if corpID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[corpID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[corpID] = struct{}{}
|
||||
|
||||
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf(
|
||||
"profile token slot %q is unreadable; on macOS first try `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`; if the ciphertext is damaged, remove only this profile with `dws auth logout --profile %q`, or use `dws auth reset` only when discarding all local profiles: %w",
|
||||
TokenAccountForCorpID(corpID), corpID, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
if err := keychain.ValidateAuthTokenEntries(keychain.Service); err != nil {
|
||||
return fmt.Errorf(
|
||||
"auth token ciphertext inventory is unreadable; on macOS first try `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`; if the ciphertext is damaged, use `dws auth reset` only when discarding all local profiles: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// preflightTokenRefreshPersistence checks only the slots a refresh can write.
|
||||
// An unrelated broken profile must not prevent the current profile from using
|
||||
// its still-valid credentials.
|
||||
func preflightTokenRefreshPersistence(data *TokenData) error {
|
||||
if h := edition.Get(); h.SaveToken != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := LoadTokenDataKeychain(); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("legacy token slot %q is unreadable: %w", keychain.AccountToken, err)
|
||||
}
|
||||
if data == nil || strings.TrimSpace(data.CorpID) == "" {
|
||||
return nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
if _, err := LoadTokenDataKeychainForCorpID(corpID); err != nil && !errors.Is(err, ErrTokenDataNotFound) {
|
||||
return fmt.Errorf("profile token slot %q is unreadable: %w", TokenAccountForCorpID(corpID), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTokenDataKeychain removes TokenData from the platform keychain.
|
||||
func DeleteTokenDataKeychain() error {
|
||||
return keychain.Remove(keychain.Service, keychain.AccountToken)
|
||||
|
||||
@@ -27,15 +27,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) {
|
||||
if err := preflightTokenPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
// Use MCP mode if clientID is from MCP server
|
||||
if IsClientIDFromMCP() {
|
||||
return p.exchangeCodeViaMCP(ctx, code)
|
||||
|
||||
@@ -110,9 +110,6 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := preflightTokenPersistence(p.configDir); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
// Fall through: full browser OAuth flow.
|
||||
if runtimeClientID, _, ok := getCompleteRuntimeCredentials(); ok {
|
||||
@@ -626,9 +623,6 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
||||
if !data.IsRefreshTokenValid() {
|
||||
return nil, fmt.Errorf("refresh_token 已过期")
|
||||
}
|
||||
if err := preflightTokenRefreshPersistence(data); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", i18n.T("本地登录态无法安全更新"), err)
|
||||
}
|
||||
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("refreshing token (dual-locked)")
|
||||
|
||||
@@ -67,11 +67,7 @@ func PortableAuthTargetPopulated(configDir string) bool {
|
||||
|
||||
// PortableAuthSourceReady reports whether encrypted auth token exists for export.
|
||||
func PortableAuthSourceReady() bool {
|
||||
if !portableAuthSourcePopulated(keychain.StorageDir(keychain.Service)) {
|
||||
return false
|
||||
}
|
||||
_, err := LoadTokenDataKeychain()
|
||||
return err == nil
|
||||
return portableAuthSourcePopulated(keychain.StorageDir(keychain.Service))
|
||||
}
|
||||
|
||||
func portableAuthSourcePopulated(keychainDir string) bool {
|
||||
@@ -97,7 +93,7 @@ func ExportPortableAuthBundle(configDir string, w io.Writer) error {
|
||||
return fmt.Errorf("missing output writer")
|
||||
}
|
||||
if !PortableExportSupported() {
|
||||
return fmt.Errorf("portable export requires file-DEK mode on macOS; set %s=1 and verify auth first, resetting and re-logging in only if the existing token cannot be decrypted", keychain.DisableKeychainEnv)
|
||||
return fmt.Errorf("portable export unavailable on macOS while DEK is in system Keychain; set %s=1, re-login, then export", keychain.DisableKeychainEnv)
|
||||
}
|
||||
keychainDir := keychain.StorageDir(keychain.Service)
|
||||
if _, err := os.Stat(keychainDir); err != nil {
|
||||
@@ -106,9 +102,6 @@ func ExportPortableAuthBundle(configDir string, w io.Writer) error {
|
||||
if !portableAuthSourcePopulated(keychainDir) {
|
||||
return fmt.Errorf("auth token is not available for export; run dws auth login first")
|
||||
}
|
||||
if _, err := LoadTokenDataKeychain(); err != nil {
|
||||
return fmt.Errorf("auth token cannot be decrypted with the portable file DEK: %w", err)
|
||||
}
|
||||
|
||||
gz := gzip.NewWriter(w)
|
||||
defer gz.Close()
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -188,7 +187,7 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
|
||||
if err == nil {
|
||||
return data, nil
|
||||
}
|
||||
if strings.TrimSpace(profile) != "" || !errors.Is(err, ErrTokenDataNotFound) {
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
return nil, err
|
||||
}
|
||||
// No explicit --profile: `selected` is the resolved current/primary
|
||||
@@ -198,8 +197,6 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
|
||||
if legacy, lerr := LoadTokenDataKeychain(); lerr == nil && legacy != nil &&
|
||||
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) {
|
||||
return legacy, nil
|
||||
} else if lerr != nil && !errors.Is(lerr, ErrTokenDataNotFound) {
|
||||
return nil, lerr
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,376 +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 darwin
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
type preflightRoundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f preflightRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func seedUnreadableTokenStorage(t *testing.T, configDir string, data *TokenData) {
|
||||
t.Helper()
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x7f}, 32), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func setPreflightTestCredentials(t *testing.T) {
|
||||
t.Helper()
|
||||
SetClientID("preflight-client-id")
|
||||
SetClientSecret("preflight-client-secret")
|
||||
resetClientIDFromMCP()
|
||||
t.Cleanup(func() {
|
||||
SetClientID("")
|
||||
SetClientSecret("")
|
||||
resetClientIDFromMCP()
|
||||
})
|
||||
}
|
||||
|
||||
func profileCiphertextPathForTest(corpID string) string {
|
||||
account := strings.ReplaceAll(TokenAccountForCorpID(corpID), ":", "_")
|
||||
return filepath.Join(keychain.StorageDir(keychain.Service), account+".enc")
|
||||
}
|
||||
|
||||
func TestLoadTokenDataFallsBackToLegacyOnlyWhenCurrentSlotIsMissing(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_fallback", "corp_fallback", "Fallback Org")
|
||||
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if err := DeleteTokenDataKeychainForCorpID(data.CorpID); err != nil {
|
||||
t.Fatalf("DeleteTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
if err := preflightTokenPersistence(configDir); err != nil {
|
||||
t.Fatalf("preflightTokenPersistence() with missing profile slot error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenData() error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != data.AccessToken {
|
||||
t.Fatalf("fallback access token = %q, want %q", loaded.AccessToken, data.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTokenDataDoesNotHideUnreadableCurrentSlotWithLegacyFallback(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_unreadable", "corp_unreadable", "Unreadable Org")
|
||||
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadTokenData(configDir)
|
||||
if err == nil {
|
||||
t.Fatalf("LoadTokenData() = %#v, nil; want unreadable profile error", loaded)
|
||||
}
|
||||
if loaded != nil {
|
||||
t.Fatalf("LoadTokenData() data = %#v, want nil", loaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightTokenPersistenceAllowsEmptyStorageWithoutCreatingDEK(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
|
||||
if err := preflightTokenPersistence(configDir); err != nil {
|
||||
t.Fatalf("preflightTokenPersistence() error = %v", err)
|
||||
}
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if _, err := os.Stat(dekPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("preflight created a DEK at %q; stat error = %v", dekPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreflightTokenPersistenceRejectsUnreadableProfileSlot(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_preflight", "corp_preflight", "Preflight Org")
|
||||
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(profileCiphertextPathForTest(data.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(profile ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
err := preflightTokenPersistence(configDir)
|
||||
if err == nil || !strings.Contains(err.Error(), "profile token slot") {
|
||||
t.Fatalf("preflightTokenPersistence() error = %v, want unreadable profile slot", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "dws auth logout --profile \""+data.CorpID+"\"") {
|
||||
t.Fatalf("preflightTokenPersistence() error = %v, want per-profile recovery hint", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodePreflightsOrphanProfileCiphertextBeforeHTTP(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_orphan", "corp_orphan", "Orphan Org")
|
||||
|
||||
// Simulate interruption after the profile ciphertext rename but before
|
||||
// profiles.json is updated by saveTokenDataLocked.
|
||||
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, profilesJSONFile)); !os.IsNotExist(err) {
|
||||
t.Fatalf("profiles.json stat error = %v, want missing metadata", err)
|
||||
}
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x6f}, 32), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
|
||||
}
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unexpected HTTP request")
|
||||
})}
|
||||
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "auth token ciphertext inventory") {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want orphan ciphertext preflight error", err)
|
||||
}
|
||||
if !keychain.IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("HTTP calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortableAuthExportRejectsCiphertextFromAnotherDEK(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_portable", "", "")
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
if !PortableAuthSourceReady() {
|
||||
t.Fatal("PortableAuthSourceReady() = false before replacing DEK")
|
||||
}
|
||||
|
||||
dekPath := filepath.Join(keychain.StorageDir(keychain.Service), "dek")
|
||||
if err := os.WriteFile(dekPath, bytes.Repeat([]byte{0x7f}, 32), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(replacement DEK) error = %v", err)
|
||||
}
|
||||
if PortableAuthSourceReady() {
|
||||
t.Fatal("PortableAuthSourceReady() = true for ciphertext from another DEK")
|
||||
}
|
||||
var bundle bytes.Buffer
|
||||
if err := ExportPortableAuthBundle(configDir, &bundle); err == nil {
|
||||
t.Fatal("ExportPortableAuthBundle() error = nil for ciphertext from another DEK")
|
||||
}
|
||||
if bundle.Len() != 0 {
|
||||
t.Fatalf("ExportPortableAuthBundle() wrote %d bytes, want 0", bundle.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshPreflightIgnoresUnreadableUnrelatedProfile(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
configDir := t.TempDir()
|
||||
dataA := testToken("at_a", "corp_a", "A Org")
|
||||
dataB := testToken("at_b", "corp_b", "B Org")
|
||||
if err := SaveTokenData(configDir, dataA); err != nil {
|
||||
t.Fatalf("SaveTokenData(A) error = %v", err)
|
||||
}
|
||||
if err := SaveTokenData(configDir, dataB); err != nil {
|
||||
t.Fatalf("SaveTokenData(B) error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(profileCiphertextPathForTest(dataA.CorpID), []byte("corrupt ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(A profile ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
if err := preflightTokenRefreshPersistence(dataB); err != nil {
|
||||
t.Fatalf("preflightTokenRefreshPersistence(B) error = %v", err)
|
||||
}
|
||||
loaded, err := NewOAuthProvider(configDir, nil).Login(context.Background(), false)
|
||||
if err != nil {
|
||||
t.Fatalf("Login() with valid B and unreadable A error = %v", err)
|
||||
}
|
||||
if loaded.AccessToken != dataB.AccessToken {
|
||||
t.Fatalf("Login() access token = %q, want %q", loaded.AccessToken, dataB.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthLoginPreflightsTokenPersistence(t *testing.T) {
|
||||
setPreflightTestCredentials(t)
|
||||
for _, force := range []bool{false, true} {
|
||||
t.Run("force="+map[bool]string{false: "false", true: "true"}[force], func(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
configDir := t.TempDir()
|
||||
seedUnreadableTokenStorage(t, configDir, testToken("at_login", "corp_login", "Login Org"))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.NoBrowser = true
|
||||
_, err := provider.Login(ctx, force)
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("Login(force=%v) error = %v, want token persistence preflight error", force, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodePreflightsBeforeHTTP(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
seedUnreadableTokenStorage(t, configDir, testToken("at_exchange", "corp_exchange", "Exchange Org"))
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unexpected HTTP request")
|
||||
})}
|
||||
_, err := provider.ExchangeAuthCode(context.Background(), "auth-code", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want token persistence preflight error", err)
|
||||
}
|
||||
if !keychain.IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v, want ciphertext key mismatch in error chain", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("HTTP calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceFlowLoginPreflightsBeforeDeviceCodeRequest(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
seedUnreadableTokenStorage(t, configDir, testToken("at_device", "corp_device", "Device Org"))
|
||||
|
||||
var calls atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls.Add(1)
|
||||
http.Error(w, "unexpected device code request", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
provider := NewDeviceFlowProvider(configDir, nil)
|
||||
provider.Output = io.Discard
|
||||
provider.SetBaseURL(server.URL)
|
||||
_, err := provider.Login(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("DeviceFlowProvider.Login() error = %v, want token persistence preflight error", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("device code requests = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockedRefreshPreflightsLegacyMirrorBeforeHTTP(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
data := testToken("at_refresh", "corp_refresh", "Refresh Org")
|
||||
data.ExpiresAt = time.Now().Add(-time.Hour)
|
||||
if err := SaveTokenData(configDir, data); err != nil {
|
||||
t.Fatalf("SaveTokenData() error = %v", err)
|
||||
}
|
||||
legacyPath := filepath.Join(keychain.StorageDir(keychain.Service), keychain.AccountToken+".enc")
|
||||
if err := os.WriteFile(legacyPath, []byte("corrupt legacy ciphertext"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(legacy ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return nil, errors.New("unexpected refresh request")
|
||||
})}
|
||||
_, err := provider.lockedRefresh(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "legacy token slot") {
|
||||
t.Fatalf("lockedRefresh() error = %v, want token persistence preflight error", err)
|
||||
}
|
||||
if got := calls.Load(); got != 0 {
|
||||
t.Fatalf("refresh HTTP calls = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExchangeAuthCodeAllowsFirstLogin(t *testing.T) {
|
||||
cleanupKeychain(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
setPreflightTestCredentials(t)
|
||||
configDir := t.TempDir()
|
||||
|
||||
var calls atomic.Int32
|
||||
provider := NewOAuthProvider(configDir, nil)
|
||||
provider.Output = io.Discard
|
||||
provider.httpClient = &http.Client{Transport: preflightRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
calls.Add(1)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp_new"}`,
|
||||
)),
|
||||
}, nil
|
||||
})}
|
||||
|
||||
data, err := provider.ExchangeAuthCode(context.Background(), "new-code", "user-new")
|
||||
if err != nil {
|
||||
t.Fatalf("ExchangeAuthCode() error = %v", err)
|
||||
}
|
||||
if data.AccessToken != "new-access" || data.UserID != "user-new" {
|
||||
t.Fatalf("ExchangeAuthCode() data = %#v", data)
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Fatalf("HTTP calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
@@ -75,10 +75,7 @@ func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Com
|
||||
Short: "查看有限的本地 Schema(静态端点模式)",
|
||||
Long: `查看有限的本地 Schema 元数据。
|
||||
|
||||
服务发现和动态 schema 已下线。静态端点模式下,schema 覆盖两类命令:
|
||||
1. helper-only 子树(如 dev):CONTENT 从其绑定的 MCP 服务实时取,source 为 mcp:<server>;
|
||||
2. 登记的本地命令(如 event):从二进制注册的 cobra flag 合成,source 为 cobra。
|
||||
其余普通产品命令和 flag 仍以当前二进制的 --help 为准。`,
|
||||
服务发现和动态 schema 已下线。静态端点模式下,仅支持 helper-only 子树的 schema 查询;普通产品命令和 flag 以当前二进制的 --help 为准。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -91,7 +88,7 @@ func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Com
|
||||
args = []string{cliPath}
|
||||
}
|
||||
|
||||
// Helper-only subtrees: schema CONTENT fetched live from the MCP server.
|
||||
// Helper-only subtrees support.
|
||||
if len(args) > 0 && helperTools != nil {
|
||||
payload, ok, err := renderHelperSchema(cmd.Context(), cmd.Root(), args[0], helperTools)
|
||||
if err != nil {
|
||||
@@ -104,19 +101,6 @@ func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Com
|
||||
}
|
||||
}
|
||||
|
||||
// Registered local subtrees (event, …): schema synthesized from cobra flags.
|
||||
if len(args) > 0 {
|
||||
payload, ok, err := renderCobraSchema(cmd.Root(), args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ok {
|
||||
data, _ := json.MarshalIndent(payload, "", " ")
|
||||
fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintln(cmd.OutOrStdout(), `{"kind":"schema","count":0,"products":[],"note":"static endpoint mode"}`)
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -1,204 +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 cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// cobraSchemaRoots are top-level command names whose subtrees answer `dws schema`
|
||||
// by SYNTHESIZING the machine-readable input schema from their cobra flags. This
|
||||
// is the local-command counterpart to helperSchemaRoots: helper subtrees (dev)
|
||||
// fetch CONTENT live from an MCP server, whereas these commands have no MCP
|
||||
// backing so the schema is built from the flags the binary actually registered.
|
||||
//
|
||||
// The output shape is the same flat object helper leaves emit — {description,
|
||||
// path, source, parameters{<flag>:{type,description,required,default?}}} — so an
|
||||
// agent gets one consistent schema format no matter the source; `source` is
|
||||
// "cobra" to mark it synthesized from flags (vs "mcp:<server>"). event is the
|
||||
// first consumer; register more command trees here as they adopt the contract.
|
||||
var cobraSchemaRoots = map[string]bool{"event": true}
|
||||
|
||||
// renderCobraSchema builds the `dws schema` payload for command subtrees listed
|
||||
// in cobraSchemaRoots. Mirrors renderHelperSchema's routing: returns
|
||||
// (payload, true) when the path targets a registered subtree so the caller
|
||||
// skips the static-mode fallback; (nil, false) otherwise.
|
||||
//
|
||||
// A runnable leaf renders the flat parameter object synthesized from its flags
|
||||
// (plus positional arguments parsed from its Use line). A group/root renders the
|
||||
// same browse listing helper groups use.
|
||||
func renderCobraSchema(root *cobra.Command, rawPath string) (map[string]any, bool, error) {
|
||||
if root == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
tokens := splitSchemaPathTokens(rawPath)
|
||||
if len(tokens) == 0 || !cobraSchemaRoots[tokens[0]] {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
target, rest, err := root.Find(tokens)
|
||||
if err != nil || target == nil {
|
||||
target = root
|
||||
rest = tokens[1:]
|
||||
}
|
||||
// Any non-flag leftover token means an unknown subcommand — surface it with
|
||||
// the closest group's children, same as renderHelperSchema.
|
||||
if unknown := firstNonFlag(rest); unknown != "" {
|
||||
return map[string]any{
|
||||
"path": rawPath,
|
||||
"error": "unknown subcommand \"" + unknown + "\" under \"" + helperCommandPath(target) + "\"",
|
||||
"available": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
if target.Runnable() && !target.HasAvailableSubCommands() {
|
||||
return cobraLeafSchema(target), true, nil
|
||||
}
|
||||
return map[string]any{
|
||||
"path": helperCommandPath(target),
|
||||
"commands": helperSubcommands(target),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
// cobraLeafSchema renders one runnable command as the flat schema object,
|
||||
// synthesizing parameters from its flags and (when present) positional
|
||||
// arguments from its Use line.
|
||||
func cobraLeafSchema(cmd *cobra.Command) map[string]any {
|
||||
out := map[string]any{
|
||||
"description": strings.TrimSpace(cmd.Short),
|
||||
"path": helperCommandPath(cmd),
|
||||
"source": "cobra",
|
||||
"parameters": cobraFlatParameters(cmd),
|
||||
}
|
||||
if args := cobraPositionalArgs(cmd); len(args) > 0 {
|
||||
out["arguments"] = args
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cobraFlatParameters projects a command's LOCAL flags into the flat
|
||||
// per-parameter object. Local (non-inherited) flags are the command-specific
|
||||
// inputs; global persistent flags inherited from the root (--profile, --verbose,
|
||||
// --jq, …) are intentionally excluded so the schema describes THIS command, not
|
||||
// the whole CLI. Hidden internal flags are skipped. Each entry is
|
||||
// {type, description, required, default?} with type mapped to a JSON-type
|
||||
// string, required read from cobra's required-flag annotation, and default only
|
||||
// when the flag has a meaningful (non-zero) default.
|
||||
func cobraFlatParameters(cmd *cobra.Command) map[string]any {
|
||||
params := map[string]any{}
|
||||
cmd.LocalFlags().VisitAll(func(f *pflag.Flag) {
|
||||
if f.Hidden {
|
||||
return
|
||||
}
|
||||
entry := map[string]any{
|
||||
"type": pflagJSONType(f),
|
||||
"description": strings.TrimSpace(f.Usage),
|
||||
"required": flagIsRequired(f),
|
||||
}
|
||||
if def, ok := meaningfulDefault(f); ok {
|
||||
entry["default"] = def
|
||||
}
|
||||
params[f.Name] = entry
|
||||
})
|
||||
return params
|
||||
}
|
||||
|
||||
// cobraPositionalArgs parses a command's Use line into structured positional
|
||||
// arguments. Cobra has no typed metadata for positionals, so the Use string
|
||||
// ("consume [event_key]", "stop [subscribe_id]") is the source of truth:
|
||||
// tokens after the command name are positional slots. <name> is required,
|
||||
// [name] is optional, a trailing "..." marks it variadic. Returns nil when the
|
||||
// command declares no positionals (e.g. flag-only commands), so the leaf object
|
||||
// simply omits the "arguments" field.
|
||||
func cobraPositionalArgs(cmd *cobra.Command) []map[string]any {
|
||||
fields := strings.Fields(cmd.Use)
|
||||
if len(fields) <= 1 {
|
||||
return nil
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for _, tok := range fields[1:] {
|
||||
variadic := strings.Contains(tok, "...")
|
||||
required := strings.HasPrefix(tok, "<")
|
||||
name := strings.Trim(tok, "[]<>.")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
arg := map[string]any{
|
||||
"name": name,
|
||||
"required": required,
|
||||
}
|
||||
if variadic {
|
||||
arg["variadic"] = true
|
||||
}
|
||||
out = append(out, arg)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pflagJSONType maps a pflag value type to a JSON-type string. Duration is
|
||||
// expressed as "string" because it is entered as a CLI string ("10m"); slice
|
||||
// and array types collapse to "array"; the numeric families collapse to
|
||||
// "integer"/"number". Unknown types default to "string" so the contract is
|
||||
// always populated.
|
||||
func pflagJSONType(f *pflag.Flag) string {
|
||||
switch f.Value.Type() {
|
||||
case "bool":
|
||||
return "boolean"
|
||||
case "int", "int8", "int16", "int32", "int64",
|
||||
"uint", "uint8", "uint16", "uint32", "uint64", "count":
|
||||
return "integer"
|
||||
case "float32", "float64":
|
||||
return "number"
|
||||
case "stringSlice", "stringArray", "intSlice", "int32Slice", "int64Slice",
|
||||
"uintSlice", "float32Slice", "float64Slice", "boolSlice", "durationSlice":
|
||||
return "array"
|
||||
default:
|
||||
// string, duration, ip, and any custom Value type read as a string.
|
||||
return "string"
|
||||
}
|
||||
}
|
||||
|
||||
// flagIsRequired reports whether cobra.MarkFlagRequired was applied to the flag
|
||||
// (it records the requirement in the flag's annotations under
|
||||
// cobra.BashCompOneRequiredFlag). Event's conditionally-required inputs
|
||||
// (--user / --group depend on the event key) are enforced at runtime, not via
|
||||
// this annotation, so they read as required:false here — the dependency is
|
||||
// documented in the command help, not the flag metadata.
|
||||
func flagIsRequired(f *pflag.Flag) bool {
|
||||
if f.Annotations == nil {
|
||||
return false
|
||||
}
|
||||
vals, ok := f.Annotations[cobra.BashCompOneRequiredFlag]
|
||||
return ok && len(vals) == 1 && vals[0] == "true"
|
||||
}
|
||||
|
||||
// meaningfulDefault returns a flag's default only when it is a real value, not
|
||||
// the zero/unset sentinel ("", "0", "0s", "false", "[]"). A zero default means
|
||||
// "no default — the value comes from you", so omitting it matches how the helper
|
||||
// renderer omits absent MCP defaults and keeps the schema free of noise.
|
||||
func meaningfulDefault(f *pflag.Flag) (string, bool) {
|
||||
def := strings.TrimSpace(f.DefValue)
|
||||
switch def {
|
||||
case "", "0", "0s", "false", "[]", "{}":
|
||||
return "", false
|
||||
default:
|
||||
return def, true
|
||||
}
|
||||
}
|
||||
@@ -1,227 +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 cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// buildEventTestTree mirrors the shape of the real `dws event` subtree closely
|
||||
// enough to exercise the cobra-flag schema renderer: a group with a runnable
|
||||
// leaf that carries a positional arg, typed flags (string/int/duration/bool),
|
||||
// a required flag, a hidden internal flag, and a defaulted flag.
|
||||
func buildEventTestTree() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
// A global persistent flag inherited by every command — must NOT appear in a
|
||||
// command's synthesized parameters (it describes the CLI, not the command).
|
||||
root.PersistentFlags().String("profile", "", "组织 profile")
|
||||
|
||||
consume := &cobra.Command{
|
||||
Use: "consume [event_key]",
|
||||
Short: "订阅事件流并输出到 stdout",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
f := consume.Flags()
|
||||
f.StringP("format", "f", "ndjson", "输出格式")
|
||||
f.String("user", "", "单聊对端 userId")
|
||||
f.String("group", "", "群 openConversationId")
|
||||
f.Int("max-events", 0, "收到 N 条后退出")
|
||||
f.Duration("duration", 0, "运行时长上限")
|
||||
f.Bool("ephemeral", false, "退出时强制退订")
|
||||
f.String("subscribe-id", "", "复用已有订阅")
|
||||
f.String("client-id", "", "内部:覆盖凭证解析")
|
||||
_ = f.MarkHidden("client-id")
|
||||
// A flag marked required via cobra — must read required:true.
|
||||
f.String("token", "", "必填令牌")
|
||||
_ = consume.MarkFlagRequired("token")
|
||||
|
||||
stop := &cobra.Command{
|
||||
Use: "stop [subscribe_id]",
|
||||
Short: "取消订阅",
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
}
|
||||
stop.Flags().Bool("all", false, "取消全部")
|
||||
|
||||
event := &cobra.Command{Use: "event", Short: "个人消息事件"}
|
||||
// A hidden internal subcommand — must not appear in the browse listing.
|
||||
bus := &cobra.Command{Use: "_bus", Short: "内部 bus", Hidden: true, Run: func(*cobra.Command, []string) {}}
|
||||
event.AddCommand(consume, stop, bus)
|
||||
|
||||
root.AddCommand(event)
|
||||
return root
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_LeafFlatShape(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
|
||||
payload, ok, err := renderCobraSchema(root, "event consume")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected cobra renderer to claim the event path")
|
||||
}
|
||||
|
||||
if payload["description"] != "订阅事件流并输出到 stdout" {
|
||||
t.Fatalf("description = %v", payload["description"])
|
||||
}
|
||||
if payload["path"] != "event consume" {
|
||||
t.Fatalf("path = %v", payload["path"])
|
||||
}
|
||||
if payload["source"] != "cobra" {
|
||||
t.Fatalf("source = %v, want cobra", payload["source"])
|
||||
}
|
||||
|
||||
params, _ := payload["parameters"].(map[string]any)
|
||||
if params == nil {
|
||||
t.Fatalf("no parameters: %#v", payload)
|
||||
}
|
||||
|
||||
// Inherited global flag must be excluded.
|
||||
if _, present := params["profile"]; present {
|
||||
t.Fatal("inherited --profile must not appear in a command's parameters")
|
||||
}
|
||||
// Hidden internal flag must be excluded.
|
||||
if _, present := params["client-id"]; present {
|
||||
t.Fatal("hidden --client-id must not appear")
|
||||
}
|
||||
|
||||
// Type mapping.
|
||||
if got := paramField(t, params, "user", "type"); got != "string" {
|
||||
t.Errorf("user type = %v, want string", got)
|
||||
}
|
||||
if got := paramField(t, params, "max-events", "type"); got != "integer" {
|
||||
t.Errorf("max-events type = %v, want integer", got)
|
||||
}
|
||||
if got := paramField(t, params, "duration", "type"); got != "string" {
|
||||
t.Errorf("duration type = %v, want string (CLI string like 10m)", got)
|
||||
}
|
||||
if got := paramField(t, params, "ephemeral", "type"); got != "boolean" {
|
||||
t.Errorf("ephemeral type = %v, want boolean", got)
|
||||
}
|
||||
|
||||
// Meaningful default is surfaced; zero defaults are omitted.
|
||||
if got := paramField(t, params, "format", "default"); got != "ndjson" {
|
||||
t.Errorf("format default = %v, want ndjson", got)
|
||||
}
|
||||
if _, hasDefault := params["max-events"].(map[string]any)["default"]; hasDefault {
|
||||
t.Error("max-events has a zero default (0) — must be omitted")
|
||||
}
|
||||
if _, hasDefault := params["ephemeral"].(map[string]any)["default"]; hasDefault {
|
||||
t.Error("ephemeral has a zero default (false) — must be omitted")
|
||||
}
|
||||
if _, hasDefault := params["duration"].(map[string]any)["default"]; hasDefault {
|
||||
t.Error("duration has a zero default (0s) — must be omitted")
|
||||
}
|
||||
|
||||
// Required annotation is honored; unmarked flags read required:false.
|
||||
if got := paramField(t, params, "token", "required"); got != true {
|
||||
t.Errorf("token required = %v, want true", got)
|
||||
}
|
||||
if got := paramField(t, params, "user", "required"); got != false {
|
||||
t.Errorf("user required = %v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_PositionalArguments(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
|
||||
payload, _, err := renderCobraSchema(root, "event.consume")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
args, _ := payload["arguments"].([]map[string]any)
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("arguments = %#v, want 1 positional", payload["arguments"])
|
||||
}
|
||||
if args[0]["name"] != "event_key" {
|
||||
t.Errorf("arg name = %v, want event_key", args[0]["name"])
|
||||
}
|
||||
// [event_key] is optional syntax → required:false.
|
||||
if args[0]["required"] != false {
|
||||
t.Errorf("arg required = %v, want false", args[0]["required"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_DotAndSpacePathEquivalent(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
dotted, _, _ := renderCobraSchema(root, "event.consume")
|
||||
spaced, _, _ := renderCobraSchema(root, "event consume")
|
||||
if dotted["path"] != spaced["path"] || dotted["path"] != "event consume" {
|
||||
t.Fatalf("dot/space forms diverged: %v vs %v", dotted["path"], spaced["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_GroupBrowse(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
|
||||
payload, ok, err := renderCobraSchema(root, "event")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
cmds, _ := payload["commands"].([]map[string]any)
|
||||
// consume + stop; hidden _bus excluded.
|
||||
if len(cmds) != 2 {
|
||||
t.Fatalf("commands = %#v, want 2 (hidden _bus excluded)", cmds)
|
||||
}
|
||||
for _, c := range cmds {
|
||||
if c["cli_path"] == "event _bus" {
|
||||
t.Fatal("hidden _bus must not be listed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_UnknownSubcommand(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
payload, ok, err := renderCobraSchema(root, "event nope")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected claim")
|
||||
}
|
||||
if payload["error"] == nil {
|
||||
t.Fatalf("expected unknown-subcommand error, got %#v", payload)
|
||||
}
|
||||
if avail, _ := payload["available"].([]map[string]any); len(avail) == 0 {
|
||||
t.Fatal("expected available subcommands listed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCobraSchema_NonRegisteredPathDeclined(t *testing.T) {
|
||||
root := buildEventTestTree()
|
||||
if _, ok, _ := renderCobraSchema(root, "dev app create"); ok {
|
||||
t.Fatal("non-registered path must not be claimed by the cobra renderer")
|
||||
}
|
||||
if _, ok, _ := renderCobraSchema(root, "ding.message.send"); ok {
|
||||
t.Fatal("non-registered path must not be claimed")
|
||||
}
|
||||
}
|
||||
|
||||
// paramField fetches params[<name>][<field>], failing the test if the param is
|
||||
// absent.
|
||||
func paramField(t *testing.T, params map[string]any, name, field string) any {
|
||||
t.Helper()
|
||||
p, _ := params[name].(map[string]any)
|
||||
if p == nil {
|
||||
t.Fatalf("missing param %q in %#v", name, params)
|
||||
}
|
||||
return p[field]
|
||||
}
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -137,15 +136,11 @@ func Spawn(cfg SpawnConfig) (pid int, err error) {
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
// waitReady reads the child's ready signal within ReadyTimeout. The first
|
||||
// byte is 'R' (ready) or 'E' (error); on 'E' the child may write its real
|
||||
// error text after the byte and close the pipe, which we surface so the
|
||||
// caller sees WHY the bus failed to start (instead of an opaque
|
||||
// ErrSpawnFailed). pr is closed by the caller on return.
|
||||
// waitReady reads exactly one byte from pr ('R' or 'E') within ReadyTimeout.
|
||||
// pr is closed by the caller on return.
|
||||
func waitReady(pr *os.File) error {
|
||||
type result struct {
|
||||
b byte
|
||||
msg string
|
||||
err error
|
||||
}
|
||||
done := make(chan result, 1)
|
||||
@@ -160,13 +155,6 @@ func waitReady(pr *os.File) error {
|
||||
done <- result{err: io.ErrUnexpectedEOF}
|
||||
return
|
||||
}
|
||||
if buf[0] == 'E' {
|
||||
// Failure: read the trailing error text (bounded), which the
|
||||
// child writes right after 'E' before closing.
|
||||
rest, _ := io.ReadAll(io.LimitReader(pr, 4096))
|
||||
done <- result{b: 'E', msg: strings.TrimSpace(string(rest))}
|
||||
return
|
||||
}
|
||||
done <- result{b: buf[0]}
|
||||
}()
|
||||
select {
|
||||
@@ -181,9 +169,6 @@ func waitReady(pr *os.File) error {
|
||||
case 'R':
|
||||
return nil
|
||||
case 'E':
|
||||
if res.msg != "" {
|
||||
return fmt.Errorf("%w: %s", ErrSpawnFailed, res.msg)
|
||||
}
|
||||
return ErrSpawnFailed
|
||||
default:
|
||||
return fmt.Errorf("busctl: unexpected ready byte %q", res.b)
|
||||
|
||||
@@ -15,11 +15,9 @@ package busctl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -248,45 +246,3 @@ func TestReadyFDFromEnv_LowFDRejected(t *testing.T) {
|
||||
t.Errorf("ReadyFDFromEnv should reject stdio fds, got %v", f)
|
||||
}
|
||||
}
|
||||
|
||||
// waitReady must surface the child's real startup error (written after the
|
||||
// 'E' byte) so consume shows WHY the bus failed, not an opaque message.
|
||||
func TestWaitReady_FailureSurfacesChildError(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses Unix pipe")
|
||||
}
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pr.Close()
|
||||
const reason = "newPersonalStreamSource: token expired for corp dinga626"
|
||||
go func() {
|
||||
_, _ = pw.Write([]byte{'E'})
|
||||
_, _ = io.WriteString(pw, reason)
|
||||
_ = pw.Close()
|
||||
}()
|
||||
err = waitReady(pr)
|
||||
if !errors.Is(err, ErrSpawnFailed) {
|
||||
t.Fatalf("want ErrSpawnFailed, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), reason) {
|
||||
t.Errorf("error must surface the child's real reason; got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A bare 'E' (no trailing text) still reports ErrSpawnFailed.
|
||||
func TestWaitReady_BareFailureByte(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses Unix pipe")
|
||||
}
|
||||
pr, pw, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pr.Close()
|
||||
go func() { _, _ = pw.Write([]byte{'E'}); _ = pw.Close() }()
|
||||
if err := waitReady(pr); !errors.Is(err, ErrSpawnFailed) {
|
||||
t.Fatalf("want ErrSpawnFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,11 +55,6 @@ type Config struct {
|
||||
// MaxEvents: stop after receiving this many events. 0 = no limit.
|
||||
MaxEvents int
|
||||
|
||||
// EventKey is the single event key being consumed. Used only for the
|
||||
// AI-subprocess contract stderr lines (`[event] ready event_key=...`).
|
||||
// Empty → the key is omitted from the marker.
|
||||
EventKey string
|
||||
|
||||
// Duration: wall-clock budget for the consume run. After this elapses,
|
||||
// Run returns nil (clean exit, exit code 0). Zero = no limit.
|
||||
//
|
||||
@@ -96,18 +91,6 @@ type Config struct {
|
||||
// Routes are pre-parsed --route specs. Empty = no routing.
|
||||
Routes []Route
|
||||
|
||||
// Stdin, when non-nil, is watched for EOF: closing stdin triggers a
|
||||
// graceful shutdown (reason: signal). This wires the AI-subprocess
|
||||
// contract — a parent closes stdin to stop the consumer. The cobra
|
||||
// layer passes os.Stdin; tests inject a controllable reader. nil →
|
||||
// stdin is not watched (backward-compatible default for callers that
|
||||
// do not opt in).
|
||||
//
|
||||
// Note: `< /dev/null` EOFs immediately and exits at once. To stay
|
||||
// resident feed a never-EOF stdin (`< <(tail -f /dev/null)`) or run
|
||||
// bounded (--max-events / --duration).
|
||||
Stdin io.Reader
|
||||
|
||||
// Stdout sink; nil → os.Stdout. Injected for tests.
|
||||
Stdout io.Writer
|
||||
// Stderr sink for status lines (HelloAck info, bye reason); nil → os.Stderr.
|
||||
@@ -149,30 +132,15 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Distinguish the exit cause for the contract's `exited` line:
|
||||
// duration deadline → timeout
|
||||
// parentCtx cancelled → signal (SIGTERM/SIGINT)
|
||||
// runCtx-only cancelled → signal (stdin EOF)
|
||||
parentCtx := ctx
|
||||
|
||||
// --duration: layer a deadline on top of caller-provided ctx. Run
|
||||
// returns nil on deadline (clean exit) rather than surfacing the
|
||||
// context.DeadlineExceeded as an error to the user.
|
||||
var timeoutCtx context.Context
|
||||
if cfg.Duration > 0 {
|
||||
var cancel context.CancelFunc
|
||||
timeoutCtx, cancel = context.WithTimeout(ctx, cfg.Duration)
|
||||
ctx, cancel = context.WithTimeout(ctx, cfg.Duration)
|
||||
defer cancel()
|
||||
ctx = timeoutCtx
|
||||
}
|
||||
|
||||
// runCtx lets the stdin watcher and the read loop share one cancel
|
||||
// without disturbing the timeout/parent contexts (so we can still tell
|
||||
// stdin-EOF from a real signal from a duration deadline).
|
||||
runCtx, cancelRun := context.WithCancel(ctx)
|
||||
defer cancelRun()
|
||||
ctx = runCtx
|
||||
|
||||
pipeline, err := BuildPipeline(cfg.Format, cfg.OutputDir, cfg.Routes, cfg.Stdout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("consume: build pipeline: %w", err)
|
||||
@@ -216,57 +184,19 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
return fmt.Errorf("consume: unexpected first frame type %q", ack.Type)
|
||||
}
|
||||
if !cfg.Quiet {
|
||||
// Contract: a fixed ready line on stderr BEFORE any stdout event.
|
||||
// Parents block on stderr until this appears, then read stdout.
|
||||
if cfg.EventKey != "" {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] ready event_key=%s bus_pid=%d\n", cfg.EventKey, ack.BusPID)
|
||||
} else {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] ready bus_pid=%d\n", ack.BusPID)
|
||||
}
|
||||
// Secondary diagnostic line (source/state/idle); not part of the
|
||||
// ready contract.
|
||||
fmt.Fprintf(cfg.Stderr, "[event] bus source=%s state=%s idle_timeout=%ds\n",
|
||||
ack.StateSource, ack.SourceState, ack.IdleTimeoutSecs)
|
||||
fmt.Fprintf(cfg.Stderr,
|
||||
"connected bus pid=%d source=%s state=%s idle_timeout=%ds\n",
|
||||
ack.BusPID, ack.StateSource, ack.SourceState, ack.IdleTimeoutSecs)
|
||||
}
|
||||
|
||||
// Watch stdin for EOF → graceful shutdown (AI-subprocess contract).
|
||||
// The cobra layer only sets Stdin when the watcher should arm (a
|
||||
// pipe-style, unbounded run); tests inject it directly.
|
||||
if cfg.Stdin != nil {
|
||||
go watchStdinEOF(runCtx, cfg.Stdin, cfg.Stderr, cancelRun)
|
||||
}
|
||||
|
||||
// Exit-reason contract: on a graceful exit emit a final stderr line
|
||||
// [event] exited — received N event(s) in Xs (reason: <r>)
|
||||
// Error returns leave reason empty → no `exited` line (an `Error:` line
|
||||
// is printed by the cobra layer instead).
|
||||
received := 0
|
||||
start := time.Now()
|
||||
reason := ""
|
||||
defer func() {
|
||||
if !cfg.Quiet && reason != "" {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] exited — received %d event(s) in %s (reason: %s)\n",
|
||||
received, time.Since(start).Round(time.Millisecond), reason)
|
||||
}
|
||||
}()
|
||||
|
||||
// classifyCancel maps a context-cancelled exit to a contract reason.
|
||||
classifyCancel := func() string {
|
||||
if timeoutCtx != nil && errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) && parentCtx.Err() == nil {
|
||||
return "timeout"
|
||||
}
|
||||
return "signal"
|
||||
}
|
||||
|
||||
for {
|
||||
raw, err := r.Read()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
reason = "bus_shutdown" // peer closed cleanly
|
||||
return nil
|
||||
return nil // peer closed cleanly
|
||||
}
|
||||
if isCtxCancelled(ctx) {
|
||||
reason = classifyCancel()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("consume: read frame: %w", err)
|
||||
@@ -289,7 +219,6 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
Type: transport.FrameTypeBye,
|
||||
Reason: "client_done",
|
||||
})
|
||||
reason = "signal"
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("consume: deliver event: %w", err)
|
||||
@@ -300,16 +229,14 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
Type: transport.FrameTypeBye,
|
||||
Reason: "client_done",
|
||||
})
|
||||
reason = "limit"
|
||||
return nil
|
||||
}
|
||||
case transport.FrameTypeBye:
|
||||
var bye transport.Bye
|
||||
_ = json.Unmarshal(raw, &bye)
|
||||
if !cfg.Quiet {
|
||||
fmt.Fprintf(cfg.Stderr, "[event] bus closing: %s\n", bye.Reason)
|
||||
fmt.Fprintf(cfg.Stderr, "bus closing: %s\n", bye.Reason)
|
||||
}
|
||||
reason = "bus_shutdown"
|
||||
return nil
|
||||
case transport.FrameTypeSourceState:
|
||||
if !cfg.Quiet {
|
||||
@@ -342,35 +269,3 @@ func isCtxCancelled(ctx context.Context) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// watchStdinEOF reads and discards stdin until EOF (or any read error),
|
||||
// then prints a self-explaining diagnostic and calls onEOF to trigger a
|
||||
// graceful shutdown. This implements the AI-subprocess contract: a parent
|
||||
// closes the child's stdin to stop it. It returns early if ctx is
|
||||
// cancelled first (the run ended for another reason), so it does not fire
|
||||
// a spurious shutdown. errOut is io.Discard under --quiet.
|
||||
func watchStdinEOF(ctx context.Context, r io.Reader, errOut io.Writer, onEOF func()) {
|
||||
buf := make([]byte, 512)
|
||||
for {
|
||||
if _, err := r.Read(buf); err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Run already ending; do not attribute this to stdin.
|
||||
default:
|
||||
fmt.Fprintln(errOut, "[event] stdin closed — shutting down. "+
|
||||
"consume treats stdin EOF as an exit signal (wired for AI subprocess callers). "+
|
||||
"To keep running: pass --max-events/--duration for a bounded run, "+
|
||||
"keep stdin open (`< <(tail -f /dev/null)` in a script), "+
|
||||
"or stop via SIGTERM instead of closing stdin.")
|
||||
onEOF()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Discard any data and keep reading until EOF.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,9 +163,9 @@ func TestRun_StdoutNDJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Stderr should carry the standardized ready marker.
|
||||
if !strings.Contains(stderr.String(), "[event] ready ") {
|
||||
t.Errorf("stderr missing ready marker:\n%s", stderr.String())
|
||||
// Stderr should have the connected-to-bus banner.
|
||||
if !strings.Contains(stderr.String(), "connected bus pid=") {
|
||||
t.Errorf("stderr missing connected banner:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,156 +309,3 @@ func TestRun_MultipleConsumersOneBus(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- AI subprocess contract tests (event-subprocess-contract.md) ---
|
||||
|
||||
// T1a/T1b: the ready marker carries event_key and precedes the first
|
||||
// stdout event.
|
||||
func TestRun_ReadyMarkerContract(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
|
||||
})
|
||||
defer func() { cancel(); <-runDone }()
|
||||
go func() { time.Sleep(150 * time.Millisecond); close(trigger) }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cc()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr,
|
||||
EventKey: "im.message.receive_v1", MaxEvents: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
// T1a: ready line with event_key.
|
||||
if !strings.Contains(stderr.String(), "[event] ready event_key=im.message.receive_v1") {
|
||||
t.Errorf("missing ready marker with event_key:\n%s", stderr.String())
|
||||
}
|
||||
// T1b: ready is on stderr, the event is on stdout — the ready line is
|
||||
// emitted before Deliver writes the first stdout line. Assert stdout
|
||||
// got exactly the event and stderr got ready before exited.
|
||||
rIdx := strings.Index(stderr.String(), "[event] ready")
|
||||
xIdx := strings.Index(stderr.String(), "[event] exited")
|
||||
if rIdx < 0 || xIdx < 0 || rIdx > xIdx {
|
||||
t.Errorf("ready must precede exited:\n%s", stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "im.message.receive_v1") {
|
||||
t.Errorf("stdout missing the event:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3a: --max-events exit → reason=limit.
|
||||
func TestRun_ExitReasonLimit(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{
|
||||
{EventID: "1", EventType: "im.message.receive_v1", Data: `{}`},
|
||||
})
|
||||
defer func() { cancel(); <-runDone }()
|
||||
go func() { time.Sleep(150 * time.Millisecond); close(trigger) }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cc()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr, MaxEvents: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: limit") {
|
||||
t.Errorf("expected reason: limit:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3b: --duration deadline with no events → reason=timeout, exit clean.
|
||||
func TestRun_ExitReasonTimeout(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
// Bring up bus but never trigger emission → consume blocks until the
|
||||
// duration deadline fires.
|
||||
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cc()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr, Duration: 300 * time.Millisecond,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run should exit clean on duration: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: timeout") {
|
||||
t.Errorf("expected reason: timeout:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3c: parent ctx cancel (signal) → reason=signal.
|
||||
func TestRun_ExitReasonSignal(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithCancel(context.Background())
|
||||
go func() { time.Sleep(250 * time.Millisecond); cc() }()
|
||||
if err := Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr,
|
||||
}); err != nil {
|
||||
t.Fatalf("Run should exit clean on signal: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: signal") {
|
||||
t.Errorf("expected reason: signal:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T2c: closing stdin triggers a graceful shutdown (reason=signal), with no
|
||||
// --max-events / --duration set.
|
||||
func TestRun_StdinEOFShutsDown(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir, sock, cancel, runDone, _ := bringUpBus(t, nil)
|
||||
defer func() { cancel(); <-runDone }()
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() { time.Sleep(250 * time.Millisecond); _ = pw.Close() }() // EOF
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
ctx, cc := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cc()
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- Run(ctx, Config{
|
||||
WorkDir: dir, IPCEndpoint: sock, ClientID: "ding_test",
|
||||
Stdout: &stdout, Stderr: &stderr, Stdin: pr,
|
||||
})
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatalf("Run should exit clean on stdin EOF: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run did not exit after stdin EOF")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "reason: signal") {
|
||||
t.Errorf("expected reason: signal on stdin EOF:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// T3d: a startup failure returns non-nil and emits NO `exited` line (the
|
||||
// cobra layer prints an Error: line instead).
|
||||
func TestRun_FailureHasNoExitedLine(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
var stderr bytes.Buffer
|
||||
// Missing WorkDir/IPCEndpoint/ClientID → immediate config error, before
|
||||
// the exit-reason machinery is armed.
|
||||
err := Run(context.Background(), Config{Stderr: &stderr})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing required config")
|
||||
}
|
||||
if strings.Contains(stderr.String(), "[event] exited") {
|
||||
t.Errorf("failure must not print an exited line:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
+10
-99
@@ -15,7 +15,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -1040,7 +1039,7 @@ func newChatCommand() *cobra.Command {
|
||||
Use: "chat",
|
||||
Aliases: []string{"im"},
|
||||
Short: "群聊 / 消息 / 机器人",
|
||||
Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取/发送/收藏会话消息、机器人消息与 Webhook。`,
|
||||
Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取会话消息、发送群消息、机器人消息与 Webhook。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
@@ -1326,12 +1325,7 @@ func newChatCommand() *cobra.Command {
|
||||
|
||||
// ── message 子命令 ────────────────────────────────────────
|
||||
|
||||
chatMessageCmd := &cobra.Command{
|
||||
Use: "message",
|
||||
Short: "会话消息管理",
|
||||
Long: `管理会话消息,包括拉取、发送、搜索、转发、钉住、收藏和撤回消息。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
chatMessageCmd := &cobra.Command{Use: "message", Short: "会话消息管理", RunE: groupRunE}
|
||||
|
||||
chatMessageListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -4249,80 +4243,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
chatMessageListPinCmd.Flags().String("cursor", "", "分页游标(首次不传,翻页时传上次返回的 nextCursor)")
|
||||
chatMessageListPinCmd.Flags().Int("size", 0, "一次拉取的消息数量(默认 20,最大 100)")
|
||||
|
||||
// ── message favorites: 收藏/取消收藏/查询收藏消息 ──────────
|
||||
|
||||
chatMessageAddFavoriteCmd := &cobra.Command{
|
||||
Use: "add-favorite",
|
||||
Short: "收藏指定消息",
|
||||
Long: `收藏指定消息。消息 ID 和会话 ID 可从 chat message list 等消息查询命令的返回结果中获取。
|
||||
|
||||
该操作会修改当前用户的消息收藏状态。`,
|
||||
Example: ` dws chat message add-favorite --open-message-id <openMessageId> --open-conversation-id <openConversationId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "open-message-id", "open-conversation-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("im", "add_message_favorite", map[string]any{
|
||||
"openMessageId": mustGetFlag(cmd, "open-message-id"),
|
||||
"openConversationId": mustGetFlag(cmd, "open-conversation-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
chatMessageAddFavoriteCmd.Flags().String("open-message-id", "", "消息 openMessageId (必填)")
|
||||
_ = chatMessageAddFavoriteCmd.MarkFlagRequired("open-message-id")
|
||||
chatMessageAddFavoriteCmd.Flags().String("open-conversation-id", "", "消息所在会话的 openConversationId (必填,支持群聊/单聊)")
|
||||
_ = chatMessageAddFavoriteCmd.MarkFlagRequired("open-conversation-id")
|
||||
|
||||
chatMessageRemoveFavoriteCmd := &cobra.Command{
|
||||
Use: "remove-favorite",
|
||||
Short: "取消收藏指定消息",
|
||||
Long: `取消收藏指定消息。消息 ID 和会话 ID 可从 chat message list 等消息查询命令的返回结果中获取。
|
||||
|
||||
该操作只移除当前用户的收藏标记,不会删除原消息。`,
|
||||
Example: ` dws chat message remove-favorite --open-message-id <openMessageId> --open-conversation-id <openConversationId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "open-message-id", "open-conversation-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("im", "remove_message_favorite", map[string]any{
|
||||
"openMessageId": mustGetFlag(cmd, "open-message-id"),
|
||||
"openConversationId": mustGetFlag(cmd, "open-conversation-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
chatMessageRemoveFavoriteCmd.Flags().String("open-message-id", "", "消息 openMessageId (必填)")
|
||||
_ = chatMessageRemoveFavoriteCmd.MarkFlagRequired("open-message-id")
|
||||
chatMessageRemoveFavoriteCmd.Flags().String("open-conversation-id", "", "消息所在会话的 openConversationId (必填,支持群聊/单聊)")
|
||||
_ = chatMessageRemoveFavoriteCmd.MarkFlagRequired("open-conversation-id")
|
||||
|
||||
chatMessageListFavoritesCmd := &cobra.Command{
|
||||
Use: "list-favorites",
|
||||
Short: "查询收藏的消息列表",
|
||||
Long: `查询当前用户收藏的消息列表,支持数字游标分页。
|
||||
|
||||
首次请求可省略分页参数,CLI 会按 Open 服务契约传 cursor=0、size="20"。
|
||||
返回 hasMore=true 时,将 nextCursor 作为下一次的 --cursor。`,
|
||||
Example: ` dws chat message list-favorites
|
||||
dws chat message list-favorites --size 50
|
||||
dws chat message list-favorites --cursor 20 --size 20`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cursor, _ := cmd.Flags().GetInt64("cursor")
|
||||
if cursor < 0 {
|
||||
return apperrors.NewValidation("--cursor must be greater than or equal to 0")
|
||||
}
|
||||
size, _ := cmd.Flags().GetInt("size")
|
||||
if size < 1 || size > 100 {
|
||||
return apperrors.NewValidation("--size must be between 1 and 100")
|
||||
}
|
||||
return callMCPToolOnServer("im", "list_message_favorites", map[string]any{
|
||||
"cursor": cursor,
|
||||
"size": strconv.Itoa(size),
|
||||
})
|
||||
},
|
||||
}
|
||||
chatMessageListFavoritesCmd.Flags().Int64("cursor", 0, "数字分页游标(默认 0;翻页时传上次返回的 nextCursor)")
|
||||
chatMessageListFavoritesCmd.Flags().Int("size", 20, "一次拉取的收藏数量(默认 20,范围 1-100)")
|
||||
|
||||
// ── group list-my-groups: 拉取我创建/管理的群 ──────────────
|
||||
|
||||
chatGroupListMyGroupsCmd := &cobra.Command{
|
||||
@@ -5007,26 +4927,17 @@ status 可选值:
|
||||
dws chat category create-smart --name "团队群" --members openDingTalkId1,openDingTalkId2
|
||||
dws chat category create-smart --name "重点群" --keywords "重点" --members openDingTalkId1`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
name := strings.TrimSpace(mustGetFlag(cmd, "name"))
|
||||
if name == "" {
|
||||
return apperrors.NewValidation("--name must not be blank")
|
||||
if err := validateRequiredFlags(cmd, "name"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"categoryName": name,
|
||||
"title": mustGetFlag(cmd, "name"),
|
||||
}
|
||||
if cmd.Flags().Changed("keywords") {
|
||||
values := parseCSVValues(mustGetFlag(cmd, "keywords"))
|
||||
if len(values) == 0 {
|
||||
return apperrors.NewValidation("--keywords must contain at least one non-empty value")
|
||||
}
|
||||
toolArgs["groupNameKeywords"] = values
|
||||
if v, _ := cmd.Flags().GetString("keywords"); v != "" {
|
||||
toolArgs["keywords"] = parseCSVValues(v)
|
||||
}
|
||||
if cmd.Flags().Changed("members") {
|
||||
values := parseCSVValues(mustGetFlag(cmd, "members"))
|
||||
if len(values) == 0 {
|
||||
return apperrors.NewValidation("--members must contain at least one non-empty value")
|
||||
}
|
||||
toolArgs["memberOpenDingTalkIds"] = values
|
||||
if v, _ := cmd.Flags().GetString("members"); v != "" {
|
||||
toolArgs["memberOpenDingTalkIds"] = parseCSVValues(v)
|
||||
}
|
||||
return callMCPToolOnServer("im", "create_smart_conv_category", toolArgs)
|
||||
},
|
||||
@@ -5098,7 +5009,7 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
chatGroupMembersCmd.AddCommand(chatGroupMembersRemoveBotCmd, chatGroupMembersListByIdsCmd)
|
||||
chatBotCmd.AddCommand(chatBotFindCmd)
|
||||
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
|
||||
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageAddFavoriteCmd, chatMessageRemoveFavoriteCmd, chatMessageListFavoritesCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
|
||||
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
|
||||
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
|
||||
|
||||
|
||||
@@ -1,208 +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 (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type chatFavoritesCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type chatFavoritesCaller struct {
|
||||
calls []chatFavoritesCall
|
||||
}
|
||||
|
||||
func (c *chatFavoritesCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, chatFavoritesCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*chatFavoritesCaller) Format() string { return "json" }
|
||||
func (*chatFavoritesCaller) DryRun() bool { return false }
|
||||
func (*chatFavoritesCaller) Fields() string { return "" }
|
||||
func (*chatFavoritesCaller) JQ() string { return "" }
|
||||
|
||||
func executeChatFavoritesCommand(t *testing.T, caller *chatFavoritesCaller, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
root := newChatCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestChatFavoritesCommandsRegistered(t *testing.T) {
|
||||
root := newChatCommand()
|
||||
tests := []struct {
|
||||
name string
|
||||
flags []string
|
||||
}{
|
||||
{name: "add-favorite", flags: []string{"open-message-id", "open-conversation-id"}},
|
||||
{name: "remove-favorite", flags: []string{"open-message-id", "open-conversation-id"}},
|
||||
{name: "list-favorites", flags: []string{"cursor", "size"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
cmd, remaining, err := root.Find([]string{"message", tt.name})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws chat message %s not registered: cmd=%v remaining=%v err=%v", tt.name, cmd, remaining, err)
|
||||
}
|
||||
for _, flag := range tt.flags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("chat message %s: missing flag --%s", tt.name, flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatFavoriteMutationMappings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
toolName string
|
||||
}{
|
||||
{name: "add", command: "add-favorite", toolName: "add_message_favorite"},
|
||||
{name: "remove", command: "remove-favorite", toolName: "remove_message_favorite"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller,
|
||||
"message", tt.command,
|
||||
"--open-message-id", "msg-1",
|
||||
"--open-conversation-id", "cid-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat message %s returned error: %v", tt.command, err)
|
||||
}
|
||||
|
||||
want := chatFavoritesCall{
|
||||
productID: "im",
|
||||
toolName: tt.toolName,
|
||||
args: map[string]any{
|
||||
"openMessageId": "msg-1",
|
||||
"openConversationId": "cid-1",
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatFavoriteMutationsRequireBothIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "add missing message", args: []string{"message", "add-favorite", "--open-conversation-id", "cid-1"}},
|
||||
{name: "add missing conversation", args: []string{"message", "add-favorite", "--open-message-id", "msg-1"}},
|
||||
{name: "remove missing message", args: []string{"message", "remove-favorite", "--open-conversation-id", "cid-1"}},
|
||||
{name: "remove missing conversation", args: []string{"message", "remove-favorite", "--open-message-id", "msg-1"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller, tt.args...)
|
||||
if err == nil {
|
||||
t.Fatal("command without all required IDs returned nil error")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesSuppliesOpenDefaults(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
if err := executeChatFavoritesCommand(t, caller, "message", "list-favorites"); err != nil {
|
||||
t.Fatalf("list-favorites returned error: %v", err)
|
||||
}
|
||||
|
||||
want := chatFavoritesCall{
|
||||
productID: "im",
|
||||
toolName: "list_message_favorites",
|
||||
args: map[string]any{
|
||||
"cursor": int64(0),
|
||||
"size": "20",
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesMapsExplicitPagination(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller,
|
||||
"message", "list-favorites", "--cursor", "42", "--size", "50")
|
||||
if err != nil {
|
||||
t.Fatalf("list-favorites returned error: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]any{"cursor": int64(42), "size": "50"}
|
||||
if len(caller.calls) != 1 || caller.calls[0].productID != "im" || caller.calls[0].toolName != "list_message_favorites" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want im/list_message_favorites %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesRejectsInvalidSize(t *testing.T) {
|
||||
for _, size := range []string{"-1", "0", "101"} {
|
||||
t.Run(size, func(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller, "message", "list-favorites", "--size", size)
|
||||
if err == nil || !strings.Contains(err.Error(), "--size must be between 1 and 100") {
|
||||
t.Fatalf("error = %v, want size validation error", err)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want validation code 3", got)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatListFavoritesRejectsNegativeCursor(t *testing.T) {
|
||||
caller := &chatFavoritesCaller{}
|
||||
err := executeChatFavoritesCommand(t, caller, "message", "list-favorites", "--cursor", "-1")
|
||||
if err == nil || !strings.Contains(err.Error(), "--cursor must be greater than or equal to 0") {
|
||||
t.Fatalf("error = %v, want cursor validation error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
}
|
||||
@@ -1,129 +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 (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type chatMessageSearchCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type chatMessageSearchCaller struct {
|
||||
calls []chatMessageSearchCall
|
||||
}
|
||||
|
||||
func (c *chatMessageSearchCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, chatMessageSearchCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*chatMessageSearchCaller) Format() string { return "json" }
|
||||
func (*chatMessageSearchCaller) DryRun() bool { return false }
|
||||
func (*chatMessageSearchCaller) Fields() string { return "" }
|
||||
func (*chatMessageSearchCaller) JQ() string { return "" }
|
||||
|
||||
func TestChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
start := "2026-07-09T00:00:00+08:00"
|
||||
end := "2026-07-11T00:00:00+08:00"
|
||||
startTime, err := time.Parse(time.RFC3339, start)
|
||||
if err != nil {
|
||||
t.Fatalf("parse start time: %v", err)
|
||||
}
|
||||
endTime, err := time.Parse(time.RFC3339, end)
|
||||
if err != nil {
|
||||
t.Fatalf("parse end time: %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
productID string
|
||||
toolName string
|
||||
wantToolArg map[string]any
|
||||
}{
|
||||
{
|
||||
name: "keyword search",
|
||||
args: []string{"message", "search", "--query", "categoryName", "--group", "cid-1", "--start", start, "--end", end, "--limit", "100", "--cursor", "0"},
|
||||
productID: "chat",
|
||||
toolName: "search_messages_by_keyword",
|
||||
wantToolArg: map[string]any{
|
||||
"keyword": "categoryName",
|
||||
"openConversationId": "cid-1",
|
||||
"startTime": startTime.UnixMilli(),
|
||||
"endTime": endTime.UnixMilli(),
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "advanced search",
|
||||
args: []string{"message", "search-advanced", "--query", "categoryName", "--conversation-ids", "cid-1,cid-2", "--start", start, "--end", end, "--limit", "100", "--cursor", "0"},
|
||||
productID: "im",
|
||||
toolName: "search_messages",
|
||||
wantToolArg: map[string]any{
|
||||
"keyword": "categoryName",
|
||||
"openConversationIds": []string{"cid-1", "cid-2"},
|
||||
"startTime": startTime.UnixMilli(),
|
||||
"endTime": endTime.UnixMilli(),
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = []string{"dws", "chat"}
|
||||
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(tt.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat search returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != tt.productID || call.toolName != tt.toolName {
|
||||
t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tt.productID, tt.toolName)
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, tt.wantToolArg) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, tt.wantToolArg)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,187 +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 (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type chatSmartCategoryCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type chatSmartCategoryCaller struct {
|
||||
calls []chatSmartCategoryCall
|
||||
}
|
||||
|
||||
func (c *chatSmartCategoryCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, chatSmartCategoryCall{
|
||||
productID: productID,
|
||||
toolName: toolName,
|
||||
args: args,
|
||||
})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*chatSmartCategoryCaller) Format() string { return "json" }
|
||||
func (*chatSmartCategoryCaller) DryRun() bool { return false }
|
||||
func (*chatSmartCategoryCaller) Fields() string { return "" }
|
||||
func (*chatSmartCategoryCaller) JQ() string { return "" }
|
||||
|
||||
func TestChatCategoryCreateSmartUsesMCPParameterNames(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
caller := &chatSmartCategoryCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs([]string{
|
||||
"category", "create-smart",
|
||||
"--name", "priority",
|
||||
"--keywords", "alpha, ,beta,",
|
||||
"--members", "open-id-1, ,open-id-2,",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat category create-smart returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "im" || call.toolName != "create_smart_conv_category" {
|
||||
t.Fatalf("tool call = %s/%s, want im/create_smart_conv_category", call.productID, call.toolName)
|
||||
}
|
||||
want := map[string]any{
|
||||
"categoryName": "priority",
|
||||
"groupNameKeywords": []string{"alpha", "beta"},
|
||||
"memberOpenDingTalkIds": []string{"open-id-1", "open-id-2"},
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, want) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCategoryCreateSmartRejectsBlankInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty name",
|
||||
args: []string{"--name", ""},
|
||||
wantErr: "--name must not be blank",
|
||||
},
|
||||
{
|
||||
name: "blank name",
|
||||
args: []string{"--name", " \t "},
|
||||
wantErr: "--name must not be blank",
|
||||
},
|
||||
{
|
||||
name: "empty keywords",
|
||||
args: []string{"--name", "priority", "--keywords", ""},
|
||||
wantErr: "--keywords must contain at least one non-empty value",
|
||||
},
|
||||
{
|
||||
name: "blank keywords CSV",
|
||||
args: []string{"--name", "priority", "--keywords", " , , "},
|
||||
wantErr: "--keywords must contain at least one non-empty value",
|
||||
},
|
||||
{
|
||||
name: "empty keywords JSON array",
|
||||
args: []string{"--name", "priority", "--keywords", "[]"},
|
||||
wantErr: "--keywords must contain at least one non-empty value",
|
||||
},
|
||||
{
|
||||
name: "empty members",
|
||||
args: []string{"--name", "priority", "--members", ""},
|
||||
wantErr: "--members must contain at least one non-empty value",
|
||||
},
|
||||
{
|
||||
name: "blank members CSV",
|
||||
args: []string{"--name", "priority", "--members", " , , "},
|
||||
wantErr: "--members must contain at least one non-empty value",
|
||||
},
|
||||
{
|
||||
name: "blank members JSON array",
|
||||
args: []string{"--name", "priority", "--members", `["", " "]`},
|
||||
wantErr: "--members must contain at least one non-empty value",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
caller := &chatSmartCategoryCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(append([]string{"category", "create-smart"}, tt.args...))
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
if got := apperrors.ExitCode(err); got != 3 {
|
||||
t.Fatalf("exit code = %d, want validation code 3", got)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool call count = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatCategoryCreateSmartAllowsOmittedRulesAndTrimsName(t *testing.T) {
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
caller := &chatSmartCategoryCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs([]string{"category", "create-smart", "--name", " priority "})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat category create-smart returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"categoryName": "priority"}
|
||||
if !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("tool args = %#v, want %#v", caller.calls[0].args, want)
|
||||
}
|
||||
}
|
||||
@@ -1,141 +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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
connectVideoStoryboardFrames = 12
|
||||
connectVideoStoryboardWidth = 480
|
||||
connectVideoStoryboardMax = 16 << 20
|
||||
)
|
||||
|
||||
var generateConnectVideoStoryboard = buildConnectVideoStoryboard
|
||||
|
||||
// prepareOpenCodeAttachments adapts media that OpenCode's file-part bridge
|
||||
// cannot safely send to the provider. OpenCode expands video files into the
|
||||
// request in-process (a real 267 MiB merged-forward recording caused Bun to
|
||||
// OOM), and the current bridge does not submit video/* as native multimodal
|
||||
// video even when the selected model supports it. A full-duration, evenly
|
||||
// sampled storyboard gives the visual model the actual sequence without
|
||||
// sacrificing the separately downloaded original file.
|
||||
//
|
||||
// The selected iDEALab model has no audio input modality. DingTalk's message
|
||||
// API normally supplies a speech-recognition transcript in the recovered
|
||||
// prompt, so the opaque audio bytes are omitted from OpenCode's file parts
|
||||
// instead of provoking a binary-file error.
|
||||
func prepareOpenCodeAttachments(ctx context.Context, prompt string, attachments []connectMediaAttachment) (string, []connectMediaAttachment) {
|
||||
prepared := make([]connectMediaAttachment, 0, len(attachments))
|
||||
for _, attachment := range attachments {
|
||||
mediaType := inboundMediaType(attachment.MediaType)
|
||||
switch mediaType {
|
||||
case "video":
|
||||
storyboard, err := generateConnectVideoStoryboard(ctx, attachment.LocalPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] OpenCode 视频故事板生成失败,跳过不安全的原视频 file part: %v\n", err)
|
||||
prompt += "\n(原视频已完整下载,但当前 OpenCode 无法安全提交视频,且关键帧故事板生成失败;请明确告知用户本轮未能读取视频画面。)"
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(strings.TrimSpace(attachment.FileName), filepath.Ext(attachment.FileName))
|
||||
if name == "" {
|
||||
name = "转发视频"
|
||||
}
|
||||
proxy := connectMediaAttachment{
|
||||
LocalPath: storyboard,
|
||||
FileName: name + ".storyboard.jpg",
|
||||
MediaType: "image",
|
||||
}
|
||||
prompt = strings.ReplaceAll(prompt, attachment.LocalPath, storyboard)
|
||||
prompt += "\n(原视频已完整下载;为避免 OpenCode 展开大视频导致内存溢出,已按完整时长均匀抽取 12 帧并生成故事板图片。请按从左到右、从上到下的时间顺序分析画面。)"
|
||||
prepared = append(prepared, proxy)
|
||||
if info, err := os.Stat(attachment.LocalPath); err == nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] OpenCode 视频故事板已生成: 原始=%d 字节 故事板=%s\n", info.Size(), storyboard)
|
||||
}
|
||||
case "audio":
|
||||
prompt = strings.ReplaceAll(prompt, attachment.LocalPath, "[语音原文件已完整下载,当前模型使用钉钉转写]")
|
||||
prompt += "\n(语音原文件已完整下载;当前 OpenCode 模型不接收音频 file part,请优先依据上述钉钉语音转写处理。)"
|
||||
default:
|
||||
prepared = append(prepared, attachment)
|
||||
}
|
||||
}
|
||||
return prompt, prepared
|
||||
}
|
||||
|
||||
func buildConnectVideoStoryboard(ctx context.Context, videoPath string) (string, error) {
|
||||
ffmpegPath, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("未安装 ffmpeg")
|
||||
}
|
||||
ffprobePath, err := exec.LookPath("ffprobe")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("未安装 ffprobe")
|
||||
}
|
||||
|
||||
probe := exec.CommandContext(ctx, ffprobePath,
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
videoPath,
|
||||
)
|
||||
rawDuration, err := probe.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ffprobe 读取视频时长失败: %w", err)
|
||||
}
|
||||
duration, err := strconv.ParseFloat(strings.TrimSpace(string(rawDuration)), 64)
|
||||
if err != nil || duration <= 0 {
|
||||
return "", fmt.Errorf("ffprobe 返回无效视频时长 %q", strings.TrimSpace(string(rawDuration)))
|
||||
}
|
||||
interval := duration / connectVideoStoryboardFrames
|
||||
if interval < 0.5 {
|
||||
interval = 0.5
|
||||
}
|
||||
|
||||
dir := filepath.Join(os.TempDir(), "dws-connect-media")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dest := filepath.Join(dir, uuid.NewString()+".storyboard.jpg")
|
||||
filter := fmt.Sprintf("fps=1/%.6f,scale=%d:-2,tile=4x3:padding=4:margin=4", interval, connectVideoStoryboardWidth)
|
||||
cmd := exec.CommandContext(ctx, ffmpegPath,
|
||||
"-nostdin", "-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", videoPath,
|
||||
"-vf", filter,
|
||||
"-frames:v", "1",
|
||||
"-q:v", "3",
|
||||
dest,
|
||||
)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
_ = os.Remove(dest)
|
||||
return "", fmt.Errorf("ffmpeg 生成视频故事板失败: %w (%s)", err, truncateRunes(strings.TrimSpace(string(output)), 300))
|
||||
}
|
||||
info, err := os.Stat(dest)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("视频故事板未生成: %w", err)
|
||||
}
|
||||
if info.Size() <= 0 || info.Size() > connectVideoStoryboardMax {
|
||||
_ = os.Remove(dest)
|
||||
return "", fmt.Errorf("视频故事板大小异常: %d 字节", info.Size())
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
@@ -1,75 +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 (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrepareOpenCodeAttachmentsUsesStoryboardAndTranscript(t *testing.T) {
|
||||
previous := generateConnectVideoStoryboard
|
||||
t.Cleanup(func() { generateConnectVideoStoryboard = previous })
|
||||
storyboard := filepath.Join(t.TempDir(), "storyboard.jpg")
|
||||
if err := os.WriteFile(storyboard, []byte("jpeg"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
generateConnectVideoStoryboard = func(_ context.Context, path string) (string, error) {
|
||||
if path != "/tmp/original.mov" {
|
||||
t.Fatalf("video path = %q", path)
|
||||
}
|
||||
return storyboard, nil
|
||||
}
|
||||
|
||||
prompt, attachments := prepareOpenCodeAttachments(context.Background(),
|
||||
"视频路径 /tmp/original.mov;语音路径 /tmp/original.ogg;转写:你是谁?",
|
||||
[]connectMediaAttachment{
|
||||
{LocalPath: "/tmp/picture.jpg", FileName: "picture.jpg", MediaType: "image"},
|
||||
{LocalPath: "/tmp/original.ogg", FileName: "voice.ogg", MediaType: "audio"},
|
||||
{LocalPath: "/tmp/original.mov", FileName: "demo.mov", MediaType: "video"},
|
||||
{LocalPath: "/tmp/report.md", FileName: "report.md", MediaType: "file"},
|
||||
},
|
||||
)
|
||||
if len(attachments) != 3 {
|
||||
t.Fatalf("attachments = %#v, want image + video storyboard + file (audio omitted)", attachments)
|
||||
}
|
||||
if attachments[0].LocalPath != "/tmp/picture.jpg" || attachments[1].LocalPath != storyboard || attachments[1].MediaType != "image" || attachments[2].LocalPath != "/tmp/report.md" {
|
||||
t.Fatalf("attachments = %#v", attachments)
|
||||
}
|
||||
if strings.Contains(prompt, "/tmp/original.mov") || strings.Contains(prompt, "/tmp/original.ogg") {
|
||||
t.Fatalf("prompt still points OpenCode at unsupported original binary: %q", prompt)
|
||||
}
|
||||
for _, want := range []string{storyboard, "12 帧", "钉钉转写", "你是谁?"} {
|
||||
if !strings.Contains(prompt, want) {
|
||||
t.Fatalf("prompt missing %q: %q", want, prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareOpenCodeAttachmentsDoesNotSubmitVideoWhenStoryboardFails(t *testing.T) {
|
||||
previous := generateConnectVideoStoryboard
|
||||
t.Cleanup(func() { generateConnectVideoStoryboard = previous })
|
||||
generateConnectVideoStoryboard = func(context.Context, string) (string, error) {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
prompt, attachments := prepareOpenCodeAttachments(context.Background(), "请看视频", []connectMediaAttachment{
|
||||
{LocalPath: "/tmp/original.mov", FileName: "demo.mov", MediaType: "video"},
|
||||
})
|
||||
if len(attachments) != 0 || !strings.Contains(prompt, "未能读取视频画面") {
|
||||
t.Fatalf("prompt=%q attachments=%#v", prompt, attachments)
|
||||
}
|
||||
}
|
||||
@@ -1,405 +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 (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// chatRecordLookup records which entries DingTalk erased to unknownMsgType in
|
||||
// a Stream callback. The indexes line up with forwardMessages returned by the
|
||||
// user-state message API, which still exposes the original message metadata.
|
||||
type chatRecordLookup struct {
|
||||
MsgID string
|
||||
UnknownIndexes []int
|
||||
}
|
||||
|
||||
type chatRecordToolCall func(context.Context, string, string, map[string]any) (string, error)
|
||||
|
||||
type chatRecordMessage struct {
|
||||
OpenMessageID string `json:"openMessageId"`
|
||||
OpenConversationID string `json:"openConversationId"`
|
||||
CreateTime string `json:"createTime"`
|
||||
Content string `json:"content"`
|
||||
ForwardMessages []chatRecordMessage `json:"forwardMessages"`
|
||||
}
|
||||
|
||||
type chatRecordMessagesEnvelope struct {
|
||||
Result struct {
|
||||
Messages []chatRecordMessage `json:"messages"`
|
||||
} `json:"result"`
|
||||
}
|
||||
|
||||
type chatRecordEnrichment struct {
|
||||
Prompt string
|
||||
Files []fileInboundInfo
|
||||
MissingCount int
|
||||
}
|
||||
|
||||
var (
|
||||
chatRecordMediaIDPattern = regexp.MustCompile(`\(?mediaId=([^\s)]+)\)?`)
|
||||
chatRecordFileIDPattern = regexp.MustCompile(`(?i)\bfileId:\s*([^\s]+)`)
|
||||
chatRecordFileNamePattern = regexp.MustCompile(`(?m)^\[文件\]\s*(.*?)(?:\s+fileId:|$)`)
|
||||
chatRecordDownloadHintPattern = regexp.MustCompile(`\s*注意:如需下载使用dws\s+(?:chat message download-media|drive download)命令下载\s*`)
|
||||
)
|
||||
|
||||
func chatRecordEntries(content interface{}) []interface{} {
|
||||
m, ok := content.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if entries, ok := m["chatRecord"].([]interface{}); ok {
|
||||
return entries
|
||||
}
|
||||
if raw, ok := m["chatRecord"].(string); ok {
|
||||
var entries []interface{}
|
||||
if json.Unmarshal([]byte(raw), &entries) == nil {
|
||||
return entries
|
||||
}
|
||||
}
|
||||
if entries, ok := m["contents"].([]interface{}); ok {
|
||||
return entries
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func chatRecordUnknownIndexes(content interface{}) []int {
|
||||
entries := chatRecordEntries(content)
|
||||
indexes := make([]int, 0)
|
||||
for i, entry := range entries {
|
||||
node, ok := entry.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, key := range []string{"msgType", "msgtype", "type"} {
|
||||
if value, ok := node[key].(string); ok && strings.EqualFold(strings.TrimSpace(value), "unknownMsgType") {
|
||||
info := parseTypedFileInbound("file", node)
|
||||
if !info.hasActionable() {
|
||||
indexes = append(indexes, i)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return indexes
|
||||
}
|
||||
|
||||
// recoverChatRecordUnknowns resolves only the entries that were
|
||||
// unknownMsgType in the Stream callback. It deliberately runs after the
|
||||
// callback returns, so user-state MCP lookups cannot delay DingTalk's ACK.
|
||||
func recoverChatRecordUnknowns(ctx context.Context, lookup chatRecordLookup, call chatRecordToolCall) (chatRecordEnrichment, error) {
|
||||
var enrichment chatRecordEnrichment
|
||||
if strings.TrimSpace(lookup.MsgID) == "" || len(lookup.UnknownIndexes) == 0 {
|
||||
return enrichment, nil
|
||||
}
|
||||
|
||||
raw, err := call(ctx, "im", "list_messages_by_ids", map[string]any{
|
||||
"openMsgIds": []string{strings.TrimSpace(lookup.MsgID)},
|
||||
})
|
||||
if err != nil {
|
||||
return enrichment, fmt.Errorf("查询合并转发消息: %w", err)
|
||||
}
|
||||
envelope, err := parseChatRecordMessages(raw)
|
||||
if err != nil {
|
||||
return enrichment, fmt.Errorf("解析合并转发消息: %w", err)
|
||||
}
|
||||
var outer *chatRecordMessage
|
||||
for i := range envelope.Result.Messages {
|
||||
if envelope.Result.Messages[i].OpenMessageID == strings.TrimSpace(lookup.MsgID) {
|
||||
outer = &envelope.Result.Messages[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if outer == nil && len(envelope.Result.Messages) == 1 {
|
||||
outer = &envelope.Result.Messages[0]
|
||||
}
|
||||
if outer == nil {
|
||||
return enrichment, fmt.Errorf("未找到外层消息 %s", strings.TrimSpace(lookup.MsgID))
|
||||
}
|
||||
|
||||
indexes := uniqueValidIndexes(lookup.UnknownIndexes, len(outer.ForwardMessages))
|
||||
if len(indexes) == 0 {
|
||||
return enrichment, fmt.Errorf("unknownMsgType 索引超出转发消息范围")
|
||||
}
|
||||
|
||||
// list_messages_by_ids keeps mediaId for images/audio but can omit fileId
|
||||
// and even stamp forwarded files with the outer conversation ID. Query every
|
||||
// candidate source conversation visible in the same record and match by the
|
||||
// stable inner openMessageId to recover the actual fileId.
|
||||
resolvedByID := make(map[string]chatRecordMessage)
|
||||
needsFileLookup := false
|
||||
for _, index := range indexes {
|
||||
message := outer.ForwardMessages[index]
|
||||
resolvedByID[message.OpenMessageID] = message
|
||||
if looksLikeForwardedFile(message.Content) && chatRecordFileID(message.Content) == "" {
|
||||
needsFileLookup = true
|
||||
}
|
||||
}
|
||||
if needsFileLookup {
|
||||
enrichForwardedFileLocators(ctx, outer.ForwardMessages, resolvedByID, call)
|
||||
}
|
||||
|
||||
lines := []string{"已通过钉钉消息接口补拉到合并转发中原先标记为 unknownMsgType 的内容:"}
|
||||
for _, index := range indexes {
|
||||
message := outer.ForwardMessages[index]
|
||||
if resolved, ok := resolvedByID[message.OpenMessageID]; ok {
|
||||
message = resolved
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. %s", index+1, humanChatRecordContent(message.Content)))
|
||||
info, ok := recoveredForwardAttachment(message)
|
||||
if !ok || !info.hasActionable() {
|
||||
if looksLikeForwardedAttachment(message.Content) {
|
||||
enrichment.MissingCount++
|
||||
}
|
||||
continue
|
||||
}
|
||||
enrichment.Files = append(enrichment.Files, info)
|
||||
}
|
||||
enrichment.Prompt = strings.Join(lines, "\n")
|
||||
return enrichment, nil
|
||||
}
|
||||
|
||||
func parseChatRecordMessages(raw string) (chatRecordMessagesEnvelope, error) {
|
||||
var envelope chatRecordMessagesEnvelope
|
||||
if err := json.Unmarshal([]byte(raw), &envelope); err != nil {
|
||||
return envelope, err
|
||||
}
|
||||
return envelope, nil
|
||||
}
|
||||
|
||||
func uniqueValidIndexes(indexes []int, length int) []int {
|
||||
seen := make(map[int]struct{}, len(indexes))
|
||||
out := make([]int, 0, len(indexes))
|
||||
for _, index := range indexes {
|
||||
if index < 0 || index >= length {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[index]; ok {
|
||||
continue
|
||||
}
|
||||
seen[index] = struct{}{}
|
||||
out = append(out, index)
|
||||
}
|
||||
sort.Ints(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func enrichForwardedFileLocators(ctx context.Context, all []chatRecordMessage, resolved map[string]chatRecordMessage, call chatRecordToolCall) {
|
||||
candidates := make(map[string]struct{})
|
||||
var earliest time.Time
|
||||
for _, message := range all {
|
||||
if conversationID := strings.TrimSpace(message.OpenConversationID); conversationID != "" {
|
||||
candidates[conversationID] = struct{}{}
|
||||
}
|
||||
if parsed, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(message.CreateTime), time.Local); err == nil && (earliest.IsZero() || parsed.Before(earliest)) {
|
||||
earliest = parsed
|
||||
}
|
||||
}
|
||||
if earliest.IsZero() {
|
||||
return
|
||||
}
|
||||
start := earliest.Add(-time.Minute).Format("2006-01-02 15:04:05")
|
||||
for conversationID := range candidates {
|
||||
raw, err := call(ctx, "chat", "list_conversation_message_v2", map[string]any{
|
||||
"openconversation_id": conversationID,
|
||||
"time": start,
|
||||
"forward": true,
|
||||
"limit": 50,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
envelope, err := parseChatRecordMessages(raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, message := range envelope.Result.Messages {
|
||||
current, wanted := resolved[message.OpenMessageID]
|
||||
if !wanted || chatRecordFileID(message.Content) == "" {
|
||||
continue
|
||||
}
|
||||
current.Content = message.Content
|
||||
current.OpenConversationID = message.OpenConversationID
|
||||
if strings.TrimSpace(current.CreateTime) == "" {
|
||||
current.CreateTime = message.CreateTime
|
||||
}
|
||||
resolved[message.OpenMessageID] = current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func recoveredForwardAttachment(message chatRecordMessage) (fileInboundInfo, bool) {
|
||||
content := strings.TrimSpace(message.Content)
|
||||
info := fileInboundInfo{
|
||||
OpenMessageID: strings.TrimSpace(message.OpenMessageID),
|
||||
OpenConversationID: strings.TrimSpace(message.OpenConversationID),
|
||||
}
|
||||
if mediaID := chatRecordMediaID(content); mediaID != "" {
|
||||
info.MediaID = mediaID
|
||||
switch {
|
||||
case strings.Contains(content, "[图片消息]"):
|
||||
info.MediaType, info.FileName = "image", "转发图片"
|
||||
case strings.Contains(content, "[语音消息]"):
|
||||
info.MediaType, info.FileName = "audio", "转发语音.bin"
|
||||
case strings.Contains(content, "[视频消息]"):
|
||||
info.MediaType, info.FileName = "video", "转发视频.bin"
|
||||
default:
|
||||
info.MediaType, info.FileName = "file", "转发媒体.bin"
|
||||
}
|
||||
return info, true
|
||||
}
|
||||
if !looksLikeForwardedFile(content) {
|
||||
return fileInboundInfo{}, false
|
||||
}
|
||||
info.FileID = chatRecordFileID(content)
|
||||
info.FileName = chatRecordFileName(content)
|
||||
if info.FileName == "" {
|
||||
info.FileName = "转发文件"
|
||||
}
|
||||
info.MediaType = mediaTypeFromFileName(info.FileName)
|
||||
return info, true
|
||||
}
|
||||
|
||||
func chatRecordMediaID(content string) string {
|
||||
match := chatRecordMediaIDPattern.FindStringSubmatch(content)
|
||||
if len(match) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
|
||||
func chatRecordFileID(content string) string {
|
||||
match := chatRecordFileIDPattern.FindStringSubmatch(content)
|
||||
if len(match) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
|
||||
func chatRecordFileName(content string) string {
|
||||
match := chatRecordFileNamePattern.FindStringSubmatch(content)
|
||||
if len(match) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(match[1])
|
||||
}
|
||||
|
||||
func looksLikeForwardedFile(content string) bool {
|
||||
return strings.Contains(content, "[文件]")
|
||||
}
|
||||
|
||||
func looksLikeForwardedAttachment(content string) bool {
|
||||
return looksLikeForwardedFile(content) || chatRecordMediaID(content) != ""
|
||||
}
|
||||
|
||||
func humanChatRecordContent(content string) string {
|
||||
content = chatRecordDownloadHintPattern.ReplaceAllString(content, "")
|
||||
content = chatRecordMediaIDPattern.ReplaceAllString(content, "")
|
||||
content = chatRecordFileIDPattern.ReplaceAllString(content, "")
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return "[无法提取文字内容]"
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func mediaTypeFromFileName(fileName string) string {
|
||||
switch strings.ToLower(filepath.Ext(strings.TrimSpace(fileName))) {
|
||||
case ".mp4", ".mov", ".m4v", ".avi", ".mkv", ".webm":
|
||||
return "video"
|
||||
case ".mp3", ".m4a", ".aac", ".wav", ".amr", ".ogg", ".flac":
|
||||
return "audio"
|
||||
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".heic":
|
||||
return "image"
|
||||
default:
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
func (c *aiCardClient) downloadRecoveredChatRecordFile(ctx context.Context, info fileInboundInfo) (string, error) {
|
||||
return c.downloadRecoveredChatRecordFileWithCall(ctx, info, callMCPToolReturnTextOnServer)
|
||||
}
|
||||
|
||||
func (c *aiCardClient) downloadRecoveredChatRecordFileWithCall(ctx context.Context, info fileInboundInfo, call chatRecordToolCall) (string, error) {
|
||||
var raw string
|
||||
var err error
|
||||
switch {
|
||||
case strings.TrimSpace(info.MediaID) != "":
|
||||
raw, err = call(ctx, "im", "get_resource_download_url", map[string]any{
|
||||
"resourceType": "mediaId",
|
||||
"resourceId": strings.TrimSpace(info.MediaID),
|
||||
"openMessageId": strings.TrimSpace(info.OpenMessageID),
|
||||
"openConversationId": strings.TrimSpace(info.OpenConversationID),
|
||||
})
|
||||
case strings.TrimSpace(info.FileID) != "":
|
||||
raw, err = call(ctx, "drive", "download_file", map[string]any{
|
||||
"fileId": strings.TrimSpace(info.FileID),
|
||||
})
|
||||
default:
|
||||
return "", fmt.Errorf("转发附件缺少 mediaId/fileId")
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resourceURL, headers, err := parseDownloadInfo(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return downloadConnectURLToTemp(ctx, c.httpClient, resourceURL, headers, info.FileName)
|
||||
}
|
||||
|
||||
func downloadConnectURLToTemp(ctx context.Context, client *http.Client, resourceURL string, headers map[string]string, fileName string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for key, value := range headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
resp, err := connectMediaDownloadClient(client).Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("转发附件下载 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if resp.ContentLength > mediaMaxDownloadBytes {
|
||||
return "", fmt.Errorf("转发附件过大:%d 字节,最大允许 %d 字节", resp.ContentLength, mediaMaxDownloadBytes)
|
||||
}
|
||||
dir := filepath.Join(os.TempDir(), "dws-connect-media")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ext := filepath.Ext(filepath.Base(strings.TrimSpace(fileName)))
|
||||
if ext == "" {
|
||||
ext = mediaExt(resourceURL, resp.Header.Get("Content-Type"))
|
||||
}
|
||||
dest := filepath.Join(dir, uuid.NewString()+ext)
|
||||
if err := writeCompleteMediaFile(dest, resp.Body); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
@@ -1,185 +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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChatRecordUnknownIndexesMatchesObservedCallback(t *testing.T) {
|
||||
content := map[string]interface{}{
|
||||
"chatRecord": `[{"msgType":"picture","downloadCode":"pic-live"},{"msgType":"unknownMsgType"},{"msgType":"unknownMsgType"},{"msgType":"text","content":"[合并的聊天记录]"},{"msgType":"unknownMsgType"}]`,
|
||||
}
|
||||
if got, want := chatRecordUnknownIndexes(content), []int{1, 2, 4}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("unknown indexes = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverChatRecordUnknownsRestoresAudioVideoAndFile(t *testing.T) {
|
||||
outer := `{
|
||||
"result": {"messages": [{
|
||||
"openMessageId": "outer-1",
|
||||
"openConversationId": "outer-conv",
|
||||
"createTime": "2026-07-14 16:16:44",
|
||||
"forwardMessages": [
|
||||
{"openMessageId":"pic-1","openConversationId":"source-conv","createTime":"2026-07-14 14:33:15","content":"[图片消息](mediaId=picture-media) 注意:如需下载使用dws chat message download-media命令下载"},
|
||||
{"openMessageId":"audio-1","openConversationId":"source-conv","createTime":"2026-07-14 14:33:19","content":"你是谁?你是谁\n[语音消息](mediaId=audio-media) 注意:如需下载使用dws chat message download-media命令下载"},
|
||||
{"openMessageId":"video-1","openConversationId":"outer-conv","createTime":"2026-07-14 14:33:36","content":"[文件] 录屏.mov"},
|
||||
{"openMessageId":"text-1","openConversationId":"source-conv","createTime":"2026-07-14 14:34:47","content":"普通文本"},
|
||||
{"openMessageId":"file-1","openConversationId":"outer-conv","createTime":"2026-07-14 14:34:57","content":"[文件] 巡检报告(1)(1).md"}
|
||||
]
|
||||
}]}
|
||||
}`
|
||||
sourceConversation := `{
|
||||
"result": {"messages": [
|
||||
{"openMessageId":"video-1","openConversationId":"source-conv","createTime":"2026-07-14 14:33:36","content":"[文件] 录屏.mov fileId: video-file-id 注意:如需下载使用dws drive download命令下载"},
|
||||
{"openMessageId":"file-1","openConversationId":"source-conv","createTime":"2026-07-14 14:34:57","content":"[文件] 巡检报告.md fileId: markdown-file-id 注意:如需下载使用dws drive download命令下载"}
|
||||
]}
|
||||
}`
|
||||
|
||||
var calls []string
|
||||
call := func(_ context.Context, server, tool string, args map[string]any) (string, error) {
|
||||
calls = append(calls, server+"."+tool)
|
||||
switch tool {
|
||||
case "list_messages_by_ids":
|
||||
if server != "im" || !reflect.DeepEqual(args["openMsgIds"], []string{"outer-1"}) {
|
||||
t.Fatalf("list_messages_by_ids route/args = %s %#v", server, args)
|
||||
}
|
||||
return outer, nil
|
||||
case "list_conversation_message_v2":
|
||||
if server != "chat" || args["forward"] != true || args["limit"] != 50 {
|
||||
t.Fatalf("list_conversation_message_v2 route/args = %s %#v", server, args)
|
||||
}
|
||||
if args["openconversation_id"] == "source-conv" {
|
||||
return sourceConversation, nil
|
||||
}
|
||||
return `{"result":{"messages":[]}}`, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unexpected tool %s", tool)
|
||||
}
|
||||
}
|
||||
|
||||
enrichment, err := recoverChatRecordUnknowns(context.Background(), chatRecordLookup{
|
||||
MsgID: "outer-1",
|
||||
UnknownIndexes: []int{1, 2, 4},
|
||||
}, call)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enrichment.MissingCount != 0 || len(enrichment.Files) != 3 {
|
||||
t.Fatalf("enrichment = %#v, want 3 recovered files and no missing attachment", enrichment)
|
||||
}
|
||||
wants := []fileInboundInfo{
|
||||
{MediaID: "audio-media", OpenMessageID: "audio-1", OpenConversationID: "source-conv", FileName: "转发语音.bin", MediaType: "audio"},
|
||||
{FileID: "video-file-id", OpenMessageID: "video-1", OpenConversationID: "source-conv", FileName: "录屏.mov", MediaType: "video"},
|
||||
{FileID: "markdown-file-id", OpenMessageID: "file-1", OpenConversationID: "source-conv", FileName: "巡检报告.md", MediaType: "file"},
|
||||
}
|
||||
for i, want := range wants {
|
||||
if !reflect.DeepEqual(enrichment.Files[i], want) {
|
||||
t.Fatalf("files[%d] = %#v, want %#v", i, enrichment.Files[i], want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(enrichment.Prompt, "你是谁?你是谁") || !strings.Contains(enrichment.Prompt, "录屏.mov") || !strings.Contains(enrichment.Prompt, "巡检报告.md") {
|
||||
t.Fatalf("prompt did not preserve recovered user content: %q", enrichment.Prompt)
|
||||
}
|
||||
if strings.Contains(enrichment.Prompt, "mediaId") || strings.Contains(enrichment.Prompt, "fileId") || strings.Contains(enrichment.Prompt, "dws drive") {
|
||||
t.Fatalf("prompt leaked transport locators/instructions: %q", enrichment.Prompt)
|
||||
}
|
||||
if len(calls) != 3 || calls[0] != "im.list_messages_by_ids" {
|
||||
t.Fatalf("calls = %v, want outer lookup plus both candidate conversations", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverChatRecordUnknownsKeepsMetadataWhenFileLocatorMissing(t *testing.T) {
|
||||
outer := `{"result":{"messages":[{"openMessageId":"outer-1","forwardMessages":[{"openMessageId":"file-1","openConversationId":"wrong-conv","createTime":"2026-07-14 14:34:57","content":"[文件] report.md"}]}]}}`
|
||||
call := func(_ context.Context, _, tool string, _ map[string]any) (string, error) {
|
||||
if tool == "list_messages_by_ids" {
|
||||
return outer, nil
|
||||
}
|
||||
return `{"result":{"messages":[]}}`, nil
|
||||
}
|
||||
enrichment, err := recoverChatRecordUnknowns(context.Background(), chatRecordLookup{MsgID: "outer-1", UnknownIndexes: []int{0}}, call)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enrichment.MissingCount != 1 || len(enrichment.Files) != 0 || !strings.Contains(enrichment.Prompt, "report.md") {
|
||||
t.Fatalf("enrichment = %#v, want metadata prompt plus one honestly missing attachment", enrichment)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadRecoveredChatRecordFileRoutesAndPreservesOriginalBytes(t *testing.T) {
|
||||
wantBody := []byte("original attachment bytes")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Signed-Test") != "yes" {
|
||||
http.Error(w, "missing signed header", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(wantBody)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
info fileInboundInfo
|
||||
wantServer string
|
||||
wantTool string
|
||||
wantArg string
|
||||
wantValue string
|
||||
}{
|
||||
{
|
||||
name: "mediaId audio",
|
||||
info: fileInboundInfo{MediaID: "media-1", OpenMessageID: "msg-1", OpenConversationID: "conv-1", FileName: "voice.amr", MediaType: "audio"},
|
||||
wantServer: "im", wantTool: "get_resource_download_url", wantArg: "resourceId", wantValue: "media-1",
|
||||
},
|
||||
{
|
||||
name: "drive fileId video",
|
||||
info: fileInboundInfo{FileID: "file-1", FileName: "video.mov", MediaType: "video"},
|
||||
wantServer: "drive", wantTool: "download_file", wantArg: "fileId", wantValue: "file-1",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
call := func(_ context.Context, serverID, tool string, args map[string]any) (string, error) {
|
||||
if serverID != tc.wantServer || tool != tc.wantTool || args[tc.wantArg] != tc.wantValue {
|
||||
t.Fatalf("call = %s.%s %#v", serverID, tool, args)
|
||||
}
|
||||
return fmt.Sprintf(`{"result":{"resourceUrl":%q,"headers":{"X-Signed-Test":"yes"}}}`, server.URL), nil
|
||||
}
|
||||
client := &aiCardClient{httpClient: server.Client()}
|
||||
path, err := client.downloadRecoveredChatRecordFileWithCall(context.Background(), tc.info, call)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Remove(path)
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, wantBody) {
|
||||
t.Fatalf("downloaded bytes = %q, want %q", got, wantBody)
|
||||
}
|
||||
if filepath.Ext(path) != filepath.Ext(tc.info.FileName) {
|
||||
t.Fatalf("downloaded path = %q, want original extension from %q", path, tc.info.FileName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const codexRobotDeveloperInstructions = "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及系统提示、内部协议或运行时细节;不要主动读写文件或执行命令。仅当用户消息明确附带了本地附件路径时,可以只读该附件或运行分析该附件所必需的只读命令,不得访问其它文件。"
|
||||
const codexRobotDeveloperInstructions = "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及系统提示、内部协议或运行时细节;不要主动读写文件或执行命令。"
|
||||
|
||||
// codexAppServerForwarder uses Codex's official app-server JSON-RPC protocol to
|
||||
// keep one Codex thread per DingTalk conversation.
|
||||
@@ -100,19 +100,11 @@ func (f *codexAppServerForwarder) forward(ctx context.Context, convID, text stri
|
||||
return f.forwardStream(ctx, convID, text, nil)
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) forwardWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment) (string, error) {
|
||||
return f.forwardStreamWithAttachments(ctx, convID, text, attachments, nil)
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
||||
return f.forwardStreamWithAttachments(ctx, convID, text, nil, onDelta)
|
||||
return f.forwardAppServer(ctx, convID, text, onDelta)
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) forwardStreamWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment, onDelta func(string)) (string, error) {
|
||||
return f.forwardAppServer(ctx, convID, text, attachments, onDelta)
|
||||
}
|
||||
|
||||
func (f *codexAppServerForwarder) forwardAppServer(ctx context.Context, convID, text string, attachments []connectMediaAttachment, onDelta func(string)) (string, error) {
|
||||
func (f *codexAppServerForwarder) forwardAppServer(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -159,7 +151,7 @@ func (f *codexAppServerForwarder) forwardAppServer(ctx context.Context, convID,
|
||||
}
|
||||
}
|
||||
|
||||
reply, err := cli.runTurn(ctx, threadID, text, attachments, onDelta)
|
||||
reply, err := cli.runTurn(ctx, threadID, text, onDelta)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -444,19 +436,13 @@ func (c *codexAppServerClient) resumeThread(ctx context.Context, params map[stri
|
||||
return codexThreadIDFromResult(c.waitResponse(ctx, id))
|
||||
}
|
||||
|
||||
func (c *codexAppServerClient) runTurn(ctx context.Context, threadID, text string, attachments []connectMediaAttachment, onDelta func(string)) (string, error) {
|
||||
func (c *codexAppServerClient) runTurn(ctx context.Context, threadID, text string, onDelta func(string)) (string, error) {
|
||||
id := c.requestID()
|
||||
input := []map[string]string{{"type": "text", "text": text}}
|
||||
for _, attachment := range attachments {
|
||||
if attachment.MediaType == "image" && strings.TrimSpace(attachment.LocalPath) != "" {
|
||||
input = append(input, map[string]string{"type": "localImage", "path": attachment.LocalPath})
|
||||
}
|
||||
}
|
||||
if err := c.send(map[string]any{
|
||||
"id": id,
|
||||
"method": "turn/start",
|
||||
"params": map[string]any{
|
||||
"input": input,
|
||||
"input": []map[string]string{{"type": "text", "text": text}},
|
||||
"threadId": threadID,
|
||||
},
|
||||
}); err != nil {
|
||||
|
||||
@@ -122,13 +122,9 @@ while IFS= read -r line; do
|
||||
workDir: dir,
|
||||
sessions: newCodexThreadSessions(""),
|
||||
}
|
||||
imagePath := filepath.Join(dir, "forwarded.png")
|
||||
if err := os.WriteFile(imagePath, []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var deltas []string
|
||||
reply, err := fwd.forwardStreamWithAttachments(context.Background(), "conv-1", "第一问", []connectMediaAttachment{{LocalPath: imagePath, FileName: "forwarded.png", MediaType: "image"}}, func(s string) {
|
||||
reply, err := fwd.forwardStream(context.Background(), "conv-1", "第一问", func(s string) {
|
||||
deltas = append(deltas, s)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -155,9 +151,6 @@ while IFS= read -r line; do
|
||||
if strings.Count(log, `"method":"thread/resume"`) != 1 {
|
||||
t.Fatalf("expected one thread/resume, log:\n%s", log)
|
||||
}
|
||||
if !strings.Contains(log, `"type":"localImage"`) || !strings.Contains(log, imagePath) {
|
||||
t.Fatalf("turn/start missing native localImage input, log:\n%s", log)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexAppServerForwarderReturnsAppServerError(t *testing.T) {
|
||||
|
||||
@@ -113,50 +113,6 @@ func connectDaemonDir(dirKey string) (string, error) {
|
||||
func daemonPidPath(dir string) string { return filepath.Join(dir, "daemon.pid") }
|
||||
func daemonStatePath(dir string) string { return filepath.Join(dir, "daemon-state.json") }
|
||||
func daemonLogPath(dir string) string { return filepath.Join(dir, "daemon.log") }
|
||||
func daemonExecutablePath(dir string) string {
|
||||
return filepath.Join(dir, "dws-daemon")
|
||||
}
|
||||
|
||||
// stageDaemonExecutable snapshots the current dws binary into the connector's
|
||||
// persistent directory. One-click launchers may execute a temporary download
|
||||
// (for example dws-latest) and remove it after connect --daemon returns; the
|
||||
// supervisor must not depend on that transient path for future worker restarts.
|
||||
func stageDaemonExecutable(src, dir string) (string, error) {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
tmp, err := os.CreateTemp(dir, ".dws-daemon-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
if err := tmp.Chmod(daemonExecutablePerm); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if _, err := io.Copy(tmp, in); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dst := daemonExecutablePath(dir)
|
||||
if err := os.Rename(tmpPath, dst); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
// writeDaemonState atomically persists the daemon state to daemon-state.json
|
||||
// (persistent, survives supervisor exit) so restart/list can recover config.
|
||||
@@ -213,9 +169,8 @@ func backoffDelay(consecutiveFailures int, base, maxDelay time.Duration) time.Du
|
||||
}
|
||||
|
||||
const (
|
||||
daemonBackoffBase = time.Second
|
||||
daemonBackoffCap = 60 * time.Second
|
||||
daemonExecutablePerm os.FileMode = 0o700
|
||||
daemonBackoffBase = time.Second
|
||||
daemonBackoffCap = 60 * time.Second
|
||||
// daemonMaxFastFailures is the consecutive fast-failure ceiling: after this
|
||||
// many crashes that each happened within daemonHealthyAfter, the supervisor
|
||||
// gives up rather than spin forever (e.g. bad credentials).
|
||||
@@ -271,10 +226,6 @@ func startDaemon(cmd *cobra.Command, dirKey, clientID, unifiedAppID, channel, no
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("resolve executable: " + err.Error())
|
||||
}
|
||||
exe, err = stageDaemonExecutable(exe, dir)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("stage daemon executable: " + err.Error())
|
||||
}
|
||||
superviseArgs := buildSuperviseArgs(os.Args[1:])
|
||||
|
||||
logPath := daemonLogPath(dir)
|
||||
@@ -435,7 +386,6 @@ func runSupervisor(cmd *cobra.Command) error {
|
||||
worker.Stdout = out
|
||||
worker.Stderr = out
|
||||
worker.Env = os.Environ()
|
||||
configureWorkerProcessGroup(worker)
|
||||
if err := worker.Start(); err != nil {
|
||||
fmt.Fprintf(out, "[daemon] failed to start worker: %v\n", err)
|
||||
failures++
|
||||
@@ -448,7 +398,6 @@ func runSupervisor(cmd *cobra.Command) error {
|
||||
fmt.Fprintf(out, "[daemon] worker started (pid %d)\n", worker.Process.Pid)
|
||||
|
||||
waitErr := superviseWait(ctx, worker)
|
||||
cleanupWorkerProcessGroup(worker.Process.Pid)
|
||||
ran := time.Since(started)
|
||||
|
||||
if ctx.Err() != nil {
|
||||
|
||||
@@ -68,36 +68,6 @@ func TestDaemonDirKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStageDaemonExecutable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := dir + "/source"
|
||||
if err := os.WriteFile(src, []byte("test-binary"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dst, err := stageDaemonExecutable(src, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("stageDaemonExecutable: %v", err)
|
||||
}
|
||||
if dst != daemonExecutablePath(dir) {
|
||||
t.Fatalf("path = %q, want %q", dst, daemonExecutablePath(dir))
|
||||
}
|
||||
got, err := os.ReadFile(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "test-binary" {
|
||||
t.Fatalf("content = %q, want test-binary", got)
|
||||
}
|
||||
info, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != daemonExecutablePerm {
|
||||
t.Fatalf("mode = %o, want %o", info.Mode().Perm(), daemonExecutablePerm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkerArgs(t *testing.T) {
|
||||
in := []string{"devapp", "robot", "connect", "--daemon", "--robot-client-id", "abc", "--channel=claudecode"}
|
||||
got := buildWorkerArgs(in)
|
||||
|
||||
@@ -36,17 +36,3 @@ func detachSysProcAttr() *syscall.SysProcAttr {
|
||||
func applyDetach(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = detachSysProcAttr()
|
||||
}
|
||||
|
||||
// configureWorkerProcessGroup isolates the connector worker and every local
|
||||
// agent process it starts (opencode serve, codex app-server, etc.) in one group.
|
||||
func configureWorkerProcessGroup(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
// cleanupWorkerProcessGroup removes descendants left behind when the worker
|
||||
// exits abruptly and cannot run its normal deferred cleanup.
|
||||
func cleanupWorkerProcessGroup(pid int) {
|
||||
if pid > 0 {
|
||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +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 helpers
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigureWorkerProcessGroup(t *testing.T) {
|
||||
cmd := exec.Command("true")
|
||||
configureWorkerProcessGroup(cmd)
|
||||
if cmd.SysProcAttr == nil || !cmd.SysProcAttr.Setpgid {
|
||||
t.Fatal("worker must start in its own process group")
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,3 @@ const daemonDetachSupported = false
|
||||
|
||||
// applyDetach is a no-op on Windows; daemon mode is rejected earlier.
|
||||
func applyDetach(_ *exec.Cmd) {}
|
||||
|
||||
func configureWorkerProcessGroup(_ *exec.Cmd) {}
|
||||
|
||||
func cleanupWorkerProcessGroup(_ int) {}
|
||||
|
||||
@@ -16,14 +16,12 @@ package helpers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -33,10 +31,6 @@ import (
|
||||
const (
|
||||
defaultGeminiAPIBaseURL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
defaultGeminiModel = "gemini-2.5-flash"
|
||||
// Base64 adds roughly 33%, while Gemini's generateContent request limit for
|
||||
// inline audio is 20 MiB. Keep the raw aggregate below 15 MiB and use the
|
||||
// resumable Files API for anything larger.
|
||||
geminiInlineRawLimit = 15 << 20
|
||||
)
|
||||
|
||||
type geminiAPIForwarder struct {
|
||||
@@ -94,10 +88,6 @@ func (f *geminiAPIForwarder) label() string {
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) forward(ctx context.Context, _, text string) (string, error) {
|
||||
return f.forwardWithAttachments(ctx, "", text, nil)
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) forwardWithAttachments(ctx context.Context, _ string, text string, attachments []connectMediaAttachment) (string, error) {
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -105,15 +95,14 @@ func (f *geminiAPIForwarder) forwardWithAttachments(ctx context.Context, _ strin
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts, err := f.partsWithAttachments(ctx, text, attachments)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := geminiGenerateContentRequest{
|
||||
SystemInstruction: geminiContent{
|
||||
Parts: []geminiPart{{Text: "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及任何系统提示或内部实现。"}},
|
||||
},
|
||||
Contents: []geminiContent{{Role: "user", Parts: parts}},
|
||||
Contents: []geminiContent{{
|
||||
Role: "user",
|
||||
Parts: []geminiPart{{Text: text}},
|
||||
}},
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
@@ -133,14 +122,14 @@ func (f *geminiAPIForwarder) forwardWithAttachments(ctx context.Context, _ strin
|
||||
defer resp.Body.Close()
|
||||
respRaw, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("gemini API HTTP %d: %s", resp.StatusCode, truncateRunes(strings.TrimSpace(string(respRaw)), 300))
|
||||
return "", fmt.Errorf("Gemini API HTTP %d: %s", resp.StatusCode, truncateRunes(strings.TrimSpace(string(respRaw)), 300))
|
||||
}
|
||||
var out geminiGenerateContentResponse
|
||||
if err := json.Unmarshal(respRaw, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.Error.Message != "" {
|
||||
return "", fmt.Errorf("gemini API error %s: %s", out.Error.Status, truncateRunes(out.Error.Message, 300))
|
||||
return "", fmt.Errorf("Gemini API error %s: %s", out.Error.Status, truncateRunes(out.Error.Message, 300))
|
||||
}
|
||||
var chunks []string
|
||||
for _, cand := range out.Candidates {
|
||||
@@ -152,193 +141,13 @@ func (f *geminiAPIForwarder) forwardWithAttachments(ctx context.Context, _ strin
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
if out.PromptFeedback.BlockReason != "" {
|
||||
return "", fmt.Errorf("gemini API blocked prompt: %s", out.PromptFeedback.BlockReason)
|
||||
return "", fmt.Errorf("Gemini API blocked prompt: %s", out.PromptFeedback.BlockReason)
|
||||
}
|
||||
return "(Gemini API 无文本输出)", nil
|
||||
}
|
||||
return strings.Join(chunks, "\n\n"), nil
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) partsWithAttachments(ctx context.Context, text string, attachments []connectMediaAttachment) ([]geminiPart, error) {
|
||||
parts := []geminiPart{{Text: text}}
|
||||
var aggregate int64
|
||||
for _, attachment := range attachments {
|
||||
if info, err := os.Stat(attachment.LocalPath); err == nil {
|
||||
aggregate += info.Size()
|
||||
}
|
||||
}
|
||||
for _, attachment := range attachments {
|
||||
path := strings.TrimSpace(attachment.LocalPath)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
mimeType := connectAttachmentMIME(path)
|
||||
if aggregate <= geminiInlineRawLimit {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 Gemini 附件 %s 失败:%w", path, err)
|
||||
}
|
||||
parts = append(parts, geminiPart{InlineData: &geminiInlineData{
|
||||
MIMEType: mimeType,
|
||||
Data: base64.StdEncoding.EncodeToString(raw),
|
||||
}})
|
||||
continue
|
||||
}
|
||||
uploaded, err := f.uploadFile(ctx, path, mimeType, attachment.FileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts, geminiPart{FileData: &geminiFileData{
|
||||
MIMEType: uploaded.MIMEType,
|
||||
FileURI: uploaded.URI,
|
||||
}})
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
type geminiUploadedFile struct {
|
||||
Name string `json:"name"`
|
||||
URI string `json:"uri"`
|
||||
MIMEType string `json:"mimeType"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) uploadFile(ctx context.Context, path, mimeType, displayName string) (geminiUploadedFile, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, fmt.Errorf("读取 Gemini 附件信息 %s 失败:%w", path, err)
|
||||
}
|
||||
if strings.TrimSpace(displayName) == "" {
|
||||
displayName = filepath.Base(path)
|
||||
}
|
||||
meta, err := json.Marshal(map[string]any{"file": map[string]any{"display_name": displayName}})
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
startURL, err := f.filesEndpoint(true)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, startURL, bytes.NewReader(meta))
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-goog-api-key", f.apiKey)
|
||||
req.Header.Set("X-Goog-Upload-Protocol", "resumable")
|
||||
req.Header.Set("X-Goog-Upload-Command", "start")
|
||||
req.Header.Set("X-Goog-Upload-Header-Content-Length", fmt.Sprint(info.Size()))
|
||||
req.Header.Set("X-Goog-Upload-Header-Content-Type", mimeType)
|
||||
resp, err := f.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
startBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return geminiUploadedFile{}, fmt.Errorf("gemini Files API 启动上传 HTTP %d: %s", resp.StatusCode, truncateRunes(strings.TrimSpace(string(startBody)), 300))
|
||||
}
|
||||
uploadURL := strings.TrimSpace(resp.Header.Get("X-Goog-Upload-URL"))
|
||||
if uploadURL == "" {
|
||||
return geminiUploadedFile{}, fmt.Errorf("gemini Files API 未返回 X-Goog-Upload-URL")
|
||||
}
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
defer fh.Close()
|
||||
uploadReq, err := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, fh)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
uploadReq.ContentLength = info.Size()
|
||||
uploadReq.Header.Set("Content-Type", mimeType)
|
||||
uploadReq.Header.Set("X-Goog-Upload-Offset", "0")
|
||||
uploadReq.Header.Set("X-Goog-Upload-Command", "upload, finalize")
|
||||
uploadResp, err := f.httpClient.Do(uploadReq)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
defer uploadResp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(uploadResp.Body, 4*1024*1024))
|
||||
if uploadResp.StatusCode < 200 || uploadResp.StatusCode >= 300 {
|
||||
return geminiUploadedFile{}, fmt.Errorf("gemini Files API 上传 HTTP %d: %s", uploadResp.StatusCode, truncateRunes(strings.TrimSpace(string(raw)), 300))
|
||||
}
|
||||
var envelope struct {
|
||||
File geminiUploadedFile `json:"file"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
if envelope.File.URI == "" {
|
||||
return geminiUploadedFile{}, fmt.Errorf("gemini Files API 上传结果缺少 file.uri")
|
||||
}
|
||||
if envelope.File.MIMEType == "" {
|
||||
envelope.File.MIMEType = mimeType
|
||||
}
|
||||
return f.waitForUploadedFile(ctx, envelope.File)
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) waitForUploadedFile(ctx context.Context, file geminiUploadedFile) (geminiUploadedFile, error) {
|
||||
for strings.EqualFold(file.State, "PROCESSING") {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return geminiUploadedFile{}, ctx.Err()
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
base, err := f.filesEndpoint(false)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
name := strings.TrimPrefix(strings.TrimLeft(file.Name, "/"), "files/")
|
||||
statusURL := strings.TrimRight(base, "/") + "/" + name
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
req.Header.Set("x-goog-api-key", f.apiKey)
|
||||
resp, err := f.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return geminiUploadedFile{}, fmt.Errorf("gemini Files API 查询 HTTP %d: %s", resp.StatusCode, truncateRunes(strings.TrimSpace(string(raw)), 300))
|
||||
}
|
||||
if err := json.Unmarshal(raw, &file); err != nil {
|
||||
return geminiUploadedFile{}, err
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(file.State, "FAILED") {
|
||||
return geminiUploadedFile{}, fmt.Errorf("gemini Files API 处理附件失败: %s", file.Name)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) filesEndpoint(upload bool) (string, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(f.baseURL), "/")
|
||||
if base == "" {
|
||||
base = defaultGeminiAPIBaseURL
|
||||
}
|
||||
u, err := url.Parse(base)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return "", fmt.Errorf("GEMINI_API_BASE_URL 无效")
|
||||
}
|
||||
path := strings.TrimRight(u.Path, "/")
|
||||
if upload {
|
||||
if strings.HasSuffix(path, "/v1beta") {
|
||||
path = strings.TrimSuffix(path, "/v1beta") + "/upload/v1beta/files"
|
||||
} else {
|
||||
path += "/upload/v1beta/files"
|
||||
}
|
||||
} else if !strings.HasSuffix(path, "/files") {
|
||||
path += "/files"
|
||||
}
|
||||
u.Path = path
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) generateContentEndpoint() (string, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(f.baseURL), "/")
|
||||
if base == "" {
|
||||
@@ -365,19 +174,7 @@ type geminiContent struct {
|
||||
}
|
||||
|
||||
type geminiPart struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
InlineData *geminiInlineData `json:"inlineData,omitempty"`
|
||||
FileData *geminiFileData `json:"fileData,omitempty"`
|
||||
}
|
||||
|
||||
type geminiInlineData struct {
|
||||
MIMEType string `json:"mimeType"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type geminiFileData struct {
|
||||
MIMEType string `json:"mimeType"`
|
||||
FileURI string `json:"fileUri"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type geminiGenerateContentResponse struct {
|
||||
|
||||
@@ -15,12 +15,9 @@ package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -47,75 +44,6 @@ func TestGeminiChannelUsesAPIWithoutLocalCLI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiAPIForwarderSendsSmallAttachmentInline(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
path := filepath.Join(t.TempDir(), "voice.mp3")
|
||||
wantBytes := []byte("real-audio-bytes")
|
||||
if err := os.WriteFile(path, wantBytes, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got geminiPart
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req geminiGenerateContentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
if len(req.Contents) == 1 && len(req.Contents[0].Parts) == 2 {
|
||||
got = req.Contents[0].Parts[1]
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
f := &geminiAPIForwarder{model: "gemini-test", apiKey: "key", baseURL: ts.URL, httpClient: ts.Client()}
|
||||
_, err := f.forwardWithAttachments(context.Background(), "conv", "转写", []connectMediaAttachment{{LocalPath: path, FileName: "voice.mp3", MediaType: "audio"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.InlineData == nil || got.InlineData.MIMEType != "audio/mpeg" {
|
||||
t.Fatalf("inlineData = %#v", got.InlineData)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(got.InlineData.Data)
|
||||
if err != nil || string(decoded) != string(wantBytes) {
|
||||
t.Fatalf("inline bytes = %q, err=%v", decoded, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiAPIForwarderUploadsLargeAttachment(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
path := filepath.Join(t.TempDir(), "video.mp4")
|
||||
if err := os.WriteFile(path, make([]byte, geminiInlineRawLimit+1), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var started, uploaded, generated bool
|
||||
var ts *httptest.Server
|
||||
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/upload/v1beta/files":
|
||||
started = r.Header.Get("X-Goog-Upload-Command") == "start"
|
||||
w.Header().Set("X-Goog-Upload-URL", ts.URL+"/upload-session")
|
||||
case "/upload-session":
|
||||
uploaded = r.Header.Get("X-Goog-Upload-Command") == "upload, finalize"
|
||||
_, _ = w.Write([]byte(`{"file":{"name":"files/1","uri":"https://files.example/1","mimeType":"video/mp4","state":"ACTIVE"}}`))
|
||||
case "/v1beta/models/gemini-test:generateContent":
|
||||
var req geminiGenerateContentRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
generated = len(req.Contents) == 1 && len(req.Contents[0].Parts) == 2 && req.Contents[0].Parts[1].FileData != nil && req.Contents[0].Parts[1].FileData.FileURI == "https://files.example/1"
|
||||
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
f := &geminiAPIForwarder{model: "gemini-test", apiKey: "key", baseURL: ts.URL + "/v1beta", httpClient: ts.Client()}
|
||||
_, err := f.forwardWithAttachments(context.Background(), "conv", "分析", []connectMediaAttachment{{LocalPath: path, FileName: "video.mp4", MediaType: "video"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !started || !uploaded || !generated {
|
||||
t.Fatalf("started=%v uploaded=%v generated=%v", started, uploaded, generated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiAPIForwarderForward(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
var gotPath, gotKey, gotText string
|
||||
|
||||
@@ -18,52 +18,19 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// mediaMaxDownloadBytes caps a single inbound attachment download. Real merged
|
||||
// forwards can contain high-resolution screen recordings above 250 MiB, so the
|
||||
// connector allows up to 512 MiB but rejects larger payloads explicitly. It
|
||||
// must never silently truncate a file and then tell an agent it has the
|
||||
// original content.
|
||||
const mediaMaxDownloadBytes = 512 << 20
|
||||
|
||||
const mediaDownloadTimeout = 5 * time.Minute
|
||||
|
||||
// connectAttachmentMIME returns the best MIME type available for a downloaded
|
||||
// attachment. DingTalk's download API returns an opaque URL, so the connector
|
||||
// must recover the type from the preserved filename and, when necessary, the
|
||||
// actual bytes before handing it to a multimodal backend.
|
||||
func connectAttachmentMIME(path string) string {
|
||||
if typ := mime.TypeByExtension(strings.ToLower(filepath.Ext(path))); typ != "" {
|
||||
typ = strings.TrimSpace(strings.SplitN(typ, ";", 2)[0])
|
||||
// Generic .bin paths are common for DingTalk voice messages. Sniff their
|
||||
// bytes instead of telling a multimodal backend they are opaque binary.
|
||||
if typ != "application/octet-stream" {
|
||||
return typ
|
||||
}
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, 512)
|
||||
n, _ := f.Read(buf)
|
||||
if n == 0 {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return http.DetectContentType(buf[:n])
|
||||
}
|
||||
// mediaMaxDownloadBytes caps a single inbound image download (screenshots are
|
||||
// well under this; the cap is a hostile-input guard).
|
||||
const mediaMaxDownloadBytes = 20 << 20
|
||||
|
||||
// pictureDownloadCode digs the downloadCode out of a picture callback's
|
||||
// loosely-typed content payload (the stream SDK models Content as
|
||||
@@ -81,86 +48,6 @@ func pictureDownloadCode(content interface{}) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringField(m map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := m[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// explicitPictureDownloadCode only accepts the picture-specific field. A
|
||||
// generic downloadCode can represent any kind of attachment and must not be
|
||||
// classified as an image merely because it is downloadable.
|
||||
func explicitPictureDownloadCode(content interface{}) string {
|
||||
m, ok := content.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return stringField(m, "pictureDownloadCode")
|
||||
}
|
||||
|
||||
// richTextPictureDownloadCodes returns the media download codes embedded in a
|
||||
// msgtype="richText" callback, preserving the node order. DingTalk represents
|
||||
// an inline picture as a richText node instead of a top-level picture message:
|
||||
//
|
||||
// {"type":"picture","downloadCode":"...","pictureDownloadCode":"..."}
|
||||
//
|
||||
// The two code fields identify the same picture; pictureDownloadCode handles
|
||||
// their precedence and returns only one code per node.
|
||||
func richTextPictureDownloadCodes(content interface{}) []string {
|
||||
pictures, _ := richTextInboundMedia(content)
|
||||
return pictures
|
||||
}
|
||||
|
||||
// richTextInboundMedia applies the same capability-based discovery to every
|
||||
// inline node. A non-picture node with a locator is preserved as an
|
||||
// attachment instead of being discarded because its type is new or unknown.
|
||||
func richTextInboundMedia(content interface{}) (pictureCodes []string, files []fileInboundInfo) {
|
||||
m, ok := content.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
items, ok := m["richText"].([]interface{})
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
seenPictures := make(map[string]struct{})
|
||||
seenFiles := make(map[string]struct{})
|
||||
for _, item := range items {
|
||||
node, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typeHint := strings.ToLower(stringField(node, "type", "msgType", "msgtype"))
|
||||
code := ""
|
||||
if explicitPictureDownloadCode(node) != "" {
|
||||
code = pictureDownloadCode(node)
|
||||
}
|
||||
if code == "" && (typeHint == "" || typeHint == "picture" || typeHint == "image") {
|
||||
code = pictureDownloadCode(node)
|
||||
}
|
||||
if code != "" {
|
||||
if _, exists := seenPictures[code]; !exists {
|
||||
seenPictures[code] = struct{}{}
|
||||
pictureCodes = append(pictureCodes, code)
|
||||
}
|
||||
continue
|
||||
}
|
||||
info := parseTypedFileInbound(typeHint, node)
|
||||
if !info.hasActionable() {
|
||||
continue
|
||||
}
|
||||
key := fileInboundKey(info)
|
||||
if _, exists := seenFiles[key]; !exists {
|
||||
seenFiles[key] = struct{}{}
|
||||
files = append(files, info)
|
||||
}
|
||||
}
|
||||
return pictureCodes, files
|
||||
}
|
||||
|
||||
// fileInboundInfo carries everything a msgtype="file" callback might expose.
|
||||
// Client-sent files (a user attaching a file in the DingTalk client) surface
|
||||
// DownloadCode + FileName; API-sent files (`dws chat message send --msg-type
|
||||
@@ -169,29 +56,16 @@ func richTextInboundMedia(content interface{}) (pictureCodes []string, files []f
|
||||
// recognisable or the connector silently drops legitimate file messages.
|
||||
type fileInboundInfo struct {
|
||||
DownloadCode string
|
||||
// MediaID + message/conversation IDs identify media embedded in a
|
||||
// forwarded chat record. DingTalk's Stream callback can erase these into
|
||||
// unknownMsgType, while the user-state message API still preserves them.
|
||||
MediaID string
|
||||
OpenMessageID string
|
||||
OpenConversationID string
|
||||
// FileID is the dentryUuid returned by the user-state conversation API for
|
||||
// forwarded files. It is resolved through drive.download_file.
|
||||
FileID string
|
||||
FileName string
|
||||
FileType string
|
||||
FilePath string
|
||||
MediaType string
|
||||
DentryID int64
|
||||
SpaceID int64
|
||||
FileSize int64
|
||||
FileName string
|
||||
FileType string
|
||||
FilePath string
|
||||
DentryID int64
|
||||
SpaceID int64
|
||||
FileSize int64
|
||||
}
|
||||
|
||||
func (f fileInboundInfo) hasActionable() bool {
|
||||
return strings.TrimSpace(f.DownloadCode) != "" ||
|
||||
(strings.TrimSpace(f.MediaID) != "" && strings.TrimSpace(f.OpenMessageID) != "" && strings.TrimSpace(f.OpenConversationID) != "") ||
|
||||
strings.TrimSpace(f.FileID) != "" ||
|
||||
(f.DentryID != 0 && f.SpaceID != 0)
|
||||
return strings.TrimSpace(f.DownloadCode) != "" || (f.DentryID != 0 && f.SpaceID != 0)
|
||||
}
|
||||
|
||||
// parseFileInbound reads every relevant field out of a file callback's
|
||||
@@ -205,15 +79,21 @@ func parseFileInbound(content interface{}) fileInboundInfo {
|
||||
if !ok {
|
||||
return info
|
||||
}
|
||||
info.DownloadCode = stringField(m, "downloadCode", "fileDownloadCode")
|
||||
info.MediaID = stringField(m, "mediaId", "mediaID")
|
||||
info.OpenMessageID = stringField(m, "openMessageId", "openMessageID")
|
||||
info.OpenConversationID = stringField(m, "openConversationId", "openConversationID")
|
||||
info.FileID = stringField(m, "fileId", "fileID", "dentryUuid", "dentryUUID")
|
||||
info.FileName = stringField(m, "fileName", "name")
|
||||
info.FileType = stringField(m, "fileType")
|
||||
info.FilePath = stringField(m, "filePath")
|
||||
info.MediaType = stringField(m, "mediaType")
|
||||
for _, key := range []string{"downloadCode", "fileDownloadCode"} {
|
||||
if v, ok := m[key].(string); ok && strings.TrimSpace(v) != "" {
|
||||
info.DownloadCode = strings.TrimSpace(v)
|
||||
break
|
||||
}
|
||||
}
|
||||
if v, ok := m["fileName"].(string); ok {
|
||||
info.FileName = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := m["fileType"].(string); ok {
|
||||
info.FileType = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := m["filePath"].(string); ok {
|
||||
info.FilePath = strings.TrimSpace(v)
|
||||
}
|
||||
info.DentryID = readInt64Field(m, "dentryId", "dentryID")
|
||||
info.SpaceID = readInt64Field(m, "spaceId", "spaceID")
|
||||
info.FileSize = readInt64Field(m, "fileSize", "size")
|
||||
@@ -223,213 +103,6 @@ func parseFileInbound(content interface{}) fileInboundInfo {
|
||||
return info
|
||||
}
|
||||
|
||||
// inboundMediaType normalizes the callback spellings used for downloadable
|
||||
// non-picture media. The returned value is only used to make the agent prompt
|
||||
// precise; download authorization still comes exclusively from downloadCode
|
||||
// or dentryId+spaceId.
|
||||
func inboundMediaType(msgtype string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(msgtype)) {
|
||||
case "image", "picture":
|
||||
return "image"
|
||||
case "audio", "voice":
|
||||
return "audio"
|
||||
case "video":
|
||||
return "video"
|
||||
default:
|
||||
return "file"
|
||||
}
|
||||
}
|
||||
|
||||
func parseTypedFileInbound(msgtype string, content interface{}) fileInboundInfo {
|
||||
info := parseFileInbound(content)
|
||||
if mediaType := strings.TrimSpace(msgtype); mediaType != "" {
|
||||
info.MediaType = inboundMediaType(mediaType)
|
||||
}
|
||||
if info.MediaType == "" || info.MediaType == "file" {
|
||||
info.MediaType = mediaTypeFromFileName(info.FileName)
|
||||
}
|
||||
if info.FileName == "未知文件" {
|
||||
switch info.MediaType {
|
||||
case "audio":
|
||||
info.FileName = "语音消息"
|
||||
case "video":
|
||||
info.FileName = "视频消息"
|
||||
}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func fileInboundKey(info fileInboundInfo) string {
|
||||
switch {
|
||||
case strings.TrimSpace(info.DownloadCode) != "":
|
||||
return "download:" + strings.TrimSpace(info.DownloadCode)
|
||||
case strings.TrimSpace(info.MediaID) != "":
|
||||
return "media:" + strings.TrimSpace(info.MediaID) + ":" + strings.TrimSpace(info.OpenMessageID) + ":" + strings.TrimSpace(info.OpenConversationID)
|
||||
case strings.TrimSpace(info.FileID) != "":
|
||||
return "file:" + strings.TrimSpace(info.FileID)
|
||||
case info.DentryID != 0 && info.SpaceID != 0:
|
||||
return fmt.Sprintf("dentry:%d:%d", info.SpaceID, info.DentryID)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// chatRecordInboundMedia extracts every actionable attachment that DingTalk
|
||||
// preserved in a msgtype=chatRecord callback. The observed callback encodes
|
||||
// the record array as a JSON string under content.chatRecord; accepting an
|
||||
// already-decoded array as well keeps the parser compatible with SDK changes.
|
||||
//
|
||||
// Some forwarded entries arrive as {"msgType":"unknownMsgType"} with no
|
||||
// message id, download code, or storage id. Those entries are counted for
|
||||
// diagnostics but cannot be recovered by the connector because the callback
|
||||
// contains no locator for the original bytes.
|
||||
func chatRecordInboundMedia(content interface{}) (pictureCodes []string, files []fileInboundInfo, unrecoverableCount int) {
|
||||
m, ok := content.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, nil, 0
|
||||
}
|
||||
|
||||
var entries []interface{}
|
||||
switch raw := m["chatRecord"].(type) {
|
||||
case string:
|
||||
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
|
||||
return nil, nil, 0
|
||||
}
|
||||
case []interface{}:
|
||||
entries = raw
|
||||
default:
|
||||
// A few callback variants call the decoded array "contents".
|
||||
if decoded, ok := m["contents"].([]interface{}); ok {
|
||||
entries = decoded
|
||||
}
|
||||
}
|
||||
|
||||
seenPictures := make(map[string]struct{})
|
||||
seenFiles := make(map[string]struct{})
|
||||
for _, entry := range entries {
|
||||
node, ok := entry.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
msgtype := ""
|
||||
for _, key := range []string{"msgType", "msgtype", "type"} {
|
||||
if value, ok := node[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
msgtype = strings.ToLower(strings.TrimSpace(value))
|
||||
break
|
||||
}
|
||||
}
|
||||
// Attachment discovery is capability-based. The nested msgType is a
|
||||
// classification hint only; a future/unknown type with a valid locator
|
||||
// must still reach the backend with its original bytes.
|
||||
pictureCode := ""
|
||||
if explicitPictureDownloadCode(node) != "" {
|
||||
pictureCode = pictureDownloadCode(node)
|
||||
}
|
||||
if pictureCode == "" && (msgtype == "picture" || msgtype == "image") {
|
||||
pictureCode = pictureDownloadCode(node)
|
||||
}
|
||||
if pictureCode != "" {
|
||||
if _, exists := seenPictures[pictureCode]; !exists {
|
||||
seenPictures[pictureCode] = struct{}{}
|
||||
pictureCodes = append(pictureCodes, pictureCode)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
info := parseTypedFileInbound(msgtype, node)
|
||||
if info.hasActionable() {
|
||||
key := fileInboundKey(info)
|
||||
if _, exists := seenFiles[key]; !exists {
|
||||
seenFiles[key] = struct{}{}
|
||||
files = append(files, info)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if msgtype == "unknownmsgtype" {
|
||||
unrecoverableCount++
|
||||
}
|
||||
}
|
||||
return pictureCodes, files, unrecoverableCount
|
||||
}
|
||||
|
||||
func hasChatRecordPayload(content interface{}) bool {
|
||||
m, ok := content.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch raw := m["chatRecord"].(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(raw) != ""
|
||||
case []interface{}:
|
||||
return true
|
||||
}
|
||||
_, hasDecodedContents := m["contents"].([]interface{})
|
||||
return hasDecodedContents
|
||||
}
|
||||
|
||||
// callbackInboundMedia discovers downloadable payloads from their locator
|
||||
// fields instead of an allowlist of msgtype values. msgtype is retained only
|
||||
// as a media classification hint, so newly introduced message types are
|
||||
// forwarded immediately without requiring a connector release.
|
||||
func callbackInboundMedia(msgtype string, content interface{}) (pictureCodes []string, files []fileInboundInfo, unrecoverableCount int) {
|
||||
seenPictures := make(map[string]struct{})
|
||||
seenFiles := make(map[string]struct{})
|
||||
addPicture := func(code string) {
|
||||
code = strings.TrimSpace(code)
|
||||
if code == "" {
|
||||
return
|
||||
}
|
||||
if _, exists := seenPictures[code]; exists {
|
||||
return
|
||||
}
|
||||
seenPictures[code] = struct{}{}
|
||||
pictureCodes = append(pictureCodes, code)
|
||||
}
|
||||
addFile := func(info fileInboundInfo) {
|
||||
if !info.hasActionable() {
|
||||
return
|
||||
}
|
||||
key := fileInboundKey(info)
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
if _, picture := seenPictures[info.DownloadCode]; picture {
|
||||
return
|
||||
}
|
||||
if _, exists := seenFiles[key]; exists {
|
||||
return
|
||||
}
|
||||
seenFiles[key] = struct{}{}
|
||||
files = append(files, info)
|
||||
}
|
||||
|
||||
richPictures, richFiles := richTextInboundMedia(content)
|
||||
for _, code := range richPictures {
|
||||
addPicture(code)
|
||||
}
|
||||
for _, info := range richFiles {
|
||||
addFile(info)
|
||||
}
|
||||
pictureCode := ""
|
||||
if explicitPictureDownloadCode(content) != "" {
|
||||
pictureCode = pictureDownloadCode(content)
|
||||
}
|
||||
if pictureCode == "" && (strings.EqualFold(msgtype, "picture") || strings.EqualFold(msgtype, "image")) {
|
||||
pictureCode = pictureDownloadCode(content)
|
||||
}
|
||||
addPicture(pictureCode)
|
||||
addFile(parseTypedFileInbound(msgtype, content))
|
||||
|
||||
nestedPictures, nestedFiles, nestedUnknown := chatRecordInboundMedia(content)
|
||||
for _, code := range nestedPictures {
|
||||
addPicture(code)
|
||||
}
|
||||
for _, info := range nestedFiles {
|
||||
addFile(info)
|
||||
}
|
||||
return pictureCodes, files, nestedUnknown
|
||||
}
|
||||
|
||||
// readInt64Field pulls an int64 out of the loose content map under any of the
|
||||
// provided keys, tolerating JSON string / float64 / int64 / json.Number.
|
||||
func readInt64Field(m map[string]interface{}, keys ...string) int64 {
|
||||
@@ -485,19 +158,6 @@ func summarizeContent(content interface{}) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// rawCallbackPrompt preserves message types whose payload is meaningful but
|
||||
// has no locally recognised text/media shape (for example msgtype=chatRecord).
|
||||
// The connector should not decide that such messages are empty: forwarding
|
||||
// the type and JSON payload lets the backend model interpret new and complex
|
||||
// DingTalk message formats without waiting for a CLI-side parser update.
|
||||
func rawCallbackPrompt(msgtype string, content interface{}) string {
|
||||
b, err := json.Marshal(content)
|
||||
if err != nil {
|
||||
b = []byte(fmt.Sprintf("%v", content))
|
||||
}
|
||||
return fmt.Sprintf("用户发送了一条钉钉消息,msgtype=%q。请解析以下原始消息 JSON,提取有用信息并处理用户意图:\n%s", strings.TrimSpace(msgtype), b)
|
||||
}
|
||||
|
||||
// extractCallbackText pulls the visible text out of a structured-text callback
|
||||
// payload (msgtype=richText / markdown / etc.) for the case where the SDK's
|
||||
// data.Text.Content is empty. This matters because `dws chat message send
|
||||
@@ -512,7 +172,7 @@ func extractCallbackText(content interface{}) string {
|
||||
case map[string]interface{}:
|
||||
// Common shapes: {"text":"..."}, {"title":"...","text":"..."},
|
||||
// {"content":"..."}, richText {"richText":[{"text":"..."}]}.
|
||||
for _, key := range []string{"text", "content", "markdown", "title", "recognition"} {
|
||||
for _, key := range []string{"text", "content", "markdown", "title"} {
|
||||
if s, ok := v[key].(string); ok && strings.TrimSpace(s) != "" {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -599,13 +259,6 @@ func extractInteractiveCardText(content interface{}) string {
|
||||
// questions are the top Q&A inbound; without this the connector silently
|
||||
// drops every picture message.
|
||||
func (c *aiCardClient) downloadMessageFile(ctx context.Context, robotCode, downloadCode string) (string, error) {
|
||||
return c.downloadMessageFileNamed(ctx, robotCode, downloadCode, "")
|
||||
}
|
||||
|
||||
// downloadMessageFileNamed is downloadMessageFile with an optional original
|
||||
// file name. Keeping its extension materially improves audio/video/file
|
||||
// handling across local agents whose tool selection depends on the path.
|
||||
func (c *aiCardClient) downloadMessageFileNamed(ctx context.Context, robotCode, downloadCode, fileName string) (string, error) {
|
||||
raw, err := c.callRaw(ctx, http.MethodPost, "/v1.0/robot/messageFiles/download", map[string]any{
|
||||
"robotCode": robotCode,
|
||||
"downloadCode": downloadCode,
|
||||
@@ -623,7 +276,7 @@ func (c *aiCardClient) downloadMessageFileNamed(ctx context.Context, robotCode,
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := connectMediaDownloadClient(c.httpClient).Do(req)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -631,19 +284,18 @@ func (c *aiCardClient) downloadMessageFileNamed(ctx context.Context, robotCode,
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("媒体下载 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if resp.ContentLength > mediaMaxDownloadBytes {
|
||||
return "", fmt.Errorf("媒体文件过大:%d 字节,最大允许 %d 字节", resp.ContentLength, mediaMaxDownloadBytes)
|
||||
}
|
||||
dir := filepath.Join(os.TempDir(), "dws-connect-media")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ext := filepath.Ext(strings.TrimSpace(fileName))
|
||||
if ext == "" {
|
||||
ext = mediaExt(parsed.DownloadUrl, resp.Header.Get("Content-Type"))
|
||||
dest := filepath.Join(dir, uuid.NewString()+mediaExt(parsed.DownloadUrl, resp.Header.Get("Content-Type")))
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
dest := filepath.Join(dir, uuid.NewString()+ext)
|
||||
if err := writeCompleteMediaFile(dest, resp.Body); err != nil {
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(f, io.LimitReader(resp.Body, mediaMaxDownloadBytes)); err != nil {
|
||||
_ = os.Remove(dest)
|
||||
return "", err
|
||||
}
|
||||
return dest, nil
|
||||
@@ -692,7 +344,7 @@ func (c *aiCardClient) downloadDentryFile(ctx context.Context, spaceID, dentryID
|
||||
for k, v := range parsed.HeadersMap {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := connectMediaDownloadClient(c.httpClient).Do(req)
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -700,9 +352,6 @@ func (c *aiCardClient) downloadDentryFile(ctx context.Context, spaceID, dentryID
|
||||
if resp.StatusCode >= 400 {
|
||||
return "", fmt.Errorf("钉盘文件下载 HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if resp.ContentLength > mediaMaxDownloadBytes {
|
||||
return "", fmt.Errorf("钉盘文件过大:%d 字节,最大允许 %d 字节", resp.ContentLength, mediaMaxDownloadBytes)
|
||||
}
|
||||
|
||||
dir := filepath.Join(os.TempDir(), "dws-connect-media")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
@@ -713,60 +362,18 @@ func (c *aiCardClient) downloadDentryFile(ctx context.Context, spaceID, dentryID
|
||||
ext = mediaExt(parsed.ResourceURL, resp.Header.Get("Content-Type"))
|
||||
}
|
||||
dest := filepath.Join(dir, uuid.NewString()+ext)
|
||||
if err := writeCompleteMediaFile(dest, resp.Body); err != nil {
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := io.Copy(f, io.LimitReader(resp.Body, mediaMaxDownloadBytes)); err != nil {
|
||||
_ = os.Remove(dest)
|
||||
return "", err
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
// writeCompleteMediaFile writes at most mediaMaxDownloadBytes and verifies the
|
||||
// stream ended. Reading one byte past the cap distinguishes an exact-size file
|
||||
// from a larger file; the latter is removed instead of leaving a corrupt local
|
||||
// artifact that an agent could mistake for the original.
|
||||
func writeCompleteMediaFile(dest string, src io.Reader) error {
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, copyErr := io.Copy(f, io.LimitReader(src, mediaMaxDownloadBytes+1))
|
||||
closeErr := f.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(dest)
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(dest)
|
||||
return closeErr
|
||||
}
|
||||
if n > mediaMaxDownloadBytes {
|
||||
_ = os.Remove(dest)
|
||||
return fmt.Errorf("媒体文件超过最大允许大小 %d 字节,未保存截断文件", mediaMaxDownloadBytes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func connectMediaDownloadClient(base *http.Client) *http.Client {
|
||||
if base == nil {
|
||||
return &http.Client{Timeout: mediaDownloadTimeout}
|
||||
}
|
||||
clone := *base
|
||||
if clone.Timeout <= 0 || clone.Timeout < mediaDownloadTimeout {
|
||||
clone.Timeout = mediaDownloadTimeout
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func cleanupConnectMediaAttachments(attachments []connectMediaAttachment) {
|
||||
root := filepath.Join(os.TempDir(), "dws-connect-media")
|
||||
for _, attachment := range attachments {
|
||||
path := filepath.Clean(strings.TrimSpace(attachment.LocalPath))
|
||||
if path == "." || filepath.Dir(path) != root {
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
// mediaExt picks a file extension from the response content type, falling
|
||||
// back to the URL path, then ".png" (DingTalk screenshots default to png).
|
||||
func mediaExt(rawURL, contentType string) string {
|
||||
|
||||
@@ -16,11 +16,9 @@ package helpers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -44,56 +42,6 @@ func TestPictureDownloadCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichTextPictureDownloadCodes(t *testing.T) {
|
||||
content := map[string]interface{}{"richText": []interface{}{
|
||||
map[string]interface{}{"text": "这个问题示例图如下:"},
|
||||
map[string]interface{}{
|
||||
"pictureDownloadCode": "picture-fallback",
|
||||
"downloadCode": "image-1",
|
||||
"type": "picture",
|
||||
},
|
||||
map[string]interface{}{"text": "中间文字"},
|
||||
map[string]interface{}{"pictureDownloadCode": "image-2", "type": "picture"},
|
||||
map[string]interface{}{"downloadCode": "not-a-picture", "type": "file"},
|
||||
}}
|
||||
|
||||
got := richTextPictureDownloadCodes(content)
|
||||
want := []string{"image-1", "image-2"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("codes = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("codes = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRichTextPictureDownloadCodesUnknownShape(t *testing.T) {
|
||||
for _, content := range []interface{}{
|
||||
nil,
|
||||
"plain text",
|
||||
map[string]interface{}{"richText": "not-an-array"},
|
||||
map[string]interface{}{"richText": []interface{}{map[string]interface{}{"text": "only text"}}},
|
||||
} {
|
||||
if got := richTextPictureDownloadCodes(content); len(got) != 0 {
|
||||
t.Fatalf("content %v: codes = %v, want none", content, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackInboundMediaPreservesFutureRichTextAttachment(t *testing.T) {
|
||||
pictures, files, unknown := callbackInboundMedia("renamedRichEnvelope", map[string]interface{}{
|
||||
"richText": []interface{}{
|
||||
map[string]interface{}{"type": "text", "text": "附件如下"},
|
||||
map[string]interface{}{"type": "futureInlineBinary", "downloadCode": "inline-1", "fileName": "demo.mp4"},
|
||||
},
|
||||
})
|
||||
if len(pictures) != 0 || len(files) != 1 || files[0].DownloadCode != "inline-1" || files[0].MediaType != "video" || unknown != 0 {
|
||||
t.Fatalf("pictures=%v files=%#v unknown=%d", pictures, files, unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 --group ... --text ...` (defaults to msgType=markdown)
|
||||
@@ -136,156 +84,6 @@ func TestExtractCallbackText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawCallbackPromptPreservesChatRecordPayload(t *testing.T) {
|
||||
content := map[string]interface{}{
|
||||
"title": "转发的聊天记录",
|
||||
"contents": []interface{}{
|
||||
map[string]interface{}{"senderName": "张三", "text": "请汇总本周风险"},
|
||||
map[string]interface{}{"senderName": "李四", "text": "发布窗口需要延期"},
|
||||
},
|
||||
}
|
||||
|
||||
got := rawCallbackPrompt(" chatRecord ", content)
|
||||
for _, want := range []string{
|
||||
`msgtype="chatRecord"`,
|
||||
`"senderName":"张三"`,
|
||||
`"text":"请汇总本周风险"`,
|
||||
`"text":"发布窗口需要延期"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("rawCallbackPrompt() missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawCallbackPromptForUnknownEmptyPayload(t *testing.T) {
|
||||
got := rawCallbackPrompt("futureMessageType", nil)
|
||||
if !strings.Contains(got, `msgtype="futureMessageType"`) || !strings.HasSuffix(got, "\nnull") {
|
||||
t.Fatalf("rawCallbackPrompt() = %q, want message type and null JSON payload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRecordInboundMediaExtractsEveryRecoverableAttachment(t *testing.T) {
|
||||
record := []interface{}{
|
||||
map[string]interface{}{"msgType": "picture", "downloadCode": "pic-1"},
|
||||
map[string]interface{}{"msgType": "picture", "downloadCode": "pic-1"}, // duplicate
|
||||
map[string]interface{}{"msgType": "audio", "downloadCode": "audio-1", "recognition": "语音转写"},
|
||||
map[string]interface{}{"msgType": "video", "downloadCode": "video-1", "fileName": "demo.mov"},
|
||||
map[string]interface{}{"msgType": "file", "downloadCode": "file-1", "fileName": "report.md"},
|
||||
map[string]interface{}{"msgType": "file", "dentryId": "123", "spaceId": "456", "fileName": "spec.pdf"},
|
||||
map[string]interface{}{"msgType": "unknownMsgType"},
|
||||
map[string]interface{}{"msgType": "unknownMsgType"},
|
||||
map[string]interface{}{"msgType": "text", "content": "请分析这些附件"},
|
||||
}
|
||||
raw, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pictures, files, unknown := chatRecordInboundMedia(map[string]interface{}{"chatRecord": string(raw)})
|
||||
if len(pictures) != 1 || pictures[0] != "pic-1" {
|
||||
t.Fatalf("pictures = %v, want [pic-1]", pictures)
|
||||
}
|
||||
if unknown != 2 {
|
||||
t.Fatalf("unknownCount = %d, want 2", unknown)
|
||||
}
|
||||
if len(files) != 4 {
|
||||
t.Fatalf("files = %#v, want 4 attachments", files)
|
||||
}
|
||||
wants := []struct {
|
||||
mediaType string
|
||||
code string
|
||||
name string
|
||||
}{
|
||||
{"audio", "audio-1", "语音消息"},
|
||||
{"video", "video-1", "demo.mov"},
|
||||
{"file", "file-1", "report.md"},
|
||||
{"file", "", "spec.pdf"},
|
||||
}
|
||||
for i, want := range wants {
|
||||
if files[i].MediaType != want.mediaType || files[i].DownloadCode != want.code || files[i].FileName != want.name {
|
||||
t.Fatalf("files[%d] = %#v, want type=%q code=%q name=%q", i, files[i], want.mediaType, want.code, want.name)
|
||||
}
|
||||
}
|
||||
if files[3].DentryID != 123 || files[3].SpaceID != 456 {
|
||||
t.Fatalf("dentry attachment = %#v, want dentry=123 space=456", files[3])
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRecordInboundMediaMatchesObservedDegradedCallback(t *testing.T) {
|
||||
record := `[{"msgType":"picture","downloadCode":"pic-live"},{"msgType":"unknownMsgType"},{"msgType":"unknownMsgType"},{"msgType":"text","content":"[合并的聊天记录]"},{"msgType":"unknownMsgType"}]`
|
||||
pictures, files, unknown := chatRecordInboundMedia(map[string]interface{}{"chatRecord": record})
|
||||
if len(pictures) != 1 || pictures[0] != "pic-live" || len(files) != 0 || unknown != 3 {
|
||||
t.Fatalf("pictures=%v files=%v unknown=%d, want one picture, no recoverable files, three unknowns", pictures, files, unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRecordInboundMediaRecoversUnknownTypeWhenLocatorSurvives(t *testing.T) {
|
||||
record := `[{"msgType":"unknownMsgType","downloadCode":"opaque-1","fileName":"payload.bin"}]`
|
||||
pictures, files, unknown := chatRecordInboundMedia(map[string]interface{}{"chatRecord": record})
|
||||
if len(pictures) != 0 || len(files) != 1 || files[0].DownloadCode != "opaque-1" || files[0].FileName != "payload.bin" || unknown != 0 {
|
||||
t.Fatalf("pictures=%v files=%#v unknown=%d", pictures, files, unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRecordInboundMediaPreservesFutureTypeWithLocator(t *testing.T) {
|
||||
record := `[{"msgType":"futureBinaryEnvelope","downloadCode":"future-1","fileName":"clip.webm"}]`
|
||||
pictures, files, unknown := chatRecordInboundMedia(map[string]interface{}{"chatRecord": record})
|
||||
if len(pictures) != 0 || len(files) != 1 || files[0].DownloadCode != "future-1" || files[0].MediaType != "video" || unknown != 0 {
|
||||
t.Fatalf("pictures=%v files=%#v unknown=%d", pictures, files, unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackInboundMediaDoesNotGateOnOuterMessageType(t *testing.T) {
|
||||
pictures, files, unknown := callbackInboundMedia("futureAttachmentV2", map[string]interface{}{
|
||||
"downloadCode": "future-2",
|
||||
"fileName": "voice.ogg",
|
||||
})
|
||||
if len(pictures) != 0 || len(files) != 1 || files[0].DownloadCode != "future-2" || files[0].MediaType != "audio" || unknown != 0 {
|
||||
t.Fatalf("pictures=%v files=%#v unknown=%d", pictures, files, unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackInboundMediaFindsChatRecordByShape(t *testing.T) {
|
||||
content := map[string]interface{}{
|
||||
"chatRecord": `[{"msgType":"picture","downloadCode":"nested-picture"},{"msgType":"futureFile","downloadCode":"nested-file","fileName":"notes.md"},{"msgType":"unknownMsgType"}]`,
|
||||
}
|
||||
pictures, files, unknown := callbackInboundMedia("renamedForwardEnvelope", content)
|
||||
if len(pictures) != 1 || pictures[0] != "nested-picture" {
|
||||
t.Fatalf("pictures=%v, want [nested-picture]", pictures)
|
||||
}
|
||||
if len(files) != 1 || files[0].DownloadCode != "nested-file" || files[0].FileName != "notes.md" {
|
||||
t.Fatalf("files=%#v, want nested-file", files)
|
||||
}
|
||||
if unknown != 1 {
|
||||
t.Fatalf("unknown=%d, want 1", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasChatRecordPayloadUsesShapeNotMessageType(t *testing.T) {
|
||||
if !hasChatRecordPayload(map[string]interface{}{
|
||||
"title": "转发记录",
|
||||
"chatRecord": `[{"msgType":"text","content":"hello"}]`,
|
||||
}) {
|
||||
t.Fatal("chatRecord JSON string should be detected by payload shape")
|
||||
}
|
||||
if hasChatRecordPayload(map[string]interface{}{"title": "普通卡片"}) {
|
||||
t.Fatal("ordinary title payload must not be detected as a chat record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatRecordInboundMediaAcceptsDecodedContents(t *testing.T) {
|
||||
pictures, files, unknown := chatRecordInboundMedia(map[string]interface{}{
|
||||
"contents": []interface{}{
|
||||
map[string]interface{}{"type": "image", "pictureDownloadCode": "pic-2"},
|
||||
map[string]interface{}{"type": "voice", "downloadCode": "voice-2"},
|
||||
},
|
||||
})
|
||||
if len(pictures) != 1 || pictures[0] != "pic-2" || len(files) != 1 || files[0].MediaType != "audio" || unknown != 0 {
|
||||
t.Fatalf("pictures=%v files=%#v unknown=%d", pictures, files, unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractInteractiveCardText covers the bot→bot @ card: the leading
|
||||
// mention leaf (whose display name may contain spaces) is dropped by leaf
|
||||
// boundary, leaving the clean instruction.
|
||||
@@ -363,61 +161,6 @@ func TestDownloadMessageFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadMessageFileNamedPreservesOriginalExtension(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
var srv *httptest.Server
|
||||
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"accessToken": "tok-1", "expireIn": 7200})
|
||||
})
|
||||
mux.HandleFunc("/v1.0/robot/messageFiles/download", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"downloadUrl": srv.URL + "/opaque.file"})
|
||||
})
|
||||
mux.HandleFunc("/opaque.file", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write([]byte("MOVDATA"))
|
||||
})
|
||||
srv = httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", "")
|
||||
path, err := c.downloadMessageFileNamed(context.Background(), "ding-client", "video-code", "screen.mov")
|
||||
if err != nil {
|
||||
t.Fatalf("downloadMessageFileNamed: %v", err)
|
||||
}
|
||||
defer os.Remove(path)
|
||||
if !strings.HasSuffix(path, ".mov") {
|
||||
t.Fatalf("path = %q, want original .mov extension", path)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil || string(raw) != "MOVDATA" {
|
||||
t.Fatalf("saved file = %q, %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadMessageFileRejectsKnownOversizePayload(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
var srv *httptest.Server
|
||||
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"accessToken": "tok-1", "expireIn": 7200})
|
||||
})
|
||||
mux.HandleFunc("/v1.0/robot/messageFiles/download", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"downloadUrl": srv.URL + "/too-large"})
|
||||
})
|
||||
mux.HandleFunc("/too-large", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", mediaMaxDownloadBytes+1))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
srv = httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
withCardAPIBase(t, srv.URL)
|
||||
|
||||
c := newAICardClient("ding-client", "ding-secret", "")
|
||||
if _, err := c.downloadMessageFileNamed(context.Background(), "ding-client", "large-code", "large.mov"); err == nil || !strings.Contains(err.Error(), "文件过大") {
|
||||
t.Fatalf("oversize download error = %v, want explicit size rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadMessageFileNoURL(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1.0/oauth2/accessToken", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -450,18 +193,6 @@ func TestMediaExt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectAttachmentMIMESniffsGenericVoiceFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "voice.bin")
|
||||
// Ogg capture pattern plus a minimal header is sufficient for
|
||||
// http.DetectContentType to identify the real container.
|
||||
if err := os.WriteFile(path, append([]byte("OggS\x00\x02"), make([]byte, 506)...), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := connectAttachmentMIME(path); got != "application/ogg" {
|
||||
t.Fatalf("connectAttachmentMIME() = %q, want application/ogg", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseFileInbound covers both callback shapes so the regression that
|
||||
// dropped every API-sent file (dentryId/spaceId, no downloadCode) can't
|
||||
// silently return: (a) client-sent shape carrying downloadCode + fileName,
|
||||
@@ -491,17 +222,6 @@ func TestParseFileInbound(t *testing.T) {
|
||||
wantName: "log.txt",
|
||||
wantActionable: true,
|
||||
},
|
||||
{
|
||||
name: "media locator fields",
|
||||
content: map[string]interface{}{
|
||||
"mediaId": "media-1",
|
||||
"openMessageId": "message-1",
|
||||
"openConversationId": "conversation-1",
|
||||
"fileName": "recording.m4a",
|
||||
},
|
||||
wantName: "recording.m4a",
|
||||
wantActionable: true,
|
||||
},
|
||||
{
|
||||
name: "API-sent dentryId/spaceId as numbers",
|
||||
content: map[string]interface{}{
|
||||
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -100,15 +99,7 @@ func (f *opencodeForwarder) forward(ctx context.Context, convID, text string) (s
|
||||
return f.forwardStream(ctx, convID, text, nil)
|
||||
}
|
||||
|
||||
func (f *opencodeForwarder) forwardWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment) (string, error) {
|
||||
return f.forwardStreamWithAttachments(ctx, convID, text, attachments, nil)
|
||||
}
|
||||
|
||||
func (f *opencodeForwarder) forwardStream(ctx context.Context, convID, text string, _ func(string)) (string, error) {
|
||||
return f.forwardStreamWithAttachments(ctx, convID, text, nil, nil)
|
||||
}
|
||||
|
||||
func (f *opencodeForwarder) forwardStreamWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment, _ func(string)) (string, error) {
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -116,15 +107,15 @@ func (f *opencodeForwarder) forwardStreamWithAttachments(ctx context.Context, co
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
reply, err := f.forwardWithClient(ctx, client, convID, text, attachments)
|
||||
reply, err := f.forwardWithClient(ctx, client, convID, text)
|
||||
if errors.Is(err, errOpencodeSessionMissing) && f.sessions != nil {
|
||||
f.sessions.reset(convID)
|
||||
reply, err = f.forwardWithClient(ctx, client, convID, text, attachments)
|
||||
reply, err = f.forwardWithClient(ctx, client, convID, text)
|
||||
}
|
||||
return reply, err
|
||||
}
|
||||
|
||||
func (f *opencodeForwarder) forwardWithClient(ctx context.Context, client *opencodeHTTPClient, convID, text string, attachments []connectMediaAttachment) (string, error) {
|
||||
func (f *opencodeForwarder) forwardWithClient(ctx context.Context, client *opencodeHTTPClient, convID, text string) (string, error) {
|
||||
sessionID := ""
|
||||
if f.sessions != nil {
|
||||
sessionID = f.sessions.id(convID)
|
||||
@@ -139,7 +130,7 @@ func (f *opencodeForwarder) forwardWithClient(ctx context.Context, client *openc
|
||||
f.sessions.set(convID, sessionID)
|
||||
}
|
||||
}
|
||||
reply, err := client.sendMessageWithAttachments(ctx, sessionID, text, f.model, attachments)
|
||||
reply, err := client.sendMessage(ctx, sessionID, text, f.model)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -468,27 +459,9 @@ func (c *opencodeHTTPClient) deleteSession(ctx context.Context, sessionID string
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *opencodeHTTPClient) sendMessageWithAttachments(ctx context.Context, sessionID, text, model string, attachments []connectMediaAttachment) (string, error) {
|
||||
parts := []map[string]any{{"type": "text", "text": text}}
|
||||
for _, attachment := range attachments {
|
||||
path := strings.TrimSpace(attachment.LocalPath)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
fileURL := (&url.URL{Scheme: "file", Path: path}).String()
|
||||
name := strings.TrimSpace(attachment.FileName)
|
||||
if name == "" {
|
||||
name = filepath.Base(path)
|
||||
}
|
||||
parts = append(parts, map[string]any{
|
||||
"type": "file",
|
||||
"url": fileURL,
|
||||
"filename": name,
|
||||
"mime": connectAttachmentMIME(path),
|
||||
})
|
||||
}
|
||||
func (c *opencodeHTTPClient) sendMessage(ctx context.Context, sessionID, text, model string) (string, error) {
|
||||
body := map[string]any{
|
||||
"parts": parts,
|
||||
"parts": []map[string]any{{"type": "text", "text": text}},
|
||||
}
|
||||
if m := opencodeModelRef(model); m != nil {
|
||||
body["model"] = m
|
||||
|
||||
@@ -19,53 +19,12 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOpencodeForwarderSendsNativeFileParts(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "evidence.png")
|
||||
if err := os.WriteFile(path, []byte("png-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var gotParts []map[string]any
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/global/health":
|
||||
_, _ = w.Write([]byte(`{"healthy":true}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session":
|
||||
_, _ = w.Write([]byte(`{"id":"ses_media"}`))
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/session/ses_media/message":
|
||||
var body struct {
|
||||
Parts []map[string]any `json:"parts"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
gotParts = body.Parts
|
||||
_, _ = w.Write([]byte(`{"parts":[{"type":"text","text":"ok"}]}`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer ts.Close()
|
||||
f := &opencodeForwarder{
|
||||
bin: "opencode", timeout: 5 * time.Second, workDir: dir,
|
||||
sessions: newOpencodeSessions(""),
|
||||
server: &opencodeServer{baseURL: ts.URL, httpClient: ts.Client()},
|
||||
}
|
||||
_, err := f.forwardWithAttachments(context.Background(), "conv", "看图", []connectMediaAttachment{{LocalPath: path, FileName: "evidence.png", MediaType: "image"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(gotParts) != 2 || gotParts[1]["type"] != "file" || gotParts[1]["filename"] != "evidence.png" || !strings.HasPrefix(fmt.Sprint(gotParts[1]["url"]), "file://") {
|
||||
t.Fatalf("parts = %#v", gotParts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeForwarderUsesServerSessionAPI(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
storePath := filepath.Join(dir, "opencode-sessions.json")
|
||||
|
||||
@@ -80,58 +80,6 @@ func (f *qoderStreamForwarder) forward(ctx context.Context, convID, text string)
|
||||
return f.forwardStream(ctx, convID, text, nil)
|
||||
}
|
||||
|
||||
// forwardWithAttachments uses qodercli's native --attachment transport for a
|
||||
// media turn. The persistent stream-json protocol has no documented file-part
|
||||
// shape, so sending the same session through a one-shot CLI process is the
|
||||
// only reliable way to provide the original bytes without enabling broad file
|
||||
// tools for every ordinary chat message.
|
||||
func (f *qoderStreamForwarder) forwardWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment) (string, error) {
|
||||
if len(attachments) == 0 {
|
||||
return f.forward(ctx, convID, text)
|
||||
}
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
args := []string{"--print", "--output-format", "text", "--max-turns", "30"}
|
||||
if f.sessions != nil {
|
||||
args = append(args, f.sessions.args(convID)...)
|
||||
}
|
||||
if f.yolo {
|
||||
args = append(args, "--permission-mode", "bypass_permissions", "--dangerously-skip-permissions")
|
||||
} else {
|
||||
args = append(args, "--system-prompt", "", "--setting-sources", "", "--tools", "")
|
||||
}
|
||||
if f.model != "" {
|
||||
args = append(args, "--model", f.model)
|
||||
}
|
||||
for _, attachment := range attachments {
|
||||
if path := strings.TrimSpace(attachment.LocalPath); path != "" {
|
||||
args = append(args, "--attachment", path)
|
||||
}
|
||||
}
|
||||
args = append(args, "-p", text)
|
||||
cmd := exec.CommandContext(ctx, f.bin, args...)
|
||||
cmd.Dir = f.cwd()
|
||||
cmd.Env = append(os.Environ(), f.env...)
|
||||
out, err := cmd.Output()
|
||||
reply := strings.TrimSpace(string(out))
|
||||
if reply != "" && !agentReplyIsError(reply) {
|
||||
return brandReply(f.name, reply), nil
|
||||
}
|
||||
if reply != "" {
|
||||
return agentBackendErrorReply(reply), nil
|
||||
}
|
||||
if err != nil {
|
||||
if f.sessions != nil {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
return "", fmt.Errorf("本地 %s agent 附件调用失败:%s", f.name, truncateRunes(execErrorMessage(err), 300))
|
||||
}
|
||||
return "(本地 agent 无文本输出)", nil
|
||||
}
|
||||
|
||||
func (f *qoderStreamForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -65,7 +65,11 @@ if "--input-format" in sys.argv:
|
||||
}), flush=True)
|
||||
else:
|
||||
prompt = sys.argv[-1] if len(sys.argv) > 1 else ""
|
||||
print("one-shot " + prompt)
|
||||
print(json.dumps({
|
||||
"type": "result",
|
||||
"subtype": "success",
|
||||
"message": {"content": [{"type": "text", "text": "one-shot " + prompt}]},
|
||||
}))
|
||||
`
|
||||
path := filepath.Join(dir, "qodercli")
|
||||
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||
@@ -74,35 +78,6 @@ else:
|
||||
return logPath
|
||||
}
|
||||
|
||||
func TestQoderForwarderUsesNativeAttachmentFlag(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
stubDir := t.TempDir()
|
||||
logPath := writeQoderStreamStub(t, stubDir)
|
||||
t.Setenv("DWS_QODER_STUB_LOG", logPath)
|
||||
attachmentPath := filepath.Join(t.TempDir(), "forwarded.mov")
|
||||
if err := os.WriteFile(attachmentPath, []byte("video-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &qoderStreamForwarder{
|
||||
name: "qoderwork", bin: filepath.Join(stubDir, "qodercli"),
|
||||
timeout: 5 * time.Second, sessions: newConvSessions(""),
|
||||
}
|
||||
reply, err := f.forwardWithAttachments(context.Background(), "conv", "分析视频", []connectMediaAttachment{{LocalPath: attachmentPath, FileName: "forwarded.mov", MediaType: "video"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reply != "one-shot 分析视频" {
|
||||
t.Fatalf("reply = %q", reply)
|
||||
}
|
||||
raw, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"--attachment", "`+attachmentPath+`"`) || !strings.Contains(string(raw), `"-p", "分析视频"`) {
|
||||
t.Fatalf("qoder args missing attachment: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQoderForwarderKeepsStreamJSONProcessAlive(t *testing.T) {
|
||||
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
|
||||
t.Setenv("DWS_AGENT_CMD", "")
|
||||
|
||||
@@ -74,44 +74,6 @@ type streamingForwarder interface {
|
||||
forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error)
|
||||
}
|
||||
|
||||
// connectMediaAttachment is a DingTalk attachment that has already been
|
||||
// authenticated and downloaded by the common Stream ingress. Keeping the
|
||||
// attachment separate from the textual prompt lets multimodal backends pass
|
||||
// the original bytes through their native protocol instead of asking the
|
||||
// model to infer a local path from prose.
|
||||
type connectMediaAttachment struct {
|
||||
LocalPath string
|
||||
FileName string
|
||||
MediaType string
|
||||
}
|
||||
|
||||
// attachmentForwarder is implemented by backends with a native attachment
|
||||
// transport (for example OpenCode file parts or Gemini inlineData). Backends
|
||||
// without one still receive the absolute local path in the prompt and can use
|
||||
// their read tool, preserving compatibility with custom agents.
|
||||
type attachmentForwarder interface {
|
||||
forwardWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment) (string, error)
|
||||
}
|
||||
|
||||
type streamingAttachmentForwarder interface {
|
||||
attachmentForwarder
|
||||
canStream() bool
|
||||
forwardStreamWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment, onDelta func(string)) (string, error)
|
||||
}
|
||||
|
||||
func forwardConnectTurn(ctx context.Context, fwd forwarder, convID, prompt string, attachments []connectMediaAttachment, onDelta func(string)) (string, error) {
|
||||
if af, ok := fwd.(streamingAttachmentForwarder); ok {
|
||||
return af.forwardStreamWithAttachments(ctx, convID, prompt, attachments, onDelta)
|
||||
}
|
||||
if af, ok := fwd.(attachmentForwarder); ok {
|
||||
return af.forwardWithAttachments(ctx, convID, prompt, attachments)
|
||||
}
|
||||
if sf, ok := fwd.(streamingForwarder); ok {
|
||||
return sf.forwardStream(ctx, convID, prompt, onDelta)
|
||||
}
|
||||
return fwd.forward(ctx, convID, prompt)
|
||||
}
|
||||
|
||||
// sessionResetter is an optional capability: a forwarder that can forget a
|
||||
// conversation's agent session, so a built-in /new or /clear command starts a
|
||||
// fresh context. Forwarders with per-conversation memory (Claude-family exec,
|
||||
@@ -377,44 +339,6 @@ func (f *execForwarder) forward(ctx context.Context, convID, text string) (strin
|
||||
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
|
||||
}
|
||||
|
||||
// forwardWithAttachments grants the Claude-family CLIs read-only access to the
|
||||
// exact directories that contain this turn's downloaded attachments. These
|
||||
// agents otherwise run from an isolated scratch directory, so an absolute path
|
||||
// in prose can still be rejected by their external-directory permission gate.
|
||||
// The custom channel is intentionally left untouched because DWS cannot assume
|
||||
// flags understood by an arbitrary user command.
|
||||
func (f *execForwarder) forwardWithAttachments(ctx context.Context, convID, text string, attachments []connectMediaAttachment) (string, error) {
|
||||
switch f.name {
|
||||
case "claudecode", "codebuddy", "workbuddy":
|
||||
default:
|
||||
return f.forward(ctx, convID, text)
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
var dirs []string
|
||||
for _, attachment := range attachments {
|
||||
path := strings.TrimSpace(attachment.LocalPath)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if _, exists := seen[dir]; exists {
|
||||
continue
|
||||
}
|
||||
seen[dir] = struct{}{}
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
if len(dirs) == 0 {
|
||||
return f.forward(ctx, convID, text)
|
||||
}
|
||||
clone := *f
|
||||
clone.argv = append([]string{f.argv[0]}, "--allowedTools", "Read")
|
||||
for _, dir := range dirs {
|
||||
clone.argv = append(clone.argv, "--add-dir", dir)
|
||||
}
|
||||
clone.argv = append(clone.argv, f.argv[1:]...)
|
||||
return clone.forward(ctx, convID, text)
|
||||
}
|
||||
|
||||
// convSessions maps a DingTalk conversation to a stable agent session ID, so a
|
||||
// channel CLI with addressable sessions keeps multi-turn context per chat.
|
||||
// First message of a conversation mints a UUID and passes `--session-id <id>`
|
||||
@@ -775,10 +699,10 @@ var agentSpecs = map[string]agentSpec{
|
||||
"workbuddy": {app: "WorkBuddy(自带 codebuddy)", bins: []string{"codebuddy"},
|
||||
globs: []string{"/Applications/WorkBuddy.app/Contents/Resources/app.asar.unpacked/cli/bin/codebuddy"},
|
||||
argvTail: []string{"--append-system-prompt",
|
||||
"你叫「WorkBuddy 助手」,是钉钉群里的智能助手。无论被问到你是谁,都只能自称 WorkBuddy 助手,绝不能提到 CodeBuddy 这个名字。请用简洁自然的中文直接回答问题;不要主动使用工具、读写文件或执行命令。仅当用户消息明确附带了本地附件路径时,可以使用 Read 工具只读该附件,不得访问其它文件。",
|
||||
"你叫「WorkBuddy 助手」,是钉钉群里的智能助手。无论被问到你是谁,都只能自称 WorkBuddy 助手,绝不能提到 CodeBuddy 这个名字。请用简洁自然的中文直接回答问题;不要使用任何工具,不要尝试读写文件或执行命令。",
|
||||
"-p"},
|
||||
streamArgvTail: []string{"--append-system-prompt",
|
||||
"你叫「WorkBuddy 助手」,是钉钉群里的智能助手。无论被问到你是谁,都只能自称 WorkBuddy 助手,绝不能提到 CodeBuddy 这个名字。请用简洁自然的中文直接回答问题;不要主动使用工具、读写文件或执行命令。仅当用户消息明确附带了本地附件路径时,可以使用 Read 工具只读该附件,不得访问其它文件。",
|
||||
"你叫「WorkBuddy 助手」,是钉钉群里的智能助手。无论被问到你是谁,都只能自称 WorkBuddy 助手,绝不能提到 CodeBuddy 这个名字。请用简洁自然的中文直接回答问题;不要使用任何工具,不要尝试读写文件或执行命令。",
|
||||
"-p", "--output-format", "stream-json", "--include-partial-messages"},
|
||||
streamParser: "cc", envFn: codebuddyEnv, hint: "https://www.codebuddy.cn/work/",
|
||||
modelFlag: "--model", ccSessions: true},
|
||||
@@ -1082,18 +1006,17 @@ type connectExtras struct {
|
||||
}
|
||||
|
||||
type connectQueuedTurn struct {
|
||||
convID string
|
||||
text string
|
||||
picCodes []string
|
||||
fileInfos []fileInboundInfo
|
||||
chatRecordLookups []chatRecordLookup
|
||||
webhook string
|
||||
msgID string
|
||||
msgType string
|
||||
senderStaffID string
|
||||
conversationID string
|
||||
conversationType string
|
||||
callbackData chatbot.BotCallbackDataModel
|
||||
convID string
|
||||
text string
|
||||
picCode string
|
||||
fileInfo fileInboundInfo
|
||||
webhook string
|
||||
msgID string
|
||||
msgType string
|
||||
senderStaffID string
|
||||
conversationID string
|
||||
conversationType string
|
||||
callbackData chatbot.BotCallbackDataModel
|
||||
}
|
||||
|
||||
func mergeConnectQueuedTurns(turns []connectQueuedTurn) connectQueuedTurn {
|
||||
@@ -1115,13 +1038,17 @@ func mergeConnectQueuedTurns(turns []connectQueuedTurn) connectQueuedTurn {
|
||||
lines = append(lines, fmt.Sprintf("%d. %s", i+1, connectTurnSummary(turn)))
|
||||
}
|
||||
merged.text = strings.Join(lines, "\n")
|
||||
merged.picCodes = nil
|
||||
merged.fileInfos = nil
|
||||
merged.chatRecordLookups = nil
|
||||
for i := range turns {
|
||||
merged.picCodes = append(merged.picCodes, turns[i].picCodes...)
|
||||
merged.fileInfos = append(merged.fileInfos, turns[i].fileInfos...)
|
||||
merged.chatRecordLookups = append(merged.chatRecordLookups, turns[i].chatRecordLookups...)
|
||||
if merged.picCode == "" && !merged.fileInfo.hasActionable() {
|
||||
for i := len(turns) - 1; i >= 0; i-- {
|
||||
if turns[i].picCode != "" {
|
||||
merged.picCode = turns[i].picCode
|
||||
break
|
||||
}
|
||||
if turns[i].fileInfo.hasActionable() {
|
||||
merged.fileInfo = turns[i].fileInfo
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
@@ -1138,21 +1065,16 @@ func connectTurnShouldStayStandalone(turn connectQueuedTurn) bool {
|
||||
|
||||
func connectTurnSummary(turn connectQueuedTurn) string {
|
||||
if text := strings.TrimSpace(turn.text); text != "" {
|
||||
if len(turn.picCodes) > 0 {
|
||||
return text + " [同时附有图片]"
|
||||
}
|
||||
return text
|
||||
}
|
||||
if len(turn.picCodes) > 0 {
|
||||
if turn.picCode != "" {
|
||||
return "[图片]"
|
||||
}
|
||||
if len(turn.fileInfos) > 0 {
|
||||
if len(turn.fileInfos) == 1 {
|
||||
if name := strings.TrimSpace(turn.fileInfos[0].FileName); name != "" {
|
||||
return "[附件: " + name + "]"
|
||||
}
|
||||
if turn.fileInfo.hasActionable() {
|
||||
if name := strings.TrimSpace(turn.fileInfo.FileName); name != "" {
|
||||
return "[文件: " + name + "]"
|
||||
}
|
||||
return fmt.Sprintf("[%d 个附件]", len(turn.fileInfos))
|
||||
return "[文件]"
|
||||
}
|
||||
return "[空消息]"
|
||||
}
|
||||
@@ -1195,7 +1117,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
mediaCli = newAICardClient(clientID, clientSecret, "")
|
||||
}
|
||||
|
||||
keepAlive := envDurationMS("DWS_CONNECT_KEEPALIVE_MS", 30*time.Second)
|
||||
keepAlive := envDurationMS("DWS_CONNECT_KEEPALIVE_MS", 30000)
|
||||
fmt.Fprintf(os.Stderr, "[connect] keepAlive=%s autoReconnect=true\n", keepAlive)
|
||||
cli := client.NewStreamClient(
|
||||
client.WithAppCredential(client.NewAppCredentialConfig(clientID, clientSecret)),
|
||||
@@ -1205,40 +1127,33 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
cli.RegisterChatBotCallbackRouter(func(_ context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
|
||||
text := strings.TrimSpace(data.Text.Content)
|
||||
msgtype := strings.TrimSpace(data.Msgtype)
|
||||
// Discover attachments by their locator fields, never by a msgtype
|
||||
// allowlist. msgtype is only a classification hint for the agent prompt.
|
||||
picCodes, fileInfos, unrecoverableCount := callbackInboundMedia(msgtype, data.Content)
|
||||
var chatRecordLookups []chatRecordLookup
|
||||
if unrecoverableCount > 0 {
|
||||
indexes := chatRecordUnknownIndexes(data.Content)
|
||||
if strings.TrimSpace(data.MsgId) != "" && len(indexes) > 0 {
|
||||
chatRecordLookups = append(chatRecordLookups, chatRecordLookup{
|
||||
MsgID: strings.TrimSpace(data.MsgId),
|
||||
UnknownIndexes: indexes,
|
||||
})
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 转发记录中有 %d 条 unknownMsgType,将在 ACK 后补拉原始内容 (msgId=%s)\n", unrecoverableCount, data.MsgId)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 转发记录中有 %d 条 unknownMsgType,但缺少外层消息 ID,保留原始 JSON 降级处理\n", unrecoverableCount)
|
||||
}
|
||||
// Picture messages carry no text — their payload is a downloadCode
|
||||
// resolved to a local file in the forward goroutine below.
|
||||
picCode := ""
|
||||
if strings.EqualFold(msgtype, "picture") {
|
||||
picCode = pictureDownloadCode(data.Content)
|
||||
}
|
||||
// File callbacks come in two shapes: client-sent files carry a
|
||||
// downloadCode; API-sent files (`dws chat message send --msg-type file
|
||||
// --dentry-id --space-id`) carry dentryId + spaceId instead and have
|
||||
// NO downloadCode. Both have to be recognisable or legit file messages
|
||||
// get silently dropped below.
|
||||
var fileInfo fileInboundInfo
|
||||
if strings.EqualFold(msgtype, "file") {
|
||||
fileInfo = parseFileInbound(data.Content)
|
||||
}
|
||||
// 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 --group ... --text ...` — which defaults
|
||||
// to msgType=markdown — hits the drop branch below and the bot looks
|
||||
// dead to the sender.
|
||||
if text == "" {
|
||||
// Forwarded records must keep their complete JSON, even if the outer
|
||||
// msgtype is renamed or a title-like field could be extracted as text.
|
||||
// Detect the record by payload shape rather than message type.
|
||||
if hasChatRecordPayload(data.Content) {
|
||||
text = rawCallbackPrompt(msgtype, data.Content)
|
||||
}
|
||||
if text == "" && picCode == "" {
|
||||
// interactiveCard (a bot @-mentioning this bot) nests the body in
|
||||
// content.cardContent and carries the mention as its own leading
|
||||
// leaf; the leaf-aware extractor drops it so the agent gets the
|
||||
// clean instruction. Detection is based on the payload shape so a
|
||||
// renamed/new type is handled identically.
|
||||
if text == "" {
|
||||
// clean instruction. Other structured-text shapes use the generic
|
||||
// extractor.
|
||||
if strings.EqualFold(msgtype, "interactiveCard") {
|
||||
text = extractInteractiveCardText(data.Content)
|
||||
}
|
||||
if text == "" {
|
||||
@@ -1246,15 +1161,13 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
text = fallback
|
||||
}
|
||||
}
|
||||
if text == "" {
|
||||
text = rawCallbackPrompt(msgtype, data.Content)
|
||||
}
|
||||
}
|
||||
if data.SessionWebhook == "" {
|
||||
// A session webhook is required for the fallback reply path. Message
|
||||
// payload shape is deliberately not filtered here: unknown and complex
|
||||
// types are forwarded as raw JSON for the backend model to interpret.
|
||||
fmt.Fprintf(os.Stderr, "[connect] 丢弃消息 msgtype=%q staffId=%s convId=%s msgId=%s content=%s (sessionWebhook 为空,无法回复)\n",
|
||||
if (text == "" && picCode == "" && !fileInfo.hasActionable()) || data.SessionWebhook == "" {
|
||||
// Observability: silent drops are the #1 reason a working connector
|
||||
// looks dead. Log msgtype + a payload summary so an unhandled shape
|
||||
// (e.g. new-style file callback without downloadCode) shows up in
|
||||
// stderr instead of being invisible.
|
||||
fmt.Fprintf(os.Stderr, "[connect] 丢弃消息 msgtype=%q staffId=%s convId=%s msgId=%s content=%s (无正文/图片/可下载文件或 sessionWebhook 为空)\n",
|
||||
msgtype, data.SenderStaffId, data.ConversationId, data.MsgId, summarizeContent(data.Content))
|
||||
return []byte(""), nil
|
||||
}
|
||||
@@ -1285,10 +1198,10 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
sender = strings.TrimSpace(data.SenderStaffId)
|
||||
}
|
||||
shown := text
|
||||
if shown == "" && len(picCodes) > 0 {
|
||||
if shown == "" && picCode != "" {
|
||||
shown = "[图片]"
|
||||
} else if shown == "" && len(fileInfos) > 0 {
|
||||
shown = connectTurnSummary(connectQueuedTurn{fileInfos: fileInfos})
|
||||
} else if shown == "" && fileInfo.hasActionable() {
|
||||
shown = "[文件: " + fileInfo.FileName + "]"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[connect] 收到 @%s: %s (convType=%s convId=%s staffId=%s msgId=%s)\n",
|
||||
sender, truncateRunes(shown, 80), data.ConversationType, data.ConversationId, data.SenderStaffId, data.MsgId)
|
||||
@@ -1305,18 +1218,17 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
}
|
||||
msgID := strings.TrimSpace(data.MsgId)
|
||||
turn := connectQueuedTurn{
|
||||
convID: convID,
|
||||
text: text,
|
||||
picCodes: picCodes,
|
||||
fileInfos: fileInfos,
|
||||
chatRecordLookups: chatRecordLookups,
|
||||
webhook: webhook,
|
||||
msgID: msgID,
|
||||
msgType: msgtype,
|
||||
senderStaffID: strings.TrimSpace(data.SenderStaffId),
|
||||
conversationID: strings.TrimSpace(data.ConversationId),
|
||||
conversationType: strings.TrimSpace(data.ConversationType),
|
||||
callbackData: *data,
|
||||
convID: convID,
|
||||
text: text,
|
||||
picCode: picCode,
|
||||
fileInfo: fileInfo,
|
||||
webhook: webhook,
|
||||
msgID: msgID,
|
||||
msgType: msgtype,
|
||||
senderStaffID: strings.TrimSpace(data.SenderStaffId),
|
||||
conversationID: strings.TrimSpace(data.ConversationId),
|
||||
conversationType: strings.TrimSpace(data.ConversationType),
|
||||
callbackData: *data,
|
||||
}
|
||||
// Same-conversation agent calls never run in parallel; messages received
|
||||
// while a turn is running are merged into one pending follow-up instead
|
||||
@@ -1327,9 +1239,8 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
fmt.Fprintf(os.Stderr, "[connect] 合并 %d 条待处理消息 (convId=%s, latestMsgId=%s)\n", len(turns), turn.convID, turn.msgID)
|
||||
}
|
||||
text := turn.text
|
||||
picCodes := turn.picCodes
|
||||
fileInfos := turn.fileInfos
|
||||
chatRecordLookups := turn.chatRecordLookups
|
||||
picCode := turn.picCode
|
||||
fileInfo := turn.fileInfo
|
||||
webhook := turn.webhook
|
||||
convID := turn.convID
|
||||
msgID := turn.msgID
|
||||
@@ -1382,96 +1293,34 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
// Assemble the forwarded prompt: resolve an attached picture (the
|
||||
// top Q&A inbound is an error screenshot), then knowledge-augment.
|
||||
prompt := text
|
||||
for _, lookup := range chatRecordLookups {
|
||||
lookupCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
enrichment, lookupErr := recoverChatRecordUnknowns(lookupCtx, lookup, callMCPToolReturnTextOnServer)
|
||||
cancel()
|
||||
if lookupErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] unknownMsgType 补拉失败,保留原始 JSON (msgId=%s): %v\n", lookup.MsgID, lookupErr)
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(enrichment.Prompt) != "" {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = enrichment.Prompt
|
||||
} else {
|
||||
prompt += "\n\n" + enrichment.Prompt
|
||||
}
|
||||
}
|
||||
fileInfos = append(fileInfos, enrichment.Files...)
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] unknownMsgType 补拉完成: 原始附件=%d 未定位=%d (msgId=%s)\n", len(enrichment.Files), enrichment.MissingCount, lookup.MsgID)
|
||||
if enrichment.MissingCount > 0 {
|
||||
prompt += fmt.Sprintf("\n(其中 %d 个转发附件仍未能定位原始文件,请明确告知用户未读取到这些附件。)", enrichment.MissingCount)
|
||||
}
|
||||
}
|
||||
var attachments []connectMediaAttachment
|
||||
for i, picCode := range picCodes {
|
||||
localPath, derr := mediaCli.downloadMessageFile(context.Background(), clientID, picCode)
|
||||
if derr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 第 %d 张图片下载失败: %v\n", i+1, derr)
|
||||
if picCode != "" {
|
||||
if localPath, derr := mediaCli.downloadMessageFile(context.Background(), clientID, picCode); derr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 图片下载失败: %v\n", derr)
|
||||
if prompt == "" {
|
||||
prompt = "(用户发来图片,但图片下载失败了。请告知用户图片没收到,建议补充文字描述。)"
|
||||
} else {
|
||||
prompt += "\n(用户同时附了一张图片,但图片下载失败,请基于现有文字回答并说明未能读取图片。)"
|
||||
prompt = "(用户发来一张图片,但图片下载失败了。请告知用户图片没收到,建议补充文字描述。)"
|
||||
}
|
||||
continue
|
||||
}
|
||||
if prompt == "" {
|
||||
} else if prompt == "" {
|
||||
prompt = "用户发来一张图片(本地路径 " + localPath + "),请查看图片内容并回答其中的问题。"
|
||||
} else {
|
||||
prompt += "\n(用户同时附了一张图片,本地路径 " + localPath + ",请结合图片内容回答。)"
|
||||
prompt = prompt + "\n(用户同时附了一张图片,本地路径 " + localPath + ",请结合图片内容回答。)"
|
||||
}
|
||||
attachments = append(attachments, connectMediaAttachment{
|
||||
LocalPath: localPath,
|
||||
FileName: filepath.Base(localPath),
|
||||
MediaType: "image",
|
||||
})
|
||||
}
|
||||
for i, fileInfo := range fileInfos {
|
||||
if !fileInfo.hasActionable() {
|
||||
continue
|
||||
}
|
||||
if fileInfo.hasActionable() {
|
||||
fileName := fileInfo.FileName
|
||||
mediaType := inboundMediaType(fileInfo.MediaType)
|
||||
mediaLabel := "文件"
|
||||
successPrompt := "请读取文件内容并回答"
|
||||
switch mediaType {
|
||||
case "image":
|
||||
mediaLabel = "图片"
|
||||
successPrompt = "请查看图片内容并回答"
|
||||
case "audio":
|
||||
mediaLabel = "语音"
|
||||
successPrompt = "请听取或转写语音内容并回答"
|
||||
case "video":
|
||||
mediaLabel = "视频"
|
||||
successPrompt = "请查看并分析视频内容后回答"
|
||||
}
|
||||
var localPath string
|
||||
var derr error
|
||||
if fileInfo.DownloadCode != "" {
|
||||
localPath, derr = mediaCli.downloadMessageFileNamed(context.Background(), clientID, fileInfo.DownloadCode, fileName)
|
||||
localPath, derr = mediaCli.downloadMessageFile(context.Background(), clientID, fileInfo.DownloadCode)
|
||||
if derr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 第 %d 个%s下载失败: %v\n", i+1, mediaLabel, derr)
|
||||
}
|
||||
} else if fileInfo.MediaID != "" || fileInfo.FileID != "" {
|
||||
downloadCtx, cancel := context.WithTimeout(context.Background(), mediaDownloadTimeout)
|
||||
localPath, derr = mediaCli.downloadRecoveredChatRecordFile(downloadCtx, fileInfo)
|
||||
cancel()
|
||||
if derr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 第 %d 个转发%s原始内容下载失败: %v\n", i+1, mediaLabel, derr)
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 文件下载失败: %v\n", derr)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case localPath != "":
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 第 %d 个%s已完整下载: %s\n", i+1, mediaLabel, localPath)
|
||||
attachments = append(attachments, connectMediaAttachment{
|
||||
LocalPath: localPath,
|
||||
FileName: fileName,
|
||||
MediaType: mediaType,
|
||||
})
|
||||
if prompt == "" {
|
||||
prompt = "用户发来一个" + mediaLabel + "「" + fileName + "」(本地路径 " + localPath + ")," + successPrompt + "。"
|
||||
prompt = "用户发来一个文件「" + fileName + "」(本地路径 " + localPath + "),请读取文件内容并回答。"
|
||||
} else {
|
||||
prompt += "\n(用户同时附了一个" + mediaLabel + "「" + fileName + "」,本地路径 " + localPath + "," + successPrompt + "。)"
|
||||
prompt = prompt + "\n(用户同时附了一个文件「" + fileName + "」,本地路径 " + localPath + ",请结合文件内容回答。)"
|
||||
}
|
||||
case fileInfo.DentryID != 0 && fileInfo.SpaceID != 0:
|
||||
// API-sent file: resolve via storage API (userId→unionId,
|
||||
@@ -1486,16 +1335,10 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
localPath = dp
|
||||
}
|
||||
if localPath != "" {
|
||||
fmt.Fprintf(os.Stderr, "[connect][media] 第 %d 个%s已完整下载: %s\n", i+1, mediaLabel, localPath)
|
||||
attachments = append(attachments, connectMediaAttachment{
|
||||
LocalPath: localPath,
|
||||
FileName: fileName,
|
||||
MediaType: mediaType,
|
||||
})
|
||||
if prompt == "" {
|
||||
prompt = "用户发来一个" + mediaLabel + "「" + fileName + "」(本地路径 " + localPath + ")," + successPrompt + "。"
|
||||
prompt = "用户发来一个文件「" + fileName + "」(本地路径 " + localPath + "),请读取文件内容并回答。"
|
||||
} else {
|
||||
prompt += "\n(用户同时附了一个" + mediaLabel + "「" + fileName + "」,本地路径 " + localPath + "," + successPrompt + "。)"
|
||||
prompt = prompt + "\n(用户同时附了一个文件「" + fileName + "」,本地路径 " + localPath + ",请结合文件内容回答。)"
|
||||
}
|
||||
} else {
|
||||
meta := fmt.Sprintf("文件名「%s」,dentryId=%d,spaceId=%d", fileName, fileInfo.DentryID, fileInfo.SpaceID)
|
||||
@@ -1512,22 +1355,11 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
}
|
||||
}
|
||||
default:
|
||||
failure := "用户发来一个" + mediaLabel + "「" + fileName + "」,但原始内容下载失败。请明确告知用户该附件未能读取,建议重新发送或补充文字描述。"
|
||||
if prompt == "" {
|
||||
prompt = "(" + failure + ")"
|
||||
} else {
|
||||
prompt += "\n(" + failure + ")"
|
||||
prompt = "(用户发来一个文件「" + fileName + "」,但文件下载失败了。请告知用户文件没收到,建议重新发送。)"
|
||||
}
|
||||
}
|
||||
}
|
||||
originalAttachments := append([]connectMediaAttachment(nil), attachments...)
|
||||
defer cleanupConnectMediaAttachments(originalAttachments)
|
||||
if _, isOpenCode := fwd.(*opencodeForwarder); isOpenCode {
|
||||
prepareCtx, cancel := context.WithTimeout(context.Background(), mediaDownloadTimeout)
|
||||
prompt, attachments = prepareOpenCodeAttachments(prepareCtx, prompt, attachments)
|
||||
cancel()
|
||||
}
|
||||
defer cleanupConnectMediaAttachments(attachments)
|
||||
if extras.kb != nil {
|
||||
prompt = extras.kb.augment(prompt)
|
||||
}
|
||||
@@ -1585,7 +1417,13 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
}
|
||||
}
|
||||
|
||||
reply, err := forwardConnectTurn(context.Background(), fwd, convID, prompt, attachments, onDelta)
|
||||
var reply string
|
||||
var err error
|
||||
if streamable {
|
||||
reply, err = sf.forwardStream(context.Background(), convID, prompt, onDelta)
|
||||
} else {
|
||||
reply, err = fwd.forward(context.Background(), convID, prompt)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect] 转发失败 (%s, 耗时 %s): %v\n", channel, time.Since(started).Round(time.Millisecond), err)
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
|
||||
@@ -5,97 +5,13 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type attachmentRecordingForwarder struct {
|
||||
attachments []connectMediaAttachment
|
||||
}
|
||||
|
||||
func (f *attachmentRecordingForwarder) label() string { return "recording" }
|
||||
func (f *attachmentRecordingForwarder) forward(context.Context, string, string) (string, error) {
|
||||
return "text-only", nil
|
||||
}
|
||||
func (f *attachmentRecordingForwarder) forwardWithAttachments(_ context.Context, _ string, _ string, attachments []connectMediaAttachment) (string, error) {
|
||||
f.attachments = append([]connectMediaAttachment(nil), attachments...)
|
||||
return "with-attachments", nil
|
||||
}
|
||||
|
||||
func TestForwardConnectTurnPreservesAttachmentsForCapableAgent(t *testing.T) {
|
||||
fwd := &attachmentRecordingForwarder{}
|
||||
want := []connectMediaAttachment{{LocalPath: "/tmp/original.mov", FileName: "original.mov", MediaType: "video"}}
|
||||
reply, err := forwardConnectTurn(context.Background(), fwd, "conv", "prompt", want, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reply != "with-attachments" || len(fwd.attachments) != 1 || fwd.attachments[0] != want[0] {
|
||||
t.Fatalf("reply=%q attachments=%#v", reply, fwd.attachments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecForwarderAllowsOnlyAttachmentDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "args.log")
|
||||
stub := filepath.Join(dir, "agent")
|
||||
script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$DWS_ARGS_LOG\"\nprintf 'ok\\n'\n"
|
||||
if err := os.WriteFile(stub, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attachmentDir := t.TempDir()
|
||||
attachmentPath := filepath.Join(attachmentDir, "report.md")
|
||||
if err := os.WriteFile(attachmentPath, []byte("body"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &execForwarder{name: "workbuddy", argv: []string{stub, "-p"}, env: []string{"DWS_ARGS_LOG=" + logPath}, timeout: time.Second}
|
||||
reply, err := f.forwardWithAttachments(context.Background(), "conv", "read it", []connectMediaAttachment{{LocalPath: attachmentPath, FileName: "report.md", MediaType: "file"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reply != "ok" {
|
||||
t.Fatalf("reply = %q", reply)
|
||||
}
|
||||
raw, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
args := string(raw)
|
||||
for _, want := range []string{"--allowedTools", "Read", "--add-dir", attachmentDir, "-p", "read it"} {
|
||||
if !strings.Contains(args, want+"\n") {
|
||||
t.Fatalf("args missing %q:\n%s", want, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryStreamBridgeAgentHasAttachmentDeliveryPath(t *testing.T) {
|
||||
implementations := map[string]any{
|
||||
"claudecode": (*execForwarder)(nil),
|
||||
"codebuddy": (*execForwarder)(nil),
|
||||
"workbuddy": (*execForwarder)(nil),
|
||||
"custom": (*execForwarder)(nil),
|
||||
"qoder": (*qoderStreamForwarder)(nil),
|
||||
"qoderwork": (*qoderStreamForwarder)(nil),
|
||||
"codex": (*codexAppServerForwarder)(nil),
|
||||
"opencode": (*opencodeForwarder)(nil),
|
||||
"gemini": (*geminiAPIForwarder)(nil),
|
||||
}
|
||||
for channel := range agentSpecs {
|
||||
impl, ok := implementations[channel]
|
||||
if !ok {
|
||||
t.Errorf("agent channel %q has no declared attachment delivery path", channel)
|
||||
continue
|
||||
}
|
||||
if _, ok := impl.(attachmentForwarder); !ok {
|
||||
t.Errorf("agent channel %q implementation %T is not attachment-aware", channel, impl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBrandReply covers the qoderwork identity rewrite using the exact replies
|
||||
// captured from a real qodercli (QoderWork.app) headless run.
|
||||
func TestBrandReply(t *testing.T) {
|
||||
@@ -263,69 +179,6 @@ func TestMergeConnectQueuedTurnsBuildsSinglePrompt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConnectQueuedTurnsPreservesAllPictures(t *testing.T) {
|
||||
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||
{convID: "conv-1", text: "第一张", picCodes: []string{"pic-1"}, msgID: "m1"},
|
||||
{convID: "conv-1", text: "再补两张", picCodes: []string{"pic-2", "pic-3"}, msgID: "m2"},
|
||||
})
|
||||
want := []string{"pic-1", "pic-2", "pic-3"}
|
||||
if len(merged.picCodes) != len(want) {
|
||||
t.Fatalf("merged picCodes = %v, want %v", merged.picCodes, want)
|
||||
}
|
||||
for i := range want {
|
||||
if merged.picCodes[i] != want[i] {
|
||||
t.Fatalf("merged picCodes = %v, want %v", merged.picCodes, want)
|
||||
}
|
||||
}
|
||||
for _, text := range []string{"第一张 [同时附有图片]", "再补两张 [同时附有图片]"} {
|
||||
if !strings.Contains(merged.text, text) {
|
||||
t.Fatalf("merged prompt missing %q:\n%s", text, merged.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConnectQueuedTurnsPreservesAllAttachments(t *testing.T) {
|
||||
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||
{
|
||||
convID: "conv-1",
|
||||
text: "第一批",
|
||||
fileInfos: []fileInboundInfo{{DownloadCode: "audio-1", FileName: "语音消息", MediaType: "audio"}},
|
||||
msgID: "m1",
|
||||
},
|
||||
{
|
||||
convID: "conv-1",
|
||||
text: "第二批",
|
||||
fileInfos: []fileInboundInfo{
|
||||
{DownloadCode: "video-1", FileName: "demo.mov", MediaType: "video"},
|
||||
{DownloadCode: "file-1", FileName: "report.md", MediaType: "file"},
|
||||
},
|
||||
msgID: "m2",
|
||||
},
|
||||
})
|
||||
if len(merged.fileInfos) != 3 {
|
||||
t.Fatalf("merged fileInfos = %#v, want all three attachments", merged.fileInfos)
|
||||
}
|
||||
for i, want := range []string{"audio-1", "video-1", "file-1"} {
|
||||
if merged.fileInfos[i].DownloadCode != want {
|
||||
t.Fatalf("merged fileInfos[%d] = %#v, want code %q", i, merged.fileInfos[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConnectQueuedTurnsKeepsEveryChatRecordLookup(t *testing.T) {
|
||||
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||
{convID: "conv-1", text: "first", chatRecordLookups: []chatRecordLookup{{MsgID: "outer-1", UnknownIndexes: []int{1}}}},
|
||||
{convID: "conv-1", text: "second", chatRecordLookups: []chatRecordLookup{{MsgID: "outer-2", UnknownIndexes: []int{0, 2}}}},
|
||||
})
|
||||
want := []chatRecordLookup{
|
||||
{MsgID: "outer-1", UnknownIndexes: []int{1}},
|
||||
{MsgID: "outer-2", UnknownIndexes: []int{0, 2}},
|
||||
}
|
||||
if !reflect.DeepEqual(merged.chatRecordLookups, want) {
|
||||
t.Fatalf("chatRecordLookups = %#v, want %#v", merged.chatRecordLookups, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConnectQueuedTurnsKeepsControlMessagesStandalone(t *testing.T) {
|
||||
for _, text := range []string{"/clear", "同意", "拒绝", "重试"} {
|
||||
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||
|
||||
+3
-64
@@ -795,7 +795,7 @@ func newDocCommand() *cobra.Command {
|
||||
dws doc create 创建文档
|
||||
dws doc update 更新文档内容
|
||||
dws doc block [list|insert|update|delete] 块级编辑
|
||||
dws doc comment [list|create|reply|update|delete|create-inline] 文档评论管理
|
||||
dws doc comment [list|create|delete] 文档评论管理
|
||||
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
|
||||
dws doc export get 查询导出任务结果 (手动兜底)
|
||||
dws doc import 导入本地文件为在线文档 (支持 docx / xlsx / md 等)
|
||||
@@ -1951,67 +1951,6 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
commentReplyCmd.Flags().Bool("emoji", false, "设为 true 时作为表情贴图回复 (默认 false)")
|
||||
commentReplyCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
|
||||
|
||||
commentUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新文档评论",
|
||||
Long: `更新指定文档中的一条评论。
|
||||
|
||||
--comment-key 为待更新评论的唯一标识,可从 comment list、create 或 create-inline 的返回结果中获取。
|
||||
可通过 --mention 指定更新后评论中被 @ 的用户 uid 列表。`,
|
||||
Example: ` dws doc comment update --node DOC_ID --comment-key COMMENT_KEY --content "已按最新数据修正"
|
||||
dws doc comment update --node DOC_ID --comment-key COMMENT_KEY --content "请确认" --mention uid1,uid2`,
|
||||
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", "content"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"commentKey": mustGetFlag(cmd, "comment-key"),
|
||||
"content": mustGetFlag(cmd, "content"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("mention"); v != "" {
|
||||
toolArgs["mentionedUserIds"] = parseCommentMentionIds(v)
|
||||
}
|
||||
return callMCPToolOnServer("doc-comment", "update_comment", toolArgs)
|
||||
},
|
||||
}
|
||||
commentUpdateCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||||
commentUpdateCmd.Flags().String("comment-key", "", "待更新评论的 commentKey,可从 list/create/create-inline 结果获取 (必填)")
|
||||
commentUpdateCmd.Flags().String("content", "", "更新后的评论文字内容,纯文本 (必填)")
|
||||
commentUpdateCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
|
||||
|
||||
commentDeleteCmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "删除文档评论",
|
||||
Long: `删除指定文档中的一条评论。
|
||||
|
||||
这是不可恢复的危险操作。执行前需要交互确认,或在用户已明确同意后传入全局 --yes 跳过确认。`,
|
||||
Example: ` dws doc comment delete --node DOC_ID --comment-key COMMENT_KEY --yes`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "comment-key"); err != nil {
|
||||
return err
|
||||
}
|
||||
commentKey := mustGetFlag(cmd, "comment-key")
|
||||
if !confirmDelete("文档评论", commentKey) {
|
||||
return nil
|
||||
}
|
||||
return callMCPToolOnServer("doc-comment", "delete_comment", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"commentKey": commentKey,
|
||||
})
|
||||
},
|
||||
}
|
||||
commentDeleteCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
|
||||
commentDeleteCmd.Flags().String("comment-key", "", "待删除评论的 commentKey,可从 list/create/create-inline 结果获取 (必填)")
|
||||
|
||||
commentCreateInlineCmd := &cobra.Command{
|
||||
Use: "create-inline",
|
||||
Short: "创建划词评论",
|
||||
@@ -2067,7 +2006,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
commentCreateInlineCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
|
||||
|
||||
// comment 子命令的 --node 隐藏别名
|
||||
commentNodeAliasCmds := []*cobra.Command{commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd, commentCreateInlineCmd}
|
||||
commentNodeAliasCmds := []*cobra.Command{commentListCmd, commentCreateCmd, commentReplyCmd, commentCreateInlineCmd}
|
||||
for _, c := range commentNodeAliasCmds {
|
||||
c.Flags().String("url", "", "--node 的别名")
|
||||
c.Flags().String("id", "", "--node 的别名")
|
||||
@@ -2081,7 +2020,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
_ = c.Flags().MarkHidden("file-id")
|
||||
}
|
||||
|
||||
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd, commentCreateInlineCmd)
|
||||
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentCreateInlineCmd)
|
||||
|
||||
// ── permission (文档协作权限) ────────────────────────────
|
||||
permissionCmd := &cobra.Command{
|
||||
|
||||
@@ -1,190 +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 (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type docCommentMutationCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type docCommentMutationCaller struct {
|
||||
calls []docCommentMutationCall
|
||||
}
|
||||
|
||||
func (c *docCommentMutationCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, docCommentMutationCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*docCommentMutationCaller) Format() string { return "json" }
|
||||
func (*docCommentMutationCaller) DryRun() bool { return false }
|
||||
func (*docCommentMutationCaller) Fields() string { return "" }
|
||||
func (*docCommentMutationCaller) JQ() string { return "" }
|
||||
|
||||
func executeDocCommentMutationCommand(t *testing.T, caller *docCommentMutationCaller, processArgs []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 = processArgs
|
||||
cmd := newDocCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestDocCommentUpdateDeleteCommandsRegistered(t *testing.T) {
|
||||
root := newDocCommand()
|
||||
cases := []struct {
|
||||
path string
|
||||
flags []string
|
||||
}{
|
||||
{path: "update", flags: []string{"node", "url", "id", "node-id", "doc-id", "file-id", "comment-key", "content", "mention"}},
|
||||
{path: "delete", flags: []string{"node", "url", "id", "node-id", "doc-id", "file-id", "comment-key"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
cmd, remaining, err := root.Find([]string{"comment", tc.path})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws doc comment %s not registered: cmd=%v remaining=%v err=%v", tc.path, cmd, remaining, err)
|
||||
}
|
||||
for _, flag := range tc.flags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("dws doc comment %s missing flag --%s", tc.path, flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentUpdateMapsOpenToolArguments(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeDocCommentMutationCommand(t, caller, []string{"dws", "doc"},
|
||||
"comment", "update", "--file-id", "doc-1", "--comment-key", "comment-1",
|
||||
"--content", "updated", "--mention", "uid-1, ,uid-2")
|
||||
if err != nil {
|
||||
t.Fatalf("doc comment update returned error: %v", err)
|
||||
}
|
||||
want := docCommentMutationCall{
|
||||
productID: "doc-comment",
|
||||
toolName: "update_comment",
|
||||
args: map[string]any{
|
||||
"nodeId": "doc-1",
|
||||
"commentKey": "comment-1",
|
||||
"content": "updated",
|
||||
"mentionedUserIds": []string{"uid-1", "uid-2"},
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentUpdateOmitsMentionWhenUnset(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeDocCommentMutationCommand(t, caller, []string{"dws", "doc"},
|
||||
"comment", "update", "--node", "doc-1", "--comment-key", "comment-1", "--content", "updated")
|
||||
if err != nil {
|
||||
t.Fatalf("doc comment update returned error: %v", err)
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1", "commentKey": "comment-1", "content": "updated"}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want args %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentMutationsRejectMissingRequiredFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "update node", args: []string{"comment", "update", "--comment-key", "comment-1", "--content", "updated"}},
|
||||
{name: "update comment key", args: []string{"comment", "update", "--node", "doc-1", "--content", "updated"}},
|
||||
{name: "update content", args: []string{"comment", "update", "--node", "doc-1", "--comment-key", "comment-1"}},
|
||||
{name: "delete node", args: []string{"comment", "delete", "--comment-key", "comment-1"}},
|
||||
{name: "delete comment key", args: []string{"comment", "delete", "--node", "doc-1"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeDocCommentMutationCommand(t, caller, []string{"dws", "doc", "--yes"}, tt.args...)
|
||||
if err == nil {
|
||||
t.Fatal("command with missing required flag returned nil error")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentDeleteMapsArgumentsAfterYesConfirmation(t *testing.T) {
|
||||
caller := &docCommentMutationCaller{}
|
||||
err := executeDocCommentMutationCommand(t, caller, []string{"dws", "doc", "comment", "delete", "--yes"},
|
||||
"comment", "delete", "--id", "doc-1", "--comment-key", "comment-1")
|
||||
if err != nil {
|
||||
t.Fatalf("doc comment delete returned error: %v", err)
|
||||
}
|
||||
want := docCommentMutationCall{
|
||||
productID: "doc-comment",
|
||||
toolName: "delete_comment",
|
||||
args: map[string]any{"nodeId": "doc-1", "commentKey": "comment-1"},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocCommentDeleteCancellationSkipsRemoteCall(t *testing.T) {
|
||||
previousStdin := os.Stdin
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe: %v", err)
|
||||
}
|
||||
if _, err := writer.WriteString("no\n"); err != nil {
|
||||
t.Fatalf("write confirmation: %v", err)
|
||||
}
|
||||
_ = writer.Close()
|
||||
os.Stdin = reader
|
||||
t.Cleanup(func() {
|
||||
os.Stdin = previousStdin
|
||||
_ = reader.Close()
|
||||
})
|
||||
|
||||
caller := &docCommentMutationCaller{}
|
||||
err = executeDocCommentMutationCommand(t, caller, []string{"dws", "doc"},
|
||||
"comment", "delete", "--node", "doc-1", "--comment-key", "comment-1")
|
||||
if err != nil {
|
||||
t.Fatalf("cancelled doc comment delete returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0 after cancellation", len(caller.calls))
|
||||
}
|
||||
}
|
||||
@@ -256,7 +256,7 @@ func newDriveCommand() *cobra.Command {
|
||||
driveCmd := &cobra.Command{
|
||||
Use: "drive",
|
||||
Short: "钉盘文件管理",
|
||||
Long: `钉盘:列出文件/文件夹、获取元数据和统计信息、创建快捷方式、下载、上传及管理文件。`,
|
||||
Long: `钉盘:列出文件/文件夹、获取元数据、下载链接、创建文件夹、获取上传信息、提交上传。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
@@ -872,56 +872,6 @@ func newDriveCommand() *cobra.Command {
|
||||
driveRenameCmd.Flags().String("node", "", "文档/文件 ID 或 URL (必填)")
|
||||
driveRenameCmd.Flags().String("name", "", "新名称 (必填)")
|
||||
|
||||
driveStatsCmd := &cobra.Command{
|
||||
Use: "stats",
|
||||
Short: "获取节点统计信息",
|
||||
Long: `获取指定节点的统计数据,包括阅读人数、阅读次数、编辑次数、评论数、点赞数、预览次数和下载次数等。
|
||||
|
||||
不同文件类型返回的统计维度可能不同。--node 支持节点 ID 或文档 URL。`,
|
||||
Example: ` dws drive stats --node <dentryUuid>
|
||||
dws drive stats --node https://alidocs.dingtalk.com/i/nodes/<dentryUuid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("drive", "get_node_stats", map[string]any{"nodeId": nodeID})
|
||||
},
|
||||
}
|
||||
driveStatsCmd.Flags().String("node", "", "节点 ID 或文档 URL (必填)")
|
||||
|
||||
driveShortcutCmd := &cobra.Command{
|
||||
Use: "shortcut",
|
||||
Short: "为节点创建快捷方式",
|
||||
Long: `为指定源节点创建快捷方式,并放置到目标文件夹或知识库。
|
||||
|
||||
通过 --node 指定源节点。--folder 和 --workspace 均为可选;都不传时由服务端选择默认位置。
|
||||
若同时指定,--folder 是目标文件夹,--workspace 用于指定其所属知识库。`,
|
||||
Example: ` dws drive shortcut --node <dentryUuid>
|
||||
dws drive shortcut --node <dentryUuid> --folder <targetFolderId>
|
||||
dws drive shortcut --node <dentryUuid> --workspace <workspaceId>`,
|
||||
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
|
||||
}
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
if v := docFolderFlag(cmd); v != "" {
|
||||
if err := validateDocFolderID(v); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["targetFolderId"] = v
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
}
|
||||
return callMCPToolOnServer("drive", "create_shortcut", toolArgs)
|
||||
},
|
||||
}
|
||||
driveShortcutCmd.Flags().String("node", "", "源节点 ID 或文档 URL (必填)")
|
||||
driveShortcutCmd.Flags().String("folder", "", "目标文件夹 nodeId (可选)")
|
||||
driveShortcutCmd.Flags().String("workspace", "", "目标知识库 ID (可选)")
|
||||
|
||||
// ── drive permission (文档节点权限管理) ──
|
||||
drivePermissionCmd := &cobra.Command{
|
||||
Use: "permission",
|
||||
@@ -1091,7 +1041,7 @@ func newDriveCommand() *cobra.Command {
|
||||
|
||||
// --node 隐藏别名(保持与迁移前 doc 命令一致)
|
||||
driveNodeAliasCmds := []*cobra.Command{
|
||||
driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
|
||||
driveCopyCmd, driveMoveCmd, driveRenameCmd,
|
||||
}
|
||||
for _, c := range driveNodeAliasCmds {
|
||||
c.Flags().String("url", "", "")
|
||||
@@ -1290,7 +1240,7 @@ func newDriveCommand() *cobra.Command {
|
||||
for _, cmd := range []*cobra.Command{
|
||||
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd,
|
||||
driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd,
|
||||
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
|
||||
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd,
|
||||
driveFolderCreateCmd,
|
||||
} {
|
||||
RegisterCrossProductAliases(cmd)
|
||||
@@ -1376,8 +1326,6 @@ func newDriveCommand() *cobra.Command {
|
||||
driveCopyCmd,
|
||||
driveMoveCmd,
|
||||
driveRenameCmd,
|
||||
driveStatsCmd,
|
||||
driveShortcutCmd,
|
||||
drivePermissionCmd,
|
||||
drivePublishCmd,
|
||||
recycleCmd,
|
||||
|
||||
@@ -1,150 +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 (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type driveStatsShortcutCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type driveStatsShortcutCaller struct {
|
||||
calls []driveStatsShortcutCall
|
||||
}
|
||||
|
||||
func (c *driveStatsShortcutCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, driveStatsShortcutCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*driveStatsShortcutCaller) Format() string { return "json" }
|
||||
func (*driveStatsShortcutCaller) DryRun() bool { return false }
|
||||
func (*driveStatsShortcutCaller) Fields() string { return "" }
|
||||
func (*driveStatsShortcutCaller) JQ() string { return "" }
|
||||
|
||||
func executeDriveStatsShortcutCommand(t *testing.T, caller *driveStatsShortcutCaller, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
cmd := newDriveCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestDriveStatsShortcutCommandsRegistered(t *testing.T) {
|
||||
root := newDriveCommand()
|
||||
cases := []struct {
|
||||
path string
|
||||
flags []string
|
||||
}{
|
||||
{path: "stats", flags: []string{"node", "url", "id", "node-id", "doc-id", "file-id"}},
|
||||
{path: "shortcut", flags: []string{"node", "url", "id", "node-id", "doc-id", "file-id", "folder", "workspace"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
cmd, remaining, err := root.Find([]string{tc.path})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws drive %s not registered: cmd=%v remaining=%v err=%v", tc.path, cmd, remaining, err)
|
||||
}
|
||||
for _, flag := range tc.flags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("dws drive %s missing flag --%s", tc.path, flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveStatsMapsOpenToolArguments(t *testing.T) {
|
||||
caller := &driveStatsShortcutCaller{}
|
||||
if err := executeDriveStatsShortcutCommand(t, caller, "stats", "--url", "https://alidocs.dingtalk.com/i/nodes/node-1"); err != nil {
|
||||
t.Fatalf("drive stats returned error: %v", err)
|
||||
}
|
||||
want := driveStatsShortcutCall{
|
||||
productID: "drive",
|
||||
toolName: "get_node_stats",
|
||||
args: map[string]any{"nodeId": "https://alidocs.dingtalk.com/i/nodes/node-1"},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveShortcutMapsOptionalTargetArguments(t *testing.T) {
|
||||
caller := &driveStatsShortcutCaller{}
|
||||
if err := executeDriveStatsShortcutCommand(t, caller,
|
||||
"shortcut", "--file-id", "source-1", "--folder", "folder-1", "--workspace", "workspace-1"); err != nil {
|
||||
t.Fatalf("drive shortcut returned error: %v", err)
|
||||
}
|
||||
want := driveStatsShortcutCall{
|
||||
productID: "drive",
|
||||
toolName: "create_shortcut",
|
||||
args: map[string]any{
|
||||
"nodeId": "source-1",
|
||||
"targetFolderId": "folder-1",
|
||||
"workspaceId": "workspace-1",
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveShortcutOmitsUnsetOptionalArguments(t *testing.T) {
|
||||
caller := &driveStatsShortcutCaller{}
|
||||
if err := executeDriveStatsShortcutCommand(t, caller, "shortcut", "--node", "source-1"); err != nil {
|
||||
t.Fatalf("drive shortcut returned error: %v", err)
|
||||
}
|
||||
want := map[string]any{"nodeId": "source-1"}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want args %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveStatsShortcutRejectMissingNode(t *testing.T) {
|
||||
for _, path := range []string{"stats", "shortcut"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
caller := &driveStatsShortcutCaller{}
|
||||
if err := executeDriveStatsShortcutCommand(t, caller, path); err == nil {
|
||||
t.Fatalf("drive %s without --node returned nil error", path)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("drive %s remote calls = %d, want 0", path, len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveShortcutRejectsNumericDocFolder(t *testing.T) {
|
||||
caller := &driveStatsShortcutCaller{}
|
||||
err := executeDriveStatsShortcutCommand(t, caller, "shortcut", "--node", "source-1", "--folder", "123456")
|
||||
if err == nil {
|
||||
t.Fatal("drive shortcut accepted a pure numeric doc folder")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
}
|
||||
@@ -44,11 +44,6 @@ func newSheetCommand() *cobra.Command {
|
||||
dws sheet find 在工作表中搜索单元格内容
|
||||
dws sheet append 在工作表末尾追加数据
|
||||
dws sheet csv-put 将 CSV 数据写入表格指定位置
|
||||
dws sheet table-get 读取结构化 table 数据
|
||||
dws sheet table-put 写入结构化 table 数据
|
||||
dws sheet pivot-table [list|create|update|delete] 透视表管理
|
||||
dws sheet show-gridline 显示工作表网格线
|
||||
dws sheet hide-gridline 隐藏工作表网格线
|
||||
dws sheet merge-cells 合并单元格
|
||||
dws sheet insert-dimension 在指定位置插入行或列
|
||||
dws sheet delete-dimension 删除指定位置的行或列
|
||||
@@ -111,8 +106,6 @@ func newSheetCommand() *cobra.Command {
|
||||
chartCmd := newChartCmd()
|
||||
exportCmd := newExportCmd()
|
||||
templateCmd := newSheetTemplateCmd()
|
||||
tableCmds := newTableCmds()
|
||||
pivotTableCmd := newPivotTableCmd()
|
||||
|
||||
batchUpdateCmd := newBatchUpdateCmd()
|
||||
rangeBatchClearCmd := newRangeBatchClearCmd()
|
||||
@@ -133,7 +126,6 @@ func newSheetCommand() *cobra.Command {
|
||||
standaloneCmds = append(standaloneCmds, dimensionCmds...)
|
||||
standaloneCmds = append(standaloneCmds, mediaCmds...)
|
||||
standaloneCmds = append(standaloneCmds, floatImageCmds...)
|
||||
standaloneCmds = append(standaloneCmds, tableCmds...)
|
||||
standaloneCmds = append(standaloneCmds, exportCmd, batchUpdateCmd)
|
||||
|
||||
// Register cross-product aliases
|
||||
@@ -148,7 +140,7 @@ func newSheetCommand() *cobra.Command {
|
||||
|
||||
// Add all to root
|
||||
root.AddCommand(standaloneCmds...)
|
||||
root.AddCommand(rangeCmd, filterCmd, filterViewCmd, condFormatCmd, chartCmd, templateCmd, pivotTableCmd)
|
||||
root.AddCommand(rangeCmd, filterCmd, filterViewCmd, condFormatCmd, chartCmd, templateCmd)
|
||||
|
||||
// Guards for grouped parent commands
|
||||
attachUnknownSubcommandGuard(root)
|
||||
@@ -157,7 +149,6 @@ func newSheetCommand() *cobra.Command {
|
||||
attachUnknownSubcommandGuard(filterViewCmd)
|
||||
attachUnknownSubcommandGuard(condFormatCmd)
|
||||
attachUnknownSubcommandGuard(chartCmd)
|
||||
attachUnknownSubcommandGuard(pivotTableCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -1,285 +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 (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type sheetPivotGridlineCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type sheetPivotGridlineCaller struct {
|
||||
calls []sheetPivotGridlineCall
|
||||
}
|
||||
|
||||
func (c *sheetPivotGridlineCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, sheetPivotGridlineCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*sheetPivotGridlineCaller) Format() string { return "json" }
|
||||
func (*sheetPivotGridlineCaller) DryRun() bool { return false }
|
||||
func (*sheetPivotGridlineCaller) Fields() string { return "" }
|
||||
func (*sheetPivotGridlineCaller) JQ() string { return "" }
|
||||
|
||||
func executeSheetPivotGridlineCommand(t *testing.T, caller *sheetPivotGridlineCaller, 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 = append([]string{"dws", "sheet"}, args...)
|
||||
root := newSheetCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestSheetPivotAndGridlineCommandsRegistered(t *testing.T) {
|
||||
root := newSheetCommand()
|
||||
cases := []struct {
|
||||
path []string
|
||||
flags []string
|
||||
}{
|
||||
{[]string{"pivot-table", "list"}, []string{"node", "sheet-id", "pivot-table-id"}},
|
||||
{[]string{"pivot-table", "create"}, []string{"node", "source", "properties", "target-sheet-id", "target-position"}},
|
||||
{[]string{"pivot-table", "update"}, []string{"node", "sheet-id", "pivot-table-id", "properties"}},
|
||||
{[]string{"pivot-table", "delete"}, []string{"node", "sheet-id", "pivot-table-id"}},
|
||||
{[]string{"show-gridline"}, []string{"node", "sheet-id"}},
|
||||
{[]string{"hide-gridline"}, []string{"node", "sheet-id"}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
cmd, remaining, err := root.Find(tc.path)
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws sheet %s not registered: cmd=%v remaining=%v err=%v", strings.Join(tc.path, " "), cmd, remaining, err)
|
||||
}
|
||||
for _, flag := range tc.flags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("dws sheet %s: missing flag --%s", strings.Join(tc.path, " "), flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotListBuildsToolArgs(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
err := executeSheetPivotGridlineCommand(t, caller,
|
||||
"pivot-table", "list", "--node", "node1", "--sheet-id", "sheet1", "--pivot-table-id", "pivot1")
|
||||
if err != nil {
|
||||
t.Fatalf("pivot-table list returned error: %v", err)
|
||||
}
|
||||
want := sheetPivotGridlineCall{
|
||||
productID: "sheet",
|
||||
toolName: "list_pivot_tables",
|
||||
args: map[string]any{
|
||||
"nodeId": "node1",
|
||||
"sheetId": "sheet1",
|
||||
"pivotTableId": "pivot1",
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotCreateBuildsToolArgs(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
err := executeSheetPivotGridlineCommand(t, caller,
|
||||
"pivot-table", "create",
|
||||
"--node", "node1",
|
||||
"--source", "'Data'!A1:D20",
|
||||
"--properties", `{"rows":[{"field":"team"}],"values":[{"field":"amount","summarize_by":" SUM "}]}`,
|
||||
"--target-sheet-id", "summary",
|
||||
"--target-position", "B2")
|
||||
if err != nil {
|
||||
t.Fatalf("pivot-table create returned error: %v", err)
|
||||
}
|
||||
want := sheetPivotGridlineCall{
|
||||
productID: "sheet",
|
||||
toolName: "create_pivot_table",
|
||||
args: map[string]any{
|
||||
"nodeId": "node1",
|
||||
"source": "'Data'!A1:D20",
|
||||
"targetSheetId": "summary",
|
||||
"targetPosition": "B2",
|
||||
"properties": map[string]any{
|
||||
"rows": []any{map[string]any{"field": "team"}},
|
||||
"values": []any{map[string]any{"field": "amount", "summarize_by": "sum"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotCreateReadsPropertiesFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pivot.json")
|
||||
if err := os.WriteFile(path, []byte(`{"values":[{"field":"amount","summarize_by":"average"}]}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
if err := executeSheetPivotGridlineCommand(t, caller,
|
||||
"pivot-table", "create", "--node", "node1", "--source", "'Data'!A1:B10", "--properties", "@"+path); err != nil {
|
||||
t.Fatalf("pivot-table create with @file returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "create_pivot_table" {
|
||||
t.Fatalf("calls = %#v, want create_pivot_table", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotUpdateAllowsPartialProperties(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
err := executeSheetPivotGridlineCommand(t, caller,
|
||||
"pivot-table", "update", "--node", "node1", "--sheet-id", "sheet1", "--pivot-table-id", "pivot1",
|
||||
"--properties", `{"show_subtotals":false}`)
|
||||
if err != nil {
|
||||
t.Fatalf("pivot-table update returned error: %v", err)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"nodeId": "node1",
|
||||
"sheetId": "sheet1",
|
||||
"pivotTableId": "pivot1",
|
||||
"properties": map[string]any{"show_subtotals": false},
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "update_pivot_table" || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
|
||||
t.Fatalf("calls = %#v, want update_pivot_table %#v", caller.calls, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotDeleteBuildsToolArgsAfterConfirmation(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = previousArgs })
|
||||
os.Args = []string{"dws", "sheet", "pivot-table", "delete", "--yes"}
|
||||
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
root := newSheetCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs([]string{"pivot-table", "delete", "--node", "node1", "--sheet-id", "sheet1", "--pivot-table-id", "pivot1"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("pivot-table delete returned error: %v", err)
|
||||
}
|
||||
wantArgs := map[string]any{"nodeId": "node1", "sheetId": "sheet1", "pivotTableId": "pivot1"}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "delete_pivot_table" || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
|
||||
t.Fatalf("calls = %#v, want delete_pivot_table %#v", caller.calls, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotRejectsInvalidPropertiesBeforeCall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
props string
|
||||
wantErr string
|
||||
}{
|
||||
{"create invalid JSON", "create", `{`, "JSON 解析失败"},
|
||||
{"create missing values", "create", `{"rows":[{"field":"team"}]}`, "缺少必填字段 values"},
|
||||
{"create empty values", "create", `{"values":[]}`, "至少包含一项"},
|
||||
{"create invalid summarize", "create", `{"values":[{"field":"amount","summarize_by":"total"}]}`, "summarize_by 不支持"},
|
||||
{"create invalid row", "create", `{"rows":[{}],"values":[{"field":"amount"}]}`, "rows[0].field"},
|
||||
{"create invalid collapse", "create", `{"values":[{"field":"amount"}],"collapse":"team"}`, "collapse 必须"},
|
||||
{"update empty properties", "update", `{}`, "不能为空对象"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
args := []string{"pivot-table", tt.command, "--node", "node1", "--properties", tt.props}
|
||||
if tt.command == "create" {
|
||||
args = append(args, "--source", "'Data'!A1:B10")
|
||||
} else {
|
||||
args = append(args, "--sheet-id", "sheet1", "--pivot-table-id", "pivot1")
|
||||
}
|
||||
err := executeSheetPivotGridlineCommand(t, caller, args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetPivotAndGridlineRequireMandatoryFlags(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"pivot-table", "list", "--node", "node1"},
|
||||
{"pivot-table", "create", "--node", "node1", "--source", "'Data'!A1:B10"},
|
||||
{"pivot-table", "update", "--node", "node1", "--sheet-id", "sheet1", "--pivot-table-id", "pivot1"},
|
||||
{"pivot-table", "delete", "--node", "node1", "--sheet-id", "sheet1"},
|
||||
{"show-gridline", "--node", "node1"},
|
||||
{"hide-gridline", "--sheet-id", "sheet1"},
|
||||
} {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
if err := executeSheetPivotGridlineCommand(t, caller, args...); err == nil {
|
||||
t.Fatalf("dws sheet %s returned nil error", strings.Join(args, " "))
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dws sheet %s made %d remote calls, want 0", strings.Join(args, " "), len(caller.calls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetGridlineCommandsBuildToolArgs(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
command string
|
||||
visibility string
|
||||
}{
|
||||
{"show-gridline", "visible"},
|
||||
{"hide-gridline", "hidden"},
|
||||
} {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
caller := &sheetPivotGridlineCaller{}
|
||||
if err := executeSheetPivotGridlineCommand(t, caller, tt.command, "--node", "node1", "--sheet-id", "sheet1"); err != nil {
|
||||
t.Fatalf("%s returned error: %v", tt.command, err)
|
||||
}
|
||||
want := sheetPivotGridlineCall{
|
||||
productID: "sheet",
|
||||
toolName: "set_gridline_visibility",
|
||||
args: map[string]any{
|
||||
"nodeId": "node1",
|
||||
"sheetId": "sheet1",
|
||||
"visibility": tt.visibility,
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,254 +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"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var validPivotSummarizeBy = map[string]bool{
|
||||
"sum": true, "count": true, "average": true,
|
||||
"max": true, "min": true, "product": true,
|
||||
"count_numbers": true, "std_dev": true, "std_dev_p": true,
|
||||
"var": true, "var_p": true, "distinct": true, "median": true,
|
||||
}
|
||||
|
||||
func validatePivotTableProperties(props map[string]any, requireValues bool) error {
|
||||
valuesRaw, hasValues := props["values"]
|
||||
if requireValues && (!hasValues || valuesRaw == nil) {
|
||||
return fmt.Errorf("--properties 缺少必填字段 values")
|
||||
}
|
||||
if hasValues && valuesRaw != nil {
|
||||
values, ok := valuesRaw.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("values 必须为数组")
|
||||
}
|
||||
if requireValues && len(values) == 0 {
|
||||
return fmt.Errorf("values 必须至少包含一项")
|
||||
}
|
||||
for i, item := range values {
|
||||
field, err := validatePivotField(item, fmt.Sprintf("values[%d]", i))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = field
|
||||
obj := item.(map[string]any)
|
||||
if raw, ok := obj["summarize_by"]; ok && raw != nil {
|
||||
value, ok := raw.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("values[%d].summarize_by 必须为字符串", i)
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if !validPivotSummarizeBy[normalized] {
|
||||
return fmt.Errorf("values[%d].summarize_by 不支持: %q", i, value)
|
||||
}
|
||||
obj["summarize_by"] = normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"rows", "columns", "filters"} {
|
||||
if err := validatePivotFieldArray(props, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if raw, ok := props["collapse"]; ok && raw != nil {
|
||||
switch raw.(type) {
|
||||
case map[string]any, []any:
|
||||
default:
|
||||
return fmt.Errorf("collapse 必须为对象或数组")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePivotField(item any, path string) (string, error) {
|
||||
obj, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s 必须为对象", path)
|
||||
}
|
||||
raw, ok := obj["field"]
|
||||
if !ok || raw == nil {
|
||||
return "", fmt.Errorf("%s.field 为必填字段", path)
|
||||
}
|
||||
field, ok := raw.(string)
|
||||
if !ok || strings.TrimSpace(field) == "" {
|
||||
return "", fmt.Errorf("%s.field 必须为非空字符串", path)
|
||||
}
|
||||
return field, nil
|
||||
}
|
||||
|
||||
func validatePivotFieldArray(props map[string]any, name string) error {
|
||||
raw, ok := props[name]
|
||||
if !ok || raw == nil {
|
||||
return nil
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s 必须为数组", name)
|
||||
}
|
||||
for i, item := range items {
|
||||
if _, err := validatePivotField(item, fmt.Sprintf("%s[%d]", name, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPivotProperties(raw string, requireValues bool) (map[string]any, error) {
|
||||
if strings.HasPrefix(raw, "@") {
|
||||
data, err := os.ReadFile(strings.TrimPrefix(raw, "@"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 properties 文件失败: %w", err)
|
||||
}
|
||||
raw = string(data)
|
||||
}
|
||||
var properties map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &properties); err != nil {
|
||||
return nil, fmt.Errorf("--properties JSON 解析失败: %w", err)
|
||||
}
|
||||
if len(properties) == 0 {
|
||||
return nil, fmt.Errorf("--properties 不能为空对象")
|
||||
}
|
||||
if err := validatePivotTableProperties(properties, requireValues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return properties, nil
|
||||
}
|
||||
|
||||
func newPivotTableCmd() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "pivot-table",
|
||||
Short: "透视表管理",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
listCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "获取透视表列表或详情",
|
||||
Example: ` dws sheet pivot-table list --node NODE_ID --sheet-id SHEET_ID
|
||||
dws sheet pivot-table list --node NODE_ID --sheet-id SHEET_ID --pivot-table-id PT_ID`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
args := map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("pivot-table-id"); value != "" {
|
||||
args["pivotTableId"] = value
|
||||
}
|
||||
return callMCPTool("list_pivot_tables", args)
|
||||
},
|
||||
}
|
||||
listCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
listCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
listCmd.Flags().String("pivot-table-id", "", "透视表 ID (可选,不传则返回全部)")
|
||||
|
||||
createCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建透视表",
|
||||
Long: `在指定数据源区域上创建原生透视表。
|
||||
|
||||
--source 必须使用带工作表前缀的 A1 范围,例如 "'Sheet1'!A1:D100"。
|
||||
--properties 为 JSON 对象或 @file,values 至少一项;可包含 rows、columns、filters、collapse 和总计显示选项。`,
|
||||
Example: ` dws sheet pivot-table create --node NODE_ID \
|
||||
--source "'Sheet1'!A1:D100" \
|
||||
--properties '{"rows":[{"field":"部门"}],"values":[{"field":"销售额","summarize_by":"sum"}]}'`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "source", "properties"); err != nil {
|
||||
return err
|
||||
}
|
||||
properties, err := readPivotProperties(mustGetFlag(cmd, "properties"), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
args := map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"source": mustGetFlag(cmd, "source"),
|
||||
"properties": properties,
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("target-sheet-id"); value != "" {
|
||||
args["targetSheetId"] = value
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("target-position"); value != "" {
|
||||
args["targetPosition"] = value
|
||||
}
|
||||
return callMCPTool("create_pivot_table", args)
|
||||
},
|
||||
}
|
||||
createCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
createCmd.Flags().String("source", "", "数据源区域,A1 表示法且包含工作表前缀 (必填)")
|
||||
createCmd.Flags().String("properties", "", "透视表配置 JSON 或 @文件路径 (必填)")
|
||||
createCmd.Flags().String("target-sheet-id", "", "目标工作表 ID 或名称 (可选,不传则自动新建)")
|
||||
createCmd.Flags().String("target-position", "", "透视表放置位置,A1 单元格地址 (可选)")
|
||||
|
||||
updateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新透视表配置",
|
||||
Example: ` dws sheet pivot-table update --node NODE_ID --sheet-id SHEET_ID \
|
||||
--pivot-table-id PT_ID --properties '{"show_subtotals":false}'`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "pivot-table-id", "properties"); err != nil {
|
||||
return err
|
||||
}
|
||||
properties, err := readPivotProperties(mustGetFlag(cmd, "properties"), false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("update_pivot_table", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"pivotTableId": mustGetFlag(cmd, "pivot-table-id"),
|
||||
"properties": properties,
|
||||
})
|
||||
},
|
||||
}
|
||||
updateCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
updateCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
updateCmd.Flags().String("pivot-table-id", "", "透视表 ID (必填)")
|
||||
updateCmd.Flags().String("properties", "", "需要更新的透视表配置 JSON 或 @文件路径 (必填)")
|
||||
|
||||
deleteCmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "[危险] 删除透视表",
|
||||
Example: ` dws sheet pivot-table delete --node NODE_ID --sheet-id SHEET_ID --pivot-table-id PT_ID --yes`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "pivot-table-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
pivotTableID := mustGetFlag(cmd, "pivot-table-id")
|
||||
if !confirmDelete("透视表", pivotTableID) {
|
||||
return nil
|
||||
}
|
||||
return callMCPTool("delete_pivot_table", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"pivotTableId": pivotTableID,
|
||||
})
|
||||
},
|
||||
}
|
||||
deleteCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
deleteCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
deleteCmd.Flags().String("pivot-table-id", "", "透视表 ID (必填)")
|
||||
|
||||
root.AddCommand(listCmd, createCmd, updateCmd, deleteCmd)
|
||||
attachUnknownSubcommandGuard(root)
|
||||
return root
|
||||
}
|
||||
@@ -1,152 +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"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newTableCmds() []*cobra.Command {
|
||||
tableGetCmd := &cobra.Command{
|
||||
Use: "table-get",
|
||||
Aliases: []string{"table-read"},
|
||||
Short: "读取结构化 table 数据",
|
||||
Long: `读取结构化 table 数据。
|
||||
|
||||
返回列名、行数据、pandas-style dtypes 和表格 number formats。`,
|
||||
Example: ` dws sheet table-get --node NODE_ID
|
||||
dws sheet table-get --node NODE_ID --sheet-id SHEET_ID --range A1:D20
|
||||
dws sheet table-get --node NODE_ID --sheet-id Sheet1 --no-header`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("sheet-id"); v != "" {
|
||||
toolArgs["sheetId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("range"); v != "" {
|
||||
toolArgs["range"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("no-header") {
|
||||
v, _ := cmd.Flags().GetBool("no-header")
|
||||
toolArgs["noHeader"] = v
|
||||
}
|
||||
return callMCPTool("table_get", toolArgs)
|
||||
},
|
||||
}
|
||||
tableGetCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
tableGetCmd.Flags().String("sheet-id", "", "工作表 ID 或名称")
|
||||
tableGetCmd.Flags().String("range", "", "读取范围,A1 表示法;可带 sheet 前缀,如 Sheet1!A1:D10")
|
||||
tableGetCmd.Flags().Bool("no-header", false, "首行不作为表头,自动生成 col1/col2/...")
|
||||
|
||||
tablePutCmd := &cobra.Command{
|
||||
Use: "table-put",
|
||||
Aliases: []string{"table-write"},
|
||||
Short: "写入结构化 table 数据",
|
||||
Long: `写入结构化 table 数据。
|
||||
|
||||
支持一次写入一个或多个 sheet;目标 sheet 存在时写入,传 name 且不存在时会创建 sheet。
|
||||
|
||||
--sheets 支持三种输入:
|
||||
JSON 数组 [{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}]
|
||||
JSON 对象 {"sheets":[{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}]}
|
||||
单个 sheet spec JSON 对象 {"name":"Sheet1","columns":["name"],"data":[["Alice"]]}
|
||||
|
||||
--sheets 可传 @filepath 从文件读取,或传 - 从 stdin 读取。`,
|
||||
Example: ` dws sheet table-put --node NODE_ID \
|
||||
--sheets '[{"name":"Sheet1","columns":["name","score"],"data":[["Alice",95]],"dtypes":{"score":"float64"}}]'
|
||||
|
||||
dws sheet table-put --node NODE_ID --sheets @table.json
|
||||
cat table.json | dws sheet table-put --node NODE_ID --sheets -`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheets"); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := readTableJSONFlag(cmd, "sheets")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheets, err := parseTablePutSheets(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("table_put", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheets": sheets,
|
||||
})
|
||||
},
|
||||
}
|
||||
tablePutCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
tablePutCmd.Flags().String("sheets", "", "sheet table JSON、@文件路径 或 - 表示 stdin (必填)")
|
||||
|
||||
return []*cobra.Command{tableGetCmd, tablePutCmd}
|
||||
}
|
||||
|
||||
func readTableJSONFlag(cmd *cobra.Command, flagName string) (string, error) {
|
||||
value := mustGetFlag(cmd, flagName)
|
||||
switch {
|
||||
case value == "-":
|
||||
data, err := io.ReadAll(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取 stdin 失败: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
case strings.HasPrefix(value, "@"):
|
||||
data, err := os.ReadFile(strings.TrimPrefix(value, "@"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取 JSON 文件失败: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseTablePutSheets(raw string) ([]any, error) {
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil, fmt.Errorf("--sheets JSON 解析失败: %w", err)
|
||||
}
|
||||
|
||||
switch value := payload.(type) {
|
||||
case []any:
|
||||
if len(value) == 0 {
|
||||
return nil, fmt.Errorf("--sheets must contain at least one sheet spec")
|
||||
}
|
||||
return value, nil
|
||||
case map[string]any:
|
||||
if sheets, ok := value["sheets"]; ok {
|
||||
items, ok := sheets.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--sheets.sheets must be a JSON array")
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("--sheets.sheets must contain at least one sheet spec")
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
return []any{value}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("--sheets must be a JSON array, an object with sheets, or a single sheet spec object")
|
||||
}
|
||||
}
|
||||
@@ -1,241 +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 (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type sheetTableCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type sheetTableCaller struct {
|
||||
calls []sheetTableCall
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *sheetTableCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, sheetTableCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
}
|
||||
|
||||
func (*sheetTableCaller) Format() string { return "json" }
|
||||
func (c *sheetTableCaller) DryRun() bool { return c.dryRun }
|
||||
func (*sheetTableCaller) Fields() string { return "" }
|
||||
func (*sheetTableCaller) JQ() string { return "" }
|
||||
|
||||
func executeSheetTableCommand(t *testing.T, caller *sheetTableCaller, stdin io.Reader, 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 = append([]string{"dws", "sheet"}, args...)
|
||||
root := newSheetCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
if stdin != nil {
|
||||
root.SetIn(stdin)
|
||||
}
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestSheetTableCommandsRegistered(t *testing.T) {
|
||||
root := newSheetCommand()
|
||||
for _, path := range []string{"table-get", "table-put", "table-read", "table-write"} {
|
||||
cmd, remaining, err := root.Find([]string{path})
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("dws sheet %s not registered: cmd=%v remaining=%v err=%v", path, cmd, remaining, err)
|
||||
}
|
||||
}
|
||||
if cmd, remaining, _ := root.Find([]string{"table"}); cmd != nil && len(remaining) == 0 {
|
||||
t.Fatal("dws sheet table parent should not be registered")
|
||||
}
|
||||
|
||||
getCmd, _, _ := root.Find([]string{"table-get"})
|
||||
for _, flag := range []string{"node", "sheet-id", "range", "no-header"} {
|
||||
if getCmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("table-get: missing flag --%s", flag)
|
||||
}
|
||||
}
|
||||
putCmd, _, _ := root.Find([]string{"table-put"})
|
||||
for _, flag := range []string{"node", "sheets"} {
|
||||
if putCmd.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("table-put: missing flag --%s", flag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTableGetBuildsToolArgs(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
err := executeSheetTableCommand(t, caller, nil,
|
||||
"table-get", "--node", "node1", "--sheet-id", "sheet1", "--range", "A1:B2", "--no-header")
|
||||
if err != nil {
|
||||
t.Fatalf("table-get returned error: %v", err)
|
||||
}
|
||||
want := sheetTableCall{
|
||||
productID: "sheet",
|
||||
toolName: "table_get",
|
||||
args: map[string]any{
|
||||
"nodeId": "node1",
|
||||
"sheetId": "sheet1",
|
||||
"range": "A1:B2",
|
||||
"noHeader": true,
|
||||
},
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0], want) {
|
||||
t.Fatalf("calls = %#v, want %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTableGetOmitsUnsetOptionalArgs(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-get", "--node", "node1"); err != nil {
|
||||
t.Fatalf("table-get returned error: %v", err)
|
||||
}
|
||||
want := map[string]any{"nodeId": "node1"}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calls = %#v, want args %#v", caller.calls, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTableGetPreservesExplicitFalseNoHeader(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-get", "--node", "node1", "--no-header=false"); err != nil {
|
||||
t.Fatalf("table-get returned error: %v", err)
|
||||
}
|
||||
if got, ok := caller.calls[0].args["noHeader"]; !ok || got != false {
|
||||
t.Fatalf("noHeader = %#v, present=%v; want explicit false", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTableGetRequiresNodeBeforeCall(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-get"); err == nil {
|
||||
t.Fatal("table-get without --node returned nil error")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTablePutNormalizesAcceptedInputs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{name: "array", input: `[ {"name":"Sheet1","columns":["name"],"data":[["Alice"]]} ]`},
|
||||
{name: "wrapped", input: `{"sheets":[{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}]}`},
|
||||
{name: "single", input: `{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-put", "--node", "node1", "--sheets", tt.input); err != nil {
|
||||
t.Fatalf("table-put returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("remote calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "sheet" || call.toolName != "table_put" || call.args["nodeId"] != "node1" {
|
||||
t.Fatalf("call = %#v", call)
|
||||
}
|
||||
sheets, ok := call.args["sheets"].([]any)
|
||||
if !ok || len(sheets) != 1 {
|
||||
t.Fatalf("sheets = %#v, want one-element array", call.args["sheets"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTablePutReadsStdinAndFile(t *testing.T) {
|
||||
payload := `[{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}]`
|
||||
|
||||
t.Run("stdin", func(t *testing.T) {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, strings.NewReader(payload), "table-put", "--node", "node1", "--sheets", "-"); err != nil {
|
||||
t.Fatalf("table-put stdin returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("remote calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file", func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "table.json")
|
||||
if err := os.WriteFile(path, []byte(payload), 0o600); err != nil {
|
||||
t.Fatalf("write table JSON: %v", err)
|
||||
}
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-put", "--node", "node1", "--sheets", "@"+path); err != nil {
|
||||
t.Fatalf("table-put file returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("remote calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSheetTablePutRejectsInvalidInputBeforeCall(t *testing.T) {
|
||||
for _, input := range []string{`not-json`, `123`, `[]`, `{"sheets":{}}`, `{"sheets":[]}`} {
|
||||
caller := &sheetTableCaller{}
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-put", "--node", "node1", "--sheets", input); err == nil {
|
||||
t.Errorf("input %q returned nil error", input)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Errorf("input %q made %d remote calls, want 0", input, len(caller.calls))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetTablePutDryRunSkipsRemoteCall(t *testing.T) {
|
||||
caller := &sheetTableCaller{dryRun: true}
|
||||
input := `[{"name":"Sheet1","columns":["name"],"data":[["Alice"]]}]`
|
||||
if err := executeSheetTableCommand(t, caller, nil, "table-put", "--node", "node1", "--sheets", input); err != nil {
|
||||
t.Fatalf("table-put dry-run returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetBatchUpdateRejectsTableCommands(t *testing.T) {
|
||||
for _, toolName := range []string{"table-get", "table-put"} {
|
||||
_, err := translateBatchOp(map[string]any{
|
||||
"toolName": toolName,
|
||||
"input": map[string]any{"sheet-id": "sheet1"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), toolName) {
|
||||
t.Errorf("translateBatchOp(%q) error = %v, want rejection naming command", toolName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,29 +261,5 @@ name 不能包含 / \ ? * [ ] : 等特殊字符,最长 100 字符。`,
|
||||
deleteSheetCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
deleteSheetCmd.Flags().String("sheet-id", "", "要删除的工作表 ID 或名称 (必填)")
|
||||
|
||||
showGridlineCmd := newGridlineVisibilityCmd("show-gridline", "显示工作表网格线", "visible")
|
||||
hideGridlineCmd := newGridlineVisibilityCmd("hide-gridline", "隐藏工作表网格线", "hidden")
|
||||
|
||||
return []*cobra.Command{createCmd, listCmd, infoCmd, newCmd, updateSheetCmd, copySheetCmd, deleteSheetCmd, showGridlineCmd, hideGridlineCmd}
|
||||
}
|
||||
|
||||
func newGridlineVisibilityCmd(use, short, visibility string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Example: fmt.Sprintf(" dws sheet %s --node NODE_ID --sheet-id SHEET_ID", use),
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("set_gridline_visibility", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"visibility": visibility,
|
||||
})
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
cmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
return cmd
|
||||
return []*cobra.Command{createCmd, listCmd, infoCmd, newCmd, updateSheetCmd, copySheetCmd, deleteSheetCmd}
|
||||
}
|
||||
|
||||
@@ -98,11 +98,6 @@ func TestWukongSyncDocCommands(t *testing.T) {
|
||||
|
||||
func TestWukongSyncSheetCommands(t *testing.T) {
|
||||
root := newSheetCommand()
|
||||
tableGetCmd := requireWukongSyncCommand(t, root, "table-get")
|
||||
requireWukongSyncFlags(t, tableGetCmd, "node", "sheet-id", "range", "no-header")
|
||||
|
||||
tablePutCmd := requireWukongSyncCommand(t, root, "table-put")
|
||||
requireWukongSyncFlags(t, tablePutCmd, "node", "sheets")
|
||||
|
||||
groupCmd := requireWukongSyncCommand(t, root, "group-dimension")
|
||||
requireWukongSyncFlags(t, groupCmd, "node", "sheet-id", "range", "group-state")
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "Due time ISO-8601 (e.g. 2026-03-10T18:00:00+08:00)",
|
||||
"执行者 userId 列表 (必填)": "Executor userId list (required)",
|
||||
"检查登录状态后重试": "Check login status and retry",
|
||||
"检查网络、代理和 DNS 配置后重试原命令": "Check network, proxy, and DNS settings, then retry the original command",
|
||||
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "Check service connectivity and retry; if persistent, verify MCP service is responding.",
|
||||
"检查服务连通性和协议版本后重试": "Check service connectivity and protocol version, then retry",
|
||||
"检查认证、权限和参数后重试原命令": "Check authentication, permissions, and parameters, then retry",
|
||||
@@ -118,7 +117,6 @@
|
||||
"完成状态: true/false": "Done status: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "Done status: true=completed, false=not completed (required)",
|
||||
"确认删除? (yes/no): ": "Confirm delete? (yes/no): ",
|
||||
"确认 MCP 服务可访问;若持续失败请稍后重试": "Verify the MCP service is reachable; if the failure persists, retry later",
|
||||
"等待用户授权...": "Waiting for user authorization...",
|
||||
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "Result format does not match client expectations; check service protocol changes or rollback to latest working version.",
|
||||
"网络错误,继续重试...": "Network error, retrying...",
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)",
|
||||
"执行者 userId 列表 (必填)": "执行者 userId 列表 (必填)",
|
||||
"检查登录状态后重试": "检查登录状态后重试",
|
||||
"检查网络、代理和 DNS 配置后重试原命令": "检查网络、代理和 DNS 配置后重试原命令",
|
||||
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。",
|
||||
"检查服务连通性和协议版本后重试": "检查服务连通性和协议版本后重试",
|
||||
"检查认证、权限和参数后重试原命令": "检查认证、权限和参数后重试原命令",
|
||||
@@ -118,7 +117,6 @@
|
||||
"完成状态: true/false": "完成状态: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "完成状态: true=已完成, false=未完成 (必填)",
|
||||
"确认删除? (yes/no): ": "确认删除? (yes/no): ",
|
||||
"确认 MCP 服务可访问;若持续失败请稍后重试": "确认 MCP 服务可访问;若持续失败请稍后重试",
|
||||
"等待用户授权...": "等待用户授权...",
|
||||
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。",
|
||||
"网络错误,继续重试...": "网络错误,继续重试...",
|
||||
|
||||
@@ -1,58 +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 darwin || linux
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func authTokenCiphertextPaths(service string) ([]string, error) {
|
||||
dir := StorageDir(service)
|
||||
dirEntries, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read keychain storage: %w", err)
|
||||
}
|
||||
|
||||
paths := make([]string, 0, len(dirEntries))
|
||||
for _, dirEntry := range dirEntries {
|
||||
if !isAuthTokenCiphertextFile(dirEntry.Name()) {
|
||||
continue
|
||||
}
|
||||
info, err := dirEntry.Info()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("inspect keychain entry %q: %w", dirEntry.Name(), err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("keychain entry %q is not a regular file", dirEntry.Name())
|
||||
}
|
||||
paths = append(paths, filepath.Join(dir, dirEntry.Name()))
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func isAuthTokenCiphertextFile(name string) bool {
|
||||
legacyName := safeFileName(AccountToken)
|
||||
return name == legacyName ||
|
||||
(strings.HasPrefix(name, strings.TrimSuffix(legacyName, ".enc")+"_") && strings.HasSuffix(name, ".enc"))
|
||||
}
|
||||
@@ -1,22 +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 keychain
|
||||
|
||||
// Windows stores token entries in the DPAPI-protected user registry rather
|
||||
// than auth-token*.enc files, so there is no file inventory to validate.
|
||||
func platformValidateAuthTokenEntries(string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -42,16 +42,10 @@ const (
|
||||
DisableKeychainEnv = "DWS_DISABLE_KEYCHAIN"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrDEKMissing means encrypted local data may exist, but the Data Encryption
|
||||
// Key needed to decrypt it is missing. Read paths must not create a new DEK,
|
||||
// because a fresh key cannot decrypt existing ciphertext.
|
||||
ErrDEKMissing = errors.New("dek missing")
|
||||
|
||||
// ErrCiphertextKeyMismatch means encrypted data exists but none of the
|
||||
// available DEKs can decrypt it. Write paths must not overwrite the data.
|
||||
ErrCiphertextKeyMismatch = errors.New("ciphertext key mismatch")
|
||||
)
|
||||
// ErrDEKMissing means encrypted local data may exist, but the Data Encryption
|
||||
// Key needed to decrypt it is missing. Read paths must not create a new DEK,
|
||||
// because a fresh key cannot decrypt existing ciphertext.
|
||||
var ErrDEKMissing = errors.New("dek missing")
|
||||
|
||||
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
|
||||
type KeychainAccess interface {
|
||||
@@ -114,10 +108,6 @@ func IsDEKMissing(err error) bool {
|
||||
return errors.Is(err, ErrDEKMissing)
|
||||
}
|
||||
|
||||
func IsCiphertextKeyMismatch(err error) bool {
|
||||
return errors.Is(err, ErrCiphertextKeyMismatch)
|
||||
}
|
||||
|
||||
func Diagnose() Diagnostic {
|
||||
return platformDiagnose()
|
||||
}
|
||||
@@ -139,21 +129,6 @@ func Remove(service, account string) error {
|
||||
return platformRemove(service, account)
|
||||
}
|
||||
|
||||
// MigrateToFileDEK re-encrypts the legacy and profile-scoped auth token entries
|
||||
// for service with the macOS file DEK. It is supported only on macOS and must
|
||||
// be invoked from a process that can still access the system Keychain. When
|
||||
// dryRun is true, all selected entries are validated without modifying data.
|
||||
func MigrateToFileDEK(service string, dryRun bool) (int, error) {
|
||||
return platformMigrateToFileDEK(service, dryRun)
|
||||
}
|
||||
|
||||
// ValidateAuthTokenEntries verifies every persisted auth-token ciphertext,
|
||||
// including profile slots not yet registered in profiles.json, without
|
||||
// creating or rotating key material.
|
||||
func ValidateAuthTokenEntries(service string) error {
|
||||
return platformValidateAuthTokenEntries(service)
|
||||
}
|
||||
|
||||
// Exists checks if an entry exists in the keychain.
|
||||
func Exists(service, account string) bool {
|
||||
val, err := Get(service, account)
|
||||
|
||||
@@ -165,7 +165,19 @@ func platformDiagnose() Diagnostic {
|
||||
}
|
||||
}
|
||||
|
||||
func getSystemDEKReadOnly(service string) ([]byte, error) {
|
||||
// getDEK retrieves or generates the Data Encryption Key.
|
||||
// When DWS_DISABLE_KEYCHAIN=1 (set in sandboxed runtimes like Codex App
|
||||
// where Keychain APIs are blocked), falls back to a file-based DEK
|
||||
// identical to the Linux scheme. See DisableKeychainEnv docs for the
|
||||
// security tradeoff.
|
||||
func getDEK(service string) ([]byte, error) {
|
||||
return getOrCreateDEK(service)
|
||||
}
|
||||
|
||||
func getDEKReadOnly(service string) ([]byte, error) {
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
return fileDEKReadOnly(service)
|
||||
}
|
||||
if err := checkDefaultKeychainAvailable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -308,71 +320,6 @@ func decryptData(data []byte, key []byte) (string, error) {
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// decryptWithAvailableDEK decrypts an existing entry without creating or
|
||||
// migrating key material. In normal macOS mode, entries previously written by
|
||||
// the explicit file-DEK fallback remain readable so another local process
|
||||
// cannot replace them with ciphertext encrypted by a different key.
|
||||
func decryptWithAvailableDEK(service string, data []byte) (string, []byte, error) {
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
key, err := fileDEKReadOnly(service)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
plaintext, err := decryptData(data, key)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("%w: file-DEK cannot decrypt existing entry", ErrCiphertextKeyMismatch)
|
||||
}
|
||||
return plaintext, key, nil
|
||||
}
|
||||
|
||||
systemKey, systemKeyErr := getSystemDEKReadOnly(service)
|
||||
if IsUnavailable(systemKeyErr) {
|
||||
return "", nil, systemKeyErr
|
||||
}
|
||||
if systemKeyErr == nil {
|
||||
if plaintext, err := decryptData(data, systemKey); err == nil {
|
||||
return plaintext, systemKey, nil
|
||||
}
|
||||
}
|
||||
|
||||
fileKey, fileKeyErr := fileDEKReadOnly(service)
|
||||
if fileKeyErr == nil {
|
||||
if plaintext, err := decryptData(data, fileKey); err == nil {
|
||||
return plaintext, fileKey, nil
|
||||
}
|
||||
}
|
||||
|
||||
if systemKeyErr != nil && fileKeyErr != nil {
|
||||
return "", nil, systemKeyErr
|
||||
}
|
||||
return "", nil, fmt.Errorf("%w: available DEKs cannot decrypt existing entry", ErrCiphertextKeyMismatch)
|
||||
}
|
||||
|
||||
// keyForNewEntry preserves the backend used by the canonical auth-token entry
|
||||
// when a related account is added from a normal macOS process. This prevents
|
||||
// profile-scoped token slots from mixing DEK backends without allowing an
|
||||
// unrelated file-backed secret to downgrade a system-Keychain-backed login.
|
||||
func keyForNewEntry(service, account string) ([]byte, error) {
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
return getOrCreateDEK(service)
|
||||
}
|
||||
if account != AccountToken && !strings.HasPrefix(account, AccountToken+":") {
|
||||
return getOrCreateDEK(service)
|
||||
}
|
||||
|
||||
anchorPath := filepath.Join(StorageDir(service), safeFileName(AccountToken))
|
||||
anchor, err := os.ReadFile(anchorPath)
|
||||
if err == nil {
|
||||
_, key, decryptErr := decryptWithAvailableDEK(service, anchor)
|
||||
return key, decryptErr
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return getOrCreateDEK(service)
|
||||
}
|
||||
|
||||
func platformGet(service, account string) (string, error) {
|
||||
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil {
|
||||
@@ -381,7 +328,11 @@ func platformGet(service, account string) (string, error) {
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
plaintext, _, err := decryptWithAvailableDEK(service, data)
|
||||
key, err := getDEKReadOnly(service)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := decryptData(data, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -389,26 +340,11 @@ func platformGet(service, account string) (string, error) {
|
||||
}
|
||||
|
||||
func platformSet(service, account, data string) error {
|
||||
dir := StorageDir(service)
|
||||
targetPath := filepath.Join(dir, safeFileName(account))
|
||||
|
||||
var (
|
||||
key []byte
|
||||
err error
|
||||
)
|
||||
existing, readErr := os.ReadFile(targetPath)
|
||||
switch {
|
||||
case readErr == nil:
|
||||
_, key, err = decryptWithAvailableDEK(service, existing)
|
||||
case os.IsNotExist(readErr):
|
||||
key, err = keyForNewEntry(service, account)
|
||||
default:
|
||||
return readErr
|
||||
}
|
||||
key, err := getOrCreateDEK(service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -417,6 +353,7 @@ func platformSet(service, account, data string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(dir, safeFileName(account))
|
||||
tmpPath := filepath.Join(dir, safeFileName(account)+"."+uuid.New().String()+".tmp")
|
||||
defer os.Remove(tmpPath)
|
||||
|
||||
@@ -431,23 +368,6 @@ func platformSet(service, account, data string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func platformValidateAuthTokenEntries(service string) error {
|
||||
paths, err := authTokenCiphertextPaths(service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, path := range paths {
|
||||
ciphertext, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read keychain entry %q: %w", filepath.Base(path), err)
|
||||
}
|
||||
if _, _, err := decryptWithAvailableDEK(service, ciphertext); err != nil {
|
||||
return fmt.Errorf("validate keychain entry %q: %w", filepath.Base(path), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func platformRemove(service, account string) error {
|
||||
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -224,215 +222,3 @@ func TestDisableKeychainOverwrite(t *testing.T) {
|
||||
t.Fatalf("Get() = %q, want %q", got, "overwritten")
|
||||
}
|
||||
}
|
||||
|
||||
func stubMacOSSystemDEK(t *testing.T, systemDEK []byte, getErr error) *int {
|
||||
t.Helper()
|
||||
|
||||
defaultKeychainPath := filepath.Join(t.TempDir(), "login.keychain-db")
|
||||
if err := os.WriteFile(defaultKeychainPath, nil, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(default keychain) error = %v", err)
|
||||
}
|
||||
|
||||
prevReadDefault := readDefaultKeychain
|
||||
prevGet := keyringGet
|
||||
prevSet := keyringSet
|
||||
readDefaultKeychain = func() ([]byte, error) {
|
||||
return []byte("\"" + defaultKeychainPath + "\"\n"), nil
|
||||
}
|
||||
keyringGet = func(service, account string) (string, error) {
|
||||
if account != "dek" {
|
||||
return "", errors.New("unexpected keyring account")
|
||||
}
|
||||
if getErr != nil {
|
||||
return "", getErr
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(systemDEK), nil
|
||||
}
|
||||
setCalls := 0
|
||||
keyringSet = func(service, account, value string) error {
|
||||
setCalls++
|
||||
return errors.New("unexpected keyring.Set")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
readDefaultKeychain = prevReadDefault
|
||||
keyringGet = prevGet
|
||||
keyringSet = prevSet
|
||||
})
|
||||
return &setCalls
|
||||
}
|
||||
|
||||
func TestSystemKeychainModePreservesFileDEKCredentials(t *testing.T) {
|
||||
t.Setenv(StorageDirEnv, t.TempDir())
|
||||
systemDEK := bytes.Repeat([]byte{0x22}, dekBytes)
|
||||
setCalls := stubMacOSSystemDEK(t, systemDEK, nil)
|
||||
|
||||
service := "test-dek-mode-switch"
|
||||
account := "auth-token"
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
if err := Set(service, account, "file-dek-token"); err != nil {
|
||||
t.Fatalf("Set() in file-DEK mode error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
got, err := Get(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() after switching to system Keychain mode error = %v", err)
|
||||
}
|
||||
if got != "file-dek-token" {
|
||||
t.Fatalf("Get() after switching modes = %q, want file-dek-token", got)
|
||||
}
|
||||
if err := Set(service, account, "updated-token"); err != nil {
|
||||
t.Fatalf("Set() after switching to system Keychain mode error = %v", err)
|
||||
}
|
||||
profileAccount := account + ":corp"
|
||||
if err := Set(service, profileAccount, "profile-token"); err != nil {
|
||||
t.Fatalf("Set() new profile account in system Keychain mode error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
got, err = Get(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() after switching back to file-DEK mode error = %v", err)
|
||||
}
|
||||
if got != "updated-token" {
|
||||
t.Fatalf("Get() after switching back = %q, want updated-token", got)
|
||||
}
|
||||
got, err = Get(service, profileAccount)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() new profile account in file-DEK mode error = %v", err)
|
||||
}
|
||||
if got != "profile-token" {
|
||||
t.Fatalf("Get() new profile account = %q, want profile-token", got)
|
||||
}
|
||||
if *setCalls != 0 {
|
||||
t.Fatalf("keyring.Set calls = %d, want 0", *setCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemKeychainModeFallsBackWhenSystemDEKIsMissing(t *testing.T) {
|
||||
t.Setenv(StorageDirEnv, t.TempDir())
|
||||
setCalls := stubMacOSSystemDEK(t, nil, keyringpkg.ErrNotFound)
|
||||
|
||||
service := "test-missing-system-dek-fallback"
|
||||
account := "auth-token"
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
if err := Set(service, account, "file-dek-token"); err != nil {
|
||||
t.Fatalf("Set() in file-DEK mode error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
got, err := Get(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() with missing system DEK error = %v", err)
|
||||
}
|
||||
if got != "file-dek-token" {
|
||||
t.Fatalf("Get() with missing system DEK = %q, want file-dek-token", got)
|
||||
}
|
||||
if *setCalls != 0 {
|
||||
t.Fatalf("keyring.Set calls = %d, want 0", *setCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileDEKModeDoesNotOverwriteSystemKeychainCredentials(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv(StorageDirEnv, root)
|
||||
systemDEK := bytes.Repeat([]byte{0x22}, dekBytes)
|
||||
stubMacOSSystemDEK(t, systemDEK, nil)
|
||||
|
||||
service := "test-system-ciphertext-preserved"
|
||||
account := "auth-token"
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
if err := Set(service, account, "system-token"); err != nil {
|
||||
t.Fatalf("Set() in system Keychain mode error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(StorageDir(service), "dek")); !os.IsNotExist(err) {
|
||||
t.Fatalf("system Keychain Set created file DEK; stat error = %v", err)
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(StorageDir(service), safeFileName(account))
|
||||
before, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(ciphertext) error = %v", err)
|
||||
}
|
||||
fileDEK := bytes.Repeat([]byte{0x11}, dekBytes)
|
||||
if err := os.WriteFile(filepath.Join(StorageDir(service), "dek"), fileDEK, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(file DEK) error = %v", err)
|
||||
}
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
if err := Set(service, account, "file-token"); !IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("Set() error = %v, want ciphertext key mismatch", err)
|
||||
}
|
||||
after, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(ciphertext after rejected Set) error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(after, before) {
|
||||
t.Fatal("rejected file-DEK Set overwrote system Keychain ciphertext")
|
||||
}
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
got, err := Get(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() in system Keychain mode error = %v", err)
|
||||
}
|
||||
if got != "system-token" {
|
||||
t.Fatalf("Get() after rejected overwrite = %q, want system-token", got)
|
||||
}
|
||||
|
||||
profileAccount := account + ":corp"
|
||||
if err := Set(service, profileAccount, "system-profile-token"); err != nil {
|
||||
t.Fatalf("Set() new profile beside system-backed root error = %v", err)
|
||||
}
|
||||
got, err = Get(service, profileAccount)
|
||||
if err != nil || got != "system-profile-token" {
|
||||
t.Fatalf("Get() new system-backed profile = %q, %v", got, err)
|
||||
}
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
if _, err := Get(service, profileAccount); !IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("file-DEK Get() of system-backed profile error = %v, want ciphertext key mismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetDoesNotOverwriteCiphertextWithUnknownDEK(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv(StorageDirEnv, root)
|
||||
systemDEK := bytes.Repeat([]byte{0x22}, dekBytes)
|
||||
stubMacOSSystemDEK(t, systemDEK, nil)
|
||||
|
||||
service := "test-unknown-ciphertext-key"
|
||||
account := "auth-token"
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
fileDEK := bytes.Repeat([]byte{0x11}, dekBytes)
|
||||
if err := os.WriteFile(filepath.Join(dir, "dek"), fileDEK, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(file DEK) error = %v", err)
|
||||
}
|
||||
unknownDEK := bytes.Repeat([]byte{0x33}, dekBytes)
|
||||
ciphertext, err := encryptData("unknown-token", unknownDEK)
|
||||
if err != nil {
|
||||
t.Fatalf("encryptData() error = %v", err)
|
||||
}
|
||||
targetPath := filepath.Join(dir, safeFileName(account))
|
||||
if err := os.WriteFile(targetPath, ciphertext, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
for _, disableKeychain := range []string{"", "1"} {
|
||||
t.Setenv(DisableKeychainEnv, disableKeychain)
|
||||
if err := Set(service, account, "replacement"); !IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("Set() with %s=%q error = %v, want ciphertext key mismatch", DisableKeychainEnv, disableKeychain, err)
|
||||
}
|
||||
after, err := os.ReadFile(targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(ciphertext after rejected Set) error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(after, ciphertext) {
|
||||
t.Fatalf("rejected Set with %s=%q overwrote ciphertext", DisableKeychainEnv, disableKeychain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,27 +157,6 @@ func platformSet(service, account, data string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func platformValidateAuthTokenEntries(service string) error {
|
||||
paths, err := authTokenCiphertextPaths(service)
|
||||
if err != nil || len(paths) == 0 {
|
||||
return err
|
||||
}
|
||||
key, err := getDEKReadOnly(service)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read DEK for auth token validation: %w", err)
|
||||
}
|
||||
for _, path := range paths {
|
||||
ciphertext, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read keychain entry %q: %w", filepath.Base(path), err)
|
||||
}
|
||||
if _, err := decryptData(ciphertext, key); err != nil {
|
||||
return fmt.Errorf("validate keychain entry %q: %w", filepath.Base(path), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func platformRemove(service, account string) error {
|
||||
err := os.Remove(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
|
||||
@@ -1,92 +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 darwin
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type fileDEKMigrationEntry struct {
|
||||
path string
|
||||
plaintext string
|
||||
encrypted []byte
|
||||
}
|
||||
|
||||
func platformMigrateToFileDEK(service string, dryRun bool) (int, error) {
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
return 0, fmt.Errorf("file-DEK migration requires system Keychain mode; unset %s and retry", DisableKeychainEnv)
|
||||
}
|
||||
|
||||
paths, err := authTokenCiphertextPaths(service)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
entries := make([]fileDEKMigrationEntry, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
ciphertext, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("read keychain entry %q: %w", filepath.Base(path), err)
|
||||
}
|
||||
plaintext, _, err := decryptWithAvailableDEK(service, ciphertext)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("validate keychain entry %q before migration: %w", filepath.Base(path), err)
|
||||
}
|
||||
entries = append(entries, fileDEKMigrationEntry{path: path, plaintext: plaintext})
|
||||
}
|
||||
if dryRun || len(entries) == 0 {
|
||||
return len(entries), nil
|
||||
}
|
||||
|
||||
fileKey, err := fileDEK(service)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prepare file DEK: %w", err)
|
||||
}
|
||||
for i := range entries {
|
||||
entries[i].encrypted, err = encryptData(entries[i].plaintext, fileKey)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("encrypt keychain entry %q: %w", filepath.Base(entries[i].path), err)
|
||||
}
|
||||
if _, err := decryptData(entries[i].encrypted, fileKey); err != nil {
|
||||
return 0, fmt.Errorf("verify migrated keychain entry %q: %w", filepath.Base(entries[i].path), err)
|
||||
}
|
||||
}
|
||||
|
||||
tempPaths := make([]string, 0, len(entries))
|
||||
defer func() {
|
||||
for _, path := range tempPaths {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}()
|
||||
for _, entry := range entries {
|
||||
tmpPath := entry.path + "." + uuid.New().String() + ".migrate.tmp"
|
||||
if err := os.WriteFile(tmpPath, entry.encrypted, 0600); err != nil {
|
||||
return 0, fmt.Errorf("stage keychain entry %q: %w", filepath.Base(entry.path), err)
|
||||
}
|
||||
tempPaths = append(tempPaths, tmpPath)
|
||||
}
|
||||
for i, entry := range entries {
|
||||
if err := os.Rename(tempPaths[i], entry.path); err != nil {
|
||||
return 0, fmt.Errorf("commit keychain entry %q: %w; rerun the migration to finish", filepath.Base(entry.path), err)
|
||||
}
|
||||
tempPaths[i] = ""
|
||||
}
|
||||
return len(entries), nil
|
||||
}
|
||||
@@ -1,177 +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 darwin
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMigrateToFileDEKReencryptsSystemEntries(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv(StorageDirEnv, root)
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
stubMacOSSystemDEK(t, bytes.Repeat([]byte{0x42}, dekBytes), nil)
|
||||
|
||||
service := "test-migrate-system-to-file"
|
||||
values := map[string]string{
|
||||
AccountToken: "legacy-token",
|
||||
AccountToken + ":corp-a": "profile-token",
|
||||
}
|
||||
if err := Set(service, "appsecret_demo", "app-secret"); err != nil {
|
||||
t.Fatalf("Set(unrelated secret) error = %v", err)
|
||||
}
|
||||
unrelatedPath := filepath.Join(StorageDir(service), safeFileName("appsecret_demo"))
|
||||
unrelatedCiphertext := []byte("unreadable unrelated ciphertext")
|
||||
if err := os.WriteFile(unrelatedPath, unrelatedCiphertext, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(unrelated secret) error = %v", err)
|
||||
}
|
||||
for account, value := range values {
|
||||
if err := Set(service, account, value); err != nil {
|
||||
t.Fatalf("Set(%q) error = %v", account, err)
|
||||
}
|
||||
}
|
||||
|
||||
count, err := MigrateToFileDEK(service, true)
|
||||
if err != nil {
|
||||
t.Fatalf("MigrateToFileDEK(dry-run) error = %v", err)
|
||||
}
|
||||
if count != len(values) {
|
||||
t.Fatalf("dry-run count = %d, want %d", count, len(values))
|
||||
}
|
||||
dekPath := filepath.Join(StorageDir(service), "dek")
|
||||
if _, err := os.Stat(dekPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("dry-run created file DEK; stat error = %v", err)
|
||||
}
|
||||
|
||||
count, err = MigrateToFileDEK(service, false)
|
||||
if err != nil {
|
||||
t.Fatalf("MigrateToFileDEK() error = %v", err)
|
||||
}
|
||||
if count != len(values) {
|
||||
t.Fatalf("migration count = %d, want %d", count, len(values))
|
||||
}
|
||||
if info, err := os.Stat(dekPath); err != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatalf("file DEK stat = %v, %v; want mode 0600", info, err)
|
||||
}
|
||||
|
||||
for _, disableKeychain := range []string{"1", ""} {
|
||||
t.Setenv(DisableKeychainEnv, disableKeychain)
|
||||
for account, want := range values {
|
||||
got, err := Get(service, account)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("Get(%q) with %s=%q = %q, %v; want %q", account, DisableKeychainEnv, disableKeychain, got, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
if count, err := MigrateToFileDEK(service, true); err != nil || count != len(values) {
|
||||
t.Fatalf("repeat dry-run = %d, %v; want %d auth entries", count, err, len(values))
|
||||
}
|
||||
if got, err := os.ReadFile(unrelatedPath); err != nil || !bytes.Equal(got, unrelatedCiphertext) {
|
||||
t.Fatalf("unrelated secret after migration = %q, %v; want byte-for-byte preserved", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateToFileDEKKeepsNewNonAuthSecretsOnSystemKeychain(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv(StorageDirEnv, root)
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
systemDEK := bytes.Repeat([]byte{0x52}, dekBytes)
|
||||
stubMacOSSystemDEK(t, systemDEK, nil)
|
||||
|
||||
service := "test-migrate-secret-isolation"
|
||||
if err := Set(service, AccountToken, "system-token"); err != nil {
|
||||
t.Fatalf("Set(auth token) error = %v", err)
|
||||
}
|
||||
if _, err := MigrateToFileDEK(service, false); err != nil {
|
||||
t.Fatalf("MigrateToFileDEK() error = %v", err)
|
||||
}
|
||||
|
||||
nonAuthEntries := map[string]string{
|
||||
"client-secret:demo": "client-secret",
|
||||
"app-token:demo": "app-token",
|
||||
"appsecret:demo": "stored-secret",
|
||||
}
|
||||
for account, value := range nonAuthEntries {
|
||||
if err := Set(service, account, value); err != nil {
|
||||
t.Fatalf("Set(%q) after migration error = %v", account, err)
|
||||
}
|
||||
ciphertext, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v", account, err)
|
||||
}
|
||||
got, err := decryptData(ciphertext, systemDEK)
|
||||
if err != nil || got != value {
|
||||
t.Fatalf("system Keychain DEK decrypt %q = %q, %v; want %q", account, got, err, value)
|
||||
}
|
||||
}
|
||||
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
if got, err := Get(service, AccountToken); err != nil || got != "system-token" {
|
||||
t.Fatalf("file-DEK Get(auth token) = %q, %v; want migrated token", got, err)
|
||||
}
|
||||
for account := range nonAuthEntries {
|
||||
if got, err := Get(service, account); !IsCiphertextKeyMismatch(err) {
|
||||
t.Fatalf("file-DEK Get(%q) = %q, %v; want ciphertext key mismatch", account, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateToFileDEKAbortsBeforeWritingWhenAnyEntryIsUnreadable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
t.Setenv(StorageDirEnv, root)
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
stubMacOSSystemDEK(t, bytes.Repeat([]byte{0x24}, dekBytes), nil)
|
||||
|
||||
service := "test-migrate-preflight"
|
||||
if err := Set(service, AccountToken, "preserve-me"); err != nil {
|
||||
t.Fatalf("Set() error = %v", err)
|
||||
}
|
||||
goodPath := filepath.Join(StorageDir(service), safeFileName(AccountToken))
|
||||
goodBefore, err := os.ReadFile(goodPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(good entry) error = %v", err)
|
||||
}
|
||||
badPath := filepath.Join(StorageDir(service), safeFileName(AccountToken+":bad"))
|
||||
if err := os.WriteFile(badPath, []byte("corrupt ciphertext"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile(bad entry) error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := MigrateToFileDEK(service, false); err == nil || !strings.Contains(err.Error(), "validate keychain entry") {
|
||||
t.Fatalf("MigrateToFileDEK() error = %v, want preflight failure", err)
|
||||
}
|
||||
goodAfter, err := os.ReadFile(goodPath)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(good entry after failure) error = %v", err)
|
||||
}
|
||||
if !bytes.Equal(goodAfter, goodBefore) {
|
||||
t.Fatal("failed migration modified a readable entry")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(StorageDir(service), "dek")); !os.IsNotExist(err) {
|
||||
t.Fatalf("failed migration created file DEK; stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateToFileDEKRequiresSystemKeychainMode(t *testing.T) {
|
||||
t.Setenv(StorageDirEnv, t.TempDir())
|
||||
t.Setenv(DisableKeychainEnv, "1")
|
||||
if _, err := MigrateToFileDEK("test-migrate-mode", true); err == nil || !strings.Contains(err.Error(), DisableKeychainEnv) {
|
||||
t.Fatalf("MigrateToFileDEK() error = %v, want mode guidance", err)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +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 !darwin
|
||||
|
||||
package keychain
|
||||
|
||||
import "fmt"
|
||||
|
||||
func platformMigrateToFileDEK(_ string, _ bool) (int, error) {
|
||||
return 0, fmt.Errorf("system Keychain to file-DEK migration is supported only on macOS")
|
||||
}
|
||||
@@ -416,7 +416,7 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
|
||||
}
|
||||
callStart := time.Now()
|
||||
|
||||
resp, err := c.doWithRetry(ctx, endpoint, body, request.Method)
|
||||
resp, err := c.doWithRetry(ctx, endpoint, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -493,19 +493,15 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte, method string) (*http.Response, error) {
|
||||
func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) (*http.Response, error) {
|
||||
endpoint = sanitizeJSONRPCEndpoint(endpoint)
|
||||
operation := strings.TrimSpace(method)
|
||||
if operation == "" {
|
||||
operation = "jsonrpc"
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= c.MaxRetries; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, apperrors.NewDiscovery(
|
||||
"failed to create JSON-RPC request",
|
||||
apperrors.WithOperation(operation),
|
||||
apperrors.WithOperation("jsonrpc"),
|
||||
apperrors.WithReason("request_build_failed"),
|
||||
apperrors.WithHint(i18n.T("请检查服务 endpoint 是否为空或格式不合法。")),
|
||||
apperrors.WithCause(&CallError{
|
||||
@@ -563,52 +559,37 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte,
|
||||
statusForLog = resp.StatusCode
|
||||
}
|
||||
delay := c.retryDelayForAttempt(attempt, retryAfter)
|
||||
logging.LogRetryAttempt(c.FileLogger, operation, c.ExecutionId, attempt, c.MaxRetries, statusForLog, delay, lastErr)
|
||||
logging.LogRetryAttempt(c.FileLogger, "jsonrpc", c.ExecutionId, attempt, c.MaxRetries, statusForLog, delay, lastErr)
|
||||
if err := c.sleepForRetry(ctx, delay); err != nil {
|
||||
opts := []apperrors.Option{
|
||||
apperrors.WithOperation(operation),
|
||||
return nil, apperrors.NewDiscovery(
|
||||
"request cancelled during retry",
|
||||
apperrors.WithOperation("jsonrpc"),
|
||||
apperrors.WithReason("request_cancelled"),
|
||||
apperrors.WithHint(i18n.T("请求在重试过程中被取消;请检查调用侧超时设置。")),
|
||||
apperrors.WithCause(&CallError{
|
||||
Stage: CallStageRequest,
|
||||
Cause: err,
|
||||
}),
|
||||
}
|
||||
if operation == "tools/call" {
|
||||
opts = append(opts, apperrors.WithActions(networkActions("")...))
|
||||
return nil, apperrors.NewAPI("request cancelled during retry", opts...)
|
||||
}
|
||||
opts = append(opts, apperrors.WithActions(discoveryActions("")...))
|
||||
return nil, apperrors.NewDiscovery("request cancelled during retry", opts...)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
reason, hint := classifyRequestFailure(lastErr)
|
||||
category := apperrors.CategoryDiscovery
|
||||
actions := discoveryActions("")
|
||||
if operation == "tools/call" {
|
||||
category = apperrors.CategoryAPI
|
||||
actions = networkActions("")
|
||||
}
|
||||
logging.LogErrorClassified(c.FileLogger, operation, c.ExecutionId,
|
||||
string(category), reason, 0, 0,
|
||||
logging.LogErrorClassified(c.FileLogger, "jsonrpc", c.ExecutionId,
|
||||
string(apperrors.CategoryDiscovery), reason, 0, 0,
|
||||
!isTimeoutError(lastErr), "")
|
||||
opts := []apperrors.Option{
|
||||
apperrors.WithOperation(operation),
|
||||
return nil, apperrors.NewDiscovery(
|
||||
fmt.Sprintf("request to %s failed: %v", RedactURL(endpoint), lastErr),
|
||||
apperrors.WithOperation("jsonrpc"),
|
||||
apperrors.WithReason(reason),
|
||||
apperrors.WithRetryable(!isTimeoutError(lastErr)),
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithActions(actions...),
|
||||
apperrors.WithActions(discoveryActions("")...),
|
||||
apperrors.WithCause(&CallError{
|
||||
Stage: CallStageRequest,
|
||||
Cause: lastErr,
|
||||
}),
|
||||
}
|
||||
message := fmt.Sprintf("request to %s failed: %v", RedactURL(endpoint), lastErr)
|
||||
if category == apperrors.CategoryAPI {
|
||||
return nil, apperrors.NewAPI(message, opts...)
|
||||
}
|
||||
return nil, apperrors.NewDiscovery(message, opts...)
|
||||
)
|
||||
}
|
||||
|
||||
func sanitizeJSONRPCEndpoint(endpoint string) string {
|
||||
@@ -1058,17 +1039,6 @@ func runtimeActions(snapshotPath string) []string {
|
||||
return actions
|
||||
}
|
||||
|
||||
func networkActions(snapshotPath string) []string {
|
||||
actions := []string{
|
||||
i18n.T("检查网络、代理和 DNS 配置后重试原命令"),
|
||||
i18n.T("确认 MCP 服务可访问;若持续失败请稍后重试"),
|
||||
}
|
||||
if snapshotPath != "" {
|
||||
actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath))
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// validateCallArguments checks string values in tool call arguments for
|
||||
// control characters and dangerous Unicode at the transport boundary.
|
||||
func validateCallArguments(args map[string]any) error {
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestDoWithRetryRedactsGatewayQueryInHeaderDebugLog(t *testing.T) {
|
||||
})})
|
||||
client.FileLogger = logger
|
||||
|
||||
resp, err := client.doWithRetry(context.Background(), "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", []byte(`{}`), "initialize")
|
||||
resp, err := client.doWithRetry(context.Background(), "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", []byte(`{}`))
|
||||
if err != nil {
|
||||
t.Fatalf("doWithRetry() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
@@ -55,7 +53,7 @@ func TestDoWithRetryRequestFailureIncludesCallMetadata(t *testing.T) {
|
||||
})})
|
||||
client.MaxRetries = 0
|
||||
|
||||
_, err := client.doWithRetry(context.Background(), "https://mcp.dingtalk.com/server", []byte(`{}`), "tools/call")
|
||||
_, err := client.doWithRetry(context.Background(), "https://mcp.dingtalk.com/server", []byte(`{}`))
|
||||
if err == nil {
|
||||
t.Fatal("doWithRetry() error = nil, want request failure")
|
||||
}
|
||||
@@ -69,125 +67,6 @@ func TestDoWithRetryRequestFailureIncludesCallMetadata(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallToolRequestFailureUsesAPIClassification(t *testing.T) {
|
||||
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("dial tcp: connection refused")
|
||||
})})
|
||||
client.MaxRetries = 0
|
||||
|
||||
_, err := client.CallTool(context.Background(), "https://mcp.dingtalk.com/server", "search_messages", map[string]any{"keyword": "marker"})
|
||||
if err == nil {
|
||||
t.Fatal("CallTool() error = nil, want request failure")
|
||||
}
|
||||
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("CallTool() error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Category != apperrors.CategoryAPI {
|
||||
t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryAPI)
|
||||
}
|
||||
if typed.Operation != "tools/call" {
|
||||
t.Fatalf("Operation = %q, want tools/call", typed.Operation)
|
||||
}
|
||||
if typed.Reason != "connection_refused" {
|
||||
t.Fatalf("Reason = %q, want connection_refused", typed.Reason)
|
||||
}
|
||||
actions := strings.Join(typed.Actions, "\n")
|
||||
if strings.Contains(actions, "internal/syncdata") || strings.Contains(actions, "sync-oss") {
|
||||
t.Fatalf("runtime network failure contains discovery-only actions: %q", actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallToolRetryCancellationUsesAPIClassification(t *testing.T) {
|
||||
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: http.NoBody}, nil
|
||||
})})
|
||||
client.MaxRetries = 1
|
||||
client.sleep = func(context.Context, time.Duration) error { return context.Canceled }
|
||||
|
||||
_, err := client.CallTool(context.Background(), "https://mcp.dingtalk.com/server", "search_messages", nil)
|
||||
if err == nil {
|
||||
t.Fatal("CallTool() error = nil, want retry cancellation")
|
||||
}
|
||||
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("CallTool() error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Category != apperrors.CategoryAPI {
|
||||
t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryAPI)
|
||||
}
|
||||
if typed.Operation != "tools/call" {
|
||||
t.Fatalf("Operation = %q, want tools/call", typed.Operation)
|
||||
}
|
||||
if typed.Reason != "request_cancelled" {
|
||||
t.Fatalf("Reason = %q, want request_cancelled", typed.Reason)
|
||||
}
|
||||
actions := strings.Join(typed.Actions, "\n")
|
||||
if strings.Contains(actions, "internal/syncdata") || strings.Contains(actions, "sync-oss") {
|
||||
t.Fatalf("runtime retry cancellation contains discovery-only actions: %q", actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoveryRequestFailuresKeepDiscoveryClassification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
failure error
|
||||
operation string
|
||||
wantReason string
|
||||
call func(*Client) error
|
||||
}{
|
||||
{
|
||||
name: "initialize connection refused",
|
||||
failure: errors.New("dial tcp: connection refused"),
|
||||
operation: "initialize",
|
||||
wantReason: "connection_refused",
|
||||
call: func(client *Client) error {
|
||||
_, err := client.Initialize(context.Background(), "https://mcp.dingtalk.com/server")
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tools list dns failure",
|
||||
failure: errors.New("dial tcp: lookup mcp.dingtalk.com: no such host"),
|
||||
operation: "tools/list",
|
||||
wantReason: "dns_resolution_failed",
|
||||
call: func(client *Client) error {
|
||||
_, err := client.ListTools(context.Background(), "https://mcp.dingtalk.com/server")
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return nil, tt.failure
|
||||
})})
|
||||
client.MaxRetries = 0
|
||||
|
||||
err := tt.call(client)
|
||||
if err == nil {
|
||||
t.Fatal("request error = nil, want failure")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("request error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Category != apperrors.CategoryDiscovery {
|
||||
t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryDiscovery)
|
||||
}
|
||||
if typed.Operation != tt.operation {
|
||||
t.Fatalf("Operation = %q, want %q", typed.Operation, tt.operation)
|
||||
}
|
||||
if typed.Reason != tt.wantReason {
|
||||
t.Fatalf("Reason = %q, want %q", typed.Reason, tt.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
|
||||
@@ -36,14 +36,6 @@ require_no_match() {
|
||||
fi
|
||||
}
|
||||
|
||||
require_no_tracked_path() {
|
||||
path="$1"
|
||||
if git ls-files -- "$path" | grep -q .; then
|
||||
git ls-files -- "$path" >&2
|
||||
err "found forbidden tracked path: $path"
|
||||
fi
|
||||
}
|
||||
|
||||
require_heading_if_linked() {
|
||||
label="$1"
|
||||
heading="$2"
|
||||
@@ -70,10 +62,6 @@ require_no_match 'scripts/test\.sh|scripts/check-semantic-fixtures\.sh|scripts/r
|
||||
require_no_match 'scripts/check-semantic-fixtures\.py|test/semantic/cli_to_mcp|test/semantic/model_to_cli|python3 -m pytest test/semantic' CONTRIBUTING.md
|
||||
require_no_match '\.github/CODEOWNERS|Tag-triggered GitHub Release asset publishing' CHANGELOG.md
|
||||
|
||||
require_no_tracked_path 'auto-test'
|
||||
require_no_tracked_path 'eval-runs'
|
||||
require_no_tracked_path '.qoder'
|
||||
|
||||
require_heading_if_linked '[Environment Variables](#environment-variables)' '## Environment Variables'
|
||||
require_heading_if_linked '[Exit Codes](#exit-codes)' '## Exit Codes'
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Replace GoReleaser's original assets with post-processed artifacts, verify
|
||||
# that GitHub serves exactly those bytes, and only then make the Draft public.
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$ROOT/dist}"
|
||||
TAG="${GITHUB_REF_NAME:?GITHUB_REF_NAME is required}"
|
||||
REPOSITORY="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}"
|
||||
PUBLISH_RELEASE="${DWS_PUBLISH_RELEASE:-true}"
|
||||
DIGEST_ATTEMPTS="${DWS_RELEASE_DIGEST_ATTEMPTS:-5}"
|
||||
DIGEST_RETRY_DELAY="${DWS_RELEASE_DIGEST_RETRY_DELAY:-2}"
|
||||
|
||||
err() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
sha256_file() {
|
||||
target="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$target" | awk '{print $1}'
|
||||
return
|
||||
fi
|
||||
shasum -a 256 "$target" | awk '{print $1}'
|
||||
}
|
||||
|
||||
case "$PUBLISH_RELEASE" in
|
||||
1|true|yes) publish_release=1 ;;
|
||||
0|false|no) publish_release=0 ;;
|
||||
*) err "invalid DWS_PUBLISH_RELEASE value: $PUBLISH_RELEASE" ;;
|
||||
esac
|
||||
|
||||
case "$DIGEST_ATTEMPTS" in
|
||||
''|*[!0-9]*) err "DWS_RELEASE_DIGEST_ATTEMPTS must be a positive integer" ;;
|
||||
0) err "DWS_RELEASE_DIGEST_ATTEMPTS must be greater than zero" ;;
|
||||
esac
|
||||
|
||||
command -v gh >/dev/null 2>&1 || err "gh is required"
|
||||
|
||||
set -- \
|
||||
"$DIST_DIR/dws-darwin-amd64.tar.gz" \
|
||||
"$DIST_DIR/dws-darwin-arm64.tar.gz" \
|
||||
"$DIST_DIR/checksums.txt" \
|
||||
"$DIST_DIR/dws-skills.zip"
|
||||
|
||||
for asset in "$@"; do
|
||||
[ -f "$asset" ] || err "finalized release asset missing: $asset"
|
||||
done
|
||||
|
||||
gh release upload "$TAG" "$@" \
|
||||
--repo "$REPOSITORY" \
|
||||
--clobber
|
||||
|
||||
# Fail before publication if GitHub still serves a pre-signing archive. Asset
|
||||
# digests can take a moment to settle after replacement, so retry.
|
||||
for asset in "$@"; do
|
||||
name="$(basename "$asset")"
|
||||
local_digest="sha256:$(sha256_file "$asset")"
|
||||
attempt=1
|
||||
while [ "$attempt" -le "$DIGEST_ATTEMPTS" ]; do
|
||||
remote_digest="$(
|
||||
gh release view "$TAG" --repo "$REPOSITORY" --json assets \
|
||||
--jq ".assets[] | select(.name == \"$name\") | .digest"
|
||||
)"
|
||||
if [ "$remote_digest" = "$local_digest" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq "$DIGEST_ATTEMPTS" ]; then
|
||||
printf 'release asset digest mismatch for %s\n' "$name" >&2
|
||||
printf ' local: %s\n' "$local_digest" >&2
|
||||
printf ' remote: %s\n' "${remote_digest:-missing}" >&2
|
||||
exit 1
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
sleep "$DIGEST_RETRY_DELAY"
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$publish_release" -eq 1 ]; then
|
||||
gh release edit "$TAG" --repo "$REPOSITORY" --draft=false
|
||||
else
|
||||
printf 'finalized assets verified; keeping release %s as Draft\n' "$TAG"
|
||||
fi
|
||||
@@ -10,9 +10,6 @@ ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
DIST_DIR="${DWS_PACKAGE_DIST_DIR:-$ROOT/dist}"
|
||||
PACKAGE_VERSION="${DWS_PACKAGE_VERSION:-}"
|
||||
RELEASE_BASE_URL="${DWS_RELEASE_BASE_URL:-}"
|
||||
APPLE_CERTIFICATE_P12="${DWS_APPLE_CERTIFICATE_P12:-}"
|
||||
APPLE_CERTIFICATE_PASSWORD_FILE="${DWS_APPLE_CERTIFICATE_PASSWORD_FILE:-}"
|
||||
REQUIRE_DEVELOPER_ID_SIGNING="${DWS_REQUIRE_DEVELOPER_ID_SIGNING:-false}"
|
||||
|
||||
export LANG=C
|
||||
export LC_ALL=C
|
||||
@@ -198,47 +195,14 @@ create_skills_zip() {
|
||||
rm -rf "$staging"
|
||||
}
|
||||
|
||||
# ---------- darwin signing ----------
|
||||
# ---------- darwin ad-hoc signing ----------
|
||||
#
|
||||
# Unsigned arm64 binaries are SIGKILL'd by amfid on Apple Silicon (macOS 11+).
|
||||
# Official releases use an Apple Developer ID certificate loaded from GitHub
|
||||
# Secrets. Fork/local builds retain ad-hoc signing so they remain runnable.
|
||||
# We unpack each dws-darwin-*.tar.gz, sign the dws binary, repack deterministically,
|
||||
# and rewrite the corresponding line in checksums.txt.
|
||||
|
||||
configure_darwin_signing() {
|
||||
case "$REQUIRE_DEVELOPER_ID_SIGNING" in
|
||||
1|true|yes) require_developer_id=1 ;;
|
||||
0|false|no|"") require_developer_id=0 ;;
|
||||
*) err "invalid DWS_REQUIRE_DEVELOPER_ID_SIGNING value: $REQUIRE_DEVELOPER_ID_SIGNING" ;;
|
||||
esac
|
||||
|
||||
if [ -n "$APPLE_CERTIFICATE_P12" ] || [ -n "$APPLE_CERTIFICATE_PASSWORD_FILE" ]; then
|
||||
[ -n "$APPLE_CERTIFICATE_P12" ] || err "DWS_APPLE_CERTIFICATE_P12 is required when Developer ID signing is configured"
|
||||
[ -n "$APPLE_CERTIFICATE_PASSWORD_FILE" ] || err "DWS_APPLE_CERTIFICATE_PASSWORD_FILE is required when Developer ID signing is configured"
|
||||
[ -f "$APPLE_CERTIFICATE_P12" ] || err "Developer ID P12 file not found: $APPLE_CERTIFICATE_P12"
|
||||
[ -f "$APPLE_CERTIFICATE_PASSWORD_FILE" ] || err "Developer ID password file not found: $APPLE_CERTIFICATE_PASSWORD_FILE"
|
||||
command -v rcodesign >/dev/null 2>&1 || err "rcodesign is required for Developer ID signing"
|
||||
DARWIN_SIGNING_MODE="developer-id"
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$require_developer_id" -eq 1 ]; then
|
||||
err "Developer ID signing is required but DWS_APPLE_CERTIFICATE_P12 and DWS_APPLE_CERTIFICATE_PASSWORD_FILE are not configured"
|
||||
fi
|
||||
DARWIN_SIGNING_MODE="ad-hoc"
|
||||
}
|
||||
# We unpack each dws-darwin-*.tar.gz, ad-hoc sign the dws binary, repack
|
||||
# deterministically, and rewrite the corresponding line in checksums.txt.
|
||||
|
||||
sign_one_darwin_binary() {
|
||||
bin="$1"
|
||||
if [ "$DARWIN_SIGNING_MODE" = "developer-id" ]; then
|
||||
rcodesign sign \
|
||||
--p12-file "$APPLE_CERTIFICATE_P12" \
|
||||
--p12-password-file "$APPLE_CERTIFICATE_PASSWORD_FILE" \
|
||||
--for-notarization \
|
||||
"$bin"
|
||||
return
|
||||
fi
|
||||
if command -v codesign >/dev/null 2>&1; then
|
||||
codesign --force --sign - "$bin"
|
||||
return
|
||||
@@ -308,22 +272,18 @@ sign_darwin_archives() {
|
||||
}
|
||||
|
||||
write_checksums() {
|
||||
# Keep this idempotent: workflow retries must not leave duplicate entries.
|
||||
checksum_path="$DIST_DIR/checksums.txt"
|
||||
# Append skills zip checksum to goreleaser's checksums file
|
||||
if [ -f "$DIST_DIR/dws-skills.zip" ]; then
|
||||
update_checksum_entry "dws-skills.zip" "$(sha256_file "$DIST_DIR/dws-skills.zip")"
|
||||
printf '%s %s\n' "$(sha256_file "$DIST_DIR/dws-skills.zip")" "dws-skills.zip" >> "$checksum_path"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------- main ----------
|
||||
|
||||
version="$(resolve_version)"
|
||||
configure_darwin_signing
|
||||
|
||||
if [ "$DARWIN_SIGNING_MODE" = "developer-id" ]; then
|
||||
say "==> Developer ID signing darwin binaries"
|
||||
else
|
||||
say "==> Ad-hoc signing darwin binaries"
|
||||
fi
|
||||
say "==> Ad-hoc signing darwin binaries"
|
||||
sign_darwin_archives
|
||||
|
||||
say "==> Creating skills zip"
|
||||
|
||||
@@ -34,7 +34,7 @@ cli_version: ">=1.0.15"
|
||||
| `aitable` | AI表格:Base/数据表/字段/记录/视图/附件/图表/仪表盘/导入导出/模板搜索 | [aitable.md](./references/products/aitable.md) |
|
||||
| `attendance` | 考勤:打卡结果/打卡流水/考勤组查询/考勤规则/汇总统计/假期类型/假期余额(P0 已落地,部分管理类命令仍属 P1) | [attendance.md](./references/products/attendance.md) |
|
||||
| `calendar` | 日历:日历列表/日程/参与者/附件/响应/会议室/闲忙查询/时间建议 | [calendar.md](./references/products/calendar.md) |
|
||||
| `chat` | 群聊与机器人:搜索群/建群/群成员管理/改群名/消息发送(文本/Markdown/图片/文件)/拉取消息/消息收藏/@我/特别关注/机器人群发/单聊/撤回/转发/引用回复/Webhook/机器人搜索 | [chat.md](./references/products/chat.md) |
|
||||
| `chat` | 群聊与机器人:搜索群/建群/群成员管理/改群名/消息发送(文本/Markdown/图片/文件)/拉取消息/@我/特别关注/机器人群发/单聊/撤回/转发/引用回复/Webhook/机器人搜索 | [chat.md](./references/products/chat.md) |
|
||||
| `contact` | 通讯录:用户查询(当前用户/搜索/详情/手机号)/花名册档案(学历/家庭/银行卡/合同)/离职员工查询(姓名/时间范围/部门)/部门查询(搜索/详情/子部门/成员)/角色查询(主管/管理员/财务/HR 等 label)/特别关注列表 | [contact.md](./references/products/contact.md) |
|
||||
| `devdoc` | 开放平台文档:搜索开发文档 | [devdoc.md](./references/products/devdoc.md) |
|
||||
| `ding` | DING消息:发送/撤回(应用内/短信/电话) | [ding.md](./references/products/ding.md) |
|
||||
@@ -132,22 +132,18 @@ Step 3 → 加 --yes 执行命令
|
||||
dws <command-path> --help
|
||||
# 例:dws calendar event list --help
|
||||
|
||||
# 结构化 schema 查询:helper-only 子树(dev.*,source=mcp:<server>)
|
||||
# helper-only schema 查询(如 dev.*),普通产品命令不要依赖 schema 推断参数
|
||||
dws schema "dev app create"
|
||||
# 登记的本地命令子树(event.*,source=cobra,从二进制 flag 合成)
|
||||
dws schema "event consume" # 叶子:parameters{<flag>:{type,required,description,default?}} + arguments[位置参数]
|
||||
dws schema event # 中间节点:列子命令
|
||||
# 其余普通产品命令不走 schema,直接看 --help。
|
||||
# 注:--jq 对 schema 输出无效(不过滤,仍返回完整对象);schema 结构里必填标在
|
||||
# .parameters.<字段>.required,没有 .tool 键。要看必填字段自行读 .parameters 即可。
|
||||
```
|
||||
|
||||
**何时用哪条路径:**
|
||||
- 只需看某个命令怎么调用 → `dws <cmd> --help`
|
||||
- 构造 `--params` / `--json` 时不确定字段类型、必填、别名 → 先看 `dws <cmd> --help`,helper-only(dev.*)和登记的本地命令(event.*)可看 `dws schema` 取机读结构
|
||||
- 构造 `--params` / `--json` 时不确定字段类型、必填、别名 → 先看 `dws <cmd> --help`,helper-only 命令再看 `dws schema`
|
||||
- 参考文档和 `--help` 冲突时 → **以 `--help` 为准**,文档视为过期
|
||||
|
||||
`dws schema` 覆盖两类命令:helper-only 子树(dev,CONTENT 从 MCP 实时取)和登记的本地命令(event,从 cobra flag 合成、source=cobra);其余普通产品命令和 flag 以 `--help` 为准。写/删操作须先向用户确认再加 `--yes`。
|
||||
`dws schema` 在静态端点模式下只保留 helper-only 子树;普通产品命令和 flag 不再通过远程 schema 动态发现。写/删操作须先向用户确认再加 `--yes`。
|
||||
|
||||
## 错误处理
|
||||
1. 遇到错误,加 `--verbose` 重试一次
|
||||
|
||||
@@ -9,14 +9,10 @@ dws auth login
|
||||
# 查看状态
|
||||
dws auth status
|
||||
|
||||
# macOS: 将系统 Keychain 登录态迁移为沙箱可读的 file-DEK(先预检)
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
|
||||
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
|
||||
|
||||
# 退出
|
||||
dws auth logout
|
||||
|
||||
# 重置全部本地凭证(仅在迁移/按 profile 恢复均失败且确认可丢弃全部登录时使用)
|
||||
# 重置本地凭证 (Token 解密失败时使用)
|
||||
dws auth reset
|
||||
```
|
||||
|
||||
@@ -31,7 +27,6 @@ dws auth reset
|
||||
|
||||
### 认证失败处理
|
||||
- 命令返回 `AUTH_TOKEN_EXPIRED` / `USER_TOKEN_ILLEGAL` / "Token验证失败" → 执行 `dws auth login` 重新登录
|
||||
- macOS 返回 `ciphertext_key_mismatch`,且普通终端仍能登录 → 先用系统 Keychain 模式执行 `auth migrate-keychain --to file-dek --dry-run`,通过后加 `--yes`;禁止直接 `auth reset`
|
||||
|
||||
### Headless 环境 (CI/CD)
|
||||
|
||||
|
||||
@@ -1317,45 +1317,6 @@ Flags:
|
||||
- 分页: hasMore=true 时,用返回的 nextCursor 作为下次 --cursor 继续翻页
|
||||
```
|
||||
|
||||
#### 收藏消息 — 为当前用户收藏指定会话中的一条消息
|
||||
```
|
||||
Usage:
|
||||
dws chat message add-favorite [flags]
|
||||
Example:
|
||||
dws chat message add-favorite --open-message-id <openMessageId> --open-conversation-id <openConversationId>
|
||||
Flags:
|
||||
--open-message-id string 消息 openMessageId (必填)
|
||||
--open-conversation-id string 消息所在会话 openConversationId (必填)
|
||||
```
|
||||
|
||||
#### 取消收藏消息 — 移除当前用户的收藏标记,不删除原消息
|
||||
```
|
||||
Usage:
|
||||
dws chat message remove-favorite [flags]
|
||||
Example:
|
||||
dws chat message remove-favorite --open-message-id <openMessageId> --open-conversation-id <openConversationId>
|
||||
Flags:
|
||||
--open-message-id string 消息 openMessageId (必填)
|
||||
--open-conversation-id string 消息所在会话 openConversationId (必填)
|
||||
```
|
||||
|
||||
#### 查询收藏消息 — 分页查询当前用户收藏的消息
|
||||
```
|
||||
Usage:
|
||||
dws chat message list-favorites [flags]
|
||||
Example:
|
||||
dws chat message list-favorites
|
||||
dws chat message list-favorites --size 50
|
||||
dws chat message list-favorites --cursor 20 --size 20
|
||||
Flags:
|
||||
--cursor int 数字分页游标,默认 0;翻页时传上次返回的 nextCursor
|
||||
--size int 一次拉取的收藏数量,默认 20,范围 1-100
|
||||
|
||||
注意:
|
||||
- 首次请求可省略分页参数,CLI 会自动向 Open 服务传入 cursor=0、size="20"
|
||||
- hasMore=true 时,将 nextCursor 作为下一次的 --cursor
|
||||
```
|
||||
|
||||
### bot (机器人管理)
|
||||
|
||||
#### 搜索【我创建的】机器人 — 仅返回当前用户自己创建的机器人
|
||||
|
||||
@@ -338,22 +338,6 @@ Flags:
|
||||
--mention string 被 @ 的用户 uid 列表,逗号分隔
|
||||
```
|
||||
|
||||
### 更新文档评论
|
||||
```text
|
||||
Usage:
|
||||
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content <CONTENT> [--mention uid1,uid2]
|
||||
```
|
||||
|
||||
`commentKey` 从 `comment list/create/create-inline` 返回中获取。只有显式传入 `--mention` 时才更新被 @ 用户列表。
|
||||
|
||||
### 删除文档评论
|
||||
```text
|
||||
Usage:
|
||||
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes
|
||||
```
|
||||
|
||||
删除评论不可恢复,必须先获得用户明确确认,再添加全局 `--yes`。
|
||||
|
||||
### 文件内容获取路由规则
|
||||
|
||||
> 当用户请求"分析/查看/读取某个文件内容"时,**必须先调用 `dws doc info` 获取文件元数据**,再根据返回的 `contentType` 和 `extension` 字段选择对应链路:
|
||||
@@ -909,24 +893,18 @@ dws doc comment create --node <DOC_ID> --content "这里需要补充数据来源
|
||||
# 再将 userId 传入 --mention
|
||||
dws doc comment create --node <DOC_ID> --content "请确认这部分内容" --mention <userId1>,<userId2> --format json
|
||||
|
||||
# 4. 更新某条评论(可选 --mention)
|
||||
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正" --format json
|
||||
|
||||
# 5. 删除某条评论(不可恢复,必须先确认)
|
||||
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes --format json
|
||||
|
||||
# 6. 回复某条评论(commentKey 从 list 或 create 返回中获取)
|
||||
# 4. 回复某条评论(commentKey 从 list 或 create 返回中获取)
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已修改" --format json
|
||||
|
||||
# 7. 用表情回复评论
|
||||
# 5. 用表情回复评论
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
|
||||
|
||||
# 8. 创建划词评论(针对文档中某段选中文本)
|
||||
# 6. 创建划词评论(针对文档中某段选中文本)
|
||||
# 先获取块列表: dws doc block list --node <DOC_ID> --format json → 提取 blockId 和文本内容
|
||||
# 确定选中文本在块内的起始偏移量 (start) 和结束偏移量 (end)
|
||||
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content "这里需要修改" --format json
|
||||
|
||||
# 9. 创建划词评论并附带引用原文 + @ 相关人
|
||||
# 7. 创建划词评论并附带引用原文 + @ 相关人
|
||||
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 5 --end 20 --content "请确认这部分" --selected-text "被选中的原文内容" --mention <userId1>,<userId2> --format json
|
||||
|
||||
# ── 工作流 10: 导出在线文档为 docx ──
|
||||
@@ -948,10 +926,10 @@ dws doc export --node <DOC_ID_OR_URL> --output ./exported.docx
|
||||
| `import` | `nodeId` / `documentUrl` / `documentName` / `documentType`;中断时提取 `taskId` | 后续 read / info / sheet 操作;中断后用 `doc import get --task-id` |
|
||||
| `drive mkdir`(原 `doc folder create`,已弃用) | `nodeId` | create 的 --folder |
|
||||
| `block list` | `blockId` | block insert 的 --ref-block, block update/delete 的 --block-id |
|
||||
| `comment list` | `commentList[].commentKey` | comment reply/update/delete 的 --comment-key |
|
||||
| `comment create` / `comment create-inline` | `commentKey` | comment reply/update/delete 的 --comment-key |
|
||||
| `comment list` | `commentList[].commentKey` | comment reply 的 --comment-key |
|
||||
| `comment create` / `comment create-inline` | `commentKey` | comment reply 的 --comment-key |
|
||||
| `block list` | `blockId` + 文本内容 | comment create-inline 的 --block-id 及 --start/--end 计算 |
|
||||
| `contact user search` | `userId` | comment create / create-inline / reply / update 的 --mention |
|
||||
| `contact user search` | `userId` | comment create / create-inline / reply 的 --mention |
|
||||
| `wiki node create`(原 `doc file create`,已弃用) | `nodeId` | 后续 read / update / block 操作的 --node(仅 adoc 支持 read/update,axls/amind 等类型用各自产品的命令) |
|
||||
| `copy` / `move` | 新 `nodeId`(copy)或原 nodeId(move) | 后续 read / info 等的 --node |
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# doc comment(文档评论:list / create / reply / update / delete / create-inline)
|
||||
# doc comment(文档评论:list / create / reply / create-inline)
|
||||
|
||||
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
|
||||
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
|
||||
@@ -61,39 +61,6 @@ Flags:
|
||||
|
||||
---
|
||||
|
||||
## doc comment update(更新评论)
|
||||
|
||||
```text
|
||||
Usage:
|
||||
dws doc comment update [flags]
|
||||
Example:
|
||||
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"
|
||||
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请确认" --mention uid1,uid2
|
||||
Flags:
|
||||
--node string 目标文档 ID 或 URL (必填)
|
||||
--comment-key string 待更新评论的 commentKey (必填)
|
||||
--content string 更新后的评论内容 (必填)
|
||||
--mention string 被 @ 的用户 uid 列表,逗号分隔
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## doc comment delete(删除评论)
|
||||
|
||||
```text
|
||||
Usage:
|
||||
dws doc comment delete [flags]
|
||||
Example:
|
||||
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes
|
||||
Flags:
|
||||
--node string 目标文档 ID 或 URL (必填)
|
||||
--comment-key string 待删除评论的 commentKey (必填)
|
||||
```
|
||||
|
||||
删除不可恢复,必须先获得用户明确确认,再传全局 `--yes`。
|
||||
|
||||
---
|
||||
|
||||
## doc comment create-inline(创建划词评论)
|
||||
|
||||
```
|
||||
@@ -116,7 +83,7 @@ Flags:
|
||||
## 关键说明
|
||||
|
||||
- `--mention` 接受 `userId` 列表(逗号分隔),需要先用 `dws contact user search --query "<姓名>"` 拿到 userId。
|
||||
- `--comment-key` 是评论唯一标识,从 `list` / `create` / `create-inline` 返回中提取,可用于 `reply` / `update` / `delete`。
|
||||
- `--comment-key` 是 13 位毫秒时间戳 + 32 位 UUID 的拼接字符串,从 `list` / `create` / `create-inline` 返回中提取。
|
||||
- 划词评论的 `--start` / `--end` 是块内文本字符偏移量,从 0 开始;通过 [`./doc-block.md`](./doc-block.md) `block list` 取 `paragraph.text` 后人工或脚本计算。
|
||||
- `reply` 加 `--emoji` 时 `--content` 填表情名称(如 `比心`、`赞`),不是文字内容。
|
||||
|
||||
@@ -124,9 +91,9 @@ Flags:
|
||||
|
||||
| 从返回中提取 | 用于 |
|
||||
|-------------|------|
|
||||
| `commentList[].commentKey` | `comment reply/update/delete` 的 `--comment-key` |
|
||||
| `comment create` `commentKey` | `comment reply/update/delete` 的 `--comment-key` |
|
||||
| `comment create-inline` `commentKey` | `comment reply/update/delete` 的 `--comment-key` |
|
||||
| `commentList[].commentKey` | `comment reply` 的 `--comment-key` |
|
||||
| `comment create` `commentKey` | `comment reply` 的 `--comment-key` |
|
||||
| `comment create-inline` `commentKey` | `comment reply` 的 `--comment-key` |
|
||||
| [`./doc-block.md`](./doc-block.md) `block list` 的 `blocks[].element.id` | `comment create-inline` 的 `--block-id` |
|
||||
| [`./doc-block.md`](./doc-block.md) `block list` 的 `blocks[].element.paragraph.text` | 计算 `create-inline` 的 `--start` / `--end` 偏移量 |
|
||||
| `dws contact user search` 的 `userId` | `comment create/reply/create-inline` 的 `--mention` |
|
||||
@@ -154,12 +121,6 @@ dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已
|
||||
# 表情回复(--content 填表情名称)
|
||||
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
|
||||
|
||||
# 更新评论(可选 --mention)
|
||||
dws doc comment update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已修正" --format json
|
||||
|
||||
# 删除评论(危险操作,需用户先明确确认)
|
||||
dws doc comment delete --node <DOC_ID> --comment-key <COMMENT_KEY> --yes --format json
|
||||
|
||||
# 划词评论(先 block list 取 blockId + paragraph.text,计算 start/end)
|
||||
dws doc block list --node <DOC_ID> --format json
|
||||
# 计算偏移后:
|
||||
|
||||
@@ -15,8 +15,6 @@ dws drive list --help
|
||||
dws drive search --help
|
||||
dws drive upload --help
|
||||
dws drive download --help
|
||||
dws drive stats --help
|
||||
dws drive shortcut --help
|
||||
```
|
||||
|
||||
规则:
|
||||
@@ -158,28 +156,6 @@ Flags:
|
||||
--space-id string 节点所属空间 ID (可选)
|
||||
```
|
||||
|
||||
### 获取节点统计信息
|
||||
|
||||
```text
|
||||
Usage:
|
||||
dws drive stats --node <NODE_ID_OR_URL>
|
||||
```
|
||||
|
||||
返回节点可用的阅读、编辑、评论、点赞、预览或下载等统计维度;不同文件类型返回字段可能不同。本命令只读。
|
||||
|
||||
### 创建节点快捷方式
|
||||
|
||||
```text
|
||||
Usage:
|
||||
dws drive shortcut --node <SOURCE_NODE> [--folder <TARGET_FOLDER>] [--workspace <WORKSPACE_ID>]
|
||||
Example:
|
||||
dws drive shortcut --node <SOURCE_NODE>
|
||||
dws drive shortcut --node <SOURCE_NODE> --folder <TARGET_FOLDER>
|
||||
dws drive shortcut --node <SOURCE_NODE> --workspace <WORKSPACE_ID>
|
||||
```
|
||||
|
||||
`--folder` 和 `--workspace` 均可省略,此时由服务端选择默认位置。创建后应通过 `drive list` 回读目标位置。
|
||||
|
||||
### 文件内容获取路由规则
|
||||
|
||||
> 当用户请求"分析/查看/读取某个钉盘文件内容"时,**必须先调用 `dws drive info` 获取文件元数据**,再根据返回的 `extension` 字段选择对应链路。
|
||||
@@ -336,8 +312,6 @@ Flags:
|
||||
用户说"钉盘空间/团队文件/有哪些空间/空间列表/团队文件列表" → `wiki space list --type orgSpace`(`drive list-spaces` 已 deprecated)
|
||||
用户说"搜索钉盘文件/钉盘里找个文件/查找某个钉盘文件/钉盘中搜索" → `search`
|
||||
用户说"文件详情/文件信息" → `info`
|
||||
用户说"文件阅读量/编辑量/评论数/下载数/节点统计" → `stats`
|
||||
用户说"给文件创建快捷方式/放一个链接到目标文件夹" → `shortcut`
|
||||
用户说"下载文件" → `download` 指定 `--output` 保存到本地
|
||||
用户说"新建文件夹/创建目录" → `mkdir`(钉盘空间)/ `wiki node create --type folder`(文档空间)
|
||||
用户说"上传文件/传文件到钉盘" → `upload`(首选此命令,自动完成三步流程)
|
||||
@@ -518,7 +492,7 @@ dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --f
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / stats / shortcut / download / delete 的 --node;list / mkdir 的 --folder;`drive copy/move/shortcut` 的 --node 或 --folder |
|
||||
| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / download / delete 的 --node;list / mkdir 的 --folder;`drive copy/move` 的 --node 或 --folder |
|
||||
| `list` | `spaceId` | info / download / mkdir / upload 的 --space-id |
|
||||
| `list` | `nextCursor` | 下次 list 的 --cursor |
|
||||
| `list-spaces` / `wiki space list` | `rootFolderId` | `drive copy/move` 的 --folder(复制/移动到钉盘 space 根目录时) |
|
||||
@@ -546,7 +520,6 @@ dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --f
|
||||
- `--file-name` 必须包含扩展名(如 `report.pdf`)
|
||||
- `download` 需要指定 `--output`,CLI 会把文件保存到本地路径或目录
|
||||
- 文件名规则:头尾不能有空格;不能含 `*`、`"`、`<`、`>`、`|`、制表符;不能以 `.` 结尾
|
||||
- `shortcut` 会创建新节点,执行后必须通过 `drive list` 回读确认目标位置;`stats` 为只读命令
|
||||
|
||||
## 自动化脚本
|
||||
|
||||
|
||||
@@ -1,101 +1,92 @@
|
||||
# dws event — 个人消息事件
|
||||
|
||||
监听当前用户收到的钉钉消息事件,NDJSON 输出到 stdout。实时监听 / 自动回复 / 订阅一律用 `dws event consume`,不要轮询消息历史。
|
||||
|
||||
## 运行方式
|
||||
|
||||
- bus 后台进程持有对钉钉的个人 Stream 长连;consume 从 bus 读事件、按 NDJSON 打到 stdout。consume 只读,不发消息(回复用 `dws chat message send`)。
|
||||
- 没有 bus 时 consume 自动拉起;通常只跑 consume。
|
||||
- 一个组织一个 bus,互不干扰、可同时跑;同组织内多个 consume 共享一个 bus。
|
||||
- 非默认组织加全局 `--profile <corpId 或 profile 名>`;漏传会退回默认 profile 而失败。
|
||||
通过个人 Stream 长连接监听当前用户收到的钉钉消息事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。实时监听、自动回复、订阅事件都必须使用 `dws event consume`,不要写脚本轮询消息历史。
|
||||
|
||||
## Core commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `dws event schema <event_key>` | 查看事件参数和输出字段 schema |
|
||||
| `dws event consume <event_key> [flags]` | 阻塞消费,事件写到 stdout,用 `-f ndjson` |
|
||||
| `dws event status --event <event_key>` | 查看个人订阅、bus、本地 consume |
|
||||
| `dws event stop <subscribe_id>` | 取消订阅并停止对应本地消费 |
|
||||
| `dws event stop --all` | 清理当前身份下全部个人订阅 |
|
||||
|
||||
注意区分两个 schema:`dws event schema <event_key>` 查事件的输出字段;`dws schema "event consume"` 查 consume 命令自身的入参(机读结构,source=cobra,含 parameters + 位置参数)。
|
||||
| `dws event schema <event_key>` | 查看事件参数和输出字段 schema,默认 JSON |
|
||||
| `dws event consume <event_key> [flags]` | 阻塞消费;事件写到 stdout,推荐 `-f ndjson` |
|
||||
| `dws event status --event <event_key>` | 查看个人订阅、personal bus 和本地 consume |
|
||||
| `dws event stop <subscribe_id>` | 取消个人订阅并停止对应本地消费 |
|
||||
| `dws event stop --all` | 清理当前身份下本地记录的全部个人订阅 |
|
||||
|
||||
## Event catalog
|
||||
|
||||
| 事件码 | 场景 | 必填参数 |
|
||||
|---|---|---|
|
||||
| `user_im_message_receive_at` | 当前用户被 @ 的消息 | 无 |
|
||||
| `user_im_message_receive_o2o` | 与指定用户的单聊消息 | `--user` |
|
||||
| `user_im_message_receive_group` | 指定群聊 / 会话的消息 | `--group` |
|
||||
| `user_im_message_receive_o2o` | 当前用户与指定用户的单聊消息 | `--user` |
|
||||
| `user_im_message_receive_group` | 当前用户所在指定群聊/会话的消息 | `--group` |
|
||||
|
||||
只承认这 3 个事件码。默认身份即当前用户,不加身份切换 flag。
|
||||
只承认上表 3 个事件码。默认身份就是当前用户,不要额外加身份切换 flag。
|
||||
|
||||
## Intent mapping
|
||||
|
||||
| 用户说 | 下一步 |
|
||||
|---|---|
|
||||
| 监听有人 @ 我 | consume `user_im_message_receive_at` |
|
||||
| 监听我和某用户的单聊 | 解析对端 userId,consume `user_im_message_receive_o2o --user <id>` |
|
||||
| 监听某群 | 先 `dws chat search --query "<群名>"` 拿 openConversationId,再 consume group |
|
||||
| 监听并自动回复某人单聊 | 解析对端 userId,启动 o2o consume;回复用 `dws chat message send` |
|
||||
| 查看事件 schema | `dws event schema <event_key>` |
|
||||
| 看订阅状态 | `dws event status --event <event_key>` |
|
||||
| 停止订阅 | `dws event stop <subscribe_id>` |
|
||||
| "监听有人 @ 我的消息" | `event consume`,事件码 `user_im_message_receive_at`,参数 `-f ndjson` |
|
||||
| "监听我和 userId 507971 的单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o`,参数 `--user 507971 -f ndjson` |
|
||||
| "监听 XX 群消息" | 先 `dws chat search --query "XX" --format json`,确认后 consume group |
|
||||
| "监听并自动回复某人的单聊消息" | 先解析对端 userId,再启动 o2o consume;不要写轮询脚本 |
|
||||
| "查看个人消息事件 schema" | `dws event schema <event_key>` |
|
||||
| "看个人事件订阅状态" | `dws event status --event <event_key>` |
|
||||
| "停止这个个人事件订阅" | `dws event stop <subscribe_id>` |
|
||||
|
||||
多候选让用户确认。缺必填 ID 且解析不出先追问,不要猜。
|
||||
多候选必须让用户确认。缺少必填 ID 且无法解析时先追问,不要猜测。
|
||||
|
||||
## Call flow
|
||||
|
||||
1. 按意图选事件码;人名 / 群名先解析成必填 ID。
|
||||
2. 需要字段时 `dws event schema <event_key>`,读 `jq_root_path` 和 `schema.properties`。
|
||||
3. 启动 `dws event consume <event_key> ... -f ndjson`,等 stderr 出现 `[event] ready event_key=<key> ...` 再读 stdout,不要 sleep。
|
||||
4. stdout 每行一个事件 JSON;`data` 字段是 JSON 字符串,按 `jq_root_path` 再 parse。
|
||||
5. `dws event status --event <event_key>` 看 Subscriptions / Consumers。
|
||||
6. `dws event stop <subscribe_id>` 取消订阅;自测可加 `--max-events` / `--duration` 自动退出。
|
||||
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
|
||||
2. 需要了解字段时运行 `dws event schema <event_key>`,读取 `jq_root_path` 和 `schema.properties`。
|
||||
3. 启动 `dws event consume <event_key> ... -f ndjson`,等待 stderr 出现 `connected bus pid=...` 后开始读 stdout。
|
||||
4. stdout 每行是一个事件 JSON;业务字段在 `data` JSON 字符串内,按 `jq_root_path` 解析。
|
||||
5. 需要确认监听状态时运行 `dws event status --event <event_key>`,查看 `Subscriptions` 和 `Consumers`。
|
||||
6. 任务完成后用 `dws event stop <subscribe_id>` 取消订阅;临时测试可以在 consume 上加 `--max-events` 或 `--duration`。
|
||||
|
||||
## Commands
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
dws event schema user_im_message_receive_at
|
||||
dws event schema user_im_message_receive_o2o
|
||||
|
||||
dws event consume user_im_message_receive_at -f ndjson
|
||||
dws event consume user_im_message_receive_o2o --user 507971 -f ndjson
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
|
||||
|
||||
dws event status --event user_im_message_receive_o2o
|
||||
dws event stop <subscribe_id>
|
||||
dws event stop --all
|
||||
dws event schema user_im_message_receive_group
|
||||
```
|
||||
|
||||
## Subprocess contract
|
||||
```bash
|
||||
dws event consume user_im_message_receive_at -f ndjson
|
||||
```
|
||||
|
||||
- 就绪:连上后 stderr 打 `[event] ready event_key=<key> bus_pid=<pid>`,父进程等这行再读 stdout。不要 `--quiet`(会抑制它)。
|
||||
- 退出:末行 `[event] exited — received N event(s) in Xs (reason: limit|timeout|signal|bus_shutdown)`;受控退出码 0,失败非 0 且无 exited 行、有 Error 行。
|
||||
- stdin 关闭 = 停机:仅当 stdin 是管道且未设 `--max-events/--duration` 时生效;交互终端和 `< /dev/null` 不触发。用管道 stdin 又要常驻就喂 `< <(tail -f /dev/null)`。
|
||||
- 订阅清理:本次新建的订阅任意退出即自动退订;`--subscribe-id` 复用的保留;`--ephemeral` 强制退订。优雅停用 SIGTERM、关 stdin,或外部 `dws event stop <subscribe_id>`。不要 `kill -9`(跳过退订、泄漏服务端订阅)。
|
||||
- 一 consume 一 event_key;监听 N 个就起 N 个 consume,共用一个 bus。
|
||||
```bash
|
||||
dws event consume user_im_message_receive_o2o \
|
||||
--user 507971 \
|
||||
-f ndjson
|
||||
```
|
||||
|
||||
```bash
|
||||
dws event consume user_im_message_receive_group \
|
||||
--group <openConversationId> \
|
||||
-f ndjson
|
||||
```
|
||||
|
||||
```bash
|
||||
dws event status --event user_im_message_receive_at
|
||||
dws event status --event user_im_message_receive_o2o
|
||||
dws event status --event user_im_message_receive_group
|
||||
dws event stop <subscribe_id>
|
||||
```
|
||||
|
||||
`status` 的 `Consumers` 表展示本地 consume 的 PID、事件码、`subscribe_id` 和 received/dropped 计数,可用于确认监听是否仍在 personal bus 上。
|
||||
|
||||
裸 `dws event stop` 不会取消订阅;批量清理必须显式使用 `dws event stop --all`。Ctrl+C、`--duration`、`--max-events` 只结束本地前台消费进程,不等价于取消服务端订阅。
|
||||
|
||||
## Output parsing
|
||||
|
||||
- 用 `-f ndjson`,一行一个事件 JSON。抓样本用 `-f json --max-events 1`。
|
||||
- 两层解析:外层事件 JSON 的 `data` 字段是 JSON 字符串,`fromjson` 后取 `payload.body.content`(正文)/ `payload.body.sender`(发送人)/ `payload.body.openConversationId`(会话)。样例:
|
||||
|
||||
```json
|
||||
{"type":"event","event_type":"user_im_message_receive_group",
|
||||
"data":"{\"payload\":{\"body\":{\"sender\":\"张三\",\"content\":\"你好\",\"openConversationId\":\"cid...==\"}},\"subject\":{\"isSelfLoop\":false}}"}
|
||||
```
|
||||
|
||||
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤):边听边回不成环;自发验证会看到 0 事件,测投递用别人 / 机器人发。
|
||||
- `--jq <表达式>` 把过滤 / 投影下推到 consume,减少输出。
|
||||
- `--debug-raw-events` 仅联调用。
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- consume 报 bus 启动失败:报错已带子进程真实原因。多为登录问题,`dws --profile <x> auth status` 看登录态(非默认组织带对 `--profile`),过期就 `auth login` 重登。
|
||||
- 本地日志:`~/.dws/events/<edition>/personal_stream/<hash>/bus.log`(`edition` 一般 `open`,`hash` 见 `dws event status` 的 Workdir);极早期失败可能无日志,以 consume 报错为准。
|
||||
- 有残留 / 连不上:`dws event status` 查 stale,`dws event stop --all` 清理重试。
|
||||
- 挂住无输出:多是误加 `--foreground`(跑 bus、不打印事件),去掉。
|
||||
- 推荐 `-f ndjson`:一行一个事件 JSON,适合 Agent 管道读取。
|
||||
- 人工取样可用 `-f json --max-events 1`。
|
||||
- `data` 是服务端业务 payload 的 JSON 字符串;读取消息内容前按 schema 的 `jq_root_path` 再解析一次。
|
||||
- 当前消息正文在 `payload.body.content`,发送人展示名在 `payload.body.sender`,会话 ID 在 `payload.body.openConversationId`。
|
||||
- `--debug-raw-events` 仅用于服务端联调,正常消费不要使用。
|
||||
|
||||
## Full reference
|
||||
|
||||
|
||||
@@ -81,8 +81,6 @@ dws sheet filter-view --help
|
||||
| `sheet new` | 新建工作表 |
|
||||
| `sheet update` | 更新工作表属性(标题/位置/隐藏/冻结) |
|
||||
| `sheet copy` | 复制工作表 |
|
||||
| `sheet show-gridline` | 显示工作表网格线 |
|
||||
| `sheet hide-gridline` | 隐藏工作表网格线 |
|
||||
| `sheet range read` | 读取工作表数据(别名: range get) |
|
||||
| `sheet range update` | 更新指定区域内容(值/公式/超链接) |
|
||||
| `sheet range clear` | 清除区域(值/格式/全部) |
|
||||
@@ -93,17 +91,11 @@ dws sheet filter-view --help
|
||||
| `sheet range batch-clear` | 批量清除多个区域(原子事务) |
|
||||
| `sheet batch-update` | 批量执行多个写操作(原子事务) |
|
||||
| `sheet csv-get` | 以 CSV 格式读取区域数据 |
|
||||
| `sheet table-get` | 读取结构化 table 数据(别名: table-read) |
|
||||
| `sheet range set-style` | 设置单元格样式 |
|
||||
| `sheet range batch-set-style` | 按配置文件批量设置样式 |
|
||||
| `sheet find` | 搜索单元格内容 |
|
||||
| `sheet append` | 在末尾追加数据行 |
|
||||
| `sheet csv-put` | 将 CSV 数据写入指定位置(纯值,自动扩容) |
|
||||
| `sheet table-put` | 写入一个或多个结构化 table(别名: table-write) |
|
||||
| `sheet pivot-table list` | 列出透视表或获取指定透视表详情 |
|
||||
| `sheet pivot-table create` | 创建原生透视表 |
|
||||
| `sheet pivot-table update` | 更新透视表配置 |
|
||||
| `sheet pivot-table delete` | 删除透视表(不可逆,删除前必须确认) |
|
||||
| `sheet delete-sheet` | 删除工作表(不可逆,删除前必须确认) |
|
||||
| `sheet replace` | 全局查找替换文本 |
|
||||
| `sheet merge-cells` | 合并单元格 |
|
||||
@@ -161,10 +153,9 @@ dws sheet filter-view --help
|
||||
|
||||
| 主题 | 子文档 | 覆盖命令 |
|
||||
|------|--------|---------|
|
||||
| 表格与工作表管理 | [sheet/sheet-workbook.md](./sheet/sheet-workbook.md) | create / list / info / new / update / copy / show-gridline / hide-gridline / delete-sheet |
|
||||
| 透视表 | [sheet/sheet-pivot-table.md](./sheet/sheet-pivot-table.md) | pivot-table list / create / update / delete |
|
||||
| 写入数据 | [sheet/sheet-write-data.md](./sheet/sheet-write-data.md) | table-put / range update(对象协议详解)/ append / csv-put |
|
||||
| 读取数据 | [sheet/sheet-read-data.md](./sheet/sheet-read-data.md) | table-get / range read / csv-get |
|
||||
| 表格与工作表管理 | [sheet/sheet-workbook.md](./sheet/sheet-workbook.md) | create / list / info / new / update / copy / delete-sheet |
|
||||
| 写入数据 | [sheet/sheet-write-data.md](./sheet/sheet-write-data.md) | range update(对象协议详解)/ append / csv-put |
|
||||
| 读取数据 | [sheet/sheet-read-data.md](./sheet/sheet-read-data.md) | range read / csv-get |
|
||||
| 区域操作 | [sheet/sheet-range-operations.md](./sheet/sheet-range-operations.md) | range clear / sort / fill / copy-to / move-to |
|
||||
| 批量操作 | [sheet/sheet-batch-operations.md](./sheet/sheet-batch-operations.md) | range batch-clear / batch-update |
|
||||
| 行列操作 | [sheet/sheet-dimension-operations.md](./sheet/sheet-dimension-operations.md) | insert / delete / update / move / add-dimension / group-dimension / ungroup-dimension |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user