Compare commits

...
Author SHA1 Message Date
修雨 68a1c7a49b fix(connect): remove message type attachment allowlist 2026-07-14 18:17:41 +08:00
修雨 9a6e60c643 fix(connect): recover forwarded unknown attachments 2026-07-14 17:46:45 +08:00
修雨 99a0952505 fix(connect): deliver forwarded attachments to all agents 2026-07-14 15:45:49 +08:00
修雨 f30aaeba91 fix(connect): forward complex message payloads 2026-07-14 11:02:37 +08:00
SCzheng 43798de088 fix(connect): preserve rich text image attachments (#606) 2026-07-13 22:13:56 +08:00
Ari c4d8987f6c feat(event): AI-subprocess contract and cobra-synthesized schema (#609)
Align `dws event consume` with an AI-subprocess contract an orchestrator
can drive deterministically, and expose a machine-readable input schema
for event commands via `dws schema`.

Subprocess contract:
- Fixed stderr ready line `[event] ready event_key=<key> bus_pid=<pid>`;
  block on it instead of sleeping.
- Final `[event] exited — received N event(s) in Xs (reason: ...)` line;
  exit 0 on controlled exit, non-zero and no exited line on failure.
- stdin-EOF graceful shutdown, armed only for a pipe stdin on an
  unbounded run; an interactive TTY and `< /dev/null` never trigger it.
- Ownership-based subscription cleanup: a run-created subscription is
  unsubscribed on any clean exit while a --subscribe-id-reused one is
  kept (--ephemeral still forces cleanup). Forward --profile to the
  detached bus so non-default orgs resolve the right credentials, and
  surface the child's real startup error over the ready pipe.

Schema:
- `dws schema "event consume"` (or event.consume) synthesizes a flat,
  machine-readable schema from the command's cobra flags:
  {description, path, source:"cobra",
  parameters{<flag>:{type,required,description,default?}}} plus an
  `arguments` array for positional inputs. Intermediate nodes list
  subcommands. Inherited global flags and hidden internal flags are
  excluded so the schema describes just that command.
- Reusable registry (cobraSchemaRoots); event is the first consumer and
  more command trees can opt in without further wiring.

Docs: mono + dingtalk-event skills document the contract and the two
schema surfaces; design notes in docs/event-subprocess-contract.md.
2026-07-13 22:08:22 +08:00
Aemeathand张卓澎 657d2c25e3 feat: sync open product command capabilities (#608)
Co-authored-by: 张卓澎 <zhuopeng.zzp@alibaba-inc.com>
2026-07-13 17:21:47 +08:00
johnand玉澜 9f7107b6bb ci: sign macOS releases with Apple Developer ID (#605)
* fix release upload of signed macOS assets

* ci: sign macOS releases with Developer ID

* fix release publication atomicity

* harden Developer ID release verification

* fix: run release script tests in CI

---------

Co-authored-by: 玉澜 <yulan.wqy@alibaba-inc.com>
2026-07-13 17:10:23 +08:00
修雨 bfd48b6a71 fix: preserve macOS auth across keychain mode changes (#597)
* fix: preserve auth across macOS keychain modes

* docs(auth): clarify per-profile recovery

* fix(auth): add safe macOS keychain migration

* ci: add native Windows auth coverage

* ci: scope Windows checks to auth paths

* fix(auth): address keychain review boundaries
2026-07-13 15:27:39 +08:00
97 changed files with 8000 additions and 379 deletions
+40
View File
@@ -61,6 +61,46 @@ 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
+115 -9
View File
@@ -18,10 +18,7 @@ jobs:
release:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
runs-on: ubuntu-latest
# 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
timeout-minutes: 30
steps:
- name: Check out repository
@@ -40,17 +37,50 @@ jobs:
- name: Multi Profile E2E
run: bash scripts/dev/test-multi-profile-e2e.sh
- name: Install rcodesign (ad-hoc sign darwin binaries from Linux)
- name: Install rcodesign (sign darwin binaries from Linux)
run: |
set -eu
RCS_VERSION="0.27.0"
set -euo pipefail
RCS_VERSION="0.29.0"
RCS_ARCHIVE_SHA256="dbe85cedd8ee4217b64e9a0e4c2aef92ab8bcaaa41f20bde99781ff02e600002"
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:
@@ -63,12 +93,88 @@ 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: Upload dws-skills.zip to release
- 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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
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
- name: Sync release to China OSS mirror
# 自动同步到国内镜像,供 install.sh 的 DWS_RELEASE_BASE 开关消费。
+3 -1
View File
@@ -67,7 +67,9 @@ release:
# 用当前运行 CI 的仓库 owner: fork CI 发到 fork, 官方 CI 发到官方, 两边都对
owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
name: dingtalk-workspace-cli
draft: false
# Keep the release private until post-processing has replaced the Darwin
# archives and verified every finalized asset digest.
draft: true
prerelease: auto
name_template: "v{{.Version}}"
mode: replace
+14
View File
@@ -6,6 +6,20 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### 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.
+10
View File
@@ -258,6 +258,16 @@ 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>
+10
View File
@@ -255,6 +255,16 @@ 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>
+115
View File
@@ -0,0 +1,115 @@
# 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.
+70 -2
View File
@@ -82,6 +82,7 @@ func buildAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
cmd.AddCommand(
newAuthLogoutCommand(),
newAuthStatusCommand(),
newAuthMigrateKeychainCommand(),
newAuthExportCommand(),
newAuthImportCommand(),
newAuthExchangeCommand(),
@@ -283,6 +284,7 @@ var (
loginRecommendScopeModeSelector = selectLoginRecommendScopeMode
loginRecommendProductSelector = selectLoginRecommendProducts
authLoginInteractiveTerminal = isInteractiveTerminal
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
)
func selectAuthLoginGuideAction() (authLoginGuideAction, error) {
@@ -521,6 +523,65 @@ 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())
@@ -596,7 +657,7 @@ func newAuthExportCommand() *cobra.Command {
}
if !authpkg.PortableExportSupported() {
return apperrors.NewValidation(fmt.Sprintf(
"macOS 默认将 DEK 存在系统 Keychain,导出的包无法在其它机器解密;请设置 %s=1 后重新登录再导出",
"macOS 导出认证包需要 file-DEK 模式;请先设置 %s=1 并运行 dws auth status 验证,只有提示密钥不匹配且确认可丢弃旧登录态时,才执行 dws auth reset 后重新登录",
keychain.DisableKeychainEnv,
))
}
@@ -1229,11 +1290,18 @@ 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: "重新登录以生成新的本地登录密钥;如仍异常,可先清理本地登录态后再登录。",
Hint: "请先恢复或统一原登录密钥;确认旧登录态不可恢复后,执行 dws auth reset,再重新登录。",
}
}
if !keychain.IsUnavailable(err) {
+102
View File
@@ -19,6 +19,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
@@ -232,6 +233,22 @@ 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) {
@@ -335,6 +352,91 @@ 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"),
+39 -3
View File
@@ -101,7 +101,9 @@ func newEventConsumeCommand() *cobra.Command {
raw 仅 SDK 原始 payload,无外层封装
compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好)
默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接。
默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加
--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用
SIGTERM、关 stdin,或 dws event stop <subscribe_id>,不要 kill -9。
--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费
通常不需要设置。`,
Args: cobra.MaximumNArgs(1),
@@ -214,6 +216,11 @@ func newEventConsumeCommand() *cobra.Command {
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 {
@@ -260,7 +267,7 @@ func newEventConsumeCommand() *cobra.Command {
f.BoolVar(&dryRun, "dry-run", false,
"仅打印解析后的配置,不连接 bus / 云端")
f.BoolVar(&foreground, "foreground", false,
"不 fork daemon,当前进程跑 bus (systemd/k8s/launchd 友好)")
"当前进程直接跑 bus 服务、不 fork、不打印事件(给 systemd/k8s 托管用);读事件不要用它")
f.StringVar(&personalOpts.SubscribeID, "subscribe-id", "",
"个人事件订阅 ID;传入后复用已有订阅")
f.StringVar(&personalOpts.Rule, "rule", "",
@@ -274,7 +281,10 @@ func newEventConsumeCommand() *cobra.Command {
f.DurationVar(&personalOpts.TTL, "ttl", 0,
"个人订阅 TTL (Go duration,如 24h;0 表示不过期)")
f.BoolVar(&personalOpts.Ephemeral, "ephemeral", false,
"consume 退出时自动取消个人订阅")
"强制退出时取消个人订阅。默认已按归属清理:本次新建的订阅退出即取消,"+
"用 --subscribe-id 复用的订阅保留。优雅停可用 SIGTERM、关闭 stdin,"+
"或从外部 dws event stop <subscribe_id>(会一并退订);"+
"请勿 kill -9(会跳过退订、泄漏服务端订阅)")
f.StringVar(&personalOpts.UserID, "user", "",
"个人单聊对端 userId")
f.StringVar(&personalOpts.GroupID, "group", "",
@@ -457,7 +467,13 @@ 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
@@ -1093,6 +1109,26 @@ 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 {
+25 -1
View File
@@ -204,6 +204,7 @@ 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,
@@ -239,7 +240,14 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
_ = client.DeleteSubscription(context.Background(), sub.SubscribeID)
_ = personal.RemoveRunStates(workDir, []string{sub.SubscribeID})
}
if opts.Ephemeral {
// 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 {
defer cleanup()
}
@@ -251,6 +259,7 @@ 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,
@@ -260,6 +269,11 @@ 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",
@@ -756,6 +770,16 @@ 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)
}
+64
View File
@@ -0,0 +1,64 @@
// 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)
}
}
}
+4
View File
@@ -163,6 +163,10 @@ 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()
+28
View File
@@ -0,0 +1,28 @@
// 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
}
+72 -1
View File
@@ -15,12 +15,14 @@ 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 (
@@ -28,6 +30,9 @@ 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 {
@@ -86,7 +91,7 @@ func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
return nil, fmt.Errorf("load from keychain: %w", err)
}
if jsonStr == "" {
return nil, fmt.Errorf("no token data in keychain account %q", account)
return nil, fmt.Errorf("%w in keychain account %q", ErrTokenDataNotFound, account)
}
var data TokenData
@@ -96,6 +101,72 @@ 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)
+5
View File
@@ -27,10 +27,15 @@ 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)
+6
View File
@@ -110,6 +110,9 @@ 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 {
@@ -623,6 +626,9 @@ 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)")
+9 -2
View File
@@ -67,7 +67,11 @@ func PortableAuthTargetPopulated(configDir string) bool {
// PortableAuthSourceReady reports whether encrypted auth token exists for export.
func PortableAuthSourceReady() bool {
return portableAuthSourcePopulated(keychain.StorageDir(keychain.Service))
if !portableAuthSourcePopulated(keychain.StorageDir(keychain.Service)) {
return false
}
_, err := LoadTokenDataKeychain()
return err == nil
}
func portableAuthSourcePopulated(keychainDir string) bool {
@@ -93,7 +97,7 @@ func ExportPortableAuthBundle(configDir string, w io.Writer) error {
return fmt.Errorf("missing output writer")
}
if !PortableExportSupported() {
return fmt.Errorf("portable export unavailable on macOS while DEK is in system Keychain; set %s=1, re-login, then export", keychain.DisableKeychainEnv)
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)
}
keychainDir := keychain.StorageDir(keychain.Service)
if _, err := os.Stat(keychainDir); err != nil {
@@ -102,6 +106,9 @@ 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()
+4 -1
View File
@@ -17,6 +17,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@@ -187,7 +188,7 @@ func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
if err == nil {
return data, nil
}
if strings.TrimSpace(profile) != "" {
if strings.TrimSpace(profile) != "" || !errors.Is(err, ErrTokenDataNotFound) {
return nil, err
}
// No explicit --profile: `selected` is the resolved current/primary
@@ -197,6 +198,8 @@ 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
}
+376
View File
@@ -0,0 +1,376 @@
// 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)
}
}
+18 -2
View File
@@ -75,7 +75,10 @@ func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Com
Short: "查看有限的本地 Schema(静态端点模式)",
Long: `查看有限的本地 Schema 元数据。
服务发现和动态 schema 已下线。静态端点模式下,仅支持 helper-only 子树的 schema 查询;普通产品命令和 flag 以当前二进制的 --help 为准。`,
服务发现和动态 schema 已下线。静态端点模式下,schema 覆盖两类命令:
1. helper-only 子树(如 dev):CONTENT 从其绑定的 MCP 服务实时取,source 为 mcp:<server>;
2. 登记的本地命令(如 event):从二进制注册的 cobra flag 合成,source 为 cobra。
其余普通产品命令和 flag 仍以当前二进制的 --help 为准。`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -88,7 +91,7 @@ func NewSchemaCommand(_ CatalogLoader, helperTools HelperToolFetcher) *cobra.Com
args = []string{cliPath}
}
// Helper-only subtrees support.
// Helper-only subtrees: schema CONTENT fetched live from the MCP server.
if len(args) > 0 && helperTools != nil {
payload, ok, err := renderHelperSchema(cmd.Context(), cmd.Root(), args[0], helperTools)
if err != nil {
@@ -101,6 +104,19 @@ 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
},
+204
View File
@@ -0,0 +1,204 @@
// 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
}
}
+227
View File
@@ -0,0 +1,227 @@
// 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]
}
+17 -2
View File
@@ -20,6 +20,7 @@ import (
"os"
"os/exec"
"strconv"
"strings"
"time"
)
@@ -136,11 +137,15 @@ func Spawn(cfg SpawnConfig) (pid int, err error) {
return pid, nil
}
// waitReady reads exactly one byte from pr ('R' or 'E') within ReadyTimeout.
// pr is closed by the caller on return.
// 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.
func waitReady(pr *os.File) error {
type result struct {
b byte
msg string
err error
}
done := make(chan result, 1)
@@ -155,6 +160,13 @@ 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 {
@@ -169,6 +181,9 @@ 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)
+44
View File
@@ -15,9 +15,11 @@ package busctl
import (
"errors"
"io"
"os"
"os/exec"
"runtime"
"strings"
"testing"
"time"
)
@@ -246,3 +248,45 @@ 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)
}
}
+111 -6
View File
@@ -55,6 +55,11 @@ 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.
//
@@ -91,6 +96,18 @@ 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.
@@ -132,15 +149,30 @@ 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
ctx, cancel = context.WithTimeout(ctx, cfg.Duration)
timeoutCtx, 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)
@@ -184,19 +216,57 @@ func Run(ctx context.Context, cfg Config) error {
return fmt.Errorf("consume: unexpected first frame type %q", ack.Type)
}
if !cfg.Quiet {
fmt.Fprintf(cfg.Stderr,
"connected bus pid=%d source=%s state=%s idle_timeout=%ds\n",
ack.BusPID, ack.StateSource, ack.SourceState, ack.IdleTimeoutSecs)
// 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)
}
// 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) {
return nil // peer closed cleanly
reason = "bus_shutdown" // peer closed cleanly
return nil
}
if isCtxCancelled(ctx) {
reason = classifyCancel()
return nil
}
return fmt.Errorf("consume: read frame: %w", err)
@@ -219,6 +289,7 @@ 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)
@@ -229,14 +300,16 @@ 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, "bus closing: %s\n", bye.Reason)
fmt.Fprintf(cfg.Stderr, "[event] bus closing: %s\n", bye.Reason)
}
reason = "bus_shutdown"
return nil
case transport.FrameTypeSourceState:
if !cfg.Quiet {
@@ -269,3 +342,35 @@ 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:
}
}
}
+156 -3
View File
@@ -163,9 +163,9 @@ func TestRun_StdoutNDJSON(t *testing.T) {
}
}
// 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())
// Stderr should carry the standardized ready marker.
if !strings.Contains(stderr.String(), "[event] ready ") {
t.Errorf("stderr missing ready marker:\n%s", stderr.String())
}
}
@@ -309,3 +309,156 @@ 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())
}
}
+82 -3
View File
@@ -1040,7 +1040,7 @@ func newChatCommand() *cobra.Command {
Use: "chat",
Aliases: []string{"im"},
Short: "群聊 / 消息 / 机器人",
Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取会话消息、发送群消息、机器人消息与 Webhook。`,
Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取/发送/收藏会话消息、机器人消息与 Webhook。`,
RunE: groupRunE,
}
@@ -1326,7 +1326,12 @@ func newChatCommand() *cobra.Command {
// ── message 子命令 ────────────────────────────────────────
chatMessageCmd := &cobra.Command{Use: "message", Short: "会话消息管理", RunE: groupRunE}
chatMessageCmd := &cobra.Command{
Use: "message",
Short: "会话消息管理",
Long: `管理会话消息,包括拉取、发送、搜索、转发、钉住、收藏和撤回消息。`,
RunE: groupRunE,
}
chatMessageListCmd := &cobra.Command{
Use: "list",
@@ -4244,6 +4249,80 @@ 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{
@@ -5019,7 +5098,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, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageAddFavoriteCmd, chatMessageRemoveFavoriteCmd, chatMessageListFavoritesCmd, 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)
+208
View File
@@ -0,0 +1,208 @@
// 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))
}
}
@@ -0,0 +1,141 @@
// 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
}
@@ -0,0 +1,75 @@
// 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)
}
}
+405
View File
@@ -0,0 +1,405 @@
// 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
}
+185
View File
@@ -0,0 +1,185 @@
// 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)
}
})
}
}
+21 -7
View File
@@ -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,11 +100,19 @@ func (f *codexAppServerForwarder) forward(ctx context.Context, convID, text stri
return f.forwardStream(ctx, convID, text, nil)
}
func (f *codexAppServerForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
return f.forwardAppServer(ctx, convID, text, onDelta)
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) forwardAppServer(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
func (f *codexAppServerForwarder) forwardStream(ctx context.Context, convID, text string, onDelta func(string)) (string, error) {
return f.forwardStreamWithAttachments(ctx, convID, text, nil, 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) {
ctx, cancel := applyTimeout(ctx, f.timeout)
defer cancel()
@@ -151,7 +159,7 @@ func (f *codexAppServerForwarder) forwardAppServer(ctx context.Context, convID,
}
}
reply, err := cli.runTurn(ctx, threadID, text, onDelta)
reply, err := cli.runTurn(ctx, threadID, text, attachments, onDelta)
if err != nil {
return "", err
}
@@ -436,13 +444,19 @@ 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, onDelta func(string)) (string, error) {
func (c *codexAppServerClient) runTurn(ctx context.Context, threadID, text string, attachments []connectMediaAttachment, 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": []map[string]string{{"type": "text", "text": text}},
"input": input,
"threadId": threadID,
},
}); err != nil {
@@ -122,9 +122,13 @@ 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.forwardStream(context.Background(), "conv-1", "第一问", func(s string) {
reply, err := fwd.forwardStreamWithAttachments(context.Background(), "conv-1", "第一问", []connectMediaAttachment{{LocalPath: imagePath, FileName: "forwarded.png", MediaType: "image"}}, func(s string) {
deltas = append(deltas, s)
})
if err != nil {
@@ -151,6 +155,9 @@ 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) {
+211 -8
View File
@@ -16,12 +16,14 @@ package helpers
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
@@ -31,6 +33,10 @@ 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 {
@@ -88,6 +94,10 @@ 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()
@@ -95,14 +105,15 @@ func (f *geminiAPIForwarder) forward(ctx context.Context, _, text string) (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: []geminiPart{{Text: text}},
}},
Contents: []geminiContent{{Role: "user", Parts: parts}},
}
raw, err := json.Marshal(body)
if err != nil {
@@ -122,14 +133,14 @@ func (f *geminiAPIForwarder) forward(ctx context.Context, _, text string) (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 {
@@ -141,13 +152,193 @@ func (f *geminiAPIForwarder) forward(ctx context.Context, _, text string) (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 == "" {
@@ -174,7 +365,19 @@ type geminiContent struct {
}
type geminiPart struct {
Text string `json:"text"`
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"`
}
type geminiGenerateContentResponse struct {
@@ -15,9 +15,12 @@ package helpers
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -44,6 +47,75 @@ 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
+435 -42
View File
@@ -18,19 +18,52 @@ 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 image download (screenshots are
// well under this; the cap is a hostile-input guard).
const mediaMaxDownloadBytes = 20 << 20
// 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])
}
// pictureDownloadCode digs the downloadCode out of a picture callback's
// loosely-typed content payload (the stream SDK models Content as
@@ -48,6 +81,86 @@ 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
@@ -56,16 +169,29 @@ func pictureDownloadCode(content interface{}) string {
// recognisable or the connector silently drops legitimate file messages.
type fileInboundInfo struct {
DownloadCode string
FileName string
FileType string
FilePath string
DentryID int64
SpaceID int64
FileSize int64
// 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
}
func (f fileInboundInfo) hasActionable() bool {
return strings.TrimSpace(f.DownloadCode) != "" || (f.DentryID != 0 && f.SpaceID != 0)
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)
}
// parseFileInbound reads every relevant field out of a file callback's
@@ -79,21 +205,15 @@ func parseFileInbound(content interface{}) fileInboundInfo {
if !ok {
return info
}
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.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")
info.DentryID = readInt64Field(m, "dentryId", "dentryID")
info.SpaceID = readInt64Field(m, "spaceId", "spaceID")
info.FileSize = readInt64Field(m, "fileSize", "size")
@@ -103,6 +223,213 @@ 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 {
@@ -158,6 +485,19 @@ 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
@@ -172,7 +512,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"} {
for _, key := range []string{"text", "content", "markdown", "title", "recognition"} {
if s, ok := v[key].(string); ok && strings.TrimSpace(s) != "" {
return strings.TrimSpace(s)
}
@@ -259,6 +599,13 @@ 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,
@@ -276,7 +623,7 @@ func (c *aiCardClient) downloadMessageFile(ctx context.Context, robotCode, downl
if err != nil {
return "", err
}
resp, err := c.httpClient.Do(req)
resp, err := connectMediaDownloadClient(c.httpClient).Do(req)
if err != nil {
return "", err
}
@@ -284,18 +631,19 @@ func (c *aiCardClient) downloadMessageFile(ctx context.Context, robotCode, downl
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
}
dest := filepath.Join(dir, uuid.NewString()+mediaExt(parsed.DownloadUrl, resp.Header.Get("Content-Type")))
f, err := os.Create(dest)
if err != nil {
return "", err
ext := filepath.Ext(strings.TrimSpace(fileName))
if ext == "" {
ext = mediaExt(parsed.DownloadUrl, resp.Header.Get("Content-Type"))
}
defer f.Close()
if _, err := io.Copy(f, io.LimitReader(resp.Body, mediaMaxDownloadBytes)); err != nil {
_ = os.Remove(dest)
dest := filepath.Join(dir, uuid.NewString()+ext)
if err := writeCompleteMediaFile(dest, resp.Body); err != nil {
return "", err
}
return dest, nil
@@ -344,7 +692,7 @@ func (c *aiCardClient) downloadDentryFile(ctx context.Context, spaceID, dentryID
for k, v := range parsed.HeadersMap {
req.Header.Set(k, v)
}
resp, err := c.httpClient.Do(req)
resp, err := connectMediaDownloadClient(c.httpClient).Do(req)
if err != nil {
return "", err
}
@@ -352,6 +700,9 @@ 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 {
@@ -362,18 +713,60 @@ 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)
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)
if err := writeCompleteMediaFile(dest, resp.Body); err != nil {
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 {
+280
View File
@@ -16,9 +16,11 @@ package helpers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -42,6 +44,56 @@ 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)
@@ -84,6 +136,156 @@ 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.
@@ -161,6 +363,61 @@ 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) {
@@ -193,6 +450,18 @@ 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,
@@ -222,6 +491,17 @@ 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{}{
+33 -6
View File
@@ -24,6 +24,7 @@ import (
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
@@ -99,7 +100,15 @@ 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()
@@ -107,15 +116,15 @@ func (f *opencodeForwarder) forwardStream(ctx context.Context, convID, text stri
if err != nil {
return "", err
}
reply, err := f.forwardWithClient(ctx, client, convID, text)
reply, err := f.forwardWithClient(ctx, client, convID, text, attachments)
if errors.Is(err, errOpencodeSessionMissing) && f.sessions != nil {
f.sessions.reset(convID)
reply, err = f.forwardWithClient(ctx, client, convID, text)
reply, err = f.forwardWithClient(ctx, client, convID, text, attachments)
}
return reply, err
}
func (f *opencodeForwarder) forwardWithClient(ctx context.Context, client *opencodeHTTPClient, convID, text string) (string, error) {
func (f *opencodeForwarder) forwardWithClient(ctx context.Context, client *opencodeHTTPClient, convID, text string, attachments []connectMediaAttachment) (string, error) {
sessionID := ""
if f.sessions != nil {
sessionID = f.sessions.id(convID)
@@ -130,7 +139,7 @@ func (f *opencodeForwarder) forwardWithClient(ctx context.Context, client *openc
f.sessions.set(convID, sessionID)
}
}
reply, err := client.sendMessage(ctx, sessionID, text, f.model)
reply, err := client.sendMessageWithAttachments(ctx, sessionID, text, f.model, attachments)
if err != nil {
return "", err
}
@@ -459,9 +468,27 @@ func (c *opencodeHTTPClient) deleteSession(ctx context.Context, sessionID string
return nil
}
func (c *opencodeHTTPClient) sendMessage(ctx context.Context, sessionID, text, model string) (string, error) {
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),
})
}
body := map[string]any{
"parts": []map[string]any{{"type": "text", "text": text}},
"parts": parts,
}
if m := opencodeModelRef(model); m != nil {
body["model"] = m
+41
View File
@@ -19,12 +19,53 @@ 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")
+52
View File
@@ -80,6 +80,58 @@ 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()
+30 -5
View File
@@ -65,11 +65,7 @@ if "--input-format" in sys.argv:
}), flush=True)
else:
prompt = sys.argv[-1] if len(sys.argv) > 1 else ""
print(json.dumps({
"type": "result",
"subtype": "success",
"message": {"content": [{"type": "text", "text": "one-shot " + prompt}]},
}))
print("one-shot " + prompt)
`
path := filepath.Join(dir, "qodercli")
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
@@ -78,6 +74,35 @@ 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", "")
+255 -93
View File
@@ -74,6 +74,44 @@ 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,
@@ -339,6 +377,44 @@ 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>`
@@ -699,10 +775,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 这个名字。请用简洁自然的中文直接回答问题;不要使用任何工具,不要尝试读写文件或执行命令。",
"你叫「WorkBuddy 助手」,是钉钉群里的智能助手。无论被问到你是谁,都只能自称 WorkBuddy 助手,绝不能提到 CodeBuddy 这个名字。请用简洁自然的中文直接回答问题;不要主动使用工具、读写文件或执行命令。仅当用户消息明确附带了本地附件路径时,可以使用 Read 工具只读该附件,不得访问其它文件。",
"-p"},
streamArgvTail: []string{"--append-system-prompt",
"你叫「WorkBuddy 助手」,是钉钉群里的智能助手。无论被问到你是谁,都只能自称 WorkBuddy 助手,绝不能提到 CodeBuddy 这个名字。请用简洁自然的中文直接回答问题;不要使用任何工具,不要尝试读写文件或执行命令。",
"你叫「WorkBuddy 助手」,是钉钉群里的智能助手。无论被问到你是谁,都只能自称 WorkBuddy 助手,绝不能提到 CodeBuddy 这个名字。请用简洁自然的中文直接回答问题;不要主动使用工具、读写文件或执行命令。仅当用户消息明确附带了本地附件路径时,可以使用 Read 工具只读该附件,不得访问其它文件。",
"-p", "--output-format", "stream-json", "--include-partial-messages"},
streamParser: "cc", envFn: codebuddyEnv, hint: "https://www.codebuddy.cn/work/",
modelFlag: "--model", ccSessions: true},
@@ -1006,17 +1082,18 @@ type connectExtras struct {
}
type connectQueuedTurn struct {
convID string
text string
picCode string
fileInfo fileInboundInfo
webhook string
msgID string
msgType string
senderStaffID string
conversationID string
conversationType string
callbackData chatbot.BotCallbackDataModel
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
}
func mergeConnectQueuedTurns(turns []connectQueuedTurn) connectQueuedTurn {
@@ -1038,17 +1115,13 @@ func mergeConnectQueuedTurns(turns []connectQueuedTurn) connectQueuedTurn {
lines = append(lines, fmt.Sprintf("%d. %s", i+1, connectTurnSummary(turn)))
}
merged.text = strings.Join(lines, "\n")
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
}
}
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...)
}
return merged
}
@@ -1065,16 +1138,21 @@ 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 turn.picCode != "" {
if len(turn.picCodes) > 0 {
return "[图片]"
}
if turn.fileInfo.hasActionable() {
if name := strings.TrimSpace(turn.fileInfo.FileName); name != "" {
return "[文件: " + name + "]"
if len(turn.fileInfos) > 0 {
if len(turn.fileInfos) == 1 {
if name := strings.TrimSpace(turn.fileInfos[0].FileName); name != "" {
return "[附件: " + name + "]"
}
}
return "[文件]"
return fmt.Sprintf("[%d 个附件]", len(turn.fileInfos))
}
return "[空消息]"
}
@@ -1127,33 +1205,40 @@ 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)
// 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)
// 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)
}
}
// 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 == "" && picCode == "" {
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)
}
// 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. Other structured-text shapes use the generic
// extractor.
if strings.EqualFold(msgtype, "interactiveCard") {
// clean instruction. Detection is based on the payload shape so a
// renamed/new type is handled identically.
if text == "" {
text = extractInteractiveCardText(data.Content)
}
if text == "" {
@@ -1161,13 +1246,15 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
text = fallback
}
}
if text == "" {
text = rawCallbackPrompt(msgtype, data.Content)
}
}
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",
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",
msgtype, data.SenderStaffId, data.ConversationId, data.MsgId, summarizeContent(data.Content))
return []byte(""), nil
}
@@ -1198,10 +1285,10 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
sender = strings.TrimSpace(data.SenderStaffId)
}
shown := text
if shown == "" && picCode != "" {
if shown == "" && len(picCodes) > 0 {
shown = "[图片]"
} else if shown == "" && fileInfo.hasActionable() {
shown = "[文件: " + fileInfo.FileName + "]"
} else if shown == "" && len(fileInfos) > 0 {
shown = connectTurnSummary(connectQueuedTurn{fileInfos: fileInfos})
}
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)
@@ -1218,17 +1305,18 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
}
msgID := strings.TrimSpace(data.MsgId)
turn := connectQueuedTurn{
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,
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,
}
// Same-conversation agent calls never run in parallel; messages received
// while a turn is running are merged into one pending follow-up instead
@@ -1239,8 +1327,9 @@ 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
picCode := turn.picCode
fileInfo := turn.fileInfo
picCodes := turn.picCodes
fileInfos := turn.fileInfos
chatRecordLookups := turn.chatRecordLookups
webhook := turn.webhook
convID := turn.convID
msgID := turn.msgID
@@ -1293,34 +1382,96 @@ 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
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 = "(用户发来一张图片,但图片下载失败了。请告知用户图片没收到,建议补充文字描述。)"
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
}
} else if prompt == "" {
prompt = "用户发来一张图片(本地路径 " + localPath + "),请查看图片内容并回答其中的问题。"
} else {
prompt = prompt + "\n(用户同时附了一张图片,本地路径 " + localPath + ",请结合图片内容回答。)"
}
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)
}
}
if fileInfo.hasActionable() {
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 prompt == "" {
prompt = "(用户发来图片,但图片下载失败了。请告知用户图片没收到,建议补充文字描述。)"
} else {
prompt += "\n(用户同时附了一张图片,但图片下载失败,请基于现有文字回答并说明未能读取图片。)"
}
continue
}
if prompt == "" {
prompt = "用户发来一张图片(本地路径 " + localPath + "),请查看图片内容并回答其中的问题。"
} else {
prompt += "\n(用户同时附了一张图片,本地路径 " + localPath + ",请结合图片内容回答。)"
}
attachments = append(attachments, connectMediaAttachment{
LocalPath: localPath,
FileName: filepath.Base(localPath),
MediaType: "image",
})
}
for i, fileInfo := range fileInfos {
if !fileInfo.hasActionable() {
continue
}
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.downloadMessageFile(context.Background(), clientID, fileInfo.DownloadCode)
localPath, derr = mediaCli.downloadMessageFileNamed(context.Background(), clientID, fileInfo.DownloadCode, fileName)
if derr != nil {
fmt.Fprintf(os.Stderr, "[connect][media] 文件下载失败: %v\n", derr)
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)
}
}
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 = "用户发来一个文件「" + fileName + "」(本地路径 " + localPath + "),请读取文件内容并回答。"
prompt = "用户发来一个" + mediaLabel + "「" + fileName + "」(本地路径 " + localPath + ")," + successPrompt + "。"
} else {
prompt = prompt + "\n(用户同时附了一个文件「" + fileName + "」,本地路径 " + localPath + ",请结合文件内容回答。)"
prompt += "\n(用户同时附了一个" + mediaLabel + "「" + fileName + "」,本地路径 " + localPath + "," + successPrompt + "。)"
}
case fileInfo.DentryID != 0 && fileInfo.SpaceID != 0:
// API-sent file: resolve via storage API (userId→unionId,
@@ -1335,10 +1486,16 @@ 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 = "用户发来一个文件「" + fileName + "」(本地路径 " + localPath + "),请读取文件内容并回答。"
prompt = "用户发来一个" + mediaLabel + "「" + fileName + "」(本地路径 " + localPath + ")," + successPrompt + "。"
} else {
prompt = prompt + "\n(用户同时附了一个文件「" + fileName + "」,本地路径 " + localPath + ",请结合文件内容回答。)"
prompt += "\n(用户同时附了一个" + mediaLabel + "「" + fileName + "」,本地路径 " + localPath + "," + successPrompt + "。)"
}
} else {
meta := fmt.Sprintf("文件名「%s」,dentryId=%d,spaceId=%d", fileName, fileInfo.DentryID, fileInfo.SpaceID)
@@ -1355,11 +1512,22 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
}
}
default:
failure := "用户发来一个" + mediaLabel + "「" + fileName + "」,但原始内容下载失败。请明确告知用户该附件未能读取,建议重新发送或补充文字描述。"
if prompt == "" {
prompt = "(用户发来一个文件「" + fileName + "」,但文件下载失败了。请告知用户文件没收到,建议重新发送。)"
prompt = "(" + failure + ")"
} else {
prompt += "\n(" + failure + ")"
}
}
}
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)
}
@@ -1417,13 +1585,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
}
}
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)
}
reply, err := forwardConnectTurn(context.Background(), fwd, convID, prompt, attachments, onDelta)
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) {
+147
View File
@@ -5,13 +5,97 @@
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) {
@@ -179,6 +263,69 @@ 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{
+64 -3
View File
@@ -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|delete] 文档评论管理
dws doc comment [list|create|reply|update|delete|create-inline] 文档评论管理
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
dws doc export get 查询导出任务结果 (手动兜底)
dws doc import 导入本地文件为在线文档 (支持 docx / xlsx / md 等)
@@ -1951,6 +1951,67 @@ 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: "创建划词评论",
@@ -2006,7 +2067,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
commentCreateInlineCmd.Flags().String("mention", "", "被 @ 的用户 uid 列表,逗号分隔")
// comment 子命令的 --node 隐藏别名
commentNodeAliasCmds := []*cobra.Command{commentListCmd, commentCreateCmd, commentReplyCmd, commentCreateInlineCmd}
commentNodeAliasCmds := []*cobra.Command{commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd, commentCreateInlineCmd}
for _, c := range commentNodeAliasCmds {
c.Flags().String("url", "", "--node 的别名")
c.Flags().String("id", "", "--node 的别名")
@@ -2020,7 +2081,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
_ = c.Flags().MarkHidden("file-id")
}
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentCreateInlineCmd)
commentCmd.AddCommand(commentListCmd, commentCreateCmd, commentReplyCmd, commentUpdateCmd, commentDeleteCmd, commentCreateInlineCmd)
// ── permission (文档协作权限) ────────────────────────────
permissionCmd := &cobra.Command{
@@ -0,0 +1,190 @@
// 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))
}
}
+55 -3
View File
@@ -256,7 +256,7 @@ func newDriveCommand() *cobra.Command {
driveCmd := &cobra.Command{
Use: "drive",
Short: "钉盘文件管理",
Long: `钉盘:列出文件/文件夹、获取元数据、下载链接、创建文件夹、获取上传信息、提交上传。`,
Long: `钉盘:列出文件/文件夹、获取元数据和统计信息、创建快捷方式、下载、上传及管理文件。`,
RunE: groupRunE,
}
@@ -872,6 +872,56 @@ 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",
@@ -1041,7 +1091,7 @@ func newDriveCommand() *cobra.Command {
// --node 隐藏别名(保持与迁移前 doc 命令一致)
driveNodeAliasCmds := []*cobra.Command{
driveCopyCmd, driveMoveCmd, driveRenameCmd,
driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
}
for _, c := range driveNodeAliasCmds {
c.Flags().String("url", "", "")
@@ -1240,7 +1290,7 @@ func newDriveCommand() *cobra.Command {
for _, cmd := range []*cobra.Command{
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd,
driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd,
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd,
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
driveFolderCreateCmd,
} {
RegisterCrossProductAliases(cmd)
@@ -1326,6 +1376,8 @@ func newDriveCommand() *cobra.Command {
driveCopyCmd,
driveMoveCmd,
driveRenameCmd,
driveStatsCmd,
driveShortcutCmd,
drivePermissionCmd,
drivePublishCmd,
recycleCmd,
@@ -0,0 +1,150 @@
// 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))
}
}
+10 -1
View File
@@ -44,6 +44,11 @@ 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 删除指定位置的行或列
@@ -106,6 +111,8 @@ func newSheetCommand() *cobra.Command {
chartCmd := newChartCmd()
exportCmd := newExportCmd()
templateCmd := newSheetTemplateCmd()
tableCmds := newTableCmds()
pivotTableCmd := newPivotTableCmd()
batchUpdateCmd := newBatchUpdateCmd()
rangeBatchClearCmd := newRangeBatchClearCmd()
@@ -126,6 +133,7 @@ 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
@@ -140,7 +148,7 @@ func newSheetCommand() *cobra.Command {
// Add all to root
root.AddCommand(standaloneCmds...)
root.AddCommand(rangeCmd, filterCmd, filterViewCmd, condFormatCmd, chartCmd, templateCmd)
root.AddCommand(rangeCmd, filterCmd, filterViewCmd, condFormatCmd, chartCmd, templateCmd, pivotTableCmd)
// Guards for grouped parent commands
attachUnknownSubcommandGuard(root)
@@ -149,6 +157,7 @@ func newSheetCommand() *cobra.Command {
attachUnknownSubcommandGuard(filterViewCmd)
attachUnknownSubcommandGuard(condFormatCmd)
attachUnknownSubcommandGuard(chartCmd)
attachUnknownSubcommandGuard(pivotTableCmd)
return root
}
@@ -0,0 +1,285 @@
// 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)
}
})
}
}
+254
View File
@@ -0,0 +1,254 @@
// 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
}
+152
View File
@@ -0,0 +1,152 @@
// 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")
}
}
+241
View File
@@ -0,0 +1,241 @@
// 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)
}
}
}
+25 -1
View File
@@ -261,5 +261,29 @@ name 不能包含 / \ ? * [ ] : 等特殊字符,最长 100 字符。`,
deleteSheetCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
deleteSheetCmd.Flags().String("sheet-id", "", "要删除的工作表 ID 或名称 (必填)")
return []*cobra.Command{createCmd, listCmd, infoCmd, newCmd, updateSheetCmd, copySheetCmd, deleteSheetCmd}
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
}
@@ -98,6 +98,11 @@ 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")
+58
View File
@@ -0,0 +1,58 @@
// 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"))
}
+22
View File
@@ -0,0 +1,22 @@
// 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
}
+29 -4
View File
@@ -42,10 +42,16 @@ const (
DisableKeychainEnv = "DWS_DISABLE_KEYCHAIN"
)
// 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")
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")
)
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
type KeychainAccess interface {
@@ -108,6 +114,10 @@ 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()
}
@@ -129,6 +139,21 @@ 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)
+101 -21
View File
@@ -165,19 +165,7 @@ func platformDiagnose() Diagnostic {
}
}
// 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)
}
func getSystemDEKReadOnly(service string) ([]byte, error) {
if err := checkDefaultKeychainAvailable(); err != nil {
return nil, err
}
@@ -320,6 +308,71 @@ 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 {
@@ -328,11 +381,7 @@ func platformGet(service, account string) (string, error) {
}
return "", err
}
key, err := getDEKReadOnly(service)
if err != nil {
return "", err
}
plaintext, err := decryptData(data, key)
plaintext, _, err := decryptWithAvailableDEK(service, data)
if err != nil {
return "", err
}
@@ -340,11 +389,26 @@ func platformGet(service, account string) (string, error) {
}
func platformSet(service, account, data string) error {
key, err := getOrCreateDEK(service)
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
}
if err != nil {
return err
}
dir := StorageDir(service)
if err := os.MkdirAll(dir, 0700); err != nil {
return err
}
@@ -353,7 +417,6 @@ 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)
@@ -368,6 +431,23 @@ 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) {
+214
View File
@@ -16,6 +16,8 @@
package keychain
import (
"bytes"
"encoding/base64"
"errors"
"os"
"path/filepath"
@@ -222,3 +224,215 @@ 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)
}
}
}
+21
View File
@@ -157,6 +157,27 @@ 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) {
+92
View File
@@ -0,0 +1,92 @@
// 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
}
+177
View File
@@ -0,0 +1,177 @@
// 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)
}
}
@@ -0,0 +1,22 @@
// 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")
}
+85
View File
@@ -0,0 +1,85 @@
#!/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
+47 -7
View File
@@ -10,6 +10,9 @@ 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
@@ -195,14 +198,47 @@ create_skills_zip() {
rm -rf "$staging"
}
# ---------- darwin ad-hoc signing ----------
# ---------- darwin signing ----------
#
# Unsigned arm64 binaries are SIGKILL'd by amfid on Apple Silicon (macOS 11+).
# We unpack each dws-darwin-*.tar.gz, ad-hoc sign the dws binary, repack
# deterministically, and rewrite the corresponding line in checksums.txt.
# 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"
}
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
@@ -272,18 +308,22 @@ sign_darwin_archives() {
}
write_checksums() {
checksum_path="$DIST_DIR/checksums.txt"
# Append skills zip checksum to goreleaser's checksums file
# Keep this idempotent: workflow retries must not leave duplicate entries.
if [ -f "$DIST_DIR/dws-skills.zip" ]; then
printf '%s %s\n' "$(sha256_file "$DIST_DIR/dws-skills.zip")" "dws-skills.zip" >> "$checksum_path"
update_checksum_entry "dws-skills.zip" "$(sha256_file "$DIST_DIR/dws-skills.zip")"
fi
}
# ---------- main ----------
version="$(resolve_version)"
configure_darwin_signing
say "==> Ad-hoc signing darwin binaries"
if [ "$DARWIN_SIGNING_MODE" = "developer-id" ]; then
say "==> Developer ID signing darwin binaries"
else
say "==> Ad-hoc signing darwin binaries"
fi
sign_darwin_archives
say "==> Creating skills zip"
+8 -4
View File
@@ -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,18 +132,22 @@ Step 3 → 加 --yes 执行命令
dws <command-path> --help
# 例:dws calendar event list --help
# helper-only schema 查询(如 dev.*),普通产品命令不要依赖 schema 推断参数
# 结构化 schema 查询:helper-only 子树(dev.*,source=mcp:<server>)
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 命令再看 `dws schema`
- 构造 `--params` / `--json` 时不确定字段类型、必填、别名 → 先看 `dws <cmd> --help`,helper-only(dev.*)和登记的本地命令(event.*)可看 `dws schema` 取机读结构
- 参考文档和 `--help` 冲突时 → **以 `--help` 为准**,文档视为过期
`dws schema` 在静态端点模式下只保留 helper-only 子树;普通产品命令和 flag 不再通过远程 schema 动态发现。写/删操作须先向用户确认再加 `--yes`。
`dws schema` 覆盖两类命令:helper-only 子树(dev,CONTENT 从 MCP 实时取)和登记的本地命令(event,从 cobra flag 合成、source=cobra);其余普通产品命令和 flag 以 `--help` 为准。写/删操作须先向用户确认再加 `--yes`。
## 错误处理
1. 遇到错误,加 `--verbose` 重试一次
+6 -1
View File
@@ -9,10 +9,14 @@ 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
# 重置本地凭证 (Token 解密失败时使用)
# 重置全部本地凭证(仅在迁移/按 profile 恢复均失败且确认可丢弃全部登录时使用)
dws auth reset
```
@@ -27,6 +31,7 @@ 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)
+39
View File
@@ -1317,6 +1317,45 @@ 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 (机器人管理)
#### 搜索【我创建的】机器人 — 仅返回当前用户自己创建的机器人
+29 -7
View File
@@ -338,6 +338,22 @@ 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` 字段选择对应链路:
@@ -893,18 +909,24 @@ dws doc comment create --node <DOC_ID> --content "这里需要补充数据来源
# 再将 userId 传入 --mention
dws doc comment create --node <DOC_ID> --content "请确认这部分内容" --mention <userId1>,<userId2> --format json
# 4. 回复某条评论(commentKey 从 list 或 create 返回中获取)
# 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 返回中获取)
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已修改" --format json
# 5. 用表情回复评论
# 7. 用表情回复评论
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
# 6. 创建划词评论(针对文档中某段选中文本)
# 8. 创建划词评论(针对文档中某段选中文本)
# 先获取块列表: 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
# 7. 创建划词评论并附带引用原文 + @ 相关人
# 9. 创建划词评论并附带引用原文 + @ 相关人
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 ──
@@ -926,10 +948,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 的 --comment-key |
| `comment create` / `comment create-inline` | `commentKey` | comment reply 的 --comment-key |
| `comment list` | `commentList[].commentKey` | comment reply/update/delete 的 --comment-key |
| `comment create` / `comment create-inline` | `commentKey` | comment reply/update/delete 的 --comment-key |
| `block list` | `blockId` + 文本内容 | comment create-inline 的 --block-id 及 --start/--end 计算 |
| `contact user search` | `userId` | comment create / create-inline / reply 的 --mention |
| `contact user search` | `userId` | comment create / create-inline / reply / update 的 --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 / create-inline)
# doc comment(文档评论:list / create / reply / update / delete / create-inline)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
@@ -61,6 +61,39 @@ 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(创建划词评论)
```
@@ -83,7 +116,7 @@ Flags:
## 关键说明
- `--mention` 接受 `userId` 列表(逗号分隔),需要先用 `dws contact user search --query "<姓名>"` 拿到 userId。
- `--comment-key` 是 13 位毫秒时间戳 + 32 位 UUID 的拼接字符串,从 `list` / `create` / `create-inline` 返回中提取。
- `--comment-key` 是评论唯一标识,从 `list` / `create` / `create-inline` 返回中提取,可用于 `reply` / `update` / `delete`。
- 划词评论的 `--start` / `--end` 是块内文本字符偏移量,从 0 开始;通过 [`./doc-block.md`](./doc-block.md) `block list` 取 `paragraph.text` 后人工或脚本计算。
- `reply` 加 `--emoji` 时 `--content` 填表情名称(如 `比心`、`赞`),不是文字内容。
@@ -91,9 +124,9 @@ Flags:
| 从返回中提取 | 用于 |
|-------------|------|
| `commentList[].commentKey` | `comment reply` 的 `--comment-key` |
| `comment create` `commentKey` | `comment reply` 的 `--comment-key` |
| `comment create-inline` `commentKey` | `comment reply` 的 `--comment-key` |
| `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` |
| [`./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` |
@@ -121,6 +154,12 @@ 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
# 计算偏移后:
+28 -1
View File
@@ -15,6 +15,8 @@ dws drive list --help
dws drive search --help
dws drive upload --help
dws drive download --help
dws drive stats --help
dws drive shortcut --help
```
规则:
@@ -156,6 +158,28 @@ 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` 字段选择对应链路。
@@ -312,6 +336,8 @@ Flags:
用户说"钉盘空间/团队文件/有哪些空间/空间列表/团队文件列表" → `wiki space list --type orgSpace`(`drive list-spaces` 已 deprecated)
用户说"搜索钉盘文件/钉盘里找个文件/查找某个钉盘文件/钉盘中搜索" → `search`
用户说"文件详情/文件信息" → `info`
用户说"文件阅读量/编辑量/评论数/下载数/节点统计" → `stats`
用户说"给文件创建快捷方式/放一个链接到目标文件夹" → `shortcut`
用户说"下载文件" → `download` 指定 `--output` 保存到本地
用户说"新建文件夹/创建目录" → `mkdir`(钉盘空间)/ `wiki node create --type folder`(文档空间)
用户说"上传文件/传文件到钉盘" → `upload`(首选此命令,自动完成三步流程)
@@ -492,7 +518,7 @@ dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --f
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / download / delete 的 --node;list / mkdir 的 --folder;`drive copy/move` 的 --node 或 --folder |
| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / stats / shortcut / download / delete 的 --node;list / mkdir 的 --folder;`drive copy/move/shortcut` 的 --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 根目录时) |
@@ -520,6 +546,7 @@ dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --f
- `--file-name` 必须包含扩展名(如 `report.pdf`)
- `download` 需要指定 `--output`,CLI 会把文件保存到本地路径或目录
- 文件名规则:头尾不能有空格;不能含 `*`、`"`、`<`、`>`、`|`、制表符;不能以 `.` 结尾
- `shortcut` 会创建新节点,执行后必须通过 `drive list` 回读确认目标位置;`stats` 为只读命令
## 自动化脚本
+60 -51
View File
@@ -1,92 +1,101 @@
# dws event — 个人消息事件
通过个人 Stream 长连接监听当前用户收到的钉钉消息事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。实时监听、自动回复、订阅事件都必须使用 `dws event consume`,不要写脚本轮询消息历史。
监听当前用户收到的钉钉消息事件,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 而失败。
## Core commands
| Command | Purpose |
|---|---|
| `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` | 清理当前身份下本地记录的全部个人订阅 |
| `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 + 位置参数)。
## 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
| 用户说 | 下一步 |
|---|---|
| "监听有人 @ 我的消息" | `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>` |
| 监听有人 @ 我 | 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>` |
多候选必须让用户确认。缺少必填 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 出现 `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`。
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` 自动退出。
## Common commands
## Commands
```bash
dws event schema user_im_message_receive_at
dws event schema user_im_message_receive_o2o
dws event schema user_im_message_receive_group
```
```bash
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
```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>
dws event stop --all
```
`status` 的 `Consumers` 表展示本地 consume 的 PID、事件码、`subscribe_id` 和 received/dropped 计数,可用于确认监听是否仍在 personal bus 上。
## Subprocess contract
裸 `dws event stop` 不会取消订阅;批量清理必须显式使用 `dws event stop --all`。Ctrl+C、`--duration`、`--max-events` 只结束本地前台消费进程,不等价于取消服务端订阅。
- 就绪:连上后 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。
## Output parsing
- 推荐 `-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` 仅用于服务端联调,正常消费不要使用。
- 用 `-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、不打印事件),去掉。
## Full reference
+12 -3
View File
@@ -81,6 +81,8 @@ 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` | 清除区域(值/格式/全部) |
@@ -91,11 +93,17 @@ 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` | 合并单元格 |
@@ -153,9 +161,10 @@ dws sheet filter-view --help
| 主题 | 子文档 | 覆盖命令 |
|------|--------|---------|
| 表格与工作表管理 | [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-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-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 |
@@ -0,0 +1,67 @@
# 透视表管理
## 使用场景
- 用户要按部门、月份等维度汇总数据 → `pivot-table create`
- 用户要查看现有透视表或取得 `pivotTableId` → `pivot-table list`
- 用户要调整行列、聚合方式或显示选项 → `pivot-table update`
- 用户明确要求删除透视表 → `pivot-table delete`(不可逆,必须先确认)
透视表是原生对象,会随源数据更新;不要用一组普通汇总公式冒充透视表。
## 列出与查看
```bash
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 <PIVOT_TABLE_ID>
```
不传 `--pivot-table-id` 时列出工作表中的全部透视表;传入时返回单个透视表详情。
## 创建
```bash
dws sheet pivot-table create --node <NODE_ID> \
--source "'Data'!A1:D100" \
--properties '{
"rows":[{"field":"部门"}],
"columns":[{"field":"月份"}],
"values":[{"field":"销售额","summarize_by":"sum"}],
"show_row_grand_total":true
}'
```
可选参数:
- `--target-sheet-id`:将透视表放到指定工作表;不传时由服务端创建目标工作表。
- `--target-position`:目标起始单元格,例如 `B2`。
- `--properties @pivot.json`:从文件读取配置。
`--source` 必须包含工作表前缀并覆盖表头和数据,例如 `"'Data'!A1:D100"`。创建时 `properties.values` 必须是非空数组。
## properties 结构
- `rows` / `columns` / `filters`:字段数组,每项至少包含非空 `field`。
- `values`:聚合字段数组,每项至少包含 `field`;创建时至少一项。
- `summarize_by`:`sum`、`count`、`average`、`max`、`min`、`product`、`count_numbers`、`std_dev`、`std_dev_p`、`var`、`var_p`、`distinct`、`median`。
- `collapse`:字段折叠配置,可为对象或数组。
- 显示选项:`show_row_grand_total`、`show_col_grand_total`、`show_subtotals`、`repeat_row_labels`。
## 更新
```bash
dws sheet pivot-table update --node <NODE_ID> --sheet-id <SHEET_ID> \
--pivot-table-id <PIVOT_TABLE_ID> \
--properties '{"show_subtotals":false}'
```
Open 接口支持部分更新,因此更新显示选项时不需要重复传入 `values`。更新行列或聚合字段时,应先用 `list --pivot-table-id` 读取当前配置,再传入需要变更的字段。
## 删除
```bash
dws sheet pivot-table delete --node <NODE_ID> --sheet-id <SHEET_ID> \
--pivot-table-id <PIVOT_TABLE_ID> --yes
```
删除不可恢复。Agent 必须先展示目标文档、工作表和 `pivotTableId`,获得用户明确同意后才能附加 `--yes`。
@@ -3,6 +3,7 @@
## 使用场景
用户说"读数据/看表格内容":
- 需要列名、二维数据、dtype 和 number format,或准备与 `table-put` 配套处理 → `table-get`
- 快速查看纯值数据、批量处理、大表分批读 → `csv-get`(token 消耗低,防爆保护)
- 需要结构化信息(值+样式+数据验证+富文本+单元格级超链接)、查看公式或原始值 → `range read`
- 需要查看合并单元格 / 表头合并结构 → `sheet info`,读取返回的 `mergedRanges`;不要在 `csv-get` 或 `range read` 里找合并信息
@@ -11,6 +12,7 @@
| 读取目的 | 推荐命令 | 说明 |
|---------|---------|------|
| DataFrame 风格结构化读取 | `table-get` | 返回 columns / data / dtypes / formats,可与 table-put 配套 |
| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 maxChars 防爆 |
| 查看数据验证配置(下拉/复选框) | `range read` | 返回 per-cell 结构,含 dataValidation |
| 查看单元格样式(背景色/字体/对齐等) | `range read` | 返回 per-cell 结构,含 cellStyles(仅显式设置的样式) |
@@ -21,6 +23,23 @@
## 命令详细参考
### 读取结构化 table 数据
```
Usage:
dws sheet table-get [flags] # 别名: dws sheet table-read
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 <SHEET_ID> --no-header
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称
--range string 读取范围,A1 表示法;可带 sheet 前缀
--no-header 首行不作为表头,自动生成 col1/col2/...
```
`table-get` 面向结构化数据处理,返回 `columns`、二维 `data`、`dtypes` 和可用时的 `formats`。未传 `--sheet-id` / `--range` 时由服务端读取目标文档中的 table 数据;大表仍应通过 `--range` 限定范围。需要单元格样式、公式、数据验证或富文本细节时,继续使用 `range read`。
### 以 CSV 格式读取工作表数据(推荐)
```
Usage:
@@ -29,6 +29,10 @@
用户说"删除工作表/移除工作表/删掉这个Sheet":
- 删除工作表 → `delete-sheet`(不可逆操作,执行前必须向用户确认)
用户说"显示网格线/隐藏网格线/去掉单元格网格":
- 显示网格线 → `show-gridline`
- 隐藏网格线 → `hide-gridline`
## 命令详细参考
### 创建钉钉表格文档
@@ -165,6 +169,18 @@ Flags:
- 不能删除隐藏的工作表(需先通过 `sheet update --hidden false` 取消隐藏再删除)
- 不能删除最后一个可见工作表(至少保留一个可见工作表)
### 显示或隐藏网格线
```
Usage:
dws sheet show-gridline --node <NODE_ID> --sheet-id <SHEET_ID>
dws sheet hide-gridline --node <NODE_ID> --sheet-id <SHEET_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
```
这两个命令只修改指定工作表的网格线显示状态,不修改单元格内容或边框样式。执行后可用同一命令反向恢复。
## 核心工作流
```bash
@@ -3,6 +3,7 @@
## 使用场景
用户说"写数据/填表/更新单元格/写入公式":
- 从 DataFrame/结构化对象一次写入一个或多个工作表 → `table-put`
- 更新数据 → `range update`
- 【强制】`--sheet-id` 必填:即使是单工作表也不能省略,不要参照 `range read` 的默认行为;未知时先执行 `dws sheet list --node <NODE_ID> --format json` 获取 `sheetId`,禁止凭空臆测为 `Sheet1`、`sheet1`、`0`、`default` 等
- 注意:如果用户的目的是替换文本、移动行列、追加空行空列、清空区域、排序、填充、复制区域或移动区域,请勿使用 `range update`,必须使用对应的专用命令(`replace`/`move-dimension`/`add-dimension`/`range clear`/`range sort`/`range fill`/`range copy-to`/`range move-to`)
@@ -16,23 +17,38 @@
- 与 `range update` 的区别:`csv-put` 接受 CSV 文本直接写入,无需手动构造二维 JSON 数组;适合大批量纯值写入
- 与 `append` 的区别:`csv-put` 写入指定位置(--start-cell),`append` 在末尾追加
**三种写入命令能力对比**:
**四种写入命令能力对比**:
| 能力 | `range update` | `append` | `csv-put` |
|------|---------------|----------|-----------|
| 公式(`=` 开头) | 支持 | 不支持 | 不支持(当文本) |
| 单元格级超链接(`hyperlink`) | 支持 | 不支持 | 不支持 |
| 富文本(片段链接/附件/图片) | 支持 | 不支持 | 不支持 |
| richText 片段样式(bold/color) | 支持 | 不支持 | 不支持 |
| `cellStyles`(背景色/字号/对齐等 cell-level 样式) | 支持 | 不支持 | 不支持 |
| `{}` 跳过(保留原值) | 支持 | 不适用 | 不适用 |
| `dataValidation`(下拉/复选框) | 支持 | 不支持 | 不支持 |
| 原始值(纯数字/字符串) | 支持 | 支持 | 支持 |
| 自动定位末尾 | 不支持 | 支持 | 不支持 |
| 自动扩容行列 | 不支持 | 支持 | 支持 |
| 能力 | `table-put` | `range update` | `append` | `csv-put` |
|------|-------------|----------------|----------|-----------|
| 多工作表结构化写入 | 支持 | 不支持 | 不支持 | 不支持 |
| columns / dtypes / formats | 支持 | 不支持 | 不支持 | 不支持 |
| 公式(`=` 开头) | 按 dtype/底层能力 | 支持 | 不支持 | 不支持(当文本) |
| 单元格级超链接(`hyperlink`) | 不使用此命令 | 支持 | 不支持 | 不支持 |
| 富文本(片段链接/附件/图片) | 不使用此命令 | 支持 | 不支持 | 不支持 |
| 原始值(纯数字/字符串) | 支持 | 支持 | 支持 | 支持 |
| 自动定位末尾 | mode=append | 不支持 | 支持 | 不支持 |
| 自动扩容行列 | 支持 | 不支持 | 支持 | 支持 |
## 命令详细参考
### 写入结构化 table 数据
```
Usage:
dws sheet table-put [flags] # 别名: dws sheet table-write
Example:
dws sheet table-put --node <NODE_ID> \
--sheets '[{"name":"Data","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 -
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheets string sheet table JSON、@文件路径 或 - 表示 stdin (必填)
```
`--sheets` 接受 JSON 数组、`{"sheets":[...]}` 包装对象或单个 sheet 对象,CLI 会统一转换为非空数组。每个 sheet 至少需要 `columns`,并提供 `sheetId` 或 `name`;`name` 必须非空且少于 31 个字符。写入后必须用 `table-get` 回读验证。`table-get/table-put` 不支持放入 `sheet batch-update`。
### 更新工作表指定区域内容
```
Usage:
+3 -1
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-chat
description: 钉钉群聊与消息。Use when 用户提到 发消息/单聊/群聊/建群/拉人进群/改群名/搜索群/群成员管理/@消息/撤回消息/机器人群发/Webhook通知/发图片或文件到群。Distinct from dingtalk-ding(紧急DING消息/短信/电话)、dingtalk-mail(邮件)、dingtalk-edu-group(班级群)。命令前缀:dws chat。
description: 钉钉群聊与消息。Use when 用户提到 发消息/单聊/群聊/建群/拉人进群/改群名/搜索群/群成员管理/@消息/收藏消息/撤回消息/机器人群发/Webhook通知/发图片或文件到群。Distinct from dingtalk-ding(紧急DING消息/短信/电话)、dingtalk-mail(邮件)、dingtalk-edu-group(班级群)。命令前缀:dws chat。
cli_version: ">=0.2.14"
metadata:
category: product
@@ -33,6 +33,8 @@ metadata:
| "改群名" / "踢人" | `dws chat group rename` / `dws chat group members remove --yes`(踢人不可逆,确认目标后加 --yes;踢群主会被 CLI 拦截,需先 `transfer-owner`)|
| "@我消息" | `dws chat message list-mentions` |
| "查群聊记录" | `dws chat message list` |
| "收藏/取消收藏这条消息" | `dws chat message add-favorite` / `dws chat message remove-favorite`(均需 `openMessageId` 和 `openConversationId`)|
| "查看我收藏的消息" | `dws chat message list-favorites`(默认 `--cursor 0 --size 20`)|
| "用机器人发消息" | `dws chat message send-by-bot --robot-code <code> --group <id> --title "<标题>" --text "<内容>"` |
| "Webhook 推一条" | `dws chat message send-by-webhook --token <token> --title "<标题>" --text "<内容>"` |
| "撤回我发的消息" | `dws chat message recall`(撤回当前用户发送的消息)|
@@ -13,3 +13,4 @@
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId> --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
| search-common-group | `chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0`(`--match-mode AND`=全在/`OR`=任一在,翻页:`hasMore=true` 时用 `nextCursor`)<br>用户说"我和XX的共同群" → nicks 包含"我"时,需先 `contact user get-self` 取自己昵称再拼接 |
| focus-messages | **零参数一行命令**:`chat message list-focused --limit 50`(拉特别关注人发的消息聚合)<br>触发 query:`"我特别关注的人最近发了什么消息"`、`"关注的人最近聊了啥"`、`"星标联系人最近的动态"`<br>**强消歧**:query 含动词【发/说/聊/讲】或名词【消息/聊天/动态】 → **必须**走本命令,**不要**先去拉 `contact relation list-my-followings`;仅当用户终点是"人员列表"(如"我关注了谁")才走 `relation list-my-followings`(详见 `dingtalk-contact/references/contact.md` 意图判断)<br>翻页:`hasMore=true` 时用 `nextCursor` 作为下次 `--cursor`<br>按人精控(可选):先 `contact relation list-my-followings` 取 `openDingTalkId`,再 `chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start <ISO> --end <ISO>` |
| favorite-message | 收藏:先从消息查询结果取得 `openMessageId` 和 `openConversationId`,再执行 `chat message add-favorite --open-message-id <id> --open-conversation-id <cid>`<br>取消收藏:对应执行 `remove-favorite`,不会删除原消息<br>查询收藏:`chat message list-favorites [--cursor N] [--size 20]`,`hasMore=true` 时继续传 `nextCursor` |
@@ -1282,6 +1282,45 @@ 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 (机器人管理)
#### 搜索【我创建的】机器人 — 仅返回当前用户自己创建的机器人
+3 -1
View File
@@ -47,6 +47,8 @@ metadata:
| "读文档内容" | `dws doc read --node <nodeId>` |
| "更新文档内容 / 分块追加" | `dws doc update --node <nodeId> --content "<分块>" --mode append` |
| "删除块" | `dws doc block delete`(需用户确认) |
| "更新文档评论" | `dws doc comment update --node <nodeId> --comment-key <key> --content "<内容>"` |
| "删除文档评论" | `dws doc comment delete --node <nodeId> --comment-key <key> --yes`(需用户确认) |
## 评测/多步文档短路径
@@ -60,7 +62,7 @@ metadata:
## 危险操作
`block delete` 不可逆,必须确认再加 `--yes`。
`block delete` 和 `comment delete` 不可逆,必须确认再加 `--yes`。
## 跨产品协作
+29 -7
View File
@@ -338,6 +338,22 @@ 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` 字段选择对应链路:
@@ -893,18 +909,24 @@ dws doc comment create --node <DOC_ID> --content "这里需要补充数据来源
# 再将 userId 传入 --mention
dws doc comment create --node <DOC_ID> --content "请确认这部分内容" --mention <userId1>,<userId2> --format json
# 4. 回复某条评论(commentKey 从 list 或 create 返回中获取)
# 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 返回中获取)
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已修改" --format json
# 5. 用表情回复评论
# 7. 用表情回复评论
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
# 6. 创建划词评论(针对文档中某段选中文本)
# 8. 创建划词评论(针对文档中某段选中文本)
# 先获取块列表: 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
# 7. 创建划词评论并附带引用原文 + @ 相关人
# 9. 创建划词评论并附带引用原文 + @ 相关人
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 ──
@@ -926,10 +948,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 的 --comment-key |
| `comment create` / `comment create-inline` | `commentKey` | comment reply 的 --comment-key |
| `comment list` | `commentList[].commentKey` | comment reply/update/delete 的 --comment-key |
| `comment create` / `comment create-inline` | `commentKey` | comment reply/update/delete 的 --comment-key |
| `block list` | `blockId` + 文本内容 | comment create-inline 的 --block-id 及 --start/--end 计算 |
| `contact user search` | `userId` | comment create / create-inline / reply 的 --mention |
| `contact user search` | `userId` | comment create / create-inline / reply / update 的 --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 / create-inline)
# doc comment(文档评论:list / create / reply / update / delete / create-inline)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
@@ -61,6 +61,39 @@ 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(创建划词评论)
```
@@ -83,7 +116,7 @@ Flags:
## 关键说明
- `--mention` 接受 `userId` 列表(逗号分隔),需要先用 `dws contact user search --query "<姓名>"` 拿到 userId。
- `--comment-key` 是 13 位毫秒时间戳 + 32 位 UUID 的拼接字符串,从 `list` / `create` / `create-inline` 返回中提取。
- `--comment-key` 是评论唯一标识,从 `list` / `create` / `create-inline` 返回中提取,可用于 `reply` / `update` / `delete`。
- 划词评论的 `--start` / `--end` 是块内文本字符偏移量,从 0 开始;通过 [`./doc-block.md`](./doc-block.md) `block list` 取 `paragraph.text` 后人工或脚本计算。
- `reply` 加 `--emoji` 时 `--content` 填表情名称(如 `比心`、`赞`),不是文字内容。
@@ -91,9 +124,9 @@ Flags:
| 从返回中提取 | 用于 |
|-------------|------|
| `commentList[].commentKey` | `comment reply` 的 `--comment-key` |
| `comment create` `commentKey` | `comment reply` 的 `--comment-key` |
| `comment create-inline` `commentKey` | `comment reply` 的 `--comment-key` |
| `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` |
| [`./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` |
@@ -121,6 +154,12 @@ 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
# 计算偏移后:
+3
View File
@@ -33,6 +33,8 @@ metadata:
| "最近访问 / 最近编辑的文档" | `dws drive recent` |
| "钉盘目录树" | `python scripts/drive_tree_list.py --depth 2` |
| "查文件元数据" | `dws drive info --node <fileId>` |
| "查阅读/编辑/评论/下载等节点统计" | `dws drive stats --node <fileId>` |
| "创建文件快捷方式" | `dws drive shortcut --node <fileId> [--folder <targetFolderId>] [--workspace <workspaceId>]` |
| "下载文件" | `dws drive download --node <fileId> --output <path>` |
| "上传本地文件" | `dws drive upload --file ./report.pdf [--folder <fileId>]` |
| "建文件夹" | `dws drive mkdir --name "<名称>" [--folder <fileId>]` |
@@ -49,6 +51,7 @@ metadata:
- `rename` 的 `--name` **只传主名,不带扩展名**;服务端会按原扩展名自动补后缀,带了扩展名会变成双扩展名(如 `报告.txt` → `报告.txt.txt`)。
- `drive download` 需要 `--output` 指定本地保存路径或目录;不要省略必填输出位置。
- 删除、覆盖、移动、公开(publish set/unset)等破坏性操作必须先确认;上传、创建文件夹、下载后要读回或列目录验证。
- `shortcut` 会创建新节点,执行后必须用 `drive list` 回读目标位置;`stats` 是只读操作。
- 所有 `dws drive` 命令加 `--format json`。
## 跨产品协作
@@ -15,6 +15,8 @@ dws drive list --help
dws drive search --help
dws drive upload --help
dws drive download --help
dws drive stats --help
dws drive shortcut --help
```
规则:
@@ -156,6 +158,28 @@ 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` 字段选择对应链路。
@@ -312,6 +336,8 @@ Flags:
用户说"钉盘空间/团队文件/有哪些空间/空间列表/团队文件列表" → `wiki space list --type orgSpace`(`drive list-spaces` 已 deprecated)
用户说"搜索钉盘文件/钉盘里找个文件/查找某个钉盘文件/钉盘中搜索" → `search`
用户说"文件详情/文件信息" → `info`
用户说"文件阅读量/编辑量/评论数/下载数/节点统计" → `stats`
用户说"给文件创建快捷方式/放一个链接到目标文件夹" → `shortcut`
用户说"下载文件" → `download` 指定 `--output` 保存到本地
用户说"新建文件夹/创建目录" → `mkdir`(钉盘空间)/ `wiki node create --type folder`(文档空间)
用户说"上传文件/传文件到钉盘" → `upload`(首选此命令,自动完成三步流程)
@@ -492,7 +518,7 @@ dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --f
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / download / delete 的 --node;list / mkdir 的 --folder;`drive copy/move` 的 --node 或 --folder |
| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / stats / shortcut / download / delete 的 --node;list / mkdir 的 --folder;`drive copy/move/shortcut` 的 --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 根目录时) |
@@ -520,6 +546,7 @@ dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --f
- `--file-name` 必须包含扩展名(如 `report.pdf`)
- `download` 需要指定 `--output`,CLI 会把文件保存到本地路径或目录
- 文件名规则:头尾不能有空格;不能含 `*`、`"`、`<`、`>`、`|`、制表符;不能以 `.` 结尾
- `shortcut` 会创建新节点,执行后必须通过 `drive list` 回读确认目标位置;`stats` 为只读命令
## 自动化脚本
+21 -7
View File
@@ -7,6 +7,13 @@ description: 钉钉个人消息事件长连接监听、订阅与消费,输出
只使用 `dws event consume` 建立个人消息事件长连接。用户要求实时监听、订阅、自动回复或驱动 Agent 时,不要写轮询脚本,不要用消息历史查询模拟事件。
## 运行方式
- bus 后台进程持有对钉钉的个人 Stream 长连;consume 从 bus 读事件、按 NDJSON 打到 stdout。consume 只读,不发消息(回复用 `dws chat message send`)。
- 没有 bus 时 consume 自动拉起;通常只跑 consume。
- 一个组织一个 bus,可同时跑;同组织内多个 consume 共享一个 bus。
- 非默认组织加全局 `--profile <corpId 或 profile 名>`;漏传会退回默认 profile 而失败。
## Core commands
| Command | Purpose |
@@ -18,6 +25,8 @@ description: 钉钉个人消息事件长连接监听、订阅与消费,输出
| `dws event stop <subscribe_id>` | 取消个人订阅并停止对应本地消费 |
| `dws event stop --all` | 清理当前身份下本地记录的全部个人订阅 |
区分两个 schema:`dws event schema <event_key>` 查事件输出字段;`dws schema "event consume"` 查 consume 命令入参(机读结构,source=cobra,含 parameters + 位置参数)。
## Event catalog
| 事件码 | 场景 | 必填参数 |
@@ -37,25 +46,30 @@ description: 钉钉个人消息事件长连接监听、订阅与消费,输出
- 用户只给单聊对端人名时,先运行 `dws aisearch person --keyword "<name>" --dimension name --format json` 解析 userId;多候选必须让用户确认。
- 用户只给群名时,先运行 `dws chat search --query "<group>" --format json` 解析 openConversationId;多候选必须让用户确认。
- 正常 Agent 消费使用 `-f ndjson`。抓一条样本可用 `--max-events 1 -f json`。
- 监听非默认组织时带 `--profile <corpId 或 profile 名>`;漏传会退回默认 profile 而失败。
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤):边监听边 `dws chat message send` 回复不成环;测试投递用别人 / 机器人发(自发会看到 0 事件)。
- `--debug-raw-events` 只用于联调确认服务端推送是否到达本地连接;正常任务不要使用。
- 排查:consume 报 bus 启动失败 → 报错已带真实原因,先查 `dws --profile <x> auth status`(非默认组织带对 `--profile`);本地日志见 `~/.dws/events/<edition>/personal_stream/<hash>/bus.log`(`hash` 见 `dws event status` 的 Workdir);有残留用 `dws event stop --all` 清理。看着"挂住"无输出多是误加了 `--foreground`(那是跑 bus、不打印事件),去掉即可。
## Call flow
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。
3. 启动 `dws event consume <event_key> ... -f ndjson`,阻塞等 stderr 出现固定就绪行 `[event] ready event_key=<key> ...` 后再开始读 stdout(不要用 sleep 猜)。
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` 自动退出。
## Subprocess contract
- `event consume` 是阻塞式长连接命令。stdout 只处理事件;stderr 只处理状态、debug 和错误。
- 不要使用 `--quiet`,否则 Agent 会看不到建联状态和排障信息。
- 无界监听需要外部进程管理;有界自测优先用 `--max-events N` 或 `--duration 10m`。
- 不要 `kill -9` 消费进程。Ctrl+C、duration、max-events 只结束本地前台进程;取消订阅必须使用 `dws event stop <subscribe_id>`。
- 不要运行裸 `dws event stop`;批量清理必须显式使用 `dws event stop --all`。
- 一个 consume 对应一个事件订阅。监听多个对象时启动多个 consume;底层本机连接可以复用,但输出按 `subscribe_id` 隔离。
- `event consume` 阻塞式长连接。stdout 只出事件;stderr 只出状态 / debug / 错误。
- 就绪:连上后 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 行。
- stdin 关闭 = 停机:仅当 stdin 是管道且未设 `--max-events/--duration` 时生效;交互终端和 `< /dev/null` 不触发。用管道 stdin 又要常驻就喂 `< <(tail -f /dev/null)`。
- 无界监听需外部进程管理;有界自测用 `--max-events N` 或 `--duration 10m`。
- 订阅清理:本次新建的订阅任意干净退出即自动退订;`--subscribe-id` 复用的保留,`--ephemeral` 强制退订。优雅停用 SIGTERM、关 stdin,或外部 `dws event stop <subscribe_id>`。不要 `kill -9`(跳过退订、泄漏服务端订阅)。
- 批量清理用 `dws event stop --all`。
- 一 consume 一事件订阅;监听多个对象起多个 consume,本机连接可复用,输出按 `subscribe_id` 隔离。
## Examples
@@ -64,7 +64,7 @@ dws event consume user_im_message_receive_group \
| `user_im_message_receive_o2o` | `--user <userId> --duration 10m -f ndjson` | 让对端用户给当前登录用户发送单聊消息 |
| `user_im_message_receive_group` | `--group <openConversationId> --duration 10m -f ndjson` | 让任意用户在该群发送消息 |
stderr 出现 `connected bus pid=...` 表示本地 consume 已连接到事件 bus。stdout 每行是一个事件 JSON。
stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid>` 表示本地 consume 已连接到事件 bus;父进程等这行再读 stdout。stdout 每行是一个事件 JSON。
## Runtime flags
@@ -154,7 +154,7 @@ dws event stop --all
## Troubleshooting
- 没有输出:先确认 stderr 已出现 `connected bus pid=...`。
- 没有输出:先确认 stderr 已出现 `[event] ready event_key=...`。
- 参数缺失:单聊必须有对端 ID,群消息必须有 openConversationId。
- 收到非预期消息:检查 stdout 的 `subscribe_id` 是否等于当前命令创建/复用的订阅 ID。
- 需要判断服务端是否推到当前连接:临时加 `--debug --debug-raw-events`,排查后去掉。
+5
View File
@@ -35,6 +35,11 @@ metadata:
| "新建工作表" | `dws sheet new --node <nodeId或URL> --name "<sheet名>"` |
| "读取单元格" | `dws sheet range read --node <nodeId或URL> --sheet-id <sheetId> --range A1:B2` |
| "写入单元格" | `dws sheet range update --node <nodeId或URL> --sheet-id <sheetId> --range A1:B2 --values '[[..]]'` |
| "结构化读取 / DataFrame 读取" | `dws sheet table-get --node <nodeId或URL> [--sheet-id <sheetId>]` |
| "结构化写入 / DataFrame 写入" | `dws sheet table-put --node <nodeId或URL> --sheets '<JSON>'` |
| "创建透视表 / 数据透视" | `dws sheet pivot-table create --node <nodeId或URL> --source "'Sheet1'!A1:D100" --properties '<JSON>'` |
| "查看透视表" | `dws sheet pivot-table list --node <nodeId或URL> --sheet-id <sheetId>` |
| "显示 / 隐藏网格线" | `dws sheet show-gridline|hide-gridline --node <nodeId或URL> --sheet-id <sheetId>` |
| "追加一行" | `dws sheet append --node <nodeId或URL> --sheet-id <sheetId> --values '[[..]]'` |
| "查找 / 替换" | `dws sheet find --node <nodeId或URL> --sheet-id <sheetId> --find "<关键词>"` / `dws sheet replace --node <nodeId或URL> --sheet-id <sheetId> --find "<旧值>" --replacement "<新值>"` |
| "插入图片到单元格" | `dws sheet write-image --node <nodeId或URL> --sheet-id <sheetId> --range A1:A1 --file <本地图片路径>`(CLI 自动上传本地图片并写入单元格;没有 --resource-id/--resource-url 这两个 flag) |
@@ -81,6 +81,8 @@ 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` | 清除区域(值/格式/全部) |
@@ -91,11 +93,17 @@ 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` | 合并单元格 |
@@ -153,9 +161,10 @@ dws sheet filter-view --help
| 主题 | 子文档 | 覆盖命令 |
|------|--------|---------|
| 表格与工作表管理 | [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-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-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 |
@@ -0,0 +1,67 @@
# 透视表管理
## 使用场景
- 用户要按部门、月份等维度汇总数据 → `pivot-table create`
- 用户要查看现有透视表或取得 `pivotTableId` → `pivot-table list`
- 用户要调整行列、聚合方式或显示选项 → `pivot-table update`
- 用户明确要求删除透视表 → `pivot-table delete`(不可逆,必须先确认)
透视表是原生对象,会随源数据更新;不要用一组普通汇总公式冒充透视表。
## 列出与查看
```bash
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 <PIVOT_TABLE_ID>
```
不传 `--pivot-table-id` 时列出工作表中的全部透视表;传入时返回单个透视表详情。
## 创建
```bash
dws sheet pivot-table create --node <NODE_ID> \
--source "'Data'!A1:D100" \
--properties '{
"rows":[{"field":"部门"}],
"columns":[{"field":"月份"}],
"values":[{"field":"销售额","summarize_by":"sum"}],
"show_row_grand_total":true
}'
```
可选参数:
- `--target-sheet-id`:将透视表放到指定工作表;不传时由服务端创建目标工作表。
- `--target-position`:目标起始单元格,例如 `B2`。
- `--properties @pivot.json`:从文件读取配置。
`--source` 必须包含工作表前缀并覆盖表头和数据,例如 `"'Data'!A1:D100"`。创建时 `properties.values` 必须是非空数组。
## properties 结构
- `rows` / `columns` / `filters`:字段数组,每项至少包含非空 `field`。
- `values`:聚合字段数组,每项至少包含 `field`;创建时至少一项。
- `summarize_by`:`sum`、`count`、`average`、`max`、`min`、`product`、`count_numbers`、`std_dev`、`std_dev_p`、`var`、`var_p`、`distinct`、`median`。
- `collapse`:字段折叠配置,可为对象或数组。
- 显示选项:`show_row_grand_total`、`show_col_grand_total`、`show_subtotals`、`repeat_row_labels`。
## 更新
```bash
dws sheet pivot-table update --node <NODE_ID> --sheet-id <SHEET_ID> \
--pivot-table-id <PIVOT_TABLE_ID> \
--properties '{"show_subtotals":false}'
```
Open 接口支持部分更新,因此更新显示选项时不需要重复传入 `values`。更新行列或聚合字段时,应先用 `list --pivot-table-id` 读取当前配置,再传入需要变更的字段。
## 删除
```bash
dws sheet pivot-table delete --node <NODE_ID> --sheet-id <SHEET_ID> \
--pivot-table-id <PIVOT_TABLE_ID> --yes
```
删除不可恢复。Agent 必须先展示目标文档、工作表和 `pivotTableId`,获得用户明确同意后才能附加 `--yes`。
@@ -3,6 +3,7 @@
## 使用场景
用户说"读数据/看表格内容":
- 需要列名、二维数据、dtype 和 number format,或准备与 `table-put` 配套处理 → `table-get`
- 快速查看纯值数据、批量处理、大表分批读 → `csv-get`(token 消耗低,防爆保护)
- 需要结构化信息(值+样式+数据验证+富文本+单元格级超链接)、查看公式或原始值 → `range read`
- 需要查看合并单元格 / 表头合并结构 → `sheet info`,读取返回的 `mergedRanges`;不要在 `csv-get` 或 `range read` 里找合并信息
@@ -11,6 +12,7 @@
| 读取目的 | 推荐命令 | 说明 |
|---------|---------|------|
| DataFrame 风格结构化读取 | `table-get` | 返回 columns / data / dtypes / formats,可与 table-put 配套 |
| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 maxChars 防爆 |
| 查看数据验证配置(下拉/复选框) | `range read` | 返回 per-cell 结构,含 dataValidation |
| 查看单元格样式(背景色/字体/对齐等) | `range read` | 返回 per-cell 结构,含 cellStyles(仅显式设置的样式) |
@@ -21,6 +23,23 @@
## 命令详细参考
### 读取结构化 table 数据
```
Usage:
dws sheet table-get [flags] # 别名: dws sheet table-read
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 <SHEET_ID> --no-header
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称
--range string 读取范围,A1 表示法;可带 sheet 前缀
--no-header 首行不作为表头,自动生成 col1/col2/...
```
`table-get` 面向结构化数据处理,返回 `columns`、二维 `data`、`dtypes` 和可用时的 `formats`。未传 `--sheet-id` / `--range` 时由服务端读取目标文档中的 table 数据;大表仍应通过 `--range` 限定范围。需要单元格样式、公式、数据验证或富文本细节时,继续使用 `range read`。
### 以 CSV 格式读取工作表数据(推荐)
```
Usage:
@@ -29,6 +29,10 @@
用户说"删除工作表/移除工作表/删掉这个Sheet":
- 删除工作表 → `delete-sheet`(不可逆操作,执行前必须向用户确认)
用户说"显示网格线/隐藏网格线/去掉单元格网格":
- 显示网格线 → `show-gridline`
- 隐藏网格线 → `hide-gridline`
## 命令详细参考
### 创建钉钉表格文档
@@ -165,6 +169,18 @@ Flags:
- 不能删除隐藏的工作表(需先通过 `sheet update --hidden false` 取消隐藏再删除)
- 不能删除最后一个可见工作表(至少保留一个可见工作表)
### 显示或隐藏网格线
```
Usage:
dws sheet show-gridline --node <NODE_ID> --sheet-id <SHEET_ID>
dws sheet hide-gridline --node <NODE_ID> --sheet-id <SHEET_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
```
这两个命令只修改指定工作表的网格线显示状态,不修改单元格内容或边框样式。执行后可用同一命令反向恢复。
## 核心工作流
```bash
@@ -3,6 +3,7 @@
## 使用场景
用户说"写数据/填表/更新单元格/写入公式":
- 从 DataFrame/结构化对象一次写入一个或多个工作表 → `table-put`
- 更新数据 → `range update`
- 【强制】`--sheet-id` 必填:即使是单工作表也不能省略,不要参照 `range read` 的默认行为;未知时先执行 `dws sheet list --node <NODE_ID> --format json` 获取 `sheetId`,禁止凭空臆测为 `Sheet1`、`sheet1`、`0`、`default` 等
- 注意:如果用户的目的是替换文本、移动行列、追加空行空列、清空区域、排序、填充、复制区域或移动区域,请勿使用 `range update`,必须使用对应的专用命令(`replace`/`move-dimension`/`add-dimension`/`range clear`/`range sort`/`range fill`/`range copy-to`/`range move-to`)
@@ -16,23 +17,38 @@
- 与 `range update` 的区别:`csv-put` 接受 CSV 文本直接写入,无需手动构造二维 JSON 数组;适合大批量纯值写入
- 与 `append` 的区别:`csv-put` 写入指定位置(--start-cell),`append` 在末尾追加
**三种写入命令能力对比**:
**四种写入命令能力对比**:
| 能力 | `range update` | `append` | `csv-put` |
|------|---------------|----------|-----------|
| 公式(`=` 开头) | 支持 | 不支持 | 不支持(当文本) |
| 单元格级超链接(`hyperlink`) | 支持 | 不支持 | 不支持 |
| 富文本(片段链接/附件/图片) | 支持 | 不支持 | 不支持 |
| richText 片段样式(bold/color) | 支持 | 不支持 | 不支持 |
| `cellStyles`(背景色/字号/对齐等 cell-level 样式) | 支持 | 不支持 | 不支持 |
| `{}` 跳过(保留原值) | 支持 | 不适用 | 不适用 |
| `dataValidation`(下拉/复选框) | 支持 | 不支持 | 不支持 |
| 原始值(纯数字/字符串) | 支持 | 支持 | 支持 |
| 自动定位末尾 | 不支持 | 支持 | 不支持 |
| 自动扩容行列 | 不支持 | 支持 | 支持 |
| 能力 | `table-put` | `range update` | `append` | `csv-put` |
|------|-------------|----------------|----------|-----------|
| 多工作表结构化写入 | 支持 | 不支持 | 不支持 | 不支持 |
| columns / dtypes / formats | 支持 | 不支持 | 不支持 | 不支持 |
| 公式(`=` 开头) | 按 dtype/底层能力 | 支持 | 不支持 | 不支持(当文本) |
| 单元格级超链接(`hyperlink`) | 不使用此命令 | 支持 | 不支持 | 不支持 |
| 富文本(片段链接/附件/图片) | 不使用此命令 | 支持 | 不支持 | 不支持 |
| 原始值(纯数字/字符串) | 支持 | 支持 | 支持 | 支持 |
| 自动定位末尾 | mode=append | 不支持 | 支持 | 不支持 |
| 自动扩容行列 | 支持 | 不支持 | 支持 | 支持 |
## 命令详细参考
### 写入结构化 table 数据
```
Usage:
dws sheet table-put [flags] # 别名: dws sheet table-write
Example:
dws sheet table-put --node <NODE_ID> \
--sheets '[{"name":"Data","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 -
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheets string sheet table JSON、@文件路径 或 - 表示 stdin (必填)
```
`--sheets` 接受 JSON 数组、`{"sheets":[...]}` 包装对象或单个 sheet 对象,CLI 会统一转换为非空数组。每个 sheet 至少需要 `columns`,并提供 `sheetId` 或 `name`;`name` 必须非空且少于 31 个字符。写入后必须用 `table-get` 回读验证。`table-get/table-put` 不支持放入 `sheet batch-update`。
### 更新工作表指定区域内容
```
Usage:
+2 -1
View File
@@ -19,6 +19,7 @@ metadata:
## 认证
- `dws auth login`(新登一个组织即新增 profile);`--device` 无头 / SSH 登录;`--recommend` 无交互批量授权
- `dws auth status [--profile <名称|corpId>]` 查看认证状态
- macOS 出现 `ciphertext_key_mismatch` 且普通终端仍可登录时:先运行 `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json`,通过后加 `--yes` 迁移;不要直接 `auth reset`
## 全局参数
- 所有命令加 `--format json` 取可解析输出
@@ -35,5 +36,5 @@ dws 可同时登录多个钉钉组织,一个 profile = 一个已登录组织
## 错误处理
- `unknown command` / `unknown flag`:先跑 `dws <path> --help` 查证再修正一次,别把自然语言当命令 / flag
- 认证失败 / token 过期:提示用户 `dws auth login` 重新登录
- 服务端 token 过期:提示用户 `dws auth login` 重新登录;本地密钥不匹配按上面的 macOS 迁移流程处理,不要混为 token 过期
- 业务错误码 / 接口语义:用 `dws devdoc article search --query "<关键词>" --format json` 查官方文档,不编造原因
+452 -15
View File
@@ -1,6 +1,10 @@
package scripts_test
import (
"archive/tar"
"compress/gzip"
"crypto/sha256"
"fmt"
"os"
"os/exec"
"path/filepath"
@@ -28,9 +32,41 @@ var expectedPackagedSkillTargets = []string{
".hermes/skills/dws",
}
// seedDistArtifacts creates fake goreleaser output archives (empty tar.gz/zip
// files) and a checksums.txt stub so that post-goreleaser.sh can run without
// an actual goreleaser build.
func writeDarwinArchive(t *testing.T, path string) {
t.Helper()
archive, err := os.Create(path)
if err != nil {
t.Fatalf("Create(%s) error = %v", path, err)
}
gzipWriter := gzip.NewWriter(archive)
tarWriter := tar.NewWriter(gzipWriter)
binary := []byte("#!/bin/sh\nexit 0\n")
if err := tarWriter.WriteHeader(&tar.Header{
Name: "dws",
Mode: 0o755,
Size: int64(len(binary)),
}); err != nil {
t.Fatalf("WriteHeader(%s) error = %v", path, err)
}
if _, err := tarWriter.Write(binary); err != nil {
t.Fatalf("Write(%s) error = %v", path, err)
}
if err := tarWriter.Close(); err != nil {
t.Fatalf("tar Close(%s) error = %v", path, err)
}
if err := gzipWriter.Close(); err != nil {
t.Fatalf("gzip Close(%s) error = %v", path, err)
}
if err := archive.Close(); err != nil {
t.Fatalf("Close(%s) error = %v", path, err)
}
}
// seedDistArtifacts creates minimal goreleaser output archives and a
// checksums.txt stub so post-goreleaser.sh can run without a real build.
// Darwin archives must be valid tar.gz files because the packaging script
// extracts and signs their dws binaries.
func seedDistArtifacts(t *testing.T, distDir string, targets []string) {
t.Helper()
if err := os.MkdirAll(distDir, 0o755); err != nil {
@@ -39,6 +75,10 @@ func seedDistArtifacts(t *testing.T, distDir string, targets []string) {
for _, target := range targets {
p := filepath.Join(distDir, target)
if strings.HasPrefix(target, "dws-darwin-") && strings.HasSuffix(target, ".tar.gz") {
writeDarwinArchive(t, p)
continue
}
if err := os.WriteFile(p, []byte("fake-archive"), 0o644); err != nil {
t.Fatalf("WriteFile(%s) error = %v", p, err)
}
@@ -55,6 +95,23 @@ func seedDistArtifacts(t *testing.T, distDir string, targets []string) {
}
}
func postGoreleaserEnv(t *testing.T, distDir, releaseBaseURL string) []string {
t.Helper()
binDir := t.TempDir()
fakeCodesign := filepath.Join(binDir, "codesign")
if err := os.WriteFile(fakeCodesign, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("WriteFile(fake codesign) error = %v", err)
}
return append(os.Environ(),
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
"DWS_PACKAGE_VERSION=v0.0.0-test",
"DWS_PACKAGE_DIST_DIR="+distDir,
"DWS_RELEASE_BASE_URL="+releaseBaseURL,
)
}
func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
t.Parallel()
@@ -77,10 +134,7 @@ func TestPostGoreleaserBuildsExpectedArtifacts(t *testing.T) {
seedDistArtifacts(t, distDir, []string{archiveName})
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"DWS_PACKAGE_DIST_DIR="+distDir,
"DWS_RELEASE_BASE_URL=https://downloads.example.com/dws/releases/v1.2.3",
)
cmd.Env = postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v1.2.3")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output))
@@ -199,10 +253,7 @@ func TestPostGoreleaserAllPlatformNpmAssets(t *testing.T) {
seedDistArtifacts(t, distDir, allArchives)
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"DWS_PACKAGE_DIST_DIR="+distDir,
"DWS_RELEASE_BASE_URL=https://downloads.example.com/dws/releases/v9.9.9",
)
cmd.Env = postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v9.9.9")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output))
@@ -279,10 +330,7 @@ func TestPostGoreleaserSkillsZipLayout(t *testing.T) {
seedDistArtifacts(t, distDir, []string{archiveName})
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"DWS_PACKAGE_DIST_DIR="+distDir,
"DWS_RELEASE_BASE_URL=https://downloads.example.com/dws/releases/v0.0.0",
)
cmd.Env = postGoreleaserEnv(t, distDir, "https://downloads.example.com/dws/releases/v0.0.0")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("post-goreleaser.sh error = %v\noutput:\n%s", err, string(output))
@@ -330,3 +378,392 @@ func TestPostGoreleaserSkillsZipLayout(t *testing.T) {
t.Fatalf("multi/ does not contain any dingtalk-* skill: %v", multiEntries)
}
}
func TestReleaseWorkflowUploadsPostProcessedDarwinAssets(t *testing.T) {
t.Parallel()
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
if err != nil {
t.Fatalf("Abs(release.yml) error = %v", err)
}
data, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
}
workflow := string(data)
postProcess := strings.Index(workflow, "./scripts/release/post-goreleaser.sh")
upload := strings.Index(workflow, "Upload finalized signed assets to release")
if postProcess == -1 || upload == -1 || upload < postProcess {
t.Fatalf("finalized asset upload must run after post-goreleaser.sh")
}
if !strings.Contains(workflow[upload:], "./scripts/release/finalize-github-release.sh") {
t.Fatal("release workflow must delegate atomic finalization to finalize-github-release.sh")
}
finalizePath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "finalize-github-release.sh"))
if err != nil {
t.Fatalf("Abs(finalize-github-release.sh) error = %v", err)
}
finalizeData, err := os.ReadFile(finalizePath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", finalizePath, err)
}
finalize := string(finalizeData)
for _, required := range []string{
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"checksums.txt",
"dws-skills.zip",
"gh release upload",
"gh release view",
"--clobber",
"release asset digest mismatch",
} {
if !strings.Contains(finalize, required) {
t.Errorf("finalized asset upload is missing %q", required)
}
}
}
func TestReleaseWorkflowConfiguresDeveloperIDSigning(t *testing.T) {
t.Parallel()
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
if err != nil {
t.Fatalf("Abs(release.yml) error = %v", err)
}
data, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
}
workflow := string(data)
prepare := strings.Index(workflow, "Prepare Apple Developer ID certificate")
goReleaser := strings.Index(workflow, "Run GoReleaser")
postProcess := strings.Index(workflow, "./scripts/release/post-goreleaser.sh")
cleanup := strings.Index(workflow, "Remove Apple Developer ID certificate")
if prepare == -1 || goReleaser == -1 || postProcess == -1 || cleanup == -1 ||
prepare > goReleaser || goReleaser > postProcess || cleanup < postProcess {
t.Fatalf("Developer ID material must be validated before GoReleaser and removed after post-processing")
}
for _, required := range []string{
`RCS_VERSION="0.29.0"`,
"secrets.APPLE_CERTIFICATE_P12_BASE64",
"secrets.APPLE_CERTIFICATE_PASSWORD",
"base64 --decode",
"openssl pkcs12 -legacy",
"DWS_APPLE_CERTIFICATE_P12",
"DWS_APPLE_CERTIFICATE_PASSWORD_FILE",
"DWS_REQUIRE_DEVELOPER_ID_SIGNING",
`GITHUB_REPOSITORY_OWNER" = "DingTalk-Real-AI`,
} {
if !strings.Contains(workflow, required) {
t.Errorf("release workflow is missing Developer ID configuration %q", required)
}
}
}
func TestPostGoreleaserSupportsDeveloperIDSigning(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "post-goreleaser.sh"))
if err != nil {
t.Fatalf("Abs(post-goreleaser.sh) error = %v", err)
}
data, err := os.ReadFile(scriptPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", scriptPath, err)
}
script := string(data)
for _, required := range []string{
`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}"`,
`--p12-file "$APPLE_CERTIFICATE_P12"`,
`--p12-password-file "$APPLE_CERTIFICATE_PASSWORD_FILE"`,
"--for-notarization",
} {
if !strings.Contains(script, required) {
t.Errorf("post-goreleaser.sh is missing Developer ID signing behavior %q", required)
}
}
if strings.Contains(script, `rcodesign verify "$bin"`) {
t.Fatal("rcodesign verify must not be treated as authoritative Apple signature validation")
}
}
func TestReleaseWorkflowVerifiesRcodesignArchiveChecksum(t *testing.T) {
t.Parallel()
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
if err != nil {
t.Fatalf("Abs(release.yml) error = %v", err)
}
data, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
}
workflow := string(data)
hash := strings.Index(workflow, `RCS_ARCHIVE_SHA256="dbe85cedd8ee4217b64e9a0e4c2aef92ab8bcaaa41f20bde99781ff02e600002"`)
checksum := strings.Index(workflow, "sha256sum --check --strict")
extract := strings.Index(workflow, "tar -xzf /tmp/rcodesign.tar.gz")
execute := strings.Index(workflow, "rcodesign --version")
if hash == -1 || checksum == -1 || extract == -1 || execute == -1 ||
!(hash < checksum && checksum < extract && extract < execute) {
t.Fatal("rcodesign archive must match the pinned SHA-256 before extraction or execution")
}
}
func TestReleaseWorkflowUsesAppleCodesignBeforePublication(t *testing.T) {
t.Parallel()
workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "release.yml"))
if err != nil {
t.Fatalf("Abs(release.yml) error = %v", err)
}
data, err := os.ReadFile(workflowPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", workflowPath, err)
}
workflow := string(data)
upload := strings.Index(workflow, "Upload finalized signed assets to release")
verifyJob := strings.Index(workflow, "verify-darwin-signatures:")
publishJob := strings.Index(workflow, "publish-release:")
if upload == -1 || verifyJob == -1 || publishJob == -1 || !(upload < verifyJob && verifyJob < publishJob) {
t.Fatal("finalized Draft assets must be uploaded, Apple-verified, and only then published")
}
codesign := strings.Index(workflow[verifyJob:publishJob], "codesign --verify --strict --verbose=4")
publish := strings.Index(workflow[publishJob:], `gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false`)
if codesign == -1 || publish == -1 {
t.Fatal("macOS codesign verification and explicit Draft publication are required")
}
buildSection := workflow[upload:verifyJob]
for _, required := range []string{
`DWS_PUBLISH_RELEASE: "false"`,
"actions/upload-artifact@v4",
"finalized-release-dist",
} {
if !strings.Contains(buildSection, required) {
t.Errorf("Draft build stage is missing %q", required)
}
}
verifySection := workflow[verifyJob:publishJob]
for _, required := range []string{
"runs-on: macos-latest",
"gh release download",
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"codesign --verify --strict --verbose=4",
} {
if !strings.Contains(verifySection, required) {
t.Errorf("Apple verification stage is missing %q", required)
}
}
publishSection := workflow[publishJob:]
for _, required := range []string{
"verify-darwin-signatures",
"actions/download-artifact@v4",
"Publish verified Draft release",
"Publish stable to npm",
"Publish prerelease to npm beta",
} {
if !strings.Contains(publishSection, required) {
t.Errorf("post-verification publication stage is missing %q", required)
}
}
}
func TestReleaseStaysDraftUntilFinalizedAssetDigestsMatch(t *testing.T) {
t.Parallel()
goreleaserPath, err := filepath.Abs(filepath.Join("..", "..", ".goreleaser.yaml"))
if err != nil {
t.Fatalf("Abs(.goreleaser.yaml) error = %v", err)
}
goreleaserData, err := os.ReadFile(goreleaserPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", goreleaserPath, err)
}
if !strings.Contains(string(goreleaserData), "draft: true") {
t.Fatal("GoReleaser must keep the release as Draft during post-processing")
}
finalizePath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "finalize-github-release.sh"))
if err != nil {
t.Fatalf("Abs(finalize-github-release.sh) error = %v", err)
}
finalizeData, err := os.ReadFile(finalizePath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", finalizePath, err)
}
finalize := string(finalizeData)
upload := strings.Index(finalize, "gh release upload")
digestFailure := strings.Index(finalize, "release asset digest mismatch")
publish := strings.Index(finalize, "gh release edit")
if upload == -1 || digestFailure == -1 || publish == -1 || !(upload < digestFailure && digestFailure < publish) {
t.Fatal("Draft publication must happen after finalized asset upload and digest verification")
}
}
func TestFinalizeGitHubReleaseDoesNotPublishAfterUploadFailure(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "finalize-github-release.sh"))
if err != nil {
t.Fatalf("Abs(finalize-github-release.sh) error = %v", err)
}
root := t.TempDir()
distDir := filepath.Join(root, "dist")
if err := os.MkdirAll(distDir, 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", distDir, err)
}
for _, name := range []string{
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"checksums.txt",
"dws-skills.zip",
} {
if err := os.WriteFile(filepath.Join(distDir, name), []byte("finalized"), 0o644); err != nil {
t.Fatalf("WriteFile(%s) error = %v", name, err)
}
}
binDir := filepath.Join(root, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", binDir, err)
}
logPath := filepath.Join(root, "gh.log")
fakeGH := `#!/bin/sh
printf '%s\n' "$*" >> "$FAKE_GH_LOG"
if [ "$1" = "release" ] && [ "$2" = "upload" ]; then
exit 42
fi
if [ "$1" = "release" ] && [ "$2" = "edit" ]; then
exit 0
fi
exit 1
`
if err := os.WriteFile(filepath.Join(binDir, "gh"), []byte(fakeGH), 0o755); err != nil {
t.Fatalf("WriteFile(fake gh) error = %v", err)
}
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
"FAKE_GH_LOG="+logPath,
"GITHUB_REF_NAME=v-test",
"GITHUB_REPOSITORY=example/dws",
"DWS_PACKAGE_DIST_DIR="+distDir,
)
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("finalize-github-release.sh unexpectedly succeeded after upload failure:\n%s", output)
}
logData, readErr := os.ReadFile(logPath)
if readErr != nil {
t.Fatalf("ReadFile(%s) error = %v", logPath, readErr)
}
logText := string(logData)
if !strings.Contains(logText, "release upload") {
t.Fatalf("fake gh did not observe release upload:\n%s", logText)
}
if strings.Contains(logText, "release edit") {
t.Fatalf("Draft release was published after upload failure:\n%s", logText)
}
}
func TestFinalizeGitHubReleaseCanVerifyWithoutPublishing(t *testing.T) {
t.Parallel()
scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "finalize-github-release.sh"))
if err != nil {
t.Fatalf("Abs(finalize-github-release.sh) error = %v", err)
}
root := t.TempDir()
distDir := filepath.Join(root, "dist")
if err := os.MkdirAll(distDir, 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", distDir, err)
}
assetContent := []byte("finalized")
for _, name := range []string{
"dws-darwin-amd64.tar.gz",
"dws-darwin-arm64.tar.gz",
"checksums.txt",
"dws-skills.zip",
} {
if err := os.WriteFile(filepath.Join(distDir, name), assetContent, 0o644); err != nil {
t.Fatalf("WriteFile(%s) error = %v", name, err)
}
}
binDir := filepath.Join(root, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("MkdirAll(%s) error = %v", binDir, err)
}
logPath := filepath.Join(root, "gh.log")
fakeGH := `#!/bin/sh
printf '%s\n' "$*" >> "$FAKE_GH_LOG"
if [ "$1" = "release" ] && [ "$2" = "upload" ]; then
exit 0
fi
if [ "$1" = "release" ] && [ "$2" = "view" ]; then
printf '%s\n' "$FAKE_REMOTE_DIGEST"
exit 0
fi
if [ "$1" = "release" ] && [ "$2" = "edit" ]; then
exit 0
fi
exit 1
`
if err := os.WriteFile(filepath.Join(binDir, "gh"), []byte(fakeGH), 0o755); err != nil {
t.Fatalf("WriteFile(fake gh) error = %v", err)
}
digest := sha256.Sum256(assetContent)
cmd := exec.Command("sh", scriptPath)
cmd.Env = append(os.Environ(),
"PATH="+binDir+string(os.PathListSeparator)+os.Getenv("PATH"),
"FAKE_GH_LOG="+logPath,
"FAKE_REMOTE_DIGEST="+fmt.Sprintf("sha256:%x", digest),
"GITHUB_REF_NAME=v-test",
"GITHUB_REPOSITORY=example/dws",
"DWS_PACKAGE_DIST_DIR="+distDir,
"DWS_PUBLISH_RELEASE=false",
"DWS_RELEASE_DIGEST_ATTEMPTS=1",
"DWS_RELEASE_DIGEST_RETRY_DELAY=0",
)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("finalize-github-release.sh error = %v\noutput:\n%s", err, output)
}
if !strings.Contains(string(output), "keeping release v-test as Draft") {
t.Fatalf("finalizer did not report preserved Draft:\n%s", output)
}
logData, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("ReadFile(%s) error = %v", logPath, err)
}
logText := string(logData)
if !strings.Contains(logText, "release upload") || !strings.Contains(logText, "release view") {
t.Fatalf("finalizer did not upload and verify assets:\n%s", logText)
}
if strings.Contains(logText, "release edit") {
t.Fatalf("finalizer published a release configured to remain Draft:\n%s", logText)
}
}