diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 638ad57d..46210e0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f7f0ded..29e1ec5b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 开关消费。 diff --git a/.gitignore b/.gitignore index 8c98ebf1..6e5a1506 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,8 @@ dws.zip # 功能测试运行产物 results.jsonl test/dev_functional/results.jsonl +/auto-test/ +/eval-runs/ +/.qoder/ .vercel .env* diff --git a/.goreleaser.yaml b/.goreleaser.yaml index b0fd8a76..ce5ab1a4 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index a3ca0155..db0c6a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,58 @@ 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= bus_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{:{type, required, description, default?}}}` plus an `arguments` array for positional inputs, with `source: "cobra"` distinguishing flag-synthesized schema from MCP-fetched (`mcp:`). 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 + +- **Cross-platform auth regression coverage** — dedicated macOS CI now runs the Darwin-only auth/keychain regression suite with race detection, Windows CI builds and tests the native DPAPI path, and recovery guidance prefers safe migration or per-profile cleanup over destructive global reset. + +## [1.0.51] - 2026-07-10 + +This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material. + +### Added + +- **Agoal product commands** (#585) — adds `dws agoal` strategy, contract, scorecard, user-objective, report, and objective-template command groups, together with static routing and the bundled mono/multi Agoal skills. +- **Wukong chat command parity** (#585) — adds `chat group notice create|edit|get|list`, `group share-invite`, `text translate`, `category create-smart`, and `message list-emotion-replies`. +- **Wukong document import commands** (#585) — adds `doc import` for starting imports and `doc import get` for querying import tasks. +- **Wukong mail command parity** (#585) — adds mailbox profile, message batch-get, sent-message recall and recall-detail, auto-reply update, plus allow-list and block-list management. +- **Wukong sheet grouping commands** (#585) — adds `sheet group-dimension` and `sheet ungroup-dimension` for whole-row or whole-column ranges. +- **Keychain health diagnostics** (#578) — `dws doctor` now includes a keychain check, while `dws auth status` distinguishes ordinary logged-out state from `keychain_unavailable` and `dek_missing` failures and returns remediation hints in table and JSON output. + +### Changed + +- **`dws pat chmod` defaults to permanent grants** (#584) — running `dws pat chmod ` without `--grant-type` now requests a `permanent` grant instead of `session`, aligning the direct CLI path with the recommend-authorization helper. Session grants remain available by passing `--grant-type session --session-id `. +- **The `dev connect --channel gemini` path now uses the Gemini `generateContent` API** (#587) — configure it with `GEMINI_API_KEY` or `GOOGLE_API_KEY`, optionally override the compatible endpoint with `GEMINI_API_BASE_URL` or `GOOGLE_GEMINI_API_BASE_URL`, and select a model with `--agent-model` or `GEMINI_MODEL`; a local `gemini` executable is no longer required. + +### Fixed + +- **Non-blocking `dev connect` turn scheduling** (#587) — stream and `@`-poll callbacks no longer wait for the active turn to finish. Turns stay serialized per conversation, messages received mid-turn are coalesced into one pending follow-up, and different conversations can continue in parallel. +- **Connect agent recovery and headless execution** (#587) — stale addressable sessions retry once with a fresh session, unsupported Qoder control requests receive an immediate response instead of hanging, OpenCode and bypass-mode channels receive non-interactive permission settings, and backend/API failures are no longer posted as successful assistant replies. +- **Side-effect-free credential reads** (#578) — keychain reads inspect encrypted credential data before looking up the DEK and never generate a replacement key on a read path. Missing DEKs and unavailable macOS Keychains are surfaced as explicit diagnostic failures instead of silently mutating credential state. + +## [1.0.50] - 2026-07-08 + +This release fixes a long-standing gap where the global `--jq` / `--fields` output filters were silently ignored on product commands, lands a JSON-mode output path for the sheet batch-style command, and aligns the bundled skill surface with the real command semantics uncovered by the round-2 real-machine QA sweep. + +### Fixed + +- **Global `--jq` / `--fields` are honored on product commands** (#575) — `Formatter.PrintJSON` / `PrintJSONUnescaped` now route through `output.WriteFiltered` when either flag is set, so product commands accept the same filters that `dws api` has always supported. The tool-caller adapter exposes `Fields()` / `JQ()` so helpers can read the flags without re-parsing. +- **`skill setup --dry-run` is a no-op preview** (#575) — it now prints what would be written without touching the skill directory, the registry, or the agent config. Help text and docs are updated to match. +- **Skill docs alignment to the real command surface** (#575) — per-product references and the cross-product intent guide clarify that `--fields` projects top-level / list keys only (use `--jq` for nested paths); `minutes_extract_todos.py`, `calendar_free_slot_finder.py`, `chat_export_messages.py` / `chat_history_with_user.py`, and `contact_dept_members.py` are rewritten against the current response shapes; `aisearch` / `aitable` / `attendance` / `calendar` / `chat` / `contact` / `dev` / `doc` / `doc-comment` / `doc-file-ops` / `doc-list` / `doc-search` / `drive` / `mail` / `minutes` / `oa` / `sheet` / `sheet-export` / `url-patterns` / `best_practices/lite-recipes.md` / `global-reference.md` / `intent-guide.md` are re-synced; the QA voice ("真机" phrasing) and environment-specific quirks stated as absolute rules are removed from the docs. + +### Changed + +- **`sheet range batch-set-style` emits per-row JSON in JSON mode** (#575) — when `--format json` is set, each update is reported as `{index, sheetId, range, ok, error}` instead of only the final aggregate, so callers can programmatically track partial failures under `--continue-on-error`. +- **Command-merge helpers exported** — `pkg/cmdutil.LeafMerge*` and the provenance helpers are now public so downstream command trees can reuse the same merge semantics. + ## [1.0.49] - 2026-07-08 This release lands a full real-machine QA sweep across the CLI, helper scripts, and skill docs (#572), and hardens the release pipeline so npm publishing can no longer be blocked by Gitee mirror issues (#570). diff --git a/README.md b/README.md index f644f2d0..3e55b022 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,9 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ... | Mode | What gets installed | Best for | |------|----------------------|----------| | **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point | -| **multi** 🧪 **EXPERIMENTAL** | 18 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call | +| **multi** 🧪 **EXPERIMENTAL** | 22 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call | -> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 18 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems. +> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 22 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems. How to pick: @@ -258,6 +258,16 @@ dws --profile 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 `, then log in again. Use `dws auth reset` only when you intend to discard every local profile. +
@@ -331,7 +341,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10 The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts: - `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default. -- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 20 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.** +- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 22 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.** After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language: @@ -406,6 +416,51 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p ## Features +
+Personal Event Subscription — real-time DingTalk messages for event-driven agents + +`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog currently covers messages that mention the current user, one-to-one messages with a specified user, and messages in a specified group. + +> **Prerequisite**: run `dws auth login`. Personal identity is resolved from the OAuth token and cannot be supplied through command-line identity flags. + +For an event-focused installation, use the official convenience installer: + +```bash +curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh +``` + +```bash +# Inspect the public personal event catalog and schema +dws event list +dws event schema user_im_message_receive_o2o + +# Listen for messages that mention the current user +dws event consume user_im_message_receive_at -f ndjson + +# Listen for one-to-one messages with a specified user +dws event consume user_im_message_receive_o2o --user -f ndjson + +# Listen for messages in a specified group +dws event consume user_im_message_receive_group --group -f ndjson + +# Inspect local consumers and cancel a subscription +dws event status +dws event stop +``` + +| Feature | Details | +|---------|---------| +| Managed lifecycle | `consume` creates or reuses the personal subscription; `stop` cancels it and cleans local state | +| Shared connection | Consumers for the same user share one local bus and cloud connection | +| Subscription isolation | Normal consumers match both event type and `subscribe_id` | +| Agent-friendly output | Stream events are written to stdout as NDJSON; status and diagnostics use stderr | +| Observability | `status` shows remote subscriptions, the personal bus, and local consumers | +| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows | + +See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters. + +
+
Raw API Access — call any DingTalk OpenAPI directly diff --git a/README_zh.md b/README_zh.md index 72f1ea22..1a4c3a70 100644 --- a/README_zh.md +++ b/README_zh.md @@ -71,9 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma | 模式 | 安装内容 | 适合场景 | |------|----------|----------| | **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 | -| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 | +| **multi** 🧪 **试验版 / Preview** | 22 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 | -> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。 +> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。22 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。 怎么选: @@ -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`。 +
@@ -328,7 +338,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10 仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局: - `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。 -- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 18 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。** +- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 22 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。** 安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉: @@ -403,6 +413,51 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi ## 功能特性 +
+个人事件订阅 — 实时接收钉钉消息,驱动事件触发的 Agent + +`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录包括:当前用户被 @ 的消息、与指定用户的单聊消息、指定群的消息。 + +> **前置条件**:先运行 `dws auth login`。个人身份从 OAuth token 解析,不允许通过命令行伪造。 + +只需要 event 能力时,可以使用官方便捷安装脚本: + +```bash +curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh +``` + +```bash +# 查看公开个人事件目录和 schema +dws event list +dws event schema user_im_message_receive_o2o + +# 监听当前用户被 @ 的消息 +dws event consume user_im_message_receive_at -f ndjson + +# 监听与指定用户的单聊消息 +dws event consume user_im_message_receive_o2o --user -f ndjson + +# 监听指定群的消息 +dws event consume user_im_message_receive_group --group -f ndjson + +# 查看本地 consume,并取消指定订阅 +dws event status +dws event stop +``` + +| 特性 | 说明 | +|------|------| +| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 | +| 共享连接 | 同一用户的多个 consumer 共享本地 bus 和云端长连接 | +| 订阅隔离 | 正常 consumer 同时按事件类型和 `subscribe_id` 匹配 | +| Agent 友好输出 | Stream 事件写入 stdout,连接状态和诊断信息写入 stderr | +| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers | +| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe | + +Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。 + +
+
Raw API 调用 — 直接调用钉钉 OpenAPI diff --git a/docs/event-subprocess-contract.md b/docs/event-subprocess-contract.md new file mode 100644 index 00000000..c068b169 --- /dev/null +++ b/docs/event-subprocess-contract.md @@ -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= bus_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=`. +- 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 ` exits ≤2s, code 0, final + line `reason: signal` (stdin-eof classified as signal). +- T2b: `dws event consume < <(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: ) +``` + +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 `; 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. diff --git a/go.mod b/go.mod index e9e57803..391b1414 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,17 @@ module github.com/DingTalk-Real-AI/dingtalk-workspace-cli go 1.25.9 require ( + github.com/Microsoft/go-winio v0.6.2 github.com/RealAlexandreAI/json-repair v0.0.15 github.com/charmbracelet/bubbletea v1.3.6 github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/fatih/color v1.18.0 github.com/google/uuid v1.6.0 + github.com/gorilla/websocket v1.5.0 github.com/itchyny/gojq v0.12.18 github.com/muesli/termenv v0.16.0 - github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 + github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.49.0 @@ -35,7 +37,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/gorilla/websocket v1.5.0 // indirect github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect diff --git a/go.sum b/go.sum index 601ddd39..20ce02f0 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/RealAlexandreAI/json-repair v0.0.15 h1:AN8/yt8rcphwQrIs/FZeki+cKaIERUNr25zf1flirIs= github.com/RealAlexandreAI/json-repair v0.0.15/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -86,8 +88,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8= -github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= +github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8= +github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= diff --git a/internal/app/auth_command.go b/internal/app/auth_command.go index b3f7d469..6aee387c 100644 --- a/internal/app/auth_command.go +++ b/internal/app/auth_command.go @@ -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) { @@ -446,6 +448,7 @@ func newAuthStatusCommand() *cobra.Command { authenticated := false refreshed := false var tokenData *authpkg.TokenData + var statusErr error provider := authpkg.NewOAuthProvider(configDir, nil) configureOAuthProviderCompatibility(provider, configDir) if data, err := provider.Status(); err == nil { @@ -468,12 +471,15 @@ func newAuthStatusCommand() *cobra.Command { if authStatusAuthenticated(tokenData) { authenticated = true } + } else { + statusErr = err } + diagnostic := authStatusDiagnosticFromError(statusErr) // Check if JSON output is requested format, _ := cmd.Root().PersistentFlags().GetString("format") if strings.EqualFold(strings.TrimSpace(format), "json") { - return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData) + return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData, diagnostic) } // Default table output @@ -503,7 +509,10 @@ func newAuthStatusCommand() *cobra.Command { } } else { fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录") - if !edition.Get().IsEmbedded { + if diagnostic != nil { + fmt.Fprintf(w, "%-16s%s\n", "原因:", diagnostic.Message) + fmt.Fprintf(w, "%-16s%s\n", "提示:", diagnostic.Hint) + } else if !edition.Get().IsEmbedded { fmt.Fprintln(w, "运行 dws auth login --recommend 进行登录") } } @@ -514,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()) @@ -589,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, )) } @@ -1199,6 +1267,8 @@ type authStatusResponse struct { Success bool `json:"success"` Authenticated bool `json:"authenticated"` Message string `json:"message,omitempty"` + Reason string `json:"reason,omitempty"` + Hint string `json:"hint,omitempty"` Refreshed bool `json:"refreshed,omitempty"` TokenValid bool `json:"token_valid,omitempty"` RefreshTokenValid bool `json:"refresh_token_valid,omitempty"` @@ -1210,14 +1280,54 @@ type authStatusResponse struct { UserName string `json:"user_name,omitempty"` } -func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData) error { +type authStatusDiagnostic struct { + Reason string + Message string + Hint string +} + +func authStatusDiagnosticFromError(err error) *authStatusDiagnostic { + if err == nil { + return nil + } + if keychain.IsCiphertextKeyMismatch(err) { + return &authStatusDiagnostic{ + Reason: "ciphertext_key_mismatch", + Message: "本地登录态与可用登录密钥不匹配,已拒绝覆盖现有凭证", + Hint: "macOS 请先在系统 Keychain 模式运行 `env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run`,预检通过后加 --yes 迁移;只有密文损坏且确认无法恢复时才按 profile 退出或执行 auth reset。", + } + } + if keychain.IsDEKMissing(err) { + return &authStatusDiagnostic{ + Reason: "dek_missing", + Message: "本地登录密钥缺失,无法解密已保存的登录态", + Hint: "请先恢复或统一原登录密钥;确认旧登录态不可恢复后,执行 dws auth reset,再重新登录。", + } + } + if !keychain.IsUnavailable(err) { + return nil + } + return &authStatusDiagnostic{ + Reason: "keychain_unavailable", + Message: "无法读取 macOS Keychain 中的登录密钥,无法判断登录状态", + Hint: "检查 macOS 默认钥匙串是否存在且已解锁;修复后重试,或在测试环境设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。", + } +} + +func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData, diagnostic *authStatusDiagnostic) error { resp := authStatusResponse{ Success: true, Authenticated: authenticated, } if !authenticated { - resp.Message = "未登录" + if diagnostic != nil { + resp.Message = diagnostic.Message + resp.Reason = diagnostic.Reason + resp.Hint = diagnostic.Hint + } else { + resp.Message = "未登录" + } } else if data != nil { resp.Refreshed = refreshed resp.TokenValid = data.IsAccessTokenValid() diff --git a/internal/app/auth_command_test.go b/internal/app/auth_command_test.go index 74db1272..0da3b831 100644 --- a/internal/app/auth_command_test.go +++ b/internal/app/auth_command_test.go @@ -16,7 +16,10 @@ package app import ( "bytes" "context" + "encoding/json" "errors" + "fmt" + "io" "net/http" "os" "path/filepath" @@ -132,6 +135,122 @@ func TestAuthImportRequiresForceWhenPopulated(t *testing.T) { } } +func TestAuthStatusJSONReportsKeychainUnavailable(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config")) + + prev := edition.Get() + edition.Override(&edition.Hooks{ + LoadToken: func(configDir string) ([]byte, error) { + return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing")) + }, + }) + t.Cleanup(func() { + edition.Override(prev) + }) + + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--format", "json", "auth", "status"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String()) + } + + var resp struct { + Success bool `json:"success"` + Authenticated bool `json:"authenticated"` + Reason string `json:"reason"` + Message string `json:"message"` + Hint string `json:"hint"` + } + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String()) + } + if !resp.Success { + t.Fatalf("success = false, want true; response=%+v", resp) + } + if resp.Authenticated { + t.Fatalf("authenticated = true, want false; response=%+v", resp) + } + if resp.Reason != "keychain_unavailable" { + t.Fatalf("reason = %q, want keychain_unavailable; response=%+v", resp.Reason, resp) + } + if !strings.Contains(resp.Message, "Keychain") && !strings.Contains(resp.Message, "钥匙串") { + t.Fatalf("message should mention Keychain/钥匙串; response=%+v", resp) + } + if !strings.Contains(resp.Hint, keychain.DisableKeychainEnv) { + t.Fatalf("hint should mention %s; response=%+v", keychain.DisableKeychainEnv, resp) + } +} + +func TestAuthStatusJSONReportsDEKMissing(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config")) + + prev := edition.Get() + edition.Override(&edition.Hooks{ + LoadToken: func(configDir string) ([]byte, error) { + return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing) + }, + }) + t.Cleanup(func() { + edition.Override(prev) + }) + + cmd := NewRootCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--format", "json", "auth", "status"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String()) + } + + var resp struct { + Success bool `json:"success"` + Authenticated bool `json:"authenticated"` + Reason string `json:"reason"` + Message string `json:"message"` + Hint string `json:"hint"` + } + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String()) + } + if !resp.Success { + t.Fatalf("success = false, want true; response=%+v", resp) + } + if resp.Authenticated { + t.Fatalf("authenticated = true, want false; response=%+v", resp) + } + if resp.Reason != "dek_missing" { + t.Fatalf("reason = %q, want dek_missing; response=%+v", resp.Reason, resp) + } + if !strings.Contains(resp.Message, "登录密钥") { + t.Fatalf("message should mention 登录密钥; response=%+v", resp) + } + 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) { // Isolate keychain storage to a per-test directory so the saved // token can't leak into other test packages running in parallel. @@ -233,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"), @@ -824,6 +1028,10 @@ func (f *authLoginRecommendSequenceCaller) Format() string { return "table" } func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false } +func (f *authLoginRecommendSequenceCaller) Fields() string { return "" } + +func (f *authLoginRecommendSequenceCaller) JQ() string { return "" } + func stringSliceArgEqual(got any, want []string) bool { if got == nil { return len(want) == 0 diff --git a/internal/app/doctor_command.go b/internal/app/doctor_command.go index 58deb460..209f6248 100644 --- a/internal/app/doctor_command.go +++ b/internal/app/doctor_command.go @@ -21,6 +21,7 @@ import ( "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" @@ -29,6 +30,8 @@ import ( "github.com/spf13/cobra" ) +var doctorKeychainDiagnose = keychain.Diagnose + // checkStatus represents the outcome of a single doctor check. type checkStatus string @@ -76,6 +79,9 @@ func runDoctor(cmd *cobra.Command, _ []string) error { authResult := doctorCheckAuth(cmd.Context(), w, jsonOut) checks = append(checks, authResult) + keychainResult := doctorCheckKeychain(w, jsonOut) + checks = append(checks, keychainResult) + networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout) checks = append(checks, networkResult) @@ -132,6 +138,19 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult data, err := provider.Status() if err != nil || data == nil { + if diagnostic := authStatusDiagnosticFromError(err); diagnostic != nil { + r := checkResult{ + Name: "auth", + Status: statusFail, + Message: diagnostic.Message, + Hint: diagnostic.Hint, + Detail: map[string]string{"reason": diagnostic.Reason}, + } + if !jsonOut { + printCheckResult(w, r) + } + return r + } r := checkResult{Name: "auth", Status: statusFail, Message: "未登录"} if !edition.Get().IsEmbedded { r.Hint = "运行 dws auth login 进行登录" @@ -182,6 +201,40 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult return r } +// ── Keychain check ───────────────────────────────────────────────────── + +func doctorCheckKeychain(w io.Writer, jsonOut bool) checkResult { + if !jsonOut { + fmt.Fprint(w, tui.Dim("检查钥匙串状态... ")) + } + + diagnostic := doctorKeychainDiagnose() + r := checkResult{ + Name: "keychain", + Status: statusPass, + Message: diagnostic.Message, + Detail: diagnostic.Detail, + } + if !diagnostic.OK { + r.Status = statusFail + r.Hint = diagnostic.Hint + if diagnostic.Detail == nil { + r.Detail = map[string]string{"reason": diagnostic.Reason} + } else if diagnostic.Reason != "" { + detail := make(map[string]string, len(diagnostic.Detail)+1) + for k, v := range diagnostic.Detail { + detail[k] = v + } + detail["reason"] = diagnostic.Reason + r.Detail = detail + } + } + if !jsonOut { + printCheckResult(w, r) + } + return r +} + // ── Network check ─────────────────────────────────────────────────────── func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult { diff --git a/internal/app/doctor_command_test.go b/internal/app/doctor_command_test.go index 7e5a6859..459efcc5 100644 --- a/internal/app/doctor_command_test.go +++ b/internal/app/doctor_command_test.go @@ -15,9 +15,16 @@ package app import ( "bytes" + "context" "encoding/json" + "errors" + "fmt" + "path/filepath" "strings" "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) func TestCountResults(t *testing.T) { @@ -127,6 +134,108 @@ func TestDoctorCheckCacheEmptyJSON(t *testing.T) { } } +func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config")) + + prev := edition.Get() + edition.Override(&edition.Hooks{ + LoadToken: func(configDir string) ([]byte, error) { + return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing")) + }, + }) + t.Cleanup(func() { + edition.Override(prev) + }) + + var buf bytes.Buffer + r := doctorCheckAuth(context.Background(), &buf, false) + + if r.Name != "auth" { + t.Fatalf("name = %q, want auth", r.Name) + } + if r.Status != statusFail { + t.Fatalf("status = %q, want fail", r.Status) + } + if !strings.Contains(r.Message, "Keychain") && !strings.Contains(r.Message, "钥匙串") { + t.Fatalf("message should mention Keychain/钥匙串; result=%+v", r) + } + if !strings.Contains(r.Hint, keychain.DisableKeychainEnv) { + t.Fatalf("hint should mention %s; result=%+v", keychain.DisableKeychainEnv, r) + } +} + +func TestDoctorCheckAuthReportsDEKMissing(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config")) + + prev := edition.Get() + edition.Override(&edition.Hooks{ + LoadToken: func(configDir string) ([]byte, error) { + return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing) + }, + }) + t.Cleanup(func() { + edition.Override(prev) + }) + + var buf bytes.Buffer + r := doctorCheckAuth(context.Background(), &buf, false) + + if r.Name != "auth" { + t.Fatalf("name = %q, want auth", r.Name) + } + if r.Status != statusFail { + t.Fatalf("status = %q, want fail", r.Status) + } + if !strings.Contains(r.Message, "登录密钥") { + t.Fatalf("message should mention 登录密钥; result=%+v", r) + } + if !strings.Contains(r.Hint, "重新登录") { + t.Fatalf("hint should mention 重新登录; result=%+v", r) + } + detail, ok := r.Detail.(map[string]string) + if !ok || detail["reason"] != "dek_missing" { + t.Fatalf("detail = %#v, want reason=dek_missing", r.Detail) + } +} + +func TestDoctorCheckKeychainReportsUnavailable(t *testing.T) { + prev := doctorKeychainDiagnose + doctorKeychainDiagnose = func() keychain.Diagnostic { + return keychain.Diagnostic{ + OK: false, + Reason: "keychain_unavailable", + Message: "macOS 默认钥匙串不存在", + Hint: "恢复默认钥匙串后重试", + Detail: map[string]string{ + "default_keychain": "/tmp/missing.keychain-db", + }, + } + } + t.Cleanup(func() { + doctorKeychainDiagnose = prev + }) + + var buf bytes.Buffer + r := doctorCheckKeychain(&buf, false) + + if r.Name != "keychain" { + t.Fatalf("name = %q, want keychain", r.Name) + } + if r.Status != statusFail { + t.Fatalf("status = %q, want fail", r.Status) + } + if r.Message != "macOS 默认钥匙串不存在" { + t.Fatalf("message = %q", r.Message) + } + if r.Hint == "" { + t.Fatalf("hint is empty; result=%+v", r) + } + detail, ok := r.Detail.(map[string]string) + if !ok || detail["default_keychain"] == "" { + t.Fatalf("detail = %#v, want default_keychain", r.Detail) + } +} + func TestDoctorCommandStructure(t *testing.T) { cmd := newDoctorCommand() if cmd.Use != "doctor" { diff --git a/internal/app/event_command.go b/internal/app/event_command.go new file mode 100644 index 00000000..0a9b531f --- /dev/null +++ b/internal/app/event_command.go @@ -0,0 +1,1143 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/signal" + "path/filepath" + "sort" + "strings" + "syscall" + "text/tabwriter" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/registry" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +// newEventCommand returns the `event` parent command and all its subcommands. +// Wired into root.go's utilityCommands list. +func newEventCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "event", + Short: "事件订阅 (DingTalk Stream 长连接)", + Long: "通过 DingTalk Stream 长连接订阅事件并以 NDJSON 输出到 stdout。详见 `dws event consume --help`。", + Args: cobra.NoArgs, + TraverseChildren: true, + DisableAutoGenTag: true, + RunE: func(c *cobra.Command, _ []string) error { return c.Help() }, + } + cmd.AddCommand( + newEventConsumeCommand(), + newEventListCommand(), + newEventSchemaCommand(), + newEventStatusCommand(), + newEventStopCommand(), + newEventBusCommand(), + ) + return cmd +} + +// ───────────────────────────────────────────────────────────────────── +// event consume +// ───────────────────────────────────────────────────────────────────── + +func newEventConsumeCommand() *cobra.Command { + var ( + eventTypes []string + filter string + compact bool + formatRaw string + outputDir string + routesRaw []string + maxEvents int + duration time.Duration + quiet bool + force bool + dryRun bool + foreground bool + asIdentity string + personalOpts personalConsumeOptions + streamOpts eventStreamTicketOptions + ) + + cmd := &cobra.Command{ + Use: "consume [event_key]", + Short: "订阅事件流并输出到 stdout", + Long: `订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。 + +输出格式(事件流默认 ndjson;显式 -f json/pretty/raw 可覆盖;-f table/csv 对 +事件流无意义会 fallback 到 ndjson): + ndjson (默认) 一行一对象,适合 jq / 管道处理 + json 每事件多行美化 JSON(必须配 --max-events 或 --duration) + pretty 同 json,未来加颜色 + raw 仅 SDK 原始 payload,无外层封装 + compact 扁平化 + 解析嵌套 + 抽取语义字段(Agent 友好) + +默认使用当前 OAuth 登录态自动创建/复用个人订阅并建立个人长连接;非默认组织加 +--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用 +SIGTERM、关 stdin,或 dws event stop ,不要 kill -9。 +--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费 +通常不需要设置。`, + Args: cobra.MaximumNArgs(1), + DisableAutoGenTag: true, + RunE: func(c *cobra.Command, args []string) error { + as, err := normalizeEventAs(asIdentity) + if err != nil { + return err + } + if as == "user" { + personalOpts.EventKey = firstArg(args) + personalOpts.Common = commonConsumeOptions{ + EventTypes: eventTypes, + Filter: filter, + Compact: compact, + FormatRaw: formatRaw, + OutputDir: outputDir, + RoutesRaw: routesRaw, + MaxEvents: maxEvents, + Duration: duration, + Quiet: quiet, + Force: force, + DryRun: dryRun, + Foreground: foreground, + } + personalOpts.StreamTicketMode = streamOpts.Mode + personalOpts.StreamTicketURL = streamOpts.TicketURL + personalOpts.StreamSourceID = streamOpts.SourceID + return runPersonalEventConsume(c, personalOpts) + } + if personalOpts.DebugRawEvents { + return fmt.Errorf("event consume: --debug-raw-events is only supported with --as user") + } + if err := rejectChangedFlags(c, "user", + "subscribe-id", + "rule", + "name", + "filter-json", + "query", + "ttl", + "ephemeral", + "user", + "group", + "personal-event-base-url", + ); err != nil { + return fmt.Errorf("event consume: %w", err) + } + if len(args) > 0 { + return fmt.Errorf("event consume: event_key is only supported with --as user") + } + ctx, cancel := signal.NotifyContext(c.Context(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + // Step 1: resolve credentials. + // Portal ticket normal mode uses portal-managed app credentials, so + // local ClientSecret is intentionally not required there. + configDir := defaultConfigDir() + clientID, clientSecret, err := resolveEventCredentials(configDir, streamOpts) + if err != nil { + return fmt.Errorf("event consume: %w", err) + } + if !streamOpts.usesPortalNormalMode() && authpkg.EnvHalfSet() { + fmt.Fprintln(c.ErrOrStderr(), + "WARN: only one of DWS_CLIENT_ID/DWS_CLIENT_SECRET is set; env fallback disabled, using keychain/app config") + } + + // Step 2: derive bus working directory + IPC endpoint. + editionName := editionNameOrDefault() + clientIDHash := dwsevent.ClientIDHash(clientID) + workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash) + ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash) + + // Step 3: parse routes. + routes, err := consume.ParseRoutes(routesRaw) + if err != nil { + return fmt.Errorf("event consume: %w", err) + } + + // Step 4: resolve format. Event command's default is ndjson; + // we check Changed on the inherited global -f/--format flag so + // the user can still override (-f json etc.). table/csv fall + // back to ndjson with a stderr WARN. See plan §3.1 输出约束. + rawFormat := "" + if f := c.Flags().Lookup("format"); f != nil && f.Changed { + rawFormat = formatRaw + } + normalised, fellback := consume.NormalizeFormat(rawFormat) + if fellback && !quiet { + fmt.Fprintf(c.ErrOrStderr(), + "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat) + } + + cfg := consume.Config{ + WorkDir: workDir, + IPCEndpoint: ipcEndpoint, + ClientID: clientID, + EventTypes: eventTypesWithDefault(eventTypes), + Filter: filter, + Compact: compact, + MaxEvents: maxEvents, + Duration: duration, + Format: normalised, + OutputDir: outputDir, + Routes: routes, + Stdout: c.OutOrStdout(), + Stderr: c.ErrOrStderr(), + Quiet: quiet, + Foreground: foreground, + Force: force, + 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 { + return err + } + // --output-dir/--route vs global hidden -o/--output. + if o := c.Flags().Lookup("output"); o != nil && o.Changed { + if err := consume.ValidateNoOutputConflict(cfg, o.Value.String()); err != nil { + return err + } + } + + // Step 6: foreground mode runs the bus in-process. Otherwise + // consume.Run discovers / forks the bus and dials it. + if foreground { + return runForegroundBus(ctx, cfg, configDir, clientSecret, streamOpts) + } + return consume.Run(ctx, cfg) + }, + } + + f := cmd.Flags() + f.StringVar(&asIdentity, "as", "user", "事件身份: user") + f.StringSliceVar(&eventTypes, "event-types", nil, + "逗号分隔事件类型;省略时按 event_key 过滤") + f.StringVar(&filter, "filter", "", + "客户端正则过滤事件类型 (下推到 bus 减少 IPC 投递量)") + f.BoolVar(&compact, "compact", false, + "提示 bus 客户端期望 compact 渲染(语义透传,bus 仍按原 payload 投递)") + f.StringVarP(&formatRaw, "format", "f", "ndjson", + "输出格式 (ndjson/json/pretty/raw/compact);事件流默认 ndjson") + f.StringVar(&outputDir, "output-dir", "", + "每事件写一个文件到该目录 ({type}_{id}_{ts}.json);与 stdout 互斥") + f.StringArrayVar(&routesRaw, "route", nil, + "按 regex 路由事件到目录:'=dir:',可重复;未命中走 stdout/--output-dir") + f.IntVar(&maxEvents, "max-events", 0, + "收到 N 条后退出 (0 = 不限)") + f.DurationVar(&duration, "duration", 0, + "运行时长上限 (Go duration 如 30s/5m);事件流专用,不复用全局 --timeout") + f.BoolVar(&quiet, "quiet", false, + "抑制 stderr 状态信息") + f.BoolVar(&force, "force", false, + "仅 --foreground 模式生效:跳过单实例锁 (慎用:会让云事件被随机切分)") + f.BoolVar(&dryRun, "dry-run", false, + "仅打印解析后的配置,不连接 bus / 云端") + f.BoolVar(&foreground, "foreground", false, + "当前进程直接跑 bus 服务、不 fork、不打印事件(给 systemd/k8s 托管用);读事件不要用它") + f.StringVar(&personalOpts.SubscribeID, "subscribe-id", "", + "个人事件订阅 ID;传入后复用已有订阅") + f.StringVar(&personalOpts.Rule, "rule", "", + "个人事件规则类型;默认根据 event_key 推断") + f.StringVar(&personalOpts.Name, "name", "", + "个人事件订阅名称") + f.StringVar(&personalOpts.FilterJSON, "filter-json", "", + "个人事件 Filter DSL JSON") + f.StringVar(&personalOpts.QueryCSV, "query", "", + "按消息文本关键词过滤,逗号分隔") + f.DurationVar(&personalOpts.TTL, "ttl", 0, + "个人订阅 TTL (Go duration,如 24h;0 表示不过期)") + f.BoolVar(&personalOpts.Ephemeral, "ephemeral", false, + "强制退出时取消个人订阅。默认已按归属清理:本次新建的订阅退出即取消,"+ + "用 --subscribe-id 复用的订阅保留。优雅停可用 SIGTERM、关闭 stdin,"+ + "或从外部 dws event stop (会一并退订);"+ + "请勿 kill -9(会跳过退订、泄漏服务端订阅)") + f.StringVar(&personalOpts.UserID, "user", "", + "个人单聊对端 userId") + f.StringVar(&personalOpts.GroupID, "group", "", + "group 规则:openConversationId") + f.StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", + "个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws") + f.BoolVar(&personalOpts.DebugRawEvents, "debug-raw-events", false, + "个人事件联调:绕过本地 event type/subscribe_id 过滤,输出当前 personal stream bus 收到的所有事件") + f.StringVar(&streamOpts.Mode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")), + "个人 Stream 建联模式;默认 normal") + f.StringVar(&streamOpts.SourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), + "个人 Stream sourceId;开源版默认 open,可由 edition 覆盖") + f.StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")), + "个人 Stream 取票 URL;默认由 MCP base URL 派生") + hideEventInternalFlags(cmd, "as") + return cmd +} + +// runForegroundBus implements --foreground: instead of dialing an existing +// bus or forking a new one, the current process IS the bus. We construct a +// source.DingtalkSource with the same credentials, then bus.Run in this +// goroutine with a ready pipe wired to stderr-only. +// +// Implementation note: a true --foreground run does NOT spawn a second +// process to consume — the consumer's NDJSON output would then have no +// stdout. For v1 we keep it simple: --foreground runs bus only; the user +// can run `dws event consume` from another shell to consume the events. +// v2 may add a "foreground + in-process consumer" combined mode. +func runForegroundBus(ctx context.Context, cfg consume.Config, configDir, clientSecret string, streamOpts eventStreamTicketOptions) error { + src, err := newEventSource(ctx, configDir, cfg.ClientID, clientSecret, streamOpts) + if err != nil { + return err + } + busCfg := bus.Config{ + WorkDir: cfg.WorkDir, + IPCEndpoint: cfg.IPCEndpoint, + ClientID: cfg.ClientID, + Edition: editionNameOrDefault(), + SourceKind: dwsevent.SourceKindAppStream, + IdentityHash: dwsevent.ClientIDHash(cfg.ClientID), + Source: src, + Logger: slog.Default(), + } + bus.ApplyEnvTuning(&busCfg) + return bus.Run(ctx, busCfg) +} + +type eventStreamTicketOptions struct { + Mode string + SourceID string + TicketURL string +} + +func (o eventStreamTicketOptions) enabled() bool { + return strings.TrimSpace(o.Mode) != "" +} + +func (o eventStreamTicketOptions) normalizedMode() string { + return strings.ToLower(strings.TrimSpace(o.Mode)) +} + +func (o eventStreamTicketOptions) usesPortalNormalMode() bool { + return o.enabled() && o.normalizedMode() == source.PortalTicketModeNormal +} + +func (o eventStreamTicketOptions) spawnArgs() []string { + if !o.enabled() { + return nil + } + args := []string{"--stream-ticket-mode", strings.TrimSpace(o.Mode)} + if sourceID := strings.TrimSpace(o.SourceID); sourceID != "" { + args = append(args, "--stream-source-id", sourceID) + } + if ticketURL := strings.TrimSpace(o.TicketURL); ticketURL != "" { + args = append(args, "--stream-ticket-url", ticketURL) + } + return args +} + +func resolveEventCredentials(configDir string, streamOpts eventStreamTicketOptions) (clientID, clientSecret string, err error) { + if !streamOpts.usesPortalNormalMode() { + clientID, clientSecret, _, _, err = authpkg.ResolveAppCredentialsStrict(configDir) + return clientID, clientSecret, err + } + return eventStreamBusID(streamOpts), "", nil +} + +func eventStreamBusID(streamOpts eventStreamTicketOptions) string { + sourceID := eventStreamSourceID(streamOpts.SourceID) + return "portal-ticket-normal:" + sourceID +} + +func newEventSource(ctx context.Context, configDir, clientID, clientSecret string, streamOpts eventStreamTicketOptions) (*source.DingtalkSource, error) { + if !streamOpts.enabled() { + return source.New(source.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + }) + } + + token, err := ResolveAuxiliaryAccessToken(ctx, configDir, "") + if err != nil { + return nil, fmt.Errorf("event stream ticket: resolve user token: %w", err) + } + if strings.TrimSpace(token) == "" { + return nil, errors.New("event stream ticket: empty user token") + } + + portalClientID := clientID + portalClientSecret := clientSecret + if streamOpts.usesPortalNormalMode() { + portalClientID = "" + portalClientSecret = "" + } + + return source.New(source.Config{ + ClientID: portalClientID, + ClientSecret: portalClientSecret, + PortalTicket: &source.PortalTicketConfig{ + TicketURL: eventStreamTicketURL(streamOpts.TicketURL), + AccessToken: token, + SourceID: eventStreamSourceID(streamOpts.SourceID), + Mode: streamOpts.Mode, + ClientID: portalClientID, + ClientSecret: portalClientSecret, + UserAgent: "dws-event-consume", + }, + }) +} + +func eventStreamTicketURL(raw string) string { + if v := strings.TrimSpace(raw); v != "" { + return v + } + return strings.TrimRight(config.GetMCPBaseURL(), "/") + "/stream/connections/ticket" +} + +func defaultEventStreamSourceID() string { + if v := strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")); v != "" { + return v + } + base := strings.ToLower(config.GetMCPBaseURL()) + if strings.Contains(base, "pre-mcp") { + return "pre_open_source" + } + return "open" +} + +func eventStreamSourceID(raw string) string { + if v := strings.TrimSpace(raw); v != "" { + return v + } + return defaultEventStreamSourceID() +} + +// ───────────────────────────────────────────────────────────────────── +// event _bus (hidden — auto-forked by consume) +// ───────────────────────────────────────────────────────────────────── + +func newEventBusCommand() *cobra.Command { + var ( + clientIDOverride string + idleTimeout time.Duration + sourceKindRaw string + streamOpts eventStreamTicketOptions + ) + cmd := &cobra.Command{ + Use: "_bus", + Short: "Internal event bus daemon (do not call directly)", + Long: "Hidden subcommand auto-spawned by `dws event consume`. Do not invoke directly.", + Hidden: true, + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(c *cobra.Command, _ []string) error { + ctx, cancel := signal.NotifyContext(c.Context(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + // Acquire ReadyPipe early so pre-bus.Run failures can signal + // 'E' to the parent process instead of silently dying. + 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 + } + + configDir := defaultConfigDir() + sourceKind := dwsevent.SourceKind(strings.TrimSpace(sourceKindRaw)) + if sourceKind == "" { + sourceKind = dwsevent.SourceKindAppStream + } + if sourceKind == dwsevent.SourceKindPersonalStream { + identity, err := resolvePersonalEventIdentity(ctx, configDir, streamOpts.SourceID) + if err != nil { + return failEarly(fmt.Errorf("event _bus: %w", err)) + } + if clientIDOverride != "" { + identity.ClientID = clientIDOverride + } + identityHash := dwsevent.IdentityHash(identity.Key()) + editionName := editionNameOrDefault() + workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) + endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) + src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{ + ConfigDir: configDir, + Identity: identity, + TicketMode: streamOpts.Mode, + TicketURL: streamOpts.TicketURL, + ClientIDOverride: clientIDOverride, + }) + if err != nil { + return failEarly(err) + } + if err := os.MkdirAll(workDir, config.DirPerm); err == nil { + if lf, ferr := os.OpenFile(filepath.Join(workDir, "bus.log"), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm); ferr == nil { + defer lf.Close() + slog.SetDefault(slog.New(slog.NewTextHandler(lf, &slog.HandlerOptions{Level: slog.LevelInfo}))) + } + } + busCfg := bus.Config{ + WorkDir: workDir, + IPCEndpoint: endpoint, + ClientID: identity.ClientID, + Edition: editionName, + SourceKind: dwsevent.SourceKindPersonalStream, + IdentityHash: identityHash, + SourceID: identity.SourceID, + Source: src, + IdleTimeout: idleTimeout, + ReadyPipe: readyPipe, + Logger: slog.Default(), + } + bus.ApplyEnvTuning(&busCfg) + return bus.Run(ctx, busCfg) + } + + resolvedID, secret, err := resolveEventCredentials(configDir, streamOpts) + if err != nil { + return failEarly(fmt.Errorf("event _bus: %w", err)) + } + clientID := resolvedID + if clientIDOverride != "" { + clientID = clientIDOverride + } + editionName := editionNameOrDefault() + clientIDHash := dwsevent.ClientIDHash(clientID) + workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash) + endpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash) + + src, err := newEventSource(ctx, configDir, clientID, secret, streamOpts) + if err != nil { + return failEarly(err) + } + + // busLogger writes to bus.log inside WorkDir so the daemon's + // own log lines never pollute stdout/stderr (which busctl/Spawn + // detached). Best-effort: if mkdir / open fails we fall back + // to slog.Default (stderr) so we at least see startup errors. + if err := os.MkdirAll(workDir, config.DirPerm); err == nil { + if lf, ferr := os.OpenFile(filepath.Join(workDir, "bus.log"), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, config.FilePerm); ferr == nil { + defer lf.Close() + slog.SetDefault(slog.New(slog.NewTextHandler(lf, &slog.HandlerOptions{Level: slog.LevelInfo}))) + } + } + + busCfg := bus.Config{ + WorkDir: workDir, + IPCEndpoint: endpoint, + ClientID: clientID, + Edition: editionName, + SourceKind: dwsevent.SourceKindAppStream, + IdentityHash: clientIDHash, + Source: src, + IdleTimeout: idleTimeout, + ReadyPipe: readyPipe, + Logger: slog.Default(), + } + // env-var tuning (only fills in fields left at zero; explicit + // flags above keep precedence). + bus.ApplyEnvTuning(&busCfg) + return bus.Run(ctx, busCfg) + }, + } + cmd.Flags().StringVar(&clientIDOverride, "client-id", "", + "override clientID resolved from app config / env (used by busctl/Spawn)") + cmd.Flags().DurationVar(&idleTimeout, "idle-timeout", 5*time.Minute, + "exit after this long with zero consumers (0 = disabled)") + cmd.Flags().StringVar(&sourceKindRaw, "source-kind", string(dwsevent.SourceKindAppStream), + "event source kind: app_stream|personal_stream") + cmd.Flags().StringVar(&streamOpts.Mode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")), + "用户 Stream 建联模式:空=SDK app credential;normal/custom=portal 取票") + cmd.Flags().StringVar(&streamOpts.SourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), + "用户 Stream sourceId;personal_stream 开源版默认 open") + cmd.Flags().StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")), + "用户 Stream 取票 URL;personal_stream 默认由 MCP base URL 派生") + return cmd +} + +// ───────────────────────────────────────────────────────────────────── +// event list +// ───────────────────────────────────────────────────────────────────── + +// listEntry is one row in `dws event list` output. Combines on-disk bus +// metadata with the live status RPC results so consumers from multiple +// buses can be rendered as a single flat table. +type listEntry struct { + ClientID string `json:"client_id"` + ClientIDHash string `json:"client_id_hash"` + SourceKind dwsevent.SourceKind `json:"source_kind,omitempty"` + SourceID string `json:"source_id,omitempty"` + Edition string `json:"edition"` + BusPID int `json:"bus_pid"` + BusState busctl.BusEntryState `json:"bus_state"` + WorkDir string `json:"workdir"` + Consumers []transport.StatusConsumer `json:"consumers,omitempty"` + // PerType is included in JSON only — text mode renders it in `status`. + PerType map[string]transport.Counters `json:"per_event_type,omitempty"` +} + +func newEventListCommand() *cobra.Command { + var ( + all bool + allEditions bool + formatRaw string + clientIDOver string + asIdentity string + category string + enabledOnly bool + includePending bool + ) + cmd := &cobra.Command{ + Use: "list", + Short: "列出个人事件目录", + Long: "列出当前支持的个人事件目录。", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(c *cobra.Command, _ []string) error { + as, err := normalizeEventAs(asIdentity) + if err != nil { + return err + } + if as == "user" { + if err := rejectPersonalEventUnsupportedFlags(c, "all", "all-editions", "client-id"); err != nil { + return fmt.Errorf("event list: %w", err) + } + return runPersonalEventList(c, personalListOptions{ + Category: category, + EnabledOnly: enabledOnly, + IncludePending: includePending, + Format: formatRaw, + }) + } + if err := rejectChangedFlags(c, "user", "category", "enabled-only", "include-pending"); err != nil { + return fmt.Errorf("event list: %w", err) + } + entries, err := collectEntries(c, clientIDOver, all, allEditions) + if err != nil { + return err + } + rendered := make([]listEntry, 0, len(entries)) + for _, qs := range entries { + rendered = append(rendered, buildListEntry(qs)) + } + return renderList(c.OutOrStdout(), rendered, formatRaw) + }, + } + cmd.Flags().BoolVar(&all, "all", false, "列出当前 edition 下所有 ClientID 的消费者") + cmd.Flags().BoolVar(&allEditions, "all-editions", false, "跨 edition 列出(罕用,调试用)") + cmd.Flags().StringVar(&clientIDOver, "client-id", "", "指定具体 ClientID(覆盖凭证解析)") + cmd.Flags().StringVarP(&formatRaw, "format", "f", "table", "输出格式: table|json") + cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user") + cmd.Flags().StringVar(&category, "category", "", "个人事件目录分类") + cmd.Flags().BoolVar(&enabledOnly, "enabled-only", false, "个人事件目录只显示 enabled") + cmd.Flags().BoolVar(&includePending, "include-pending", false, "个人事件目录包含 pending 项") + hideEventInternalFlags(cmd, "as", "all", "all-editions", "client-id") + return cmd +} + +// ───────────────────────────────────────────────────────────────────── +// event status +// ───────────────────────────────────────────────────────────────────── + +func newEventStatusCommand() *cobra.Command { + var ( + all bool + allEditions bool + formatRaw string + clientIDOver string + failOnOrphan bool + asIdentity string + personalOpts personalStatusOptions + ) + cmd := &cobra.Command{ + Use: "status", + Short: "显示个人事件订阅和本地消费状态", + Long: "显示当前用户个人事件订阅、personal bus 状态和本地 consume 进程。", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + RunE: func(c *cobra.Command, _ []string) error { + as, err := normalizeEventAs(asIdentity) + if err != nil { + return err + } + if as == "user" { + if err := rejectPersonalEventUnsupportedFlags(c, "all", "all-editions", "client-id", "fail-on-orphan"); err != nil { + return fmt.Errorf("event status: %w", err) + } + personalOpts.Format = formatRaw + return runPersonalEventStatus(c, personalOpts) + } + if err := rejectChangedFlags(c, "user", "event", "status", "subscribe-id", "personal-event-base-url", "stream-source-id"); err != nil { + return fmt.Errorf("event status: %w", err) + } + entries, err := collectEntries(c, clientIDOver, all, allEditions) + if err != nil { + return err + } + if err := renderStatus(c.OutOrStdout(), entries, formatRaw); err != nil { + return err + } + if failOnOrphan { + for _, qs := range entries { + if qs.Entry.State == busctl.BusStateOrphan { + return &consume.ValidationError{Msg: "orphan bus detected (--fail-on-orphan set)"} + } + } + } + return nil + }, + } + cmd.Flags().BoolVar(&all, "all", false, "当前 edition 下所有 ClientID") + cmd.Flags().BoolVar(&allEditions, "all-editions", false, "跨 edition") + cmd.Flags().StringVar(&clientIDOver, "client-id", "", "指定具体 ClientID") + cmd.Flags().StringVarP(&formatRaw, "format", "f", "text", "输出格式: text|json") + cmd.Flags().BoolVar(&failOnOrphan, "fail-on-orphan", false, "检测到 orphan 时退出码 2") + cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user") + cmd.Flags().StringVar(&personalOpts.EventKey, "event", "", "个人事件 event_key 过滤") + cmd.Flags().StringVar(&personalOpts.Status, "status", "active", "个人订阅状态过滤: active|paused|error|deleted|all") + cmd.Flags().StringVar(&personalOpts.SubscribeID, "subscribe-id", "", "个人订阅 ID 过滤") + cmd.Flags().StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws") + cmd.Flags().StringVar(&personalOpts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), + "个人事件 sourceId;开源版默认 open,可由 edition 覆盖") + hideEventInternalFlags(cmd, "as", "all", "all-editions", "client-id", "fail-on-orphan") + return cmd +} + +// ───────────────────────────────────────────────────────────────────── +// list/status 共享 helpers +// ───────────────────────────────────────────────────────────────────── + +// collectEntries resolves which BusEntries the command should operate on +// and queries each one's live status. Returns at most one entry when +// neither --all nor --all-editions is set. +func collectEntries(c *cobra.Command, clientIDOver string, all, allEditions bool) ([]busctl.EntryStatus, error) { + configDir := defaultConfigDir() + editionName := editionNameOrDefault() + + // --all-editions trumps --all (scan whole tree) + if allEditions { + entries, err := busctl.EnumerateBuses(configDir, "") + if err != nil { + return nil, err + } + return queryAll(entries), nil + } + if all { + entries, err := busctl.EnumerateBuses(configDir, editionName) + if err != nil { + return nil, err + } + return queryAll(entries), nil + } + + // Single ClientID path. If --client-id passed use it directly, + // otherwise resolve via strict resolver. + clientID := clientIDOver + if clientID == "" { + resolved, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir) + if err != nil { + return nil, fmt.Errorf("event status: resolve credentials: %w (or pass --client-id)", err) + } + clientID = resolved + } + hash := dwsevent.ClientIDHash(clientID) + entry := busctl.FindBusByClientID(configDir, editionName, hash) + if entry == nil { + // No directory at all — render an empty "not running" so the user + // sees a useful answer instead of an error. + return []busctl.EntryStatus{ + {Entry: busctl.BusEntry{ + WorkDir: eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, hash), + Edition: editionName, + SourceKind: dwsevent.SourceKindAppStream, + ClientIDHash: hash, + IdentityHash: hash, + State: busctl.BusStateNotRunning, + Meta: &bus.Meta{ + ClientID: clientID, + Edition: editionName, + SourceKind: dwsevent.SourceKindAppStream, + IdentityHash: hash, + }, + }}, + }, nil + } + if entry.Meta == nil { + entry.Meta = &bus.Meta{ClientID: clientID, Edition: editionName} + } + return []busctl.EntryStatus{busctl.QueryEntry(*entry)}, nil +} + +func queryAll(entries []busctl.BusEntry) []busctl.EntryStatus { + out := make([]busctl.EntryStatus, 0, len(entries)) + for _, e := range entries { + out = append(out, busctl.QueryEntry(e)) + } + return out +} + +func buildListEntry(qs busctl.EntryStatus) listEntry { + le := listEntry{ + ClientIDHash: qs.Entry.ClientIDHash, + SourceKind: qs.Entry.SourceKind, + Edition: qs.Entry.Edition, + BusPID: qs.Entry.HolderPID, + BusState: qs.Entry.State, + WorkDir: qs.Entry.WorkDir, + } + if qs.Entry.Meta != nil { + le.ClientID = qs.Entry.Meta.ClientID + le.SourceID = qs.Entry.Meta.SourceID + } + if qs.Live != nil { + le.Consumers = qs.Live.Consumers + le.PerType = qs.Live.PerEventTypeCounters + } + return le +} + +// renderList prints either a table or a JSON array. +func renderList(w io.Writer, entries []listEntry, format string) error { + if format == "json" { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(entries) + } + // Table: one line per consumer, prefixed by client_id. Buses with no + // consumers still get one row (so users see they exist). + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "SOURCE\tCLIENT_ID\tBUS\tCONSUMER PID\tEVENT KEYS\tSUBSCRIBE_ID\tRECEIVED\tDROPPED") + for _, le := range entries { + busDisplay := fmt.Sprintf("%s pid=%d", le.BusState, le.BusPID) + if le.BusState == busctl.BusStateNotRunning { + busDisplay = string(le.BusState) + } + clientLabel := le.ClientID + if clientLabel == "" { + clientLabel = "(unknown — hash=" + le.ClientIDHash + ")" + } + if len(le.Consumers) == 0 { + fmt.Fprintf(tw, "%s\t%s\t%s\t-\t-\t-\t-\t-\n", sourceKindLabel(le.SourceKind), clientLabel, busDisplay) + continue + } + for _, cs := range le.Consumers { + keys := strings.Join(cs.EventTypes, ",") + if keys == "" { + keys = "(catch-all)" + } + subID := cs.SubscribeID + if subID == "" { + subID = "-" + } + fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%s\t%s\t%d\t%d\n", + sourceKindLabel(le.SourceKind), clientLabel, busDisplay, cs.PID, keys, subID, cs.Received, cs.Dropped) + } + } + return tw.Flush() +} + +// renderStatus prints a multi-line block per bus. JSON mode dumps the raw +// EntryStatus slice. +func renderStatus(w io.Writer, entries []busctl.EntryStatus, format string) error { + if format == "json" { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(entries) + } + for i, qs := range entries { + if i > 0 { + fmt.Fprintln(w) + } + renderStatusBlock(w, qs) + } + return nil +} + +func renderStatusBlock(w io.Writer, qs busctl.EntryStatus) { + clientLabel := qs.Entry.ClientIDHash + if qs.Entry.Meta != nil && qs.Entry.Meta.ClientID != "" { + clientLabel = qs.Entry.Meta.ClientID + } + fmt.Fprintf(w, "ClientID: %s\n", clientLabel) + fmt.Fprintf(w, " Edition : %s\n", qs.Entry.Edition) + fmt.Fprintf(w, " Source : %s\n", sourceKindLabel(qs.Entry.SourceKind)) + if qs.Entry.Meta != nil && qs.Entry.Meta.SourceID != "" { + fmt.Fprintf(w, " SourceID : %s\n", qs.Entry.Meta.SourceID) + } + fmt.Fprintf(w, " Workdir : %s\n", qs.Entry.WorkDir) + + switch qs.Entry.State { + case busctl.BusStateNotRunning: + fmt.Fprintln(w, " Bus : not_running") + return + case busctl.BusStateOrphan: + fmt.Fprintf(w, " Bus : orphan (last_pid=%d not alive)\n", qs.Entry.HolderPID) + if qs.Entry.Meta != nil && !qs.Entry.Meta.StartedAt.IsZero() { + fmt.Fprintf(w, " Started : %s (from bus.meta)\n", qs.Entry.Meta.StartedAt.Format(time.RFC3339)) + } + fmt.Fprintln(w, " Action : run `dws event consume` to force-restart, or rm -rf the workdir") + return + } + + // Running: include live RPC results when present. + uptime := "" + if qs.Live != nil { + uptime = fmt.Sprintf("uptime=%ds", qs.Live.Bus.UptimeSecs) + } else if qs.Entry.Meta != nil && !qs.Entry.Meta.StartedAt.IsZero() { + uptime = fmt.Sprintf("uptime=%s (from bus.meta)", time.Since(qs.Entry.Meta.StartedAt).Round(time.Second)) + } + fmt.Fprintf(w, " Bus : running pid=%d %s\n", qs.Entry.HolderPID, uptime) + + if qs.Live == nil { + fmt.Fprintln(w, " (status RPC failed — bus may be shutting down)") + return + } + live := qs.Live + fmt.Fprintf(w, " Source : state=%s state_source=%s reconnects=%d\n", + live.SourceState.State, live.SourceState.Source, live.SourceState.ReconnectCount) + fmt.Fprintf(w, " Consumers: %d active\n", len(live.Consumers)) + + if len(live.PerEventTypeCounters) > 0 { + fmt.Fprintln(w, " Per-event-type counters (since bus start):") + keys := make([]string, 0, len(live.PerEventTypeCounters)) + for k := range live.PerEventTypeCounters { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + c := live.PerEventTypeCounters[k] + fmt.Fprintf(w, " %s received=%d dropped=%d\n", k, c.Received, c.Dropped) + } + } + if len(live.Consumers) > 0 { + fmt.Fprintln(w, " Consumers:") + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, " PID\tEVENT KEYS\tSUBSCRIBE_ID\tRECEIVED\tDROPPED") + for _, cs := range live.Consumers { + keys := strings.Join(cs.EventTypes, ",") + if keys == "" { + keys = "(catch-all)" + } + subID := cs.SubscribeID + if subID == "" { + subID = "-" + } + fmt.Fprintf(tw, " %d\t%s\t%s\t%d\t%d\n", cs.PID, keys, subID, cs.Received, cs.Dropped) + } + _ = tw.Flush() + } +} + +func newEventStopCommand() *cobra.Command { + var asIdentity string + var opts personalStopOptions + cmd := &cobra.Command{ + Use: "stop [subscribe_id]", + Short: "取消个人事件订阅并停止本地消费", + Long: `取消个人事件订阅并停止本地消费,清理对应本地消费状态,并尝试停止对应前台 consume。 + +传 subscribe_id 时取消该个人订阅;传 --all 时取消当前身份下本地记录的全部个人订阅。 +取消后如果没有剩余本地个人订阅,会停止 personal bus。`, + Args: cobra.MaximumNArgs(1), + DisableAutoGenTag: true, + RunE: func(c *cobra.Command, args []string) error { + as, err := normalizeEventAs(asIdentity) + if err != nil { + return err + } + if as == "user" { + opts.SubscribeID = firstArg(args) + return runPersonalEventStop(c, opts) + } + if err := rejectChangedFlags(c, "user", "all", "personal-event-base-url", "stream-source-id"); err != nil { + return fmt.Errorf("event stop: %w", err) + } + if len(args) > 0 { + return fmt.Errorf("event stop: subscribe_id is only supported with --as user") + } + configDir := defaultConfigDir() + clientID, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir) + if err != nil { + return fmt.Errorf("event stop: %w", err) + } + editionName := editionNameOrDefault() + clientIDHash := dwsevent.ClientIDHash(clientID) + workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash) + if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil { + if errors.Is(err, busctl.ErrNotRunning) { + fmt.Fprintln(c.OutOrStdout(), "bus is not running") + return nil + } + return err + } + fmt.Fprintln(c.OutOrStdout(), "bus stopped") + return nil + }, + } + cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user") + cmd.Flags().StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws") + cmd.Flags().StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), + "个人事件 sourceId;开源版默认 open,可由 edition 覆盖") + cmd.Flags().BoolVar(&opts.All, "all", false, "取消当前身份下本地记录的所有个人订阅") + hideEventInternalFlags(cmd, "as") + return cmd +} + +// ───────────────────────────────────────────────────────────────────── +// helpers +// ───────────────────────────────────────────────────────────────────── + +// editionNameOrDefault returns edition.Get().Name with "open" fallback. +// Centralised so every event subcommand agrees on the path prefix. +func editionNameOrDefault() string { + name := edition.Get().Name + if name == "" { + return "open" + } + return name +} + +// defaultIPCEndpoint returns the canonical Unix socket path / Windows pipe +// name for the bus. Thin wrapper over dwsevent.IPCEndpoint, which owns the +// platform shape and the too-long-socket-path fallback. +func defaultIPCEndpoint(workDir, editionName string, sourceKind dwsevent.SourceKind, identityHash string) string { + return dwsevent.IPCEndpoint(workDir, editionName, sourceKind, identityHash) +} + +func eventWorkDir(configDir, editionName string, sourceKind dwsevent.SourceKind, identityHash string) string { + if sourceKind == "" { + sourceKind = dwsevent.SourceKindAppStream + } + return filepath.Join(configDir, "events", editionName, string(sourceKind), identityHash) +} + +func sourceKindLabel(kind dwsevent.SourceKind) string { + if kind == "" { + return string(dwsevent.SourceKindAppStream) + } + return string(kind) +} + +func normalizeEventAs(v string) (string, error) { + v = strings.ToLower(strings.TrimSpace(v)) + switch v { + case "", "user": + return "user", nil + case "app", "bot": + // App stream implementation is intentionally retained for future use, + // while the public event command currently exposes only user events. + return "", fmt.Errorf("app event is not publicly available yet") + default: + return "", fmt.Errorf("--as only supports user") + } +} + +func hideEventInternalFlags(cmd *cobra.Command, names ...string) { + for _, name := range names { + _ = cmd.Flags().MarkHidden(name) + } +} + +func rejectPersonalEventUnsupportedFlags(c *cobra.Command, names ...string) error { + changed := make([]string, 0, len(names)) + for _, name := range names { + if f := c.Flags().Lookup(name); f != nil && f.Changed { + changed = append(changed, "--"+name) + } + } + if len(changed) == 0 { + return nil + } + return fmt.Errorf("%s are not supported for personal events", strings.Join(changed, ", ")) +} + +func rejectChangedFlags(c *cobra.Command, supportedAs string, names ...string) error { + changed := make([]string, 0, len(names)) + for _, name := range names { + if f := c.Flags().Lookup(name); f != nil && f.Changed { + changed = append(changed, "--"+name) + } + } + if len(changed) == 0 { + return nil + } + return fmt.Errorf("%s are only supported with --as %s", strings.Join(changed, ", "), supportedAs) +} + +func firstArg(args []string) string { + if len(args) == 0 { + return "" + } + 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 { + if len(types) > 0 { + return types + } + return registry.CatchAllEventTypes() +} + +// compile-time guard: avoid "imported and not used" if any of these +// indirect imports become unused after future refactors. +var _ = io.Discard diff --git a/internal/app/event_command_test.go b/internal/app/event_command_test.go new file mode 100644 index 00000000..3e20f5d8 --- /dev/null +++ b/internal/app/event_command_test.go @@ -0,0 +1,69 @@ +// 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 ( + "encoding/json" + "errors" + "os" + "testing" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source" +) + +func writeEventTestAppConfig(t *testing.T, dir string, cfg authpkg.AppConfig) { + t.Helper() + raw, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("marshal app config: %v", err) + } + if err := os.WriteFile(authpkg.GetAppConfigPath(dir), raw, 0o600); err != nil { + t.Fatalf("write app config: %v", err) + } +} + +func TestResolveEventCredentials_PortalNormalAllowsMissingClientSecret(t *testing.T) { + t.Setenv(authpkg.EnvClientID, "") + t.Setenv(authpkg.EnvClientSecret, "") + dir := t.TempDir() + + clientID, clientSecret, err := resolveEventCredentials(dir, eventStreamTicketOptions{ + Mode: source.PortalTicketModeNormal, + SourceID: "pre_open_source", + }) + if err != nil { + t.Fatalf("resolveEventCredentials: %v", err) + } + if clientID != "portal-ticket-normal:pre_open_source" { + t.Fatalf("clientID = %q, want portal-ticket-normal:pre_open_source", clientID) + } + if clientSecret != "" { + t.Fatalf("clientSecret = %q, want empty", clientSecret) + } +} + +func TestResolveEventCredentials_PortalCustomStillRequiresClientSecret(t *testing.T) { + t.Setenv(authpkg.EnvClientID, "") + t.Setenv(authpkg.EnvClientSecret, "") + dir := t.TempDir() + writeEventTestAppConfig(t, dir, authpkg.AppConfig{ClientID: "ding-custom"}) + + _, _, err := resolveEventCredentials(dir, eventStreamTicketOptions{ + Mode: source.PortalTicketModeCustom, + }) + if !errors.Is(err, authpkg.ErrClientSecretEmpty) { + t.Fatalf("err = %v, want ErrClientSecretEmpty", err) + } +} diff --git a/internal/app/event_personal_command.go b/internal/app/event_personal_command.go new file mode 100644 index 00000000..5bc76975 --- /dev/null +++ b/internal/app/event_personal_command.go @@ -0,0 +1,869 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "text/tabwriter" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type commonConsumeOptions struct { + EventTypes []string + Filter string + Compact bool + FormatRaw string + OutputDir string + RoutesRaw []string + MaxEvents int + Duration time.Duration + Quiet bool + Force bool + DryRun bool + Foreground bool +} + +type personalConsumeOptions struct { + Common commonConsumeOptions + EventKey string + DebugRawEvents bool + SubscribeID string + Rule string + Name string + FilterJSON string + QueryCSV string + TTL time.Duration + Ephemeral bool + UserID string + GroupID string + ControlBaseURL string + StreamTicketMode string + StreamTicketURL string + StreamSourceID string +} + +type personalListOptions struct { + Category string + EnabledOnly bool + IncludePending bool + Format string +} + +type personalStatusOptions struct { + EventKey string + Status string + SubscribeID string + Format string + ControlBaseURL string + StreamSourceID string +} + +type personalStopOptions struct { + SubscribeID string + All bool + ControlBaseURL string + StreamSourceID string +} + +type personalStreamSourceOptions struct { + ConfigDir string + Identity personal.Identity + TicketMode string + TicketURL string + ClientIDOverride string +} + +func newEventSchemaCommand() *cobra.Command { + var asIdentity string + var formatRaw string + cmd := &cobra.Command{ + Use: "schema ", + Short: "显示事件 schema", + Args: cobra.ExactArgs(1), + DisableAutoGenTag: true, + RunE: func(c *cobra.Command, args []string) error { + as, err := normalizeEventAs(asIdentity) + if err != nil { + return err + } + if as != "user" { + return fmt.Errorf("event schema is only supported with --as user") + } + def, ok := personal.Lookup(args[0]) + if !ok { + return fmt.Errorf("unknown personal event key %q", args[0]) + } + if !def.Public { + return personal.PublicAvailabilityError(args[0]) + } + return renderPersonalSchema(c.OutOrStdout(), def, formatRaw) + }, + } + cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user") + cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json") + hideEventInternalFlags(cmd, "as") + return cmd +} + +func runPersonalEventList(c *cobra.Command, opts personalListOptions) error { + items := personal.Catalog(opts.Category, opts.EnabledOnly, opts.IncludePending) + if opts.Format == "json" { + enc := json.NewEncoder(c.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(items) + } + tw := tabwriter.NewWriter(c.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "EVENT_KEY\tRULE\tSTATUS\tDESCRIPTION") + for _, it := range items { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", + it.EventKey, it.RuleType, it.Status, it.Description) + } + return tw.Flush() +} + +func renderPersonalSchema(w io.Writer, def personal.Definition, format string) error { + format = strings.ToLower(strings.TrimSpace(format)) + if format == "" { + format = "json" + } + if format != "json" { + return fmt.Errorf("event schema only supports json output") + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(personal.BuildSchemaDocument(def)) +} + +func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error { + ctx := c.Context() + if err := ensurePublicPersonalEvent(opts.EventKey); err != nil { + return err + } + configDir := defaultConfigDir() + identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID) + if err != nil { + return fmt.Errorf("event consume --as user: %w", err) + } + identityHash := dwsevent.IdentityHash(identity.Key()) + editionName := editionNameOrDefault() + workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) + ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) + + routes, err := consume.ParseRoutes(opts.Common.RoutesRaw) + if err != nil { + return fmt.Errorf("event consume --as user: %w", err) + } + rawFormat := "" + if f := c.Flags().Lookup("format"); f != nil && f.Changed { + rawFormat = opts.Common.FormatRaw + } + normalised, fellback := consume.NormalizeFormat(rawFormat) + if fellback && !opts.Common.Quiet { + fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat) + } + + if opts.Common.DryRun { + cfg := consume.Config{ + WorkDir: workDir, + IPCEndpoint: ipcEndpoint, + ClientID: identity.ClientID, + SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)), + Compact: opts.Common.Compact, + MaxEvents: opts.Common.MaxEvents, + Duration: opts.Common.Duration, + EventKey: opts.EventKey, + Format: normalised, + OutputDir: opts.Common.OutputDir, + Routes: routes, + Stderr: c.ErrOrStderr(), + Quiet: opts.Common.Quiet, + Foreground: opts.Common.Foreground, + Force: opts.Common.Force, + DryRun: true, + } + applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey) + return consume.Run(ctx, cfg) + } + + client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity) + sub, eventKey, ruleType, err := ensurePersonalSubscription(ctx, client, identity, opts) + if err != nil { + return fmt.Errorf("event consume --as user: %w", err) + } + if sub.SubscribeID == "" { + return fmt.Errorf("event consume --as user: server returned empty subscribe_id") + } + if err := personal.UpsertRunState(workDir, personal.RunState{ + SubscribeID: sub.SubscribeID, + EventKey: eventKey, + RuleType: ruleType, + ClientID: identity.ClientID, + SourceID: identity.SourceID, + IdentityHash: identityHash, + }); err != nil { + return fmt.Errorf("event consume --as user: save run state: %w", err) + } + cleanup := func() { + _ = client.DeleteSubscription(context.Background(), sub.SubscribeID) + _ = personal.RemoveRunStates(workDir, []string{sub.SubscribeID}) + } + // Ownership-based cleanup (AI-subprocess contract, aligned with + // lark-cli): a subscription this run CREATED is unsubscribed on exit + // (any exit — SIGTERM / stdin-EOF / limit / timeout / error), so nothing + // leaks server-side. A subscription REUSED via --subscribe-id is left + // intact — the caller owns its lifecycle. --ephemeral forces cleanup + // either way. + selfCreated := strings.TrimSpace(opts.SubscribeID) == "" + if opts.Ephemeral || selfCreated { + defer cleanup() + } + + cfg := consume.Config{ + WorkDir: workDir, + IPCEndpoint: ipcEndpoint, + ClientID: identity.ClientID, + SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL), + Compact: opts.Common.Compact, + MaxEvents: opts.Common.MaxEvents, + Duration: opts.Common.Duration, + EventKey: eventKey, + Format: normalised, + OutputDir: opts.Common.OutputDir, + Routes: routes, + Stdout: c.OutOrStdout(), + Stderr: c.ErrOrStderr(), + Quiet: opts.Common.Quiet, + Foreground: opts.Common.Foreground, + Force: opts.Common.Force, + } + // Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded + // run (see shouldWatchStdinEOF). + 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", + workDir, filepath.Join(workDir, "bus.log")) + } + if err := consume.ValidateConfig(cfg); err != nil { + return err + } + if o := c.Flags().Lookup("output"); o != nil && o.Changed { + if err := consume.ValidateNoOutputConflict(cfg, o.Value.String()); err != nil { + return err + } + } + if opts.Common.Foreground { + src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{ + ConfigDir: configDir, + Identity: identity, + TicketMode: opts.StreamTicketMode, + TicketURL: opts.StreamTicketURL, + }) + if err != nil { + if !opts.Ephemeral { + cleanup() + } + return err + } + busCfg := bus.Config{ + WorkDir: workDir, + IPCEndpoint: ipcEndpoint, + ClientID: identity.ClientID, + Edition: editionName, + SourceKind: dwsevent.SourceKindPersonalStream, + IdentityHash: identityHash, + SourceID: identity.SourceID, + Source: src, + } + bus.ApplyEnvTuning(&busCfg) + err = bus.Run(ctx, busCfg) + if err != nil && !opts.Ephemeral { + cleanup() + } + return err + } + err = consume.Run(ctx, cfg) + if err != nil && !opts.Ephemeral { + cleanup() + } + return err +} + +func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) { + if cfg == nil { + return + } + if opts.DebugRawEvents { + cfg.EventTypes = nil + cfg.Filter = "" + cfg.SubscribeID = "" + return + } + cfg.EventTypes = personalEventTypes(eventKey, opts.Common.EventTypes) + cfg.Filter = opts.Common.Filter + cfg.SubscribeID = strings.TrimSpace(subscribeID) +} + +func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) { + if strings.TrimSpace(opts.SubscribeID) != "" { + sub, err := client.GetSubscription(ctx, opts.SubscribeID) + if err != nil { + return nil, "", "", err + } + eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey) + if eventKey == "" { + return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key") + } + if err := ensurePublicPersonalEvent(eventKey); err != nil { + return nil, "", "", err + } + ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule) + if ruleType == "" { + if def, ok := personal.Lookup(eventKey); ok { + ruleType = def.RuleType + } + } + sub.SubscribeID = strings.TrimSpace(opts.SubscribeID) + return sub, eventKey, ruleType, nil + } + if strings.TrimSpace(opts.EventKey) == "" { + return nil, "", "", fmt.Errorf("event_key is required unless --subscribe-id is provided") + } + if err := ensurePublicPersonalEvent(opts.EventKey); err != nil { + return nil, "", "", err + } + ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{ + RuleType: opts.Rule, + UserID: opts.UserID, + GroupID: opts.GroupID, + }) + if err != nil { + return nil, "", "", err + } + filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV) + if err != nil { + return nil, "", "", err + } + req := personal.CreateSubscriptionRequest{ + EventKey: opts.EventKey, + RuleType: ruleType, + Name: opts.Name, + RuleParam: ruleParam, + Filter: filter, + Delivery: map[string]any{"mode": "stream"}, + IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical), + } + if opts.TTL > 0 { + req.TTLSeconds = int64(opts.TTL.Seconds()) + } + sub, err := client.CreateSubscription(ctx, req) + if err != nil { + return nil, "", "", err + } + return sub, opts.EventKey, ruleType, nil +} + +func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error { + ctx := c.Context() + if err := ensurePublicPersonalEvent(opts.EventKey); err != nil { + return err + } + configDir := defaultConfigDir() + identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID) + if err != nil { + return fmt.Errorf("event status --as user: %w", err) + } + identityHash := dwsevent.IdentityHash(identity.Key()) + editionName := editionNameOrDefault() + workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) + entry := busctl.FindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) + var qs busctl.EntryStatus + if entry != nil { + qs = busctl.QueryEntry(*entry) + } else { + qs = busctl.EntryStatus{Entry: busctl.BusEntry{ + WorkDir: workDir, + Edition: editionName, + SourceKind: dwsevent.SourceKindPersonalStream, + ClientIDHash: identityHash, + IdentityHash: identityHash, + State: busctl.BusStateNotRunning, + Meta: &bus.Meta{ + ClientID: identity.ClientID, + Edition: editionName, + SourceKind: dwsevent.SourceKindPersonalStream, + IdentityHash: identityHash, + SourceID: identity.SourceID, + }, + }} + } + status := opts.Status + if status == "" || status == "all" { + status = "" + } + subs, err := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity).ListSubscriptions(ctx, personal.ListOptions{ + Status: status, + EventKey: opts.EventKey, + SubscribeID: opts.SubscribeID, + }) + if err != nil { + return fmt.Errorf("event status --as user: %w", err) + } + if opts.Format == "json" { + enc := json.NewEncoder(c.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(map[string]any{ + "identity": redactedPersonalIdentity(identity, identityHash), + "subscriptions": subs, + "bus": qs, + }) + } + renderPersonalStatusText(c.OutOrStdout(), identity, identityHash, subs, qs) + return nil +} + +func ensurePublicPersonalEvent(eventKey string) error { + eventKey = strings.TrimSpace(eventKey) + if eventKey == "" { + return nil + } + if def, ok := personal.Lookup(eventKey); ok && !def.Public { + return personal.PublicAvailabilityError(eventKey) + } + return nil +} + +func renderPersonalStatusText(w io.Writer, identity personal.Identity, identityHash string, subs []personal.Subscription, qs busctl.EntryStatus) { + fmt.Fprintf(w, "Personal identity: corp=%s user=%s client=%s source=%s hash=%s\n", + displayIdentityPart(identity.CorpID), displayIdentityPart(identity.UserID), identity.ClientID, identity.SourceID, identityHash) + fmt.Fprintf(w, "Bus: %s", qs.Entry.State) + if qs.Entry.HolderPID > 0 { + fmt.Fprintf(w, " pid=%d", qs.Entry.HolderPID) + } + fmt.Fprintf(w, "\nWorkdir: %s\n", qs.Entry.WorkDir) + if len(subs) == 0 { + fmt.Fprintln(w, "Subscriptions: none") + } else { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "SUBSCRIBE_ID\tEVENT_KEY\tRULE\tSTATUS\tSOURCE") + for _, sub := range subs { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + sub.SubscribeID, sub.EventKey, sub.RuleType, sub.Status, sub.SourceID) + } + _ = tw.Flush() + } + renderPersonalConsumers(w, qs) +} + +func renderPersonalConsumers(w io.Writer, qs busctl.EntryStatus) { + if qs.Entry.State != busctl.BusStateRunning { + fmt.Fprintln(w, "Consumers: none") + return + } + if qs.Live == nil { + fmt.Fprintln(w, "Consumers: unavailable (status RPC failed)") + return + } + if len(qs.Live.Consumers) == 0 { + fmt.Fprintln(w, "Consumers: none") + return + } + fmt.Fprintln(w, "Consumers:") + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "PID\tEVENT_KEYS\tSUBSCRIBE_ID\tFILTER\tRECEIVED\tDROPPED") + for _, cs := range qs.Live.Consumers { + eventKeys := strings.Join(cs.EventTypes, ",") + if eventKeys == "" { + eventKeys = "(catch-all)" + } + subscribeID := displayPersonalStatusValue(cs.SubscribeID) + filter := displayPersonalStatusValue(cs.Filter) + fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%d\t%d\n", + cs.PID, eventKeys, subscribeID, filter, cs.Received, cs.Dropped) + } + _ = tw.Flush() +} + +func displayPersonalStatusValue(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "-" + } + return v +} + +func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error { + ctx := c.Context() + explicitSubscribeID := strings.TrimSpace(opts.SubscribeID) + isSingleTarget := explicitSubscribeID != "" + if explicitSubscribeID != "" && opts.All { + return fmt.Errorf("event stop --as user: subscribe_id and --all are mutually exclusive") + } + if explicitSubscribeID == "" && !opts.All { + return fmt.Errorf("event stop --as user: subscribe_id is required unless --all is set") + } + + configDir := defaultConfigDir() + identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID) + if err != nil { + return fmt.Errorf("event stop --as user: %w", err) + } + identityHash := dwsevent.IdentityHash(identity.Key()) + editionName := editionNameOrDefault() + workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) + ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash) + subscribeIDs, err := personalStopTargets(workDir, explicitSubscribeID, opts.All) + if err != nil { + return fmt.Errorf("event stop --as user: %w", err) + } + client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity) + for _, id := range subscribeIDs { + if err := client.DeleteSubscription(ctx, id); err != nil { + return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err) + } + } + if err := personal.RemoveRunStates(workDir, subscribeIDs); err != nil { + return fmt.Errorf("event stop --as user: update local state: %w", err) + } + if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil { + fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err) + } + + remaining, err := personal.LoadRunStates(workDir) + if err != nil { + return fmt.Errorf("event stop --as user: load remaining local state: %w", err) + } + if len(remaining) > 0 { + printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, "personal bus still running") + return nil + } + + busState := "personal bus stopped" + if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil { + if errors.Is(err, busctl.ErrNotRunning) { + busState = "personal bus is not running" + } else { + return err + } + } + printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, busState) + return nil +} + +func personalStopTargets(workDir, explicit string, all bool) ([]string, error) { + explicit = strings.TrimSpace(explicit) + if explicit != "" && all { + return nil, fmt.Errorf("subscribe_id and --all are mutually exclusive") + } + if explicit != "" { + return []string{explicit}, nil + } + if !all { + return nil, fmt.Errorf("subscribe_id is required unless --all is set") + } + states, err := personal.LoadRunStates(workDir) + if err != nil { + return nil, err + } + ids := make([]string, 0, len(states)) + for _, st := range states { + if st.SubscribeID != "" { + ids = append(ids, st.SubscribeID) + } + } + sort.Strings(ids) + return ids, nil +} + +func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error { + targets := make(map[string]struct{}, len(subscribeIDs)) + for _, id := range subscribeIDs { + id = strings.TrimSpace(id) + if id != "" { + targets[id] = struct{}{} + } + } + if ipcEndpoint == "" || len(targets) == 0 { + return nil + } + status, err := busctl.QueryStatus(ipcEndpoint) + if err != nil { + return nil + } + signalled := make(map[int]struct{}) + for _, consumer := range status.Consumers { + if _, ok := targets[strings.TrimSpace(consumer.SubscribeID)]; !ok { + continue + } + if consumer.PID <= 0 || consumer.PID == os.Getpid() { + continue + } + if _, ok := signalled[consumer.PID]; ok { + continue + } + proc, err := os.FindProcess(consumer.PID) + if err != nil { + return fmt.Errorf("find consume pid=%d: %w", consumer.PID, err) + } + if err := proc.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) { + return fmt.Errorf("signal consume pid=%d: %w", consumer.PID, err) + } + signalled[consumer.PID] = struct{}{} + } + return nil +} + +func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, busState string) { + if single && len(subscribeIDs) == 1 { + fmt.Fprintf(w, "cancelled personal subscription %s; %s\n", subscribeIDs[0], busState) + return + } + fmt.Fprintf(w, "cancelled %d personal subscription(s); %s\n", len(subscribeIDs), busState) +} + +func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) { + accessToken, err := ResolveAuxiliaryAccessToken(ctx, configDir, "") + if err != nil { + return personal.Identity{}, err + } + tokenData, _ := authpkg.LoadTokenData(configDir) + var corpID, userID, clientID, refreshToken string + if tokenData != nil { + corpID = tokenData.CorpID + userID = tokenData.UserID + clientID = tokenData.ClientID + refreshToken = tokenData.RefreshToken + } + if corpID == "" { + corpID = resolveRuntimeDefault(ctx, "$corpId") + } + if userID == "" { + userID = resolveRuntimeDefault(ctx, "$currentUserId") + } + if clientID == "" { + clientID = authpkg.ClientID() + } + if clientID == "" { + if id, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir); err == nil { + clientID = id + } + } + if clientID == "" { + return personal.Identity{}, fmt.Errorf("cannot resolve OAuth client_id for personal events") + } + sourceID := strings.TrimSpace(sourceIDOverride) + if sourceID == "" { + sourceID = personalEventStreamSourceID("") + } + localSubject := "" + if strings.TrimSpace(corpID) == "" || strings.TrimSpace(userID) == "" { + localSubject = personalTokenSubject("refresh", refreshToken) + if localSubject == "" { + localSubject = personalTokenSubject("access", accessToken) + } + } + return personal.Identity{ + AccessToken: accessToken, + LocalSubject: localSubject, + CorpID: corpID, + UserID: userID, + ClientID: clientID, + SourceID: sourceID, + }, nil +} + +func personalTokenSubject(kind, token string) string { + token = strings.TrimSpace(token) + if token == "" { + return "" + } + sum := sha256.Sum256([]byte(token)) + return strings.TrimSpace(kind) + ":" + hex.EncodeToString(sum[:]) +} + +func resolveRuntimeDefault(ctx context.Context, key string) string { + if fnMap := edition.Get().RuntimeDefaults; fnMap != nil { + if fn := fnMap()[key]; fn != nil { + if v, ok := fn(ctx); ok { + return strings.TrimSpace(v) + } + } + } + return "" +} + +func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptions) (*source.PersonalSource, error) { + mode := strings.TrimSpace(opts.TicketMode) + if mode == "" { + mode = "normal" + } + if mode != "normal" && mode != "custom" { + return nil, fmt.Errorf("stream ticket mode must be normal or custom") + } + ticketURL := strings.TrimSpace(opts.TicketURL) + if ticketURL == "" { + ticketURL = personalEventStreamTicketURL("", opts.ConfigDir) + } + clientID := opts.Identity.ClientID + clientSecret := "" + if mode == "custom" { + resolvedID, secret, _, _, err := authpkg.ResolveAppCredentialsStrict(opts.ConfigDir) + if err != nil { + return nil, err + } + if opts.ClientIDOverride != "" { + clientID = opts.ClientIDOverride + } else if clientID == "" { + clientID = resolvedID + } + clientSecret = secret + } + _ = ctx + return source.NewPersonal(source.PersonalConfig{ + AccessToken: opts.Identity.AccessToken, + ClientID: clientID, + ClientSecret: clientSecret, + SourceID: opts.Identity.SourceID, + TicketURL: ticketURL, + TicketMode: mode, + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + }) +} + +func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string) []string { + args := []string{ + "--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) + } + if strings.TrimSpace(ticketURL) != "" { + args = append(args, "--stream-ticket-url", ticketURL) + } + return args +} + +func personalEventTypes(eventKey string, explicit []string) []string { + if len(explicit) > 0 { + return explicit + } + if strings.TrimSpace(eventKey) == "" { + return nil + } + return []string{eventKey} +} + +func redactedPersonalIdentity(identity personal.Identity, identityHash string) map[string]string { + return map[string]string{ + "corp_id": displayIdentityPart(identity.CorpID), + "user_id": displayIdentityPart(identity.UserID), + "client_id": identity.ClientID, + "source_id": identity.SourceID, + "identity_hash": identityHash, + } +} + +func displayIdentityPart(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "unknown" + } + return v +} + +func firstNonEmptyPersonalString(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func personalEventControlBaseURL(raw, configDir string) string { + if v := strings.TrimSpace(raw); v != "" { + return strings.TrimRight(v, "/") + } + return personalEventMCPBaseURL(configDir) + personal.DefaultBasePath +} + +func personalEventStreamTicketURL(raw, configDir string) string { + if v := strings.TrimSpace(raw); v != "" { + return strings.TrimRight(v, "/") + } + return personalEventMCPBaseURL(configDir) + "/stream/connections/ticket" +} + +func personalEventStreamSourceID(raw string) string { + if v := strings.TrimSpace(raw); v != "" { + return v + } + if v := strings.TrimSpace(edition.PersonalEventSourceID()); v != "" { + return v + } + return "open" +} + +func personalEventMCPBaseURL(configDir string) string { + if v := configuredMCPBaseURL(configDir); v != "" { + return strings.TrimRight(v, "/") + } + return config.DefaultMCPBaseURL +} + +func configuredMCPBaseURL(configDir string) string { + if strings.TrimSpace(configDir) == "" { + configDir = defaultConfigDir() + } + data, err := os.ReadFile(filepath.Join(configDir, "mcp_url")) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} diff --git a/internal/app/event_personal_debug_test.go b/internal/app/event_personal_debug_test.go new file mode 100644 index 00000000..5ce66182 --- /dev/null +++ b/internal/app/event_personal_debug_test.go @@ -0,0 +1,131 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" +) + +func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) { + cfg := consume.Config{} + opts := personalConsumeOptions{ + DebugRawEvents: true, + Common: commonConsumeOptions{ + EventTypes: []string{"should-not-survive"}, + Filter: "^should-not-survive$", + }, + } + applyPersonalConsumeFilters(&cfg, opts, "sub-1", "user_im_message_receive_o2o") + if cfg.EventTypes != nil || cfg.Filter != "" || cfg.SubscribeID != "" { + t.Fatalf("raw debug filters = eventTypes=%#v filter=%q subscribeID=%q, want catch-all", cfg.EventTypes, cfg.Filter, cfg.SubscribeID) + } +} + +func TestApplyPersonalConsumeFiltersDefault(t *testing.T) { + cfg := consume.Config{} + opts := personalConsumeOptions{Common: commonConsumeOptions{Filter: "^user_im_"}} + applyPersonalConsumeFilters(&cfg, opts, "sub-1", "user_im_message_receive_o2o") + if len(cfg.EventTypes) != 1 || cfg.EventTypes[0] != "user_im_message_receive_o2o" { + t.Fatalf("eventTypes = %#v", cfg.EventTypes) + } + if cfg.Filter != "^user_im_" || cfg.SubscribeID != "sub-1" { + t.Fatalf("filter=%q subscribeID=%q", cfg.Filter, cfg.SubscribeID) + } +} + +func TestEventConsumeDebugRawEventsRequiresUserMode(t *testing.T) { + cmd := newEventConsumeCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--as", "app", "--debug-raw-events"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") { + t.Fatalf("Execute() error = %v, want public availability guard", err) + } +} + +func TestEventConsumeAsAppRejectedBeforeEventKeyValidation(t *testing.T) { + cmd := newEventConsumeCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--as", "app", personal.EventSingleChat}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") { + t.Fatalf("Execute() error = %v, want public availability guard", err) + } +} + +func TestEventConsumePersonalParamSpecFlags(t *testing.T) { + cmd := newEventConsumeCommand() + for _, name := range []string{"user", "group", "query"} { + if cmd.Flags().Lookup(name) == nil { + t.Fatalf("flag --%s is not registered", name) + } + } + for _, name := range []string{ + "peer-user-id", + "peer-union-id", + "sender-user-id", + "sender-union-id", + "open-conversation-id", + "keyword", + } { + if cmd.Flags().Lookup(name) != nil { + t.Fatalf("retired flag --%s is still registered", name) + } + } +} + +func TestEventConsumeRetiredPersonalFlagsAreUnknown(t *testing.T) { + for _, name := range []string{ + "peer-user-id", + "peer-union-id", + "sender-user-id", + "sender-union-id", + "open-conversation-id", + "keyword", + } { + t.Run(name, func(t *testing.T) { + cmd := newEventConsumeCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{personal.EventSingleChat, "--" + name, "x"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "unknown flag: --"+name) { + t.Fatalf("Execute() error = %v, want unknown flag", err) + } + }) + } +} + +func TestEventConsumeAsAppRejectedBeforePersonalParamSpecFlags(t *testing.T) { + for _, args := range [][]string{ + {"--as", "app", "--user", "507971"}, + {"--as", "app", "--group", "cid"}, + {"--as", "app", "--query", "报警"}, + } { + cmd := newEventConsumeCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs(args) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") { + t.Fatalf("Execute(%v) error = %v, want public availability guard", args, err) + } + } +} diff --git a/internal/app/event_personal_identity_test.go b/internal/app/event_personal_identity_test.go new file mode 100644 index 00000000..afdbdbe7 --- /dev/null +++ b/internal/app/event_personal_identity_test.go @@ -0,0 +1,211 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +func TestResolvePersonalEventIdentityUsesCorpUserWhenAvailable(t *testing.T) { + configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{ + AccessToken: "access-1", + RefreshToken: "refresh-1", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: "corp-1", + UserID: "user-1", + ClientID: "client-1", + }) + + identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source") + if err != nil { + t.Fatalf("resolvePersonalEventIdentity() error = %v", err) + } + if identity.LocalSubject != "" { + t.Fatalf("LocalSubject = %q, want empty when corp/user are available", identity.LocalSubject) + } + wantKey := "corp_user\x00corp-1\x00user-1\x00client-1\x00pre_open_source" + if got := identity.Key(); got != wantKey { + t.Fatalf("identity key = %q, want %q", got, wantKey) + } +} + +func TestResolvePersonalEventIdentityFallsBackToRefreshTokenSubject(t *testing.T) { + configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{ + AccessToken: "access-1", + RefreshToken: "refresh-1", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + ClientID: "client-1", + }) + + identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source") + if err != nil { + t.Fatalf("resolvePersonalEventIdentity() error = %v", err) + } + wantSubject := personalTokenSubject("refresh", "refresh-1") + if identity.LocalSubject != wantSubject { + t.Fatalf("LocalSubject = %q, want %q", identity.LocalSubject, wantSubject) + } + if strings.Contains(identity.Key(), "refresh-1") || strings.Contains(identity.Key(), "access-1") { + t.Fatalf("identity key leaked raw token: %q", identity.Key()) + } + + body, err := json.Marshal(redactedPersonalIdentity(identity, "identity-hash-1")) + if err != nil { + t.Fatalf("marshal redacted identity: %v", err) + } + if strings.Contains(string(body), wantSubject) || strings.Contains(string(body), "refresh-1") || strings.Contains(string(body), "access-1") { + t.Fatalf("redacted identity leaked local subject/token: %s", string(body)) + } + if !strings.Contains(string(body), "unknown") { + t.Fatalf("redacted identity should mark missing corp/user as unknown: %s", string(body)) + } +} + +func TestResolvePersonalEventIdentityFallsBackToAccessTokenSubject(t *testing.T) { + configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{ + AccessToken: "access-1", + ExpiresAt: time.Now().Add(time.Hour), + ClientID: "client-1", + }) + + identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source") + if err != nil { + t.Fatalf("resolvePersonalEventIdentity() error = %v", err) + } + wantSubject := personalTokenSubject("access", "access-1") + if identity.LocalSubject != wantSubject { + t.Fatalf("LocalSubject = %q, want %q", identity.LocalSubject, wantSubject) + } + + var out bytes.Buffer + renderPersonalStatusText(&out, identity, "identity-hash-1", nil, busctl.EntryStatus{ + Entry: busctl.BusEntry{WorkDir: "wd", State: busctl.BusStateNotRunning}, + }) + rendered := out.String() + if !strings.Contains(rendered, "corp=unknown user=unknown") { + t.Fatalf("status output = %q, want unknown corp/user", rendered) + } + if strings.Contains(rendered, wantSubject) || strings.Contains(rendered, "access-1") { + t.Fatalf("status output leaked local subject/token: %q", rendered) + } +} + +func TestResolvePersonalEventIdentityDefaultsSourceIDToOpen(t *testing.T) { + configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{ + AccessToken: "access-1", + ExpiresAt: time.Now().Add(time.Hour), + CorpID: "corp-1", + UserID: "user-1", + ClientID: "client-1", + }) + + identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "") + if err != nil { + t.Fatalf("resolvePersonalEventIdentity() error = %v", err) + } + if identity.SourceID != "open" { + t.Fatalf("SourceID = %q, want open", identity.SourceID) + } +} + +func TestPersonalEventDefaultsUseProductionWithoutMCPConfig(t *testing.T) { + dir := t.TempDir() + t.Setenv("DWS_CONFIG_DIR", dir) + prev := edition.Get() + edition.Override(&edition.Hooks{}) + t.Cleanup(func() { edition.Override(prev) }) + + if got := personalEventControlBaseURL("", dir); got != "https://mcp.dingtalk.com/dws" { + t.Fatalf("personalEventControlBaseURL() = %q, want production control URL", got) + } + if got := personalEventStreamTicketURL("", dir); got != "https://mcp.dingtalk.com/stream/connections/ticket" { + t.Fatalf("personalEventStreamTicketURL() = %q, want production ticket URL", got) + } + if got := personalEventStreamSourceID(""); got != "open" { + t.Fatalf("personalEventStreamSourceID() = %q, want open", got) + } + if got := config.GetMCPBaseURL(); got != "https://mcp.dingtalk.com" { + t.Fatalf("config.GetMCPBaseURL() = %q, want production MCP URL", got) + } +} + +func TestPersonalEventDefaultsRespectExplicitAndMCPConfig(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://custom-mcp.example.com\n"), 0o600); err != nil { + t.Fatalf("write mcp_url: %v", err) + } + + if got := personalEventControlBaseURL("", dir); got != "https://custom-mcp.example.com/dws" { + t.Fatalf("personalEventControlBaseURL() = %q, want configured control URL", got) + } + if got := personalEventStreamTicketURL("", dir); got != "https://custom-mcp.example.com/stream/connections/ticket" { + t.Fatalf("personalEventStreamTicketURL() = %q, want configured ticket URL", got) + } + if got := personalEventControlBaseURL(" https://override.example.com/dws/ ", dir); got != "https://override.example.com/dws" { + t.Fatalf("explicit control URL = %q, want trimmed override", got) + } + if got := personalEventStreamTicketURL(" https://override.example.com/ticket/ ", dir); got != "https://override.example.com/ticket" { + t.Fatalf("explicit ticket URL = %q, want trimmed override", got) + } + if got := personalEventStreamSourceID("flag_source"); got != "flag_source" { + t.Fatalf("explicit sourceID = %q, want flag_source", got) + } +} + +func TestPersonalEventSourceIDPrefersEditionOverride(t *testing.T) { + prev := edition.Get() + edition.Override(&edition.Hooks{PersonalEventSourceID: "edition_source"}) + t.Cleanup(func() { edition.Override(prev) }) + + if got := personalEventStreamSourceID(""); got != "edition_source" { + t.Fatalf("personalEventStreamSourceID() = %q, want edition_source", got) + } + if got := personalEventStreamSourceID("flag_source"); got != "flag_source" { + t.Fatalf("explicit sourceID = %q, want flag_source", got) + } +} + +func setupPersonalIdentityToken(t *testing.T, data *authpkg.TokenData) string { + t.Helper() + configDir := t.TempDir() + raw, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal token data: %v", err) + } + prev := edition.Get() + edition.Override(&edition.Hooks{ + LoadToken: func(dir string) ([]byte, error) { + if filepath.Clean(dir) != filepath.Clean(configDir) { + t.Fatalf("LoadToken dir = %q, want %q", dir, configDir) + } + return raw, nil + }, + }) + t.Cleanup(func() { edition.Override(prev) }) + return configDir +} diff --git a/internal/app/event_personal_output_test.go b/internal/app/event_personal_output_test.go new file mode 100644 index 00000000..fc5409b5 --- /dev/null +++ b/internal/app/event_personal_output_test.go @@ -0,0 +1,349 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" + "github.com/spf13/cobra" +) + +func TestPersonalEventListHidesSchemaIDs(t *testing.T) { + for _, tc := range []struct { + name string + args []string + }{ + {name: "table", args: []string{"--as", "user"}}, + {name: "json", args: []string{"--as", "user", "--format", "json"}}, + } { + t.Run(tc.name, func(t *testing.T) { + cmd := newEventListCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs(tc.args) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + got := out.String() + assertPersonalOutputHidesSchemaIDs(t, got) + if strings.Contains(got, personal.EventFromUser) { + t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got) + } + }) + } +} + +func TestEventListDefaultsToUser(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + cmd := newEventListCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + var out bytes.Buffer + cmd.SetOut(&out) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + got := out.String() + if !strings.Contains(got, personal.EventSingleChat) || !strings.Contains(got, "EVENT_KEY") { + t.Fatalf("list output = %s, want personal event catalog", got) + } + if strings.Contains(got, personal.EventFromUser) { + t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got) + } + if strings.Contains(got, "CLIENT_ID") || strings.Contains(got, "ClientSecret") { + t.Fatalf("list default appears to use legacy application output: %s", got) + } +} + +func TestEventPublicHelpHidesAppMode(t *testing.T) { + for _, tc := range []struct { + name string + cmd *cobra.Command + }{ + {name: "consume", cmd: newEventConsumeCommand()}, + {name: "list", cmd: newEventListCommand()}, + {name: "schema", cmd: newEventSchemaCommand()}, + {name: "status", cmd: newEventStatusCommand()}, + {name: "stop", cmd: newEventStopCommand()}, + } { + t.Run(tc.name, func(t *testing.T) { + var out bytes.Buffer + tc.cmd.SetOut(&out) + tc.cmd.SetArgs([]string{"--help"}) + if tc.name == "schema" { + tc.cmd.SetArgs([]string{personal.EventSingleChat, "--help"}) + } + if err := tc.cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + got := out.String() + for _, hidden := range []string{"--as", "user|app", "应用事件" + " Stream"} { + if strings.Contains(got, hidden) { + t.Fatalf("%s help leaked %q:\n%s", tc.name, hidden, got) + } + } + }) + } +} + +func TestEventListAppOnlyFlagsRejectedForPersonalEvents(t *testing.T) { + cmd := newEventListCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--all"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "--all are not supported for personal events") { + t.Fatalf("Execute() error = %v, want unsupported flag validation", err) + } +} + +func TestEventAsAppRejected(t *testing.T) { + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + for _, cmd := range []*cobra.Command{ + newEventListCommand(), + newEventStatusCommand(), + newEventConsumeCommand(), + newEventStopCommand(), + newEventSchemaCommand(), + } { + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--as", "app"}) + if cmd.Use == "schema " { + cmd.SetArgs([]string{personal.EventSingleChat, "--as", "app"}) + } + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") { + t.Fatalf("%s Execute() error = %v, want public availability guard", cmd.Use, err) + } + } +} + +func TestEventStatusAppOnlyFlagsRejectedForPersonalEvents(t *testing.T) { + cmd := newEventStatusCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--all", "--fail-on-orphan"}) + err := cmd.Execute() + if err == nil || + !strings.Contains(err.Error(), "--all") || + !strings.Contains(err.Error(), "--fail-on-orphan") || + !strings.Contains(err.Error(), "not supported for personal events") { + t.Fatalf("Execute() error = %v, want unsupported flag validation", err) + } +} + +func TestPersonalEventSchemaHidesSchemaIDs(t *testing.T) { + for _, tc := range []struct { + name string + args []string + }{ + {name: "default", args: []string{personal.EventSingleChat, "--as", "user"}}, + {name: "json", args: []string{personal.EventSingleChat, "--as", "user", "--format", "json"}}, + } { + t.Run(tc.name, func(t *testing.T) { + cmd := newEventSchemaCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs(tc.args) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + assertPersonalOutputHidesSchemaIDs(t, out.String()) + if strings.Contains(out.String(), "Schemas") { + t.Fatalf("schema output contains Schemas line: %s", out.String()) + } + }) + } +} + +func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) { + for _, eventKey := range []string{ + personal.EventMention, + personal.EventSingleChat, + personal.EventInChat, + } { + t.Run(eventKey, func(t *testing.T) { + cmd := newEventSchemaCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{eventKey}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + got := out.String() + var doc map[string]any + if err := json.Unmarshal(out.Bytes(), &doc); err != nil { + t.Fatalf("schema output for %s is not JSON: %v\n%s", eventKey, err, got) + } + for _, want := range []string{ + "event_key", + "display_name", + "description", + "category", + "rule_type", + "required_params", + "jq_root_path", + "schema", + "event_id", + "timestamp", + "subscribe_id", + "content", + "sender", + "sender_open_dingtalk_id", + "conversation_id", + "message_id", + "create_time", + "event_time", + } { + if !strings.Contains(got, want) { + t.Fatalf("schema output for %s missing %q: %s", eventKey, want, got) + } + } + for _, leaked := range []string{ + "message.text", + "chat.openConversationId", + "sender.userId", + "sender.unionId", + "auth", + "resolved_output_schema", + "decoded_data_schema", + "filter_schema", + "payload_schema", + "output_schema", + "data_json_path", + "headers", + "audit", + "tenant", + "subject", + "traceId", + "msgIdMetaq", + "at_users", + "sender_user_id", + } { + if strings.Contains(got, leaked) { + t.Fatalf("schema output for %s leaked %q: %s", eventKey, leaked, got) + } + } + if doc["jq_root_path"] != ".data | fromjson" { + t.Fatalf("jq_root_path = %#v, want .data | fromjson", doc["jq_root_path"]) + } + schema, ok := doc["schema"].(map[string]any) + if !ok { + t.Fatalf("schema = %#v, want object", doc["schema"]) + } + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatalf("schema.properties = %#v, want object", schema["properties"]) + } + if _, ok := props["content"].(map[string]any); !ok { + t.Fatalf("schema.properties.content = %#v, want object", props["content"]) + } + }) + } +} + +func TestEventSchemaDefaultsToUser(t *testing.T) { + cmd := newEventSchemaCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{personal.EventSingleChat}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + var doc map[string]any + if err := json.Unmarshal(out.Bytes(), &doc); err != nil { + t.Fatalf("schema output is not JSON: %v\n%s", err, out.String()) + } + if doc["event_key"] != personal.EventSingleChat { + t.Fatalf("event_key = %#v, want %s", doc["event_key"], personal.EventSingleChat) + } +} + +func TestPersonalEventFromUserIsNotPubliclyAvailable(t *testing.T) { + for _, tc := range []struct { + name string + cmd *cobra.Command + args []string + }{ + { + name: "schema", + cmd: newEventSchemaCommand(), + args: []string{personal.EventFromUser}, + }, + { + name: "consume", + cmd: newEventConsumeCommand(), + args: []string{personal.EventFromUser, "--user", "507971", "--dry-run"}, + }, + { + name: "status", + cmd: newEventStatusCommand(), + args: []string{"--event", personal.EventFromUser}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + tc.cmd.SilenceUsage = true + tc.cmd.SilenceErrors = true + tc.cmd.SetArgs(tc.args) + err := tc.cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "event "+personal.EventFromUser+" is not publicly available yet") { + t.Fatalf("Execute() error = %v, want not publicly available", err) + } + }) + } +} + +func TestPersonalEventSchemaRejectsTableFormat(t *testing.T) { + cmd := newEventSchemaCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{personal.EventSingleChat, "--format", "table"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "event schema only supports json output") { + t.Fatalf("Execute() error = %v, want json-only format validation", err) + } +} + +func TestEventAsBotRejected(t *testing.T) { + cmd := newEventListCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--as", "bot"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") { + t.Fatalf("Execute() error = %v, want public availability guard", err) + } +} + +func assertPersonalOutputHidesSchemaIDs(t *testing.T, out string) { + t.Helper() + for _, leaked := range []string{"SCHEMA_IDS", "schema_ids", "im_msg_23", "im_msg_29"} { + if strings.Contains(out, leaked) { + t.Fatalf("output leaked %q: %s", leaked, out) + } + } +} diff --git a/internal/app/event_personal_status_test.go b/internal/app/event_personal_status_test.go new file mode 100644 index 00000000..68b4a031 --- /dev/null +++ b/internal/app/event_personal_status_test.go @@ -0,0 +1,105 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "bytes" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +func TestRenderPersonalStatusTextShowsConsumersWithoutSubscriptions(t *testing.T) { + var out bytes.Buffer + renderPersonalStatusText(&out, personal.Identity{ + CorpID: "corp-1", + UserID: "user-1", + ClientID: "client-1", + SourceID: "source-1", + }, "identity-hash-1", nil, busctl.EntryStatus{ + Entry: busctl.BusEntry{ + WorkDir: "wd", + State: busctl.BusStateRunning, + HolderPID: 100, + }, + Live: &transport.StatusResp{ + Consumers: []transport.StatusConsumer{ + { + PID: 12345, + EventTypes: []string{"user_im_message_receive_o2o"}, + SubscribeID: "subId-1", + Filter: "content", + Received: 3, + Dropped: 1, + }, + { + PID: 12346, + Received: 5, + }, + }, + }, + }) + got := out.String() + for _, want := range []string{ + "Subscriptions: none", + "Consumers:", + "PID", + "EVENT_KEYS", + "SUBSCRIBE_ID", + "RECEIVED", + "DROPPED", + "12345", + "user_im_message_receive_o2o", + "subId-1", + "content", + "3", + "1", + "(catch-all)", + "-", + } { + if !strings.Contains(got, want) { + t.Fatalf("status output missing %q:\n%s", want, got) + } + } +} + +func TestRenderPersonalStatusTextConsumersUnavailableWhenRPCFails(t *testing.T) { + var out bytes.Buffer + renderPersonalStatusText(&out, personal.Identity{ClientID: "client-1", SourceID: "source-1"}, "identity-hash-1", nil, busctl.EntryStatus{ + Entry: busctl.BusEntry{ + WorkDir: "wd", + State: busctl.BusStateRunning, + HolderPID: 100, + }, + }) + if got := out.String(); !strings.Contains(got, "Consumers: unavailable (status RPC failed)") { + t.Fatalf("status output = %q, want unavailable consumers", got) + } +} + +func TestRenderPersonalStatusTextConsumersNoneWhenBusNotRunning(t *testing.T) { + var out bytes.Buffer + renderPersonalStatusText(&out, personal.Identity{ClientID: "client-1", SourceID: "source-1"}, "identity-hash-1", nil, busctl.EntryStatus{ + Entry: busctl.BusEntry{ + WorkDir: "wd", + State: busctl.BusStateNotRunning, + }, + }) + if got := out.String(); !strings.Contains(got, "Consumers: none") { + t.Fatalf("status output = %q, want no consumers", got) + } +} diff --git a/internal/app/event_stdin_gate_test.go b/internal/app/event_stdin_gate_test.go new file mode 100644 index 00000000..00da83ab --- /dev/null +++ b/internal/app/event_stdin_gate_test.go @@ -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 ; 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) + } + } +} diff --git a/internal/app/event_stop_test.go b/internal/app/event_stop_test.go new file mode 100644 index 00000000..e8999ae2 --- /dev/null +++ b/internal/app/event_stop_test.go @@ -0,0 +1,127 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "bytes" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal" +) + +func TestEventStopHelpDescribesPersonalSubscription(t *testing.T) { + cmd := newEventStopCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetArgs([]string{"--help"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + got := out.String() + for _, want := range []string{ + "stop [subscribe_id]", + "取消个人事件订阅并停止本地消费", + "取消个人事件订阅并停止本地消费,清理对应本地消费状态", + } { + if !strings.Contains(got, want) { + t.Fatalf("help missing %q:\n%s", want, got) + } + } + for _, stale := range []string{"优雅停止 bus 守护进程", strings.Join([]string{"--as", "app"}, " "), "应用事件"} { + if strings.Contains(got, stale) { + t.Fatalf("help still contains stale public app wording %q:\n%s", stale, got) + } + } +} + +func TestEventStopRequiresSubscribeIDOrAll(t *testing.T) { + cmd := newEventStopCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "subscribe_id is required unless --all is set") { + t.Fatalf("Execute() error = %v, want subscribe_id requirement", err) + } +} + +func TestEventStopSubscribeIDAndAllAreMutuallyExclusive(t *testing.T) { + cmd := newEventStopCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"subId-1", "--all"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "subscribe_id and --all are mutually exclusive") { + t.Fatalf("Execute() error = %v, want mutual exclusion", err) + } +} + +func TestEventStopAsAppRejectsSubscribeID(t *testing.T) { + cmd := newEventStopCommand() + cmd.SilenceUsage = true + cmd.SilenceErrors = true + cmd.SetArgs([]string{"--as", "app", "subId-1"}) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") { + t.Fatalf("Execute() error = %v, want public availability guard", err) + } +} + +func TestPersonalStopTargets(t *testing.T) { + workDir := t.TempDir() + if err := personal.UpsertRunState(workDir, personal.RunState{SubscribeID: "sub-b"}); err != nil { + t.Fatalf("UpsertRunState() error = %v", err) + } + if err := personal.UpsertRunState(workDir, personal.RunState{SubscribeID: "sub-a"}); err != nil { + t.Fatalf("UpsertRunState() error = %v", err) + } + + got, err := personalStopTargets(workDir, "sub-explicit", false) + if err != nil { + t.Fatalf("personalStopTargets(explicit) error = %v", err) + } + if want := []string{"sub-explicit"}; !reflect.DeepEqual(got, want) { + t.Fatalf("explicit targets = %#v, want %#v", got, want) + } + + got, err = personalStopTargets(workDir, "", true) + if err != nil { + t.Fatalf("personalStopTargets(all) error = %v", err) + } + if want := []string{"sub-a", "sub-b"}; !reflect.DeepEqual(got, want) { + t.Fatalf("all targets = %#v, want %#v", got, want) + } + + if _, err := personalStopTargets(workDir, "", false); err == nil || !strings.Contains(err.Error(), "subscribe_id is required unless --all is set") { + t.Fatalf("personalStopTargets(no target) error = %v, want required error", err) + } + if _, err := personalStopTargets(workDir, "sub-explicit", true); err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("personalStopTargets(explicit+all) error = %v, want mutual exclusion", err) + } +} + +func TestPrintPersonalStopResult(t *testing.T) { + var out bytes.Buffer + printPersonalStopResult(&out, []string{"sub-1"}, true, "personal bus stopped") + if got := out.String(); got != "cancelled personal subscription sub-1; personal bus stopped\n" { + t.Fatalf("single output = %q", got) + } + + out.Reset() + printPersonalStopResult(&out, []string{"sub-1", "sub-2"}, false, "personal bus still running") + if got := out.String(); got != "cancelled 2 personal subscription(s); personal bus still running\n" { + t.Fatalf("multi output = %q", got) + } +} diff --git a/internal/app/pat_auth_retry_test.go b/internal/app/pat_auth_retry_test.go index 6bddf530..a1ea8df6 100644 --- a/internal/app/pat_auth_retry_test.go +++ b/internal/app/pat_auth_retry_test.go @@ -590,6 +590,33 @@ func makePATErrorJSONWithURI(flowID, clientID, uri string) string { return string(data) } +func makePATErrorJSONWithAuthorizationURL(flowID, clientID, authURL string) string { + type patData struct { + Desc string `json:"desc"` + FlowID string `json:"flowId"` + AuthorizationURL string `json:"authorizationUrl"` + ClientID string `json:"clientId"` + } + payload := struct { + Code string `json:"code"` + Data patData `json:"data"` + }{ + Code: "AGENT_CODE_NOT_EXISTS", + Data: patData{ + Desc: "test auth", + FlowID: flowID, + AuthorizationURL: authURL, + ClientID: clientID, + }, + } + data, _ := json.Marshal(payload) + return string(data) +} + +func patTestAuthorizationURL(server *httptest.Server) string { + return server.URL + "/pat" +} + func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *testing.T) { rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-copy%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-copy&userCode=QZYH-D64W" raw := makePATErrorJSONWithURI("flow-copy", "test-client-id", rawURI) @@ -664,10 +691,13 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) { func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) { t.Setenv(authpkg.AgentCodeEnv, "") - server, _ := setupHandlePATServer(t, "APPROVED", "") + server, configDir := setupHandlePATServer(t, "APPROVED", "") defer server.Close() + if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil { + t.Fatalf("SetBrowserPolicy(default) error = %v", err) + } - patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")} + patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))} var retried atomic.Bool var retryHadKey atomic.Bool err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{}, patErr, func(ctx context.Context) error { @@ -691,7 +721,7 @@ func TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry(t *testing.T) { server, _ := setupHandlePATServer(t, "APPROVED", "") defer server.Close() - patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")} + patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))} var out bytes.Buffer err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out) if err != nil { @@ -721,7 +751,7 @@ func TestRunDirectPATAuthCheckWaitOnly_SuppressesBrowserOpen(t *testing.T) { } t.Cleanup(func() { openBrowserFunc = origOpenBrowser }) - patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")} + patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))} var out bytes.Buffer err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out) if err != nil { @@ -1196,7 +1226,8 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) { fallback: mock, globalFlags: &GlobalFlags{Format: "table"}, } - raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","authorizationUrl":"https://example.com/pat","clientId":"test-client-id"}}` + authURL := patTestAuthorizationURL(server) + raw := makePATErrorJSONWithAuthorizationURL("flow-approved", "test-client-id", authURL) var buf bytes.Buffer _, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{ @@ -1216,7 +1247,7 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) { if !strings.Contains(buf.String(), "需要 PAT 授权") { t.Fatalf("expected human-readable PAT output, got %q", buf.String()) } - if !strings.Contains(buf.String(), "授权链接: https://example.com/pat") { + if !strings.Contains(buf.String(), "授权链接: "+authURL) { t.Fatalf("expected authorization URL in human-readable PAT output, got %q", buf.String()) } if strings.Contains(buf.String(), "PAT_AUTHORIZATION_URL=") { diff --git a/internal/app/root.go b/internal/app/root.go index eefa78cb..d7fbd2ae 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -356,6 +356,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) newCatalogCommand(loader), newConfigCommand(), newDoctorCommand(), + newEventCommand(), newAuditCommand(), newCompletionCommand(root), newRecoveryCommand(rootCtx, loader, flags), diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index c07424e3..6ca7c5f7 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -64,7 +64,7 @@ skill 源默认取二进制内嵌的版本(升级二进制即升级 skill) dws skill setup --mode mono --yes # 非交互装 mono dws skill setup --mode multi --target claude # multi 全装到 ~/.claude/skills/ dws skill setup --mode multi -s aitable -s calendar # 只装 aitable + calendar - dws skill setup --mode multi -x live -x devdoc # 装其余 18 个,剔除 2 个 + dws skill setup --mode multi -x live -x devdoc # 装其余 20 个,剔除 2 个 dws skill setup --source /path/to/repo # 显式指定 skill 源`, DisableAutoGenTag: true, RunE: runSkillSetup, @@ -128,6 +128,19 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames) } + // --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。 + if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun { + fmt.Fprintf(out, "[DRY-RUN] 预览(不写入任何文件):mode=%s,来源 %s\n", mode, skillSrc) + fmt.Fprintln(out, "将安装到:") + for _, d := range dests { + fmt.Fprintf(out, " - %s\n", d) + } + if mode == skillSetupModeMulti && len(multiSkillNames) > 0 { + fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", ")) + } + return nil + } + if !autoYes { ok, err := confirmSkillSetup(out, mode, skillSrc, dests, multiSkillNames) if err != nil { @@ -203,8 +216,7 @@ func normalizeMultiSkillName(name string) string { return multiSkillPrefix + n } -// filterMultiSkillNames narrows `all` by include / exclude lists. -// Semantics mirror lark-cli's `npx skills add -s lark-calendar`: +// filterMultiSkillNames narrows `all` by include / exclude lists: // // - include + exclude are mutually exclusive (both → error) // - names accept short or full form; normalized before matching @@ -493,7 +505,7 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki if mode == skillSetupModeMulti { fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────") fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)") - fmt.Fprintln(out, " · 20 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable") + fmt.Fprintln(out, " · 22 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable") fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整") fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈") fmt.Fprintln(out, " 稳定版请用 --mode mono") diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go index f6e3651e..d35c5d77 100644 --- a/internal/app/skill_setup_test.go +++ b/internal/app/skill_setup_test.go @@ -445,8 +445,7 @@ func TestFilterMultiSkillNames(t *testing.T) { // TestSkillSetupMultiAdditivePreservesSiblings verifies the key UX promise of // `dws skill setup --mode multi -s aitable`: installing a subset must NOT -// touch already-installed dingtalk-* siblings (additive semantics, matches -// lark-cli `npx skills add -s lark-calendar`). +// touch already-installed dingtalk-* siblings (additive semantics). func TestSkillSetupMultiAdditivePreservesSiblings(t *testing.T) { src := writeMultiSkillSource(t, []string{ "dingtalk-aitable", "dingtalk-calendar", "dingtalk-doc", diff --git a/internal/app/testmain_test.go b/internal/app/testmain_test.go index 75333de3..76d74c17 100644 --- a/internal/app/testmain_test.go +++ b/internal/app/testmain_test.go @@ -35,6 +35,11 @@ import ( // Setting keychain.StorageDirEnv here forces every keychain read/write in // this binary into a per-process tempdir, eliminating that contamination // without touching production code. +// +// PAT authorization tests also exercise code paths that normally open the +// system browser. Keep the package-wide default opener inert so running the +// test binary never launches a page on the developer's machine; tests that +// need to assert the URL can still replace openBrowserFunc locally. func TestMain(m *testing.M) { tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-") if err != nil { @@ -44,6 +49,7 @@ func TestMain(m *testing.M) { _ = os.RemoveAll(tmpDir) panic("set " + keychain.StorageDirEnv + ": " + err.Error()) } + openBrowserFunc = func(string) error { return nil } code := m.Run() _ = os.RemoveAll(tmpDir) os.Exit(code) diff --git a/internal/app/tool_caller_adapter.go b/internal/app/tool_caller_adapter.go index 05b385c0..bd055454 100644 --- a/internal/app/tool_caller_adapter.go +++ b/internal/app/tool_caller_adapter.go @@ -54,6 +54,20 @@ func (a *toolCallerAdapter) DryRun() bool { return a.flags != nil && a.flags.DryRun } +func (a *toolCallerAdapter) Fields() string { + if a.flags != nil { + return a.flags.Fields + } + return "" +} + +func (a *toolCallerAdapter) JQ() string { + if a.flags != nil { + return a.flags.JQ + } + return "" +} + func convertResult(r executor.Result) *edition.ToolResult { resp := r.Response if resp == nil { diff --git a/internal/auth/app_config_strict.go b/internal/auth/app_config_strict.go new file mode 100644 index 00000000..6fd6f767 --- /dev/null +++ b/internal/auth/app_config_strict.go @@ -0,0 +1,157 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "errors" + "fmt" + "os" +) + +// CredentialSource identifies where a particular credential field +// (ClientID or ClientSecret) was loaded from. It is exposed in +// `dws event status` and the HelloAck IPC frame so users can verify which +// credential channel is actually in use — important because env vars, +// keychain, and config file can all coexist and silently override each +// other (see plan §1 决策 "凭证来源拆字段"). +type CredentialSource string + +const ( + CredentialSourceUnknown CredentialSource = "unknown" + CredentialSourceEnv CredentialSource = "env" + CredentialSourceAppConfig CredentialSource = "app_config" // value pulled from app config (plain or SecretRef metadata) + CredentialSourceKeychain CredentialSource = "keychain" // SecretRef resolved through OS keychain + CredentialSourcePlainConfig CredentialSource = "plain_config" // SecretInput stored as plaintext in config file (insecure but supported) +) + +// Strict resolver error sentinels. Use errors.Is to distinguish failure +// modes; see plan §8 strict resolver decision (4 classes). +var ( + // ErrAppConfigMissing — no app config file on disk AND no env-var + // credentials present. Prompt the user to either `dws config init` or + // set DWS_CLIENT_ID + DWS_CLIENT_SECRET. + ErrAppConfigMissing = errors.New("app config missing: run `dws config init` or set DWS_CLIENT_ID/DWS_CLIENT_SECRET env vars") + // ErrClientIDEmpty — neither env nor config supplies a non-empty ClientID. + ErrClientIDEmpty = errors.New("ClientID is empty") + // ErrClientSecretEmpty — there's a ClientID but ClientSecret resolved to "". + ErrClientSecretEmpty = errors.New("ClientSecret is empty") + // ErrSecretResolve — the secret-resolution backend (keychain) failed + // unrecoverably. Typically headless Linux without gnome-keyring, locked + // macOS keychain, or CI sandboxes. Suggest the env-var fallback. + ErrSecretResolve = errors.New("ClientSecret resolution failed (keychain unavailable?); try DWS_CLIENT_ID/DWS_CLIENT_SECRET env vars") +) + +// Env var names used by the env fallback channel. Must be set as a pair — +// any single-variable configuration is rejected so users cannot accidentally +// "set the env half-way" and silently fall back to keychain. +const ( + EnvClientID = "DWS_CLIENT_ID" + EnvClientSecret = "DWS_CLIENT_SECRET" +) + +// ResolveAppCredentialsStrict is the credentials channel used by the event +// subsystem (and by future commands that need fine-grained failure +// reporting). It distinguishes 4 failure classes and reports the source of +// each successfully-resolved field separately. +// +// Resolution order: +// 1. Env var override: if BOTH DWS_CLIENT_ID and DWS_CLIENT_SECRET are +// set non-empty, use them as a pair and skip keychain/config entirely. +// Single-variable configuration is detected and reported via the +// EnvHalfSet flag in the warning channel (callers MAY log a warning). +// 2. App config from disk: +// - ClientID from cfg.ClientID +// - ClientSecret from ResolveSecret(cfg.ClientSecret): +// - SecretInput.IsPlain() → CredentialSourcePlainConfig +// - SecretRef → CredentialSourceKeychain (or whatever Ref.Source says) +// +// Empty returns: clientID and secret may be empty when err is non-nil; +// callers must NOT use them in that case. +func ResolveAppCredentialsStrict(configDir string) ( + clientID, secret string, + clientIDSource, secretSource CredentialSource, + err error, +) { + // Step 1: env var fallback (atomic pair) + envID := os.Getenv(EnvClientID) + envSecret := os.Getenv(EnvClientSecret) + if envID != "" && envSecret != "" { + return envID, envSecret, CredentialSourceEnv, CredentialSourceEnv, nil + } + // Note: if only one of the two is set we explicitly do NOT use it. + // The half-set warning is surfaced via EnvHalfSet() so the CLI can + // stderr-warn the user during preflight. + + // Step 2: app config from disk + cfg, loadErr := LoadAppConfig(configDir) + if loadErr != nil { + return "", "", CredentialSourceUnknown, CredentialSourceUnknown, + fmt.Errorf("load app config: %w", loadErr) + } + if cfg == nil { + return "", "", CredentialSourceUnknown, CredentialSourceUnknown, ErrAppConfigMissing + } + + if cfg.ClientID == "" { + return "", "", CredentialSourceUnknown, CredentialSourceUnknown, ErrClientIDEmpty + } + clientID = cfg.ClientID + clientIDSource = CredentialSourceAppConfig + + // Resolve secret. Source depends on the SecretInput shape: + // - IsPlain (no Ref) → it's stored as plaintext in the config file + // - has Ref → it's a SecretRef pointing at keychain/file + wasPlain := cfg.ClientSecret.IsPlain() + resolved, resolveErr := ResolveSecret(cfg.ClientSecret) + if resolveErr != nil { + return "", "", CredentialSourceUnknown, CredentialSourceUnknown, + fmt.Errorf("%w: %v", ErrSecretResolve, resolveErr) + } + if resolved == "" { + return "", "", clientIDSource, CredentialSourceUnknown, ErrClientSecretEmpty + } + + secret = resolved + if wasPlain { + secretSource = CredentialSourcePlainConfig + } else { + // For SecretRef we map Source verbatim (keychain / file / future) + switch cfg.ClientSecret.Ref.Source { + case "keychain": + secretSource = CredentialSourceKeychain + default: + // File-backed secrets share the "plain_config" category from + // the consumer's perspective: stored as readable bytes outside + // keychain. Status output renders them as "plain_config" so + // users see "secret is not in keychain". + secretSource = CredentialSourcePlainConfig + } + } + + return clientID, secret, clientIDSource, secretSource, nil +} + +// EnvHalfSet reports whether exactly one of (DWS_CLIENT_ID, DWS_CLIENT_SECRET) +// is set. Used by CLI preflight to emit a clear stderr warning of the form: +// +// WARN: DWS_CLIENT_ID is set but DWS_CLIENT_SECRET is not — env fallback +// disabled; using keychain/app config. Set both or unset both to +// avoid this warning. +// +// The strict resolver itself does NOT log; logging is the caller's job. +func EnvHalfSet() bool { + id := os.Getenv(EnvClientID) != "" + secret := os.Getenv(EnvClientSecret) != "" + return id != secret +} diff --git a/internal/auth/app_config_strict_test.go b/internal/auth/app_config_strict_test.go new file mode 100644 index 00000000..1ed44307 --- /dev/null +++ b/internal/auth/app_config_strict_test.go @@ -0,0 +1,165 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +// resetStrictResolverState clears caches the strict resolver shares with +// the existing legacy resolver. Tests must call this between scenarios +// because GetCachedAppConfig and the resolved-credential cache outlive +// individual t.TempDir setups. +func resetStrictResolverState(t *testing.T) { + t.Helper() + cachedAppConfigMu.Lock() + cachedAppConfig = nil + cachedAppConfigMu.Unlock() + cachedResolvedMu.Lock() + cachedResolvedValid = false + cachedResolvedID = "" + cachedResolvedSecret = "" + cachedResolvedMu.Unlock() +} + +// writeAppConfig drops a config JSON into dir. clientSecret == "" produces +// the legacy "no SecretInput field" shape (treated as empty). +func writeAppConfig(t *testing.T, dir, clientID, clientSecret string) { + t.Helper() + cfg := AppConfig{ + ClientID: clientID, + CreatedAt: time.Now(), + } + if clientSecret != "" { + cfg.ClientSecret = PlainSecret(clientSecret) + } + path := GetAppConfigPath(dir) + b, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("marshal: %v", err) + } + if err := os.WriteFile(path, b, 0o600); err != nil { + t.Fatalf("write: %v", err) + } +} + +func unsetEnv(t *testing.T) { + t.Helper() + t.Setenv(EnvClientID, "") + t.Setenv(EnvClientSecret, "") + _ = os.Unsetenv(EnvClientID) + _ = os.Unsetenv(EnvClientSecret) +} + +func TestResolveStrict_AppConfigMissing(t *testing.T) { + resetStrictResolverState(t) + unsetEnv(t) + dir := t.TempDir() + + _, _, _, _, err := ResolveAppCredentialsStrict(dir) + if !errors.Is(err, ErrAppConfigMissing) { + t.Fatalf("err = %v, want ErrAppConfigMissing", err) + } +} + +func TestResolveStrict_ClientIDEmpty(t *testing.T) { + resetStrictResolverState(t) + unsetEnv(t) + dir := t.TempDir() + writeAppConfig(t, dir, "", "some-secret") + + _, _, _, _, err := ResolveAppCredentialsStrict(dir) + if !errors.Is(err, ErrClientIDEmpty) { + t.Fatalf("err = %v, want ErrClientIDEmpty", err) + } +} + +func TestResolveStrict_ClientSecretEmpty(t *testing.T) { + resetStrictResolverState(t) + unsetEnv(t) + dir := t.TempDir() + writeAppConfig(t, dir, "ding_abc", "") + + _, _, _, _, err := ResolveAppCredentialsStrict(dir) + if !errors.Is(err, ErrClientSecretEmpty) { + t.Fatalf("err = %v, want ErrClientSecretEmpty", err) + } +} + +func TestResolveStrict_PlainConfigSuccess(t *testing.T) { + resetStrictResolverState(t) + unsetEnv(t) + dir := t.TempDir() + writeAppConfig(t, dir, "ding_abc", "supersecret123") + + id, secret, idSrc, secretSrc, err := ResolveAppCredentialsStrict(dir) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if id != "ding_abc" { + t.Errorf("id = %q", id) + } + if secret != "supersecret123" { + t.Errorf("secret = %q", secret) + } + if idSrc != CredentialSourceAppConfig { + t.Errorf("idSrc = %s, want app_config", idSrc) + } + if secretSrc != CredentialSourcePlainConfig { + t.Errorf("secretSrc = %s, want plain_config (PlainSecret was used)", secretSrc) + } +} + +func TestResolveStrict_SecretRefFileSuccess(t *testing.T) { + resetStrictResolverState(t) + unsetEnv(t) + dir := t.TempDir() + // Write secret file + secretPath := filepath.Join(dir, "secret.txt") + if err := os.WriteFile(secretPath, []byte("via-file-secret\n"), 0o600); err != nil { + t.Fatal(err) + } + // Write app config with file SecretRef + cfg := AppConfig{ + ClientID: "ding_abc", + ClientSecret: SecretInput{ + Ref: &SecretRef{Source: "file", ID: secretPath}, + }, + CreatedAt: time.Now(), + } + b, _ := json.MarshalIndent(cfg, "", " ") + if err := os.WriteFile(GetAppConfigPath(dir), b, 0o600); err != nil { + t.Fatal(err) + } + + id, secret, idSrc, secretSrc, err := ResolveAppCredentialsStrict(dir) + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if id != "ding_abc" || secret != "via-file-secret" { + t.Errorf("id/secret = %q/%q", id, secret) + } + if idSrc != CredentialSourceAppConfig { + t.Errorf("idSrc = %s", idSrc) + } + // File-backed secrets are reported as plain_config (not in keychain). + if secretSrc != CredentialSourcePlainConfig { + t.Errorf("secretSrc = %s, want plain_config for file-backed secret", secretSrc) + } +} diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index e5a466dc..8d25da53 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -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() diff --git a/internal/auth/keychain_migration.go b/internal/auth/keychain_migration.go new file mode 100644 index 00000000..35f4f362 --- /dev/null +++ b/internal/auth/keychain_migration.go @@ -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 +} diff --git a/internal/auth/keychain_store.go b/internal/auth/keychain_store.go index e0390430..716678b9 100644 --- a/internal/auth/keychain_store.go +++ b/internal/auth/keychain_store.go @@ -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) diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index 49eef99a..1385dab6 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -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) diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 56ba079e..f766096d 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -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)") diff --git a/internal/auth/portable_store.go b/internal/auth/portable_store.go index a32023df..91b1c538 100644 --- a/internal/auth/portable_store.go +++ b/internal/auth/portable_store.go @@ -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() diff --git a/internal/auth/token.go b/internal/auth/token.go index 3582263c..3cf93904 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -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 } diff --git a/internal/auth/token_preflight_test.go b/internal/auth/token_preflight_test.go new file mode 100644 index 00000000..4c7e72ce --- /dev/null +++ b/internal/auth/token_preflight_test.go @@ -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) + } +} diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go index 9a96db9d..7c9a1c00 100644 --- a/internal/cli/canonical.go +++ b/internal/cli/canonical.go @@ -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:; + 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 }, diff --git a/internal/cli/cobra_schema.go b/internal/cli/cobra_schema.go new file mode 100644 index 00000000..2fe33af7 --- /dev/null +++ b/internal/cli/cobra_schema.go @@ -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{:{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:"). 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. 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 + } +} diff --git a/internal/cli/cobra_schema_test.go b/internal/cli/cobra_schema_test.go new file mode 100644 index 00000000..ce597573 --- /dev/null +++ b/internal/cli/cobra_schema_test.go @@ -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[][], 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] +} diff --git a/internal/event/bus/daemon.go b/internal/event/bus/daemon.go new file mode 100644 index 00000000..0e42aa51 --- /dev/null +++ b/internal/event/bus/daemon.go @@ -0,0 +1,525 @@ +// 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 bus + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net" + "os" + "path/filepath" + "sync" + "sync/atomic" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/dedup" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" +) + +// SourceAdapter is the interface daemon.go uses to talk to the cloud Source +// (in practice internal/event/source.DingtalkSource). Kept abstract so the +// bus daemon can be tested without spinning up the real Stream SDK; the +// integration test substitutes a fake. +type SourceAdapter interface { + // Start opens the cloud connection and blocks until ctx is cancelled + // or a fatal error occurs. emit is called for each incoming event. + Start(ctx context.Context, emit dwsevent.EmitFn) error +} + +// Config bundles everything Run needs to start a daemon. All paths and +// identifiers come from busctl/Source so the bus itself stays oblivious +// to ConfigDir / edition rules. +type Config struct { + // WorkDir is the bus working directory: + // /events//// + // The caller MUST mkdir this with pkg/config.DirPerm before calling Run. + WorkDir string + + // IPCEndpoint is the Unix socket path or Windows pipe name. Caller + // computes this from WorkDir (Unix) or edition/clientIDHash (Windows). + IPCEndpoint string + + // ClientID is the human-readable identifier written into bus.meta and + // status output. NOT used in any path. + ClientID string + + // SourceKind/IdentityHash/SourceID are diagnostic identity fields used by + // list/status. Empty SourceKind is interpreted as app_stream for backward + // compatibility. + SourceKind dwsevent.SourceKind + IdentityHash string + SourceID string + + // Edition is written into bus.meta. Comes from edition.Get().Name with + // "open" fallback applied by the caller. + Edition string + + // SDKVersion is recorded in bus.meta for diagnostics. + SDKVersion string + + // Source is the cloud adapter. Required. + Source SourceAdapter + + // IdleTimeout: bus self-exits after this long with zero consumers. + // Zero disables (bus runs until SIGTERM). + IdleTimeout time.Duration + + // ConsumerBuffer overrides per-consumer sendCh capacity. Zero uses + // DefaultSendBuffer. + ConsumerBuffer int + + // DedupCapacity overrides event_id LRU size. Zero uses dedup.DefaultCapacity. + DedupCapacity int + + // DropWarnPercent is the per-event-type drop-rate threshold (whole + // percentage points) that triggers a slog WARN in bus.log. Zero or + // out-of-range values fall back to DefaultDropWarnPercent. Overridable + // via env DWS_EVENT_DROP_WARN_PCT (read by the cobra layer). + DropWarnPercent int + + // ReadyPipe receives a single byte ('R' on success, 'E' on failure) + // once the bus has either come up or failed startup, so the parent + // process forked by busctl/spawn can stop polling and either dial or + // surface the error. nil disables (foreground mode). + ReadyPipe *os.File + + // Logger sink. Nil → slog.Default. + Logger *slog.Logger +} + +// Run starts the bus daemon. Lifecycle (plan §4 invariant #6): +// 1. Acquire bus.lock (single-instance enforcement) +// 2. Write bus.meta +// 3. Listen IPC (so consumers can connect before SDK starts pushing) +// 4. Signal readiness via ReadyPipe +// 5. Start the Source (cloud SDK); concurrent with consumer accept loop +// 6. Wait on ctx for shutdown signal +// 7. Graceful: broadcast Bye → close listener → close source → release lock +// +// Run blocks until ctx is cancelled, the Source returns an error, or a fatal +// startup error occurs. +func Run(ctx context.Context, cfg Config) error { + if cfg.Source == nil { + return errors.New("bus: Source is required") + } + if cfg.Logger == nil { + cfg.Logger = slog.Default() + } + log := cfg.Logger.With("component", "bus", "client_id", cfg.ClientID, "edition", cfg.Edition) + + // 1. Acquire bus.lock + if err := os.MkdirAll(cfg.WorkDir, config.DirPerm); err != nil { + return failReady(cfg.ReadyPipe, fmt.Errorf("bus: mkdir workdir: %w", err)) + } + lockPath := filepath.Join(cfg.WorkDir, LockFileName) + lock, err := Acquire(lockPath) + if err != nil { + return failReady(cfg.ReadyPipe, fmt.Errorf("bus: acquire lock: %w", err)) + } + defer lock.Close() + + // 2. Write bus.meta + meta := Meta{ + ClientID: cfg.ClientID, + Edition: cfg.Edition, + SourceKind: cfg.SourceKind, + IdentityHash: cfg.IdentityHash, + SourceID: cfg.SourceID, + StartedAt: time.Now().UTC(), + SDKVersion: cfg.SDKVersion, + BusPID: os.Getpid(), + } + if err := WriteMeta(cfg.WorkDir, meta); err != nil { + return failReady(cfg.ReadyPipe, fmt.Errorf("bus: write meta: %w", err)) + } + + // 3. IPC listen + listener, err := transport.Listen(cfg.IPCEndpoint) + if err != nil { + return failReady(cfg.ReadyPipe, fmt.Errorf("bus: ipc listen: %w", err)) + } + defer listener.Close() + + hub := NewHub(cfg.ConsumerBuffer) + dd := dedup.NewWithCapacity(cfg.DedupCapacity) + + d := &daemon{ + cfg: cfg, + log: log, + lock: lock, + listener: listener, + hub: hub, + dedup: dd, + started: time.Now().UTC(), + idleStop: make(chan struct{}), + } + + // 4. Signal ready BEFORE accepting consumers (avoids a slow-fork + // scenario where the parent thinks bus is dead but it's actually mid- + // startup). Source.Start hasn't yet pulled events from the cloud, but + // any consumer that connects gets queued for the first events. + signalReady(cfg.ReadyPipe) + + // runCtx is a child of the caller's ctx that ALL background goroutines + // (acceptLoop / idleWatch / dropWarnWatcher / source.Start) listen on. + // On idle-timeout shutdown the parent ctx is never cancelled, so we + // cancel runCtx ourselves before waiting for the goroutines — otherwise + // dropWarnWatcher (which only exits on ctx.Done) hangs forever. + runCtx, cancelRun := context.WithCancel(ctx) + defer cancelRun() + + // 5. Start accept loop and Source concurrently. runCtx cancellation + // propagates to both. + acceptDone := make(chan struct{}) + go func() { + defer close(acceptDone) + d.acceptLoop(runCtx) + }() + + idleDone := make(chan struct{}) + go func() { + defer close(idleDone) + d.idleWatch(runCtx) + }() + + // Drop-rate WARN: per-event-type back-pressure monitoring. Runs in + // the background until runCtx cancellation; emits one WARN per scan + // when a type's drop rate first crosses the threshold (hysteresis + // suppresses repeats unless the rate jumps further). + dropWarnDone := make(chan struct{}) + go func() { + defer close(dropWarnDone) + dropWarnWatcher(runCtx, hub.Counters(), log, cfg.DropWarnPercent) + }() + + srcErr := make(chan error, 1) + go func() { + // emit is called from inside the SDK callback goroutine. It MUST + // NOT block (plan invariant #1) — dedup + Hub.Deliver are both + // non-blocking by construction. + emit := func(raw *dwsevent.RawEvent) { + if raw == nil { + return + } + if dd.Seen(raw.DedupKey()) { + return // duplicate redelivery from the cloud + } + hub.Deliver(raw) + } + srcErr <- cfg.Source.Start(runCtx, emit) + }() + + // 6. Wait for shutdown trigger. + var exitErr error + select { + case <-ctx.Done(): + log.Info("bus: shutdown requested by ctx", "reason", ctx.Err()) + case err := <-srcErr: + log.Error("bus: source exited", "err", err) + exitErr = err + case <-d.idleStop: + log.Info("bus: idle timeout reached, shutting down") + } + + // 7. Graceful shutdown — cancel runCtx first so all background + // goroutines wake up, then close listener / drain consumers. + cancelRun() + d.shutdown() + <-acceptDone + <-idleDone + <-dropWarnDone + + return exitErr +} + +// daemon is the in-memory state of one bus run. Lifetime equals one Run() call. +type daemon struct { + cfg Config + log *slog.Logger + lock *Lock + listener transport.Listener + hub *Hub + dedup *dedup.LRU + started time.Time + + consumerWG sync.WaitGroup // tracks live connection handler goroutines + conns sync.Map // map[net.Conn]struct{} for forced shutdown close + shutdownMu sync.Mutex + shuttingDown atomic.Bool + idleStop chan struct{} +} + +// acceptLoop drives the IPC accept goroutine. Each accepted connection is +// passed to handleConnection in its own goroutine; the accept loop returns +// when the listener Close()s (typically during shutdown). +func (d *daemon) acceptLoop(ctx context.Context) { + for { + conn, err := d.listener.Accept() + if err != nil { + if d.shuttingDown.Load() { + return + } + // Transient errors: log + continue. Non-transient (listener + // closed) shows up as net.ErrClosed → also a clean exit. + if errors.Is(err, net.ErrClosed) { + return + } + d.log.Warn("bus: accept error", "err", err) + continue + } + d.consumerWG.Add(1) + go func() { + defer d.consumerWG.Done() + d.handleConnection(ctx, conn) + }() + } +} + +// handleConnection processes one IPC connection's full lifecycle: read +// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye. +// Always Unregisters and Closes on exit (plan invariant #5). +func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) { + d.conns.Store(conn, struct{}{}) + defer func() { + d.conns.Delete(conn) + conn.Close() + }() + + r := transport.NewReader(conn) + w := transport.NewWriter(conn) + + // Expect Hello first. + var hello transport.Hello + if err := r.ReadJSON(&hello); err != nil { + d.log.Warn("bus: hello read failed", "err", err) + return + } + if hello.Type != transport.FrameTypeHello { + d.log.Warn("bus: first frame not hello", "type", hello.Type) + return + } + + // Ad-hoc tooling (status/list/stop) — short-lived RPC, no Hub register. + if hello.Role == transport.HelloRoleStatus { + d.handleStatusRPC(w, r) + return + } + if hello.Role == transport.HelloRoleStop { + // Signal shutdown by cancelling our parent ctx via shutdown(). + // For now, return after acking — daemon.shutdown is wired through + // daemon's exit path (the busctl/stop command sends SIGTERM in + // addition to this RPC for v1). + _ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "stop_request"}) + go d.triggerShutdown("stop_request") + return + } + + // Regular consumer registration + c, err := d.hub.Register(hello) + if err != nil { + d.log.Warn("bus: register failed", "err", err, "pid", hello.ConsumerPID) + _ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: "register_failed: " + err.Error()}) + return + } + + // HelloAck — credentials_source fields are filled in by the daemon + // runner (which knows from the strict resolver) and exposed via the + // adapter for forward-compat. v1 leaves them empty here; daemon.Run + // passes them through future config if the caller wishes. + idleSecs := int(d.cfg.IdleTimeout / time.Second) + if err := w.WriteJSON(transport.HelloAck{ + Type: transport.FrameTypeHelloAck, + BusPID: os.Getpid(), + SourceState: "connected", // best-effort; full state machine pushed via SourceState frames + StateSource: "inferred", + IdleTimeoutSecs: idleSecs, + }); err != nil { + d.log.Warn("bus: helloack write failed", "err", err) + return + } + + // Writer goroutine pulls from SendCh and writes to the wire. + writerDone := make(chan struct{}) + go func() { + defer close(writerDone) + for frame := range c.SendCh { + if err := w.WriteJSON(frame); err != nil { + // Wire error: peer dead. Returning here will let the + // reader goroutine notice EOF and Unregister. + return + } + } + }() + + // Reader loop: wait for Bye or EOF. Both trigger Unregister. + for { + raw, err := r.Read() + if err != nil { + if !errors.Is(err, io.EOF) { + d.log.Debug("bus: consumer read error", "pid", c.PID, "err", err) + } + break + } + typ, err := transport.PeekType(raw) + if err != nil { + continue + } + if typ == transport.FrameTypeBye { + break + } + // Heartbeat / future client→bus frames: ignored for v1. + } + // Order matters: Unregister first (closes SendCh), then wait for the + // writer goroutine to drain. The reverse order would deadlock because + // the writer loops on `range SendCh` until close, but only the Hub + // can close that channel via Unregister. + d.hub.Unregister(c.ID) + <-writerDone + _ = ctx // for future use (writer ctx-cancel propagation) +} + +// handleStatusRPC services a single status_req and returns. The connection +// is closed by the caller's defer. +func (d *daemon) handleStatusRPC(w *transport.Writer, r *transport.Reader) { + var req transport.StatusReq + if err := r.ReadJSON(&req); err != nil { + return + } + resp := transport.StatusResp{ + Type: transport.FrameTypeStatusResp, + Bus: transport.StatusBus{ + PID: os.Getpid(), + UptimeSecs: int64(time.Since(d.started).Seconds()), + IdleTimeoutSec: int(d.cfg.IdleTimeout / time.Second), + ClientID: d.cfg.ClientID, + Edition: d.cfg.Edition, + SourceKind: d.cfg.SourceKind, + IdentityHash: d.cfg.IdentityHash, + SourceID: d.cfg.SourceID, + }, + SourceState: transport.StatusSource{ + State: "connected", // v1: source state plumbed in P3+ + Source: "inferred", + }, + Consumers: d.hub.Snapshot(), + PerEventTypeCounters: d.hub.Counters().Snapshot(), + } + _ = w.WriteJSON(resp) +} + +// idleWatch fires d.idleStop when IdleTimeout passes with zero registered +// consumers. Disabled when IdleTimeout <= 0 (returns immediately; idleStop +// is then never closed and Run's select branch on it is effectively dead). +// +// Pre-condition: d.idleStop has already been allocated by Run so the parent +// select never races on a nil channel (which would block forever). +func (d *daemon) idleWatch(ctx context.Context) { + if d.cfg.IdleTimeout <= 0 { + return + } + tick := time.NewTicker(d.cfg.IdleTimeout / 4) + defer tick.Stop() + emptySince := time.Time{} + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + if d.hub.Len() == 0 { + if emptySince.IsZero() { + emptySince = time.Now() + } else if time.Since(emptySince) >= d.cfg.IdleTimeout { + close(d.idleStop) + return + } + } else { + emptySince = time.Time{} + } + } + } +} + +// triggerShutdown is called from RPC handlers that want to end the bus. +// It works by closing the listener (which unblocks Run's select via the +// source error path, indirectly). For v1 a full ctx-cancellation hook is +// out of scope; busctl/stop also sends SIGTERM which is the authoritative +// shutdown path. +func (d *daemon) triggerShutdown(reason string) { + d.log.Info("bus: shutdown triggered via IPC", "reason", reason) + _ = d.listener.Close() // unblocks Accept(), but doesn't kill Source + // Best-effort: a future version wires a context.CancelFunc here. +} + +// shutdown performs the graceful tear-down sequence: +// 1. mark shuttingDown so acceptLoop exits cleanly +// 2. broadcast Bye to all consumers +// 3. close listener (interrupts pending Accept) +// 4. wait for all per-connection goroutines to drain +// 5. lock + meta cleanup via Run's defers +func (d *daemon) shutdown() { + d.shutdownMu.Lock() + defer d.shutdownMu.Unlock() + if !d.shuttingDown.CompareAndSwap(false, true) { + return + } + d.hub.Broadcast(transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"}) + _ = d.listener.Close() + // Force-close all open IPC connections so any reader goroutine blocked + // on Read() returns with a network error and exits cleanly. Without + // this the consumerWG never drains and Run hangs forever. + d.conns.Range(func(k, _ any) bool { + if c, ok := k.(net.Conn); ok { + _ = c.Close() + } + return true + }) + // Give consumers a brief moment to drain final frames before we tear + // down their channels. + doneCh := make(chan struct{}) + go func() { + d.consumerWG.Wait() + close(doneCh) + }() + select { + case <-doneCh: + case <-time.After(2 * time.Second): + d.log.Warn("bus: shutdown: consumer goroutines did not drain within 2s") + } +} + +// signalReady writes a single 'R' byte to the ready pipe (if provided) and +// closes it. The parent process (busctl/spawn) reads one byte and proceeds. +func signalReady(p *os.File) { + if p == nil { + return + } + _, _ = p.Write([]byte{'R'}) + _ = p.Close() +} + +// failReady writes 'E' to the ready pipe (if provided) and returns err. +// Used by the startup-failure paths so the parent can distinguish "still +// starting up" from "failed to start". +func failReady(p *os.File, err error) error { + if p != nil { + _, _ = p.Write([]byte{'E'}) + _ = p.Close() + } + return err +} diff --git a/internal/event/bus/daemon_test.go b/internal/event/bus/daemon_test.go new file mode 100644 index 00000000..9cefabbf --- /dev/null +++ b/internal/event/bus/daemon_test.go @@ -0,0 +1,358 @@ +// 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 bus + +import ( + "context" + "errors" + "io" + "os" + "path/filepath" + "runtime" + "testing" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +// fakeSource is a minimal SourceAdapter that emits a configured slice of +// events then blocks on ctx until cancel. Used by daemon integration tests +// in place of the real Stream SDK. +// +// If trigger is non-nil, fakeSource waits for it to close before emitting. +// Tests use this to register a consumer first (avoids the race where events +// flow before Hello completes and end up dropped by Hub for lack of a +// matching consumer). +type fakeSource struct { + events []dwsevent.RawEvent + delay time.Duration // optional delay between emits to let the consumer drain + trigger <-chan struct{} // optional gate; nil = emit immediately +} + +func (f *fakeSource) Start(ctx context.Context, emit dwsevent.EmitFn) error { + if f.trigger != nil { + select { + case <-f.trigger: + case <-ctx.Done(): + return ctx.Err() + } + } + for i := range f.events { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + ev := f.events[i] + ev.ReceivedAt = time.Now().UTC() + emit(&ev) + if f.delay > 0 { + time.Sleep(f.delay) + } + } + <-ctx.Done() + return ctx.Err() +} + +func skipOnWindows(t *testing.T, reason string) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skipf("skip on windows: %s", reason) + } +} + +// shortTempDir returns a temp dir under /tmp so the resulting unix socket +// path stays under the macOS 104-byte sun_path limit. t.TempDir() lives in +// $TMPDIR (/var/folders/.../T/...) which can easily exceed that. +func shortTempDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("/tmp", "dws-bus-") + if err != nil { + t.Fatalf("mktemp: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} + +// waitForFile polls until path exists or timeout elapses. +func waitForFile(t *testing.T, path string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("file %q did not appear within %s", path, timeout) +} + +func TestDaemon_RunStartsAndShutsDownCleanly(t *testing.T) { + skipOnWindows(t, "Unix socket path semantics differ; covered by transport_windows_test") + workDir := shortTempDir(t) + sockPath := filepath.Join(workDir, "bus.sock") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + src := &fakeSource{} + runDone := make(chan error, 1) + go func() { + runDone <- Run(ctx, Config{ + WorkDir: workDir, + IPCEndpoint: sockPath, + ClientID: "ding_test", + Edition: "open", + Source: src, + }) + }() + waitForFile(t, sockPath, 2*time.Second) + + if pid := ReadHolderPID(filepath.Join(workDir, LockFileName)); pid != os.Getpid() { + t.Errorf("bus.lock pid = %d, want %d", pid, os.Getpid()) + } + if _, err := ReadMeta(workDir); err != nil { + t.Errorf("bus.meta missing: %v", err) + } + + cancel() + select { + case err := <-runDone: + if err != nil && !errors.Is(err, context.Canceled) { + t.Fatalf("Run returned %v, want nil or canceled", err) + } + case <-time.After(3 * time.Second): + t.Fatal("Run did not return after ctx cancel") + } +} + +func TestDaemon_ConsumerReceivesEvents(t *testing.T) { + skipOnWindows(t, "uses Unix socket dial") + workDir := shortTempDir(t) + sockPath := filepath.Join(workDir, "bus.sock") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + trigger := make(chan struct{}) + src := &fakeSource{ + events: []dwsevent.RawEvent{ + {EventID: "1", EventType: "im.message.receive_v1", Data: `{"text":"hi"}`}, + {EventID: "2", EventType: "approval.task", Data: `{"task":"x"}`}, + {EventID: "3", EventType: "im.message.at_v1", Data: `{"at":1}`}, + }, + delay: 5 * time.Millisecond, + trigger: trigger, + } + runDone := make(chan error, 1) + go func() { + runDone <- Run(ctx, Config{ + WorkDir: workDir, + IPCEndpoint: sockPath, + ClientID: "ding_test", + Edition: "open", + Source: src, + }) + }() + waitForFile(t, sockPath, 2*time.Second) + + conn, err := transport.Dial(sockPath) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer conn.Close() + + w := transport.NewWriter(conn) + r := transport.NewReader(conn) + if err := w.WriteJSON(transport.Hello{ + Type: transport.FrameTypeHello, + ConsumerPID: os.Getpid(), + EventTypes: []string{"im.*"}, + }); err != nil { + t.Fatalf("write hello: %v", err) + } + + var ack transport.HelloAck + if err := r.ReadJSON(&ack); err != nil { + t.Fatalf("read ack: %v", err) + } + if ack.Type != transport.FrameTypeHelloAck { + t.Fatalf("ack type = %s", ack.Type) + } + + // Consumer is now registered; trigger fakeSource to emit. + close(trigger) + + received := 0 + deadline := time.After(3 * time.Second) + for received < 2 { + select { + case <-deadline: + t.Fatalf("only received %d events, want 2", received) + default: + } + raw, err := r.Read() + if err != nil { + t.Fatalf("read frame: %v", err) + } + typ, _ := transport.PeekType(raw) + if typ == transport.FrameTypeEvent { + received++ + } + } + + cancel() + <-runDone +} + +func TestDaemon_LockBusyOnSecondRun(t *testing.T) { + skipOnWindows(t, "uses Unix socket / flock semantics") + workDir := shortTempDir(t) + sockPath := filepath.Join(workDir, "bus.sock") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + _ = Run(ctx, Config{ + WorkDir: workDir, + IPCEndpoint: sockPath, + ClientID: "ding_test", + Edition: "open", + Source: &fakeSource{}, + }) + }() + waitForFile(t, sockPath, 2*time.Second) + + err := Run(context.Background(), Config{ + WorkDir: workDir, + IPCEndpoint: sockPath + ".other", + ClientID: "ding_test", + Edition: "open", + Source: &fakeSource{}, + }) + if !errors.Is(err, ErrBusy) { + t.Fatalf("second Run = %v, want ErrBusy", err) + } +} + +func TestDaemon_IdleTimeoutSelfStops(t *testing.T) { + skipOnWindows(t, "uses Unix socket") + workDir := shortTempDir(t) + sockPath := filepath.Join(workDir, "bus.sock") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + runDone := make(chan error, 1) + go func() { + runDone <- Run(ctx, Config{ + WorkDir: workDir, + IPCEndpoint: sockPath, + ClientID: "ding_test", + Edition: "open", + Source: &fakeSource{}, + IdleTimeout: 200 * time.Millisecond, + }) + }() + waitForFile(t, sockPath, 2*time.Second) + + select { + case <-runDone: + // success + case <-time.After(3 * time.Second): + t.Fatal("bus did not idle-stop within deadline") + } +} + +func TestDaemon_ReadyPipeSignalsR(t *testing.T) { + skipOnWindows(t, "uses Unix socket + pipe") + workDir := shortTempDir(t) + sockPath := filepath.Join(workDir, "bus.sock") + + pr, pw, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer pr.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go Run(ctx, Config{ + WorkDir: workDir, + IPCEndpoint: sockPath, + ClientID: "ding_test", + Edition: "open", + Source: &fakeSource{}, + ReadyPipe: pw, + }) + + buf := make([]byte, 1) + pr.SetReadDeadline(time.Now().Add(2 * time.Second)) + n, err := pr.Read(buf) + if err != nil && !errors.Is(err, io.EOF) { + t.Fatalf("read ready pipe: %v", err) + } + if n != 1 || buf[0] != 'R' { + t.Fatalf("ready byte = %q n=%d, want 'R'", buf, n) + } + cancel() +} + +func TestDaemon_ConsumerEOFAutoUnregisters(t *testing.T) { + skipOnWindows(t, "uses Unix socket") + workDir := shortTempDir(t) + sockPath := filepath.Join(workDir, "bus.sock") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go Run(ctx, Config{ + WorkDir: workDir, + IPCEndpoint: sockPath, + ClientID: "ding_test", + Edition: "open", + Source: &fakeSource{}, + }) + waitForFile(t, sockPath, 2*time.Second) + + conn, err := transport.Dial(sockPath) + if err != nil { + t.Fatal(err) + } + w := transport.NewWriter(conn) + r := transport.NewReader(conn) + _ = w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, ConsumerPID: 12345}) + var ack transport.HelloAck + _ = r.ReadJSON(&ack) + + // Slam the connection shut without sending Bye. + conn.Close() + + time.Sleep(200 * time.Millisecond) + c2, err := transport.Dial(sockPath) + if err != nil { + t.Fatal(err) + } + defer c2.Close() + w2 := transport.NewWriter(c2) + r2 := transport.NewReader(c2) + _ = w2.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, Role: transport.HelloRoleStatus}) + _ = w2.WriteJSON(transport.StatusReq{Type: transport.FrameTypeStatusReq}) + var resp transport.StatusResp + if err := r2.ReadJSON(&resp); err != nil { + t.Fatalf("read status: %v", err) + } + for _, c := range resp.Consumers { + if c.PID == 12345 { + t.Fatalf("dead consumer 12345 still present in status: %+v", resp.Consumers) + } + } +} diff --git a/internal/event/bus/doc.go b/internal/event/bus/doc.go new file mode 100644 index 00000000..c8736521 --- /dev/null +++ b/internal/event/bus/doc.go @@ -0,0 +1,34 @@ +// 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 bus implements the daemon side of the dws event subsystem: one +// long-lived process per ClientID that holds the single cloud connection +// and fans out events to N local consumers over IPC. +// +// Files (mirroring plan §5 layout): +// +// daemon.go main loop: lock → meta → IPC listen → ready → source.Start +// hub.go consumer registry, per-consumer sendCh, drop-oldest backpressure +// metrics.go per-event-type + per-consumer received/dropped counters +// lockfile.go single bus.lock (flock + PID content + stale recovery) +// meta.go bus.meta JSON (clientID/edition/started_at) for list/status reverse mapping +// +// Lifecycle invariants (plan §4 invariants 1–7): +// 1. emit non-blocking (drop-oldest, never block SDK callback) +// 2. dedup on event_id (LRU) to absorb cloud-side redelivery +// 3. single bus per ClientID (bus.lock enforces, all FS paths use clientIDHash) +// 4. upstream always full subscription; consumer filter only affects bus→consume +// 5. dead-consumer auto-reap on socket EOF +// 6. startup order: lock → meta → IPC listen → ready pipe → Source.Start +// 7. stdio detach when fork'd by busctl/spawn +package bus diff --git a/internal/event/bus/dropwarn.go b/internal/event/bus/dropwarn.go new file mode 100644 index 00000000..3e0e3062 --- /dev/null +++ b/internal/event/bus/dropwarn.go @@ -0,0 +1,81 @@ +// 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 bus + +import ( + "context" + "log/slog" + "time" +) + +// DefaultDropWarnPercent is the threshold above which per-event-type drop +// rate triggers a slog WARN line in bus.log. 5% is the plan default +// (§15 已决项); overridable via DWS_EVENT_DROP_WARN_PCT. +// +// We use whole-percentage granularity (int) because the counter math is +// integer; sub-percent precision would just add noise. +const DefaultDropWarnPercent = 5 + +// dropWarnTickInterval is how often the watcher samples counters. 30s +// balances responsiveness ("see the warning while the burst is still +// happening") with log noise (one warning per scan, not one per drop). +const dropWarnTickInterval = 30 * time.Second + +// dropWarnState memoises the last warned drop rate per event type so we +// only emit a fresh WARN when the situation worsens by at least +// dropWarnHysteresis percentage points. Without hysteresis a steady-state +// burst would re-warn every tick. +const dropWarnHysteresis = 5 + +// dropWarnWatcher periodically samples per-event-type counters; for any +// type whose drop rate crosses the threshold (and hasn't recently been +// warned at the same level), it emits a slog WARN. Runs as a daemon +// goroutine spawned from bus.Run. +// +// Lifecycle: returns when ctx is cancelled (bus shutdown). Never holds +// any external lock — uses the counters' own concurrency-safe Snapshot. +func dropWarnWatcher(ctx context.Context, counters *PerTypeCounters, log *slog.Logger, threshold int) { + if threshold <= 0 || threshold > 100 { + threshold = DefaultDropWarnPercent + } + tick := time.NewTicker(dropWarnTickInterval) + defer tick.Stop() + lastWarnedPct := make(map[string]int) + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + for _, et := range counters.SortedTypes() { + pct := counters.DropRatePercent(et) + if pct < threshold { + // drop rate is healthy → forget any prior warning so + // a future spike re-triggers a fresh WARN + delete(lastWarnedPct, et) + continue + } + prev, warned := lastWarnedPct[et] + if warned && pct < prev+dropWarnHysteresis { + continue // not significantly worse; suppress + } + log.Warn("bus: event type backpressure", + "event_type", et, + "drop_pct", pct, + "threshold_pct", threshold, + ) + lastWarnedPct[et] = pct + } + } + } +} diff --git a/internal/event/bus/dropwarn_test.go b/internal/event/bus/dropwarn_test.go new file mode 100644 index 00000000..6ac60617 --- /dev/null +++ b/internal/event/bus/dropwarn_test.go @@ -0,0 +1,51 @@ +// 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 bus + +import ( + "testing" +) + +// dropWarnWatcher is exercised end-to-end by daemon integration tests +// (it runs as a goroutine inside bus.Run). At the unit level we just +// verify the threshold-clamping behaviour in isolation so a misconfigured +// env var doesn't disable the safety net silently. +func TestDropWarnWatcher_ThresholdClamp(t *testing.T) { + // The function applies its own clamp before using the threshold; + // we re-derive the expected effective value through the same path + // (calling helper-style code below). + for _, in := range []int{-1, 0, 101, 1000} { + got := clampDropWarnPctForTest(in) + if got != DefaultDropWarnPercent { + t.Errorf("invalid threshold %d should clamp to %d, got %d", + in, DefaultDropWarnPercent, got) + } + } + for _, in := range []int{1, 5, 10, 50, 100} { + if got := clampDropWarnPctForTest(in); got != in { + t.Errorf("valid threshold %d should pass through, got %d", in, got) + } + } +} + +// clampDropWarnPctForTest mirrors the clamp logic in dropWarnWatcher. +// Kept separate so the public Watcher signature does not have to expose +// internal validation as a method — the watcher reads threshold from +// closure, and tests want to assert on the validation contract. +func clampDropWarnPctForTest(threshold int) int { + if threshold <= 0 || threshold > 100 { + return DefaultDropWarnPercent + } + return threshold +} diff --git a/internal/event/bus/hub.go b/internal/event/bus/hub.go new file mode 100644 index 00000000..8aae86a1 --- /dev/null +++ b/internal/event/bus/hub.go @@ -0,0 +1,399 @@ +// 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 bus + +import ( + "regexp" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +// DefaultSendBuffer is the per-consumer channel capacity used when the Hub +// is constructed via NewHub. Sized to absorb a short event burst (~100ms at +// ~1k evt/s) without backpressure. Overridable via DWS_EVENT_CONSUMER_BUFFER +// at daemon start (plan §15 已决但暴露方式). +const DefaultSendBuffer = 100 + +// Consumer represents one registered IPC connection to the bus. Wire-side +// reader/writer goroutines are owned by daemon.go; the Hub holds the +// metadata + sendCh. +type Consumer struct { + ID int // monotonic, assigned by Hub + PID int // from Hello.ConsumerPID + EventTypes []string // raw wildcard patterns from Hello + Filter string // raw regex from Hello (for status display) + SubscribeID string // optional personal subscription label and local isolation key + SubscribedAt time.Time + SendCh chan any // bus → consume frames (Event/SourceState/Heartbeat/Bye) + matcher consumerMatcher + sendMu sync.Mutex // serialises Deliver/Broadcast with SendCh close + closed bool // guarded by sendMu + seq atomic.Uint64 // monotonic per-consumer sequence, starts at 1 + received atomic.Uint64 + dropped atomic.Uint64 +} + +// consumerMatcher pre-compiles EventTypes wildcard patterns and the optional +// Filter regex into a fast checker invoked once per delivered event per +// consumer. Empty EventTypes means catch-all (everything matches except +// what Filter excludes). Non-empty SubscribeID is an additional exact-match +// constraint used by personal_stream consumers so same event_type subscriptions +// do not fan out to each other. +type consumerMatcher struct { + catchAll bool + exact map[string]struct{} // patterns without '*' + prefixes []string // patterns ending in ".*" or "*" — store the prefix only + filter *regexp.Regexp // nil if no filter + subscribeID string // empty = no subscribe_id filtering +} + +func compileMatcher(eventTypes []string, filter string, subscribeID string) (consumerMatcher, error) { + m := consumerMatcher{ + exact: make(map[string]struct{}), + subscribeID: strings.TrimSpace(subscribeID), + } + if len(eventTypes) == 0 { + m.catchAll = true + } + for _, raw := range eventTypes { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + // Treat trailing '*' or '.*' as a prefix wildcard (e.g. "im.*", + // "im.message.*"). Middle/leading wildcards are rare for event_type + // strings — if a real use case appears we can switch to regex + // compilation here without breaking the wire format. + switch { + case raw == "*": + m.catchAll = true + case strings.HasSuffix(raw, ".*"): + m.prefixes = append(m.prefixes, raw[:len(raw)-1]) // keep the dot, drop the * + case strings.HasSuffix(raw, "*"): + m.prefixes = append(m.prefixes, raw[:len(raw)-1]) + default: + m.exact[raw] = struct{}{} + } + } + if filter != "" { + re, err := regexp.Compile(filter) + if err != nil { + return consumerMatcher{}, err + } + m.filter = re + } + return m, nil +} + +func (m *consumerMatcher) matches(raw *dwsevent.RawEvent) bool { + if raw == nil { + return false + } + if m.subscribeID != "" && raw.SubscribeID != m.subscribeID { + return false + } + eventType := raw.EventType + // First the include rules: catchAll OR exact-list OR prefix-list. + included := m.catchAll + if !included { + if _, ok := m.exact[eventType]; ok { + included = true + } + } + if !included { + for _, p := range m.prefixes { + if strings.HasPrefix(eventType, p) { + included = true + break + } + } + } + if !included { + return false + } + // Filter is an additional AND constraint (regex on the event_type + // string). Empty filter is no-op. + if m.filter != nil && !m.filter.MatchString(eventType) { + return false + } + return true +} + +// Hub is the bus's fan-out engine. It owns the set of registered consumers +// and the bus-wide per-event-type counters. The Hub is concurrency-safe +// across Register/Unregister/Deliver/Snapshot; Deliver is the hot path and +// is RLock-only. +type Hub struct { + mu sync.RWMutex + consumers map[int]*Consumer + nextID int + bufferSize int + counters *PerTypeCounters +} + +// NewHub returns a Hub with the given per-consumer channel buffer size. +// Zero or negative uses DefaultSendBuffer. +func NewHub(bufferSize int) *Hub { + if bufferSize <= 0 { + bufferSize = DefaultSendBuffer + } + return &Hub{ + consumers: make(map[int]*Consumer), + bufferSize: bufferSize, + counters: NewPerTypeCounters(), + } +} + +// Counters exposes the bus-wide per-event-type counter set for daemon-side +// rendering (status RPC, drop-rate warning). +func (h *Hub) Counters() *PerTypeCounters { return h.counters } + +// RegisterError wraps the matcher compile error so the daemon can refuse +// the Hello and return a clean error to the consume client (instead of +// silently accepting a bad filter regex). +type RegisterError struct{ Err error } + +func (e *RegisterError) Error() string { return "bus: register consumer: " + e.Err.Error() } +func (e *RegisterError) Unwrap() error { return e.Err } + +// Register adds a consumer derived from a Hello frame. Returns a new +// Consumer with the populated ID + sendCh ready to use, or a RegisterError +// if the Hello's Filter regex is invalid. +func (h *Hub) Register(hello transport.Hello) (*Consumer, error) { + m, err := compileMatcher(hello.EventTypes, hello.Filter, hello.SubscribeID) + if err != nil { + return nil, &RegisterError{Err: err} + } + h.mu.Lock() + defer h.mu.Unlock() + h.nextID++ + c := &Consumer{ + ID: h.nextID, + PID: hello.ConsumerPID, + EventTypes: append([]string(nil), hello.EventTypes...), + Filter: hello.Filter, + SubscribeID: strings.TrimSpace(hello.SubscribeID), + SubscribedAt: time.Now().UTC(), + SendCh: make(chan any, h.bufferSize), + matcher: m, + } + h.consumers[c.ID] = c + return c, nil +} + +// Unregister removes a consumer by ID and closes its sendCh. Idempotent — +// calling twice or on an unknown ID is a no-op. closeSend shares the same +// per-consumer lock as Deliver/Broadcast, so a stale Hub snapshot cannot send +// to the channel after it has been closed. +func (h *Hub) Unregister(id int) { + h.mu.Lock() + c, ok := h.consumers[id] + if !ok { + h.mu.Unlock() + return + } + delete(h.consumers, id) + h.mu.Unlock() + c.closeSend() +} + +// Deliver fans the raw event out to every matching consumer. Updates +// bus-wide and per-consumer counters. Always non-blocking — drops the +// oldest entry in any full sendCh (plan invariant #1). +// +// Called from the bus daemon's main loop after dedup; safe for concurrent +// callers (Hub uses an RLock, Consumer drop-oldest is single-producer-safe +// because the daemon serialises Deliver per event). +func (h *Hub) Deliver(raw *dwsevent.RawEvent) { + if raw == nil { + return + } + h.counters.AddReceived(raw.EventType) + + h.mu.RLock() + matched := make([]*Consumer, 0, len(h.consumers)) + for _, c := range h.consumers { + if c.matcher.matches(raw) { + matched = append(matched, c) + } + } + h.mu.RUnlock() + + for _, c := range matched { + c.deliver(raw, h.counters) + } +} + +// deliver builds the per-consumer Event frame (assigning seq) and pushes +// it onto sendCh with drop-oldest semantics. Updates per-consumer and +// bus-wide drop counters. +func (c *Consumer) deliver(raw *dwsevent.RawEvent, hubCounters *PerTypeCounters) { + c.sendMu.Lock() + defer c.sendMu.Unlock() + if c.closed { + return + } + + seq := c.seq.Add(1) + frame := transport.Event{ + Type: transport.FrameTypeEvent, + Seq: seq, + EventID: raw.EventID, + EventBornTime: raw.EventBornTime, + EventCorpID: raw.EventCorpID, + EventType: raw.EventType, + EventUnifiedAppID: raw.EventUnifiedAppID, + EventScope: raw.EventScope, + SubscribeID: raw.SubscribeID, + SourceID: raw.SourceID, + RuleType: raw.RuleType, + Data: raw.Data, + Headers: raw.Headers, + ReceivedAtUnixMS: raw.ReceivedAt.UnixMilli(), + } + success, evicted := c.tryPushOrDropOldestLocked(frame) + if evicted { + // An older event we had previously enqueued is gone. + c.dropped.Add(1) + c.received.Add(^uint64(0)) // -1 + hubCounters.AddDropped(raw.EventType) + } + if success { + c.received.Add(1) + } else { + // New event also didn't make it (rare: lost the race after eviction) + c.dropped.Add(1) + if !evicted { + // No eviction happened but push still failed → unexpected; only + // reached via concurrent reader-then-something. Count the bus-wide + // drop too so the metric matches per-consumer drops. + hubCounters.AddDropped(raw.EventType) + } + } +} + +// tryPushOrDropOldestLocked tries to push frame to SendCh non-blockingly. If +// full, it pops the oldest entry to make room and tries once more. Caller must +// hold c.sendMu, which makes drop-oldest a true single-producer operation even +// when Deliver and Broadcast run concurrently. +// +// Returns: +// +// success: true if the new frame is now in the channel +// evicted: true if we removed an older queued frame to make room +// +// "received" semantics in deliver() interpret these: +// +// success=true, evicted=false → +1 received (normal push) +// success=true, evicted=true → net 0 (lost 1, gained 1); +1 dropped +// success=false, evicted=true → -1 received, +2 dropped (both lost; rare) +// success=false, evicted=false → only possible if reader drained between +// try and we still missed (extremely rare); +1 dropped only. +// +// The per-consumer send lock makes this block the sole SendCh producer, so the +// second push cannot race another producer. The transport writer may receive +// between our two operations, which only makes more room — never less. +func (c *Consumer) tryPushOrDropOldestLocked(frame any) (success bool, evicted bool) { + select { + case c.SendCh <- frame: + return true, false + default: + } + // Drop oldest, then retry. + select { + case <-c.SendCh: + evicted = true + default: + // Reader took it, room is back. + } + select { + case c.SendCh <- frame: + return true, evicted + default: + return false, evicted + } +} + +func (c *Consumer) enqueue(frame any) bool { + c.sendMu.Lock() + defer c.sendMu.Unlock() + if c.closed { + return false + } + success, _ := c.tryPushOrDropOldestLocked(frame) + return success +} + +func (c *Consumer) closeSend() { + c.sendMu.Lock() + defer c.sendMu.Unlock() + if c.closed { + return + } + c.closed = true + close(c.SendCh) +} + +// Broadcast sends the same frame (e.g. SourceState change, Bye on shutdown) +// to every consumer using drop-oldest semantics. Returns the number of +// consumers the frame was successfully enqueued for. +func (h *Hub) Broadcast(frame any) int { + h.mu.RLock() + cs := make([]*Consumer, 0, len(h.consumers)) + for _, c := range h.consumers { + cs = append(cs, c) + } + h.mu.RUnlock() + ok := 0 + for _, c := range cs { + if c.enqueue(frame) { + ok++ + } + } + return ok +} + +// Snapshot returns a deterministic StatusConsumer slice (sorted by PID) +// for status RPC encoding. Caller MUST NOT mutate the returned slice. +func (h *Hub) Snapshot() []transport.StatusConsumer { + h.mu.RLock() + defer h.mu.RUnlock() + out := make([]transport.StatusConsumer, 0, len(h.consumers)) + for _, c := range h.consumers { + out = append(out, transport.StatusConsumer{ + PID: c.PID, + EventTypes: append([]string(nil), c.EventTypes...), + Filter: c.Filter, + SubscribeID: c.SubscribeID, + SubscribedAtMS: c.SubscribedAt.UnixMilli(), + Received: c.received.Load(), + Dropped: c.dropped.Load(), + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID }) + return out +} + +// Len returns the current number of registered consumers. +func (h *Hub) Len() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.consumers) +} diff --git a/internal/event/bus/hub_test.go b/internal/event/bus/hub_test.go new file mode 100644 index 00000000..251c3542 --- /dev/null +++ b/internal/event/bus/hub_test.go @@ -0,0 +1,411 @@ +// 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 bus + +import ( + "errors" + "sync" + "testing" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +func mkEvent(typ, id string) *dwsevent.RawEvent { + return &dwsevent.RawEvent{ + EventID: id, + EventType: typ, + Data: `{}`, + ReceivedAt: time.Now().UTC(), + } +} + +func drain(c *Consumer, n int, t *testing.T) []*transport.Event { + t.Helper() + out := make([]*transport.Event, 0, n) + for i := 0; i < n; i++ { + select { + case f := <-c.SendCh: + ev, ok := f.(transport.Event) + if !ok { + t.Fatalf("frame %d is not Event: %T", i, f) + } + out = append(out, &ev) + case <-time.After(time.Second): + t.Fatalf("timed out waiting for event %d", i) + } + } + return out +} + +func assertNoEvent(c *Consumer, t *testing.T) { + t.Helper() + select { + case f := <-c.SendCh: + t.Fatalf("unexpected frame: %#v", f) + case <-time.After(50 * time.Millisecond): + } +} + +func TestHub_RegisterAssignsMonotonicID(t *testing.T) { + h := NewHub(10) + c1, err := h.Register(transport.Hello{ConsumerPID: 1}) + if err != nil { + t.Fatal(err) + } + c2, err := h.Register(transport.Hello{ConsumerPID: 2}) + if err != nil { + t.Fatal(err) + } + if c1.ID >= c2.ID { + t.Fatalf("IDs not monotonic: %d, %d", c1.ID, c2.ID) + } +} + +func TestHub_DeliverMatchesPrefix(t *testing.T) { + h := NewHub(10) + c, err := h.Register(transport.Hello{EventTypes: []string{"im.message.*"}}) + if err != nil { + t.Fatal(err) + } + h.Deliver(mkEvent("im.message.receive_v1", "1")) + h.Deliver(mkEvent("approval.task", "2")) // no match + h.Deliver(mkEvent("im.message.at_v1", "3")) + + got := drain(c, 2, t) + if got[0].EventID != "1" || got[1].EventID != "3" { + t.Fatalf("expected 1,3 got %s,%s", got[0].EventID, got[1].EventID) + } + if got[0].Seq != 1 || got[1].Seq != 2 { + t.Fatalf("seq mismatch: %d %d", got[0].Seq, got[1].Seq) + } +} + +func TestHub_DeliverCatchAll(t *testing.T) { + h := NewHub(10) + c, _ := h.Register(transport.Hello{}) // empty == catch-all + h.Deliver(mkEvent("im.message.receive_v1", "1")) + h.Deliver(mkEvent("approval.task", "2")) + h.Deliver(mkEvent("foo.bar", "3")) + got := drain(c, 3, t) + if got[0].EventID != "1" || got[1].EventID != "2" || got[2].EventID != "3" { + t.Fatalf("catch-all missed events: %+v", got) + } +} + +func TestHub_DeliverFilterRegex(t *testing.T) { + h := NewHub(10) + c, err := h.Register(transport.Hello{Filter: `^im\.`}) + if err != nil { + t.Fatal(err) + } + h.Deliver(mkEvent("im.message.receive_v1", "1")) + h.Deliver(mkEvent("approval.task", "2")) // filtered out + h.Deliver(mkEvent("im.chat.member.bot.added_v1", "3")) + got := drain(c, 2, t) + if got[0].EventID != "1" || got[1].EventID != "3" { + t.Fatalf("filter regex missed: %+v", got) + } +} + +func TestHub_DeliverFiltersBySubscribeID(t *testing.T) { + h := NewHub(10) + c, err := h.Register(transport.Hello{ + EventTypes: []string{"user_im_message_receive_o2o"}, + SubscribeID: "sub-b", + }) + if err != nil { + t.Fatal(err) + } + + wrong := mkEvent("user_im_message_receive_o2o", "1") + wrong.SubscribeID = "sub-c" + h.Deliver(wrong) + assertNoEvent(c, t) + + right := mkEvent("user_im_message_receive_o2o", "2") + right.SubscribeID = "sub-b" + h.Deliver(right) + + got := drain(c, 1, t) + if got[0].EventID != "2" || got[0].SubscribeID != "sub-b" { + t.Fatalf("event = %#v, want sub-b event 2", got[0]) + } +} + +func TestHub_DeliverSubscribeIDSeparatesSameEventType(t *testing.T) { + h := NewHub(10) + b, err := h.Register(transport.Hello{ + EventTypes: []string{"user_im_message_receive_o2o"}, + SubscribeID: "sub-b", + }) + if err != nil { + t.Fatal(err) + } + c, err := h.Register(transport.Hello{ + EventTypes: []string{"user_im_message_receive_o2o"}, + SubscribeID: "sub-c", + }) + if err != nil { + t.Fatal(err) + } + + evB := mkEvent("user_im_message_receive_o2o", "b-msg") + evB.SubscribeID = "sub-b" + evC := mkEvent("user_im_message_receive_o2o", "c-msg") + evC.SubscribeID = "sub-c" + h.Deliver(evB) + h.Deliver(evC) + + gotB := drain(b, 1, t) + gotC := drain(c, 1, t) + if gotB[0].EventID != "b-msg" || gotB[0].SubscribeID != "sub-b" { + t.Fatalf("B consumer got %#v", gotB[0]) + } + if gotC[0].EventID != "c-msg" || gotC[0].SubscribeID != "sub-c" { + t.Fatalf("C consumer got %#v", gotC[0]) + } + assertNoEvent(b, t) + assertNoEvent(c, t) +} + +func TestHub_DeliverDropsMissingSubscribeIDForSpecificConsumer(t *testing.T) { + h := NewHub(10) + c, err := h.Register(transport.Hello{ + EventTypes: []string{"user_im_message_receive_group"}, + SubscribeID: "sub-group", + }) + if err != nil { + t.Fatal(err) + } + + h.Deliver(mkEvent("user_im_message_receive_group", "missing-sub")) + + assertNoEvent(c, t) +} + +func TestHub_DeliverEmptyConsumerSubscribeIDReceivesAnySubscribeID(t *testing.T) { + h := NewHub(10) + c, err := h.Register(transport.Hello{ + EventTypes: []string{"user_im_message_receive_o2o"}, + }) + if err != nil { + t.Fatal(err) + } + ev := mkEvent("user_im_message_receive_o2o", "1") + ev.SubscribeID = "sub-any" + h.Deliver(ev) + + got := drain(c, 1, t) + if got[0].SubscribeID != "sub-any" { + t.Fatalf("event subscribe_id = %q, want sub-any", got[0].SubscribeID) + } +} + +func TestHub_DeliverEventTypeMismatchEvenWithSubscribeID(t *testing.T) { + h := NewHub(10) + c, err := h.Register(transport.Hello{ + EventTypes: []string{"user_im_message_receive_o2o"}, + SubscribeID: "sub-1", + }) + if err != nil { + t.Fatal(err) + } + ev := mkEvent("user_im_message_receive_group", "1") + ev.SubscribeID = "sub-1" + + h.Deliver(ev) + + assertNoEvent(c, t) +} + +func TestHub_RegisterRejectsBadFilterRegex(t *testing.T) { + h := NewHub(10) + _, err := h.Register(transport.Hello{Filter: `(unclosed`}) + if err == nil { + t.Fatal("expected RegisterError for bad regex") + } + var re *RegisterError + if !errors.As(err, &re) { + t.Fatalf("err = %v, want *RegisterError", err) + } +} + +func TestHub_DropOldestOnFullChannel(t *testing.T) { + h := NewHub(2) // small buffer + c, _ := h.Register(transport.Hello{}) + + // Push 5 events without draining → 2 stay, 3 dropped + for i := 0; i < 5; i++ { + h.Deliver(mkEvent("foo", string(rune('0'+i)))) + } + if c.received.Load() != 2 { + t.Fatalf("received = %d, want 2", c.received.Load()) + } + if c.dropped.Load() != 3 { + t.Fatalf("dropped = %d, want 3", c.dropped.Load()) + } + // Bus counters reflect it too + snap := h.Counters().Snapshot() + if snap["foo"].Dropped != 3 { + t.Fatalf("hub dropped = %d, want 3", snap["foo"].Dropped) + } +} + +func TestHub_UnregisterClosesChannel(t *testing.T) { + h := NewHub(10) + c, _ := h.Register(transport.Hello{}) + h.Unregister(c.ID) + + // Channel should be closed; receive returns zero value with ok=false + _, ok := <-c.SendCh + if ok { + t.Fatal("SendCh should be closed after Unregister") + } + + // Further Deliver must not panic (closed flag prevents send) + h.Deliver(mkEvent("foo", "x")) + if h.Len() != 0 { + t.Fatalf("Len after Unregister = %d, want 0", h.Len()) + } +} + +func TestHub_UnregisterIdempotent(t *testing.T) { + h := NewHub(10) + c, _ := h.Register(transport.Hello{}) + h.Unregister(c.ID) + h.Unregister(c.ID) // must not panic + h.Unregister(9999) // unknown ID +} + +func TestHub_ConcurrentDeliverBroadcastUnregister(t *testing.T) { + for iteration := 0; iteration < 200; iteration++ { + h := NewHub(4) + c, err := h.Register(transport.Hello{ConsumerPID: iteration + 1}) + if err != nil { + t.Fatal(err) + } + + start := make(chan struct{}) + var wg sync.WaitGroup + for producer := 0; producer < 4; producer++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + for i := 0; i < 25; i++ { + h.Deliver(mkEvent("foo", "event")) + h.Broadcast(transport.SourceState{Type: transport.FrameTypeSourceState, State: "connected"}) + } + }() + } + wg.Add(1) + go func() { + defer wg.Done() + <-start + h.Unregister(c.ID) + }() + + close(start) + wg.Wait() + + deadline := time.After(time.Second) + drain: + for { + select { + case _, ok := <-c.SendCh: + if !ok { + break drain + } + case <-deadline: + t.Fatal("SendCh was not closed after concurrent unregister") + } + } + + seq := c.seq.Load() + received := c.received.Load() + dropped := c.dropped.Load() + h.Deliver(mkEvent("foo", "after-close")) + h.Broadcast(transport.Bye{Type: transport.FrameTypeBye}) + if c.seq.Load() != seq || c.received.Load() != received || c.dropped.Load() != dropped { + t.Fatal("closed consumer counters changed after unregister") + } + } +} + +func TestHub_BroadcastReachesAllConsumers(t *testing.T) { + h := NewHub(10) + a, _ := h.Register(transport.Hello{ConsumerPID: 1}) + b, _ := h.Register(transport.Hello{ConsumerPID: 2}) + + bye := transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"} + if got := h.Broadcast(bye); got != 2 { + t.Fatalf("Broadcast delivered to %d, want 2", got) + } + + for _, c := range []*Consumer{a, b} { + select { + case f := <-c.SendCh: + if _, ok := f.(transport.Bye); !ok { + t.Errorf("PID %d got %T, want Bye", c.PID, f) + } + case <-time.After(time.Second): + t.Errorf("PID %d did not receive broadcast", c.PID) + } + } +} + +func TestHub_Snapshot_SortedByPID(t *testing.T) { + h := NewHub(10) + for _, pid := range []int{30, 10, 20} { + _, _ = h.Register(transport.Hello{ConsumerPID: pid, EventTypes: []string{"a"}}) + } + snap := h.Snapshot() + if len(snap) != 3 || snap[0].PID != 10 || snap[1].PID != 20 || snap[2].PID != 30 { + t.Fatalf("snapshot not sorted: %+v", snap) + } +} + +func TestHub_PerConsumerSeqRestartsAtOne(t *testing.T) { + h := NewHub(10) + a, _ := h.Register(transport.Hello{ConsumerPID: 1}) + b, _ := h.Register(transport.Hello{ConsumerPID: 2}) + + h.Deliver(mkEvent("foo", "x")) + h.Deliver(mkEvent("foo", "y")) + + ea := drain(a, 2, t) + eb := drain(b, 2, t) + + for i, ev := range ea { + if ev.Seq != uint64(i+1) { + t.Errorf("consumer a seq[%d] = %d, want %d", i, ev.Seq, i+1) + } + } + for i, ev := range eb { + if ev.Seq != uint64(i+1) { + t.Errorf("consumer b seq[%d] = %d, want %d", i, ev.Seq, i+1) + } + } +} + +func TestHub_NilEventNoOp(t *testing.T) { + h := NewHub(10) + c, _ := h.Register(transport.Hello{}) + h.Deliver(nil) + if c.received.Load() != 0 { + t.Fatal("nil event should not increment") + } +} diff --git a/internal/event/bus/lockfile.go b/internal/event/bus/lockfile.go new file mode 100644 index 00000000..e84a9360 --- /dev/null +++ b/internal/event/bus/lockfile.go @@ -0,0 +1,157 @@ +// 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 bus + +import ( + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process" +) + +// LockFileName is the on-disk name of the bus single-instance lock. It lives +// inside the bus working directory +// (/events////). +const LockFileName = "bus.lock" + +// ErrBusy is re-exported from lock for callers that only depend on bus. +var ErrBusy = lock.ErrBusy + +// ErrStaleOwnerAlive indicates the PID stored in bus.lock points at a +// live process — there is already a bus running for this ClientID and we +// must not start another one. (This case is hit when the holder is still +// alive but its flock was somehow released; in practice flock + PID always +// agree, so this is mostly defensive.) +var ErrStaleOwnerAlive = errors.New("bus: lock file PID is alive but flock was released; assuming live owner") + +// Lock represents a held bus.lock. Close releases the flock and removes the +// PID file, so a subsequent bus can acquire cleanly. A zero Lock is unusable. +type Lock struct { + inner *lock.File +} + +// Acquire takes the bus lock at path and writes our PID into the file body. +// +// If the file already has a PID written by a previous run: +// 1. Try the flock first — if another process holds it, return ErrBusy +// (a live bus is running, abort). +// 2. flock acquired but file contains a PID → check if that PID is +// alive via process.Alive(). If alive → return ErrStaleOwnerAlive +// (defensive; release our flock first). If dead → take over (orphan +// cleanup) and overwrite PID with our own. +// +// On success the returned Lock owns an exclusive flock and a file body +// containing our PID. Concurrent competing processes will get ErrBusy. +func Acquire(path string) (*Lock, error) { + l, err := lock.TryAcquire(path) + if err != nil { + return nil, err // already wraps ErrBusy when busy + } + + // flock acquired. Read existing PID (if any). + f := l.File() + if _, err := f.Seek(0, io.SeekStart); err != nil { + _ = l.Close() + return nil, fmt.Errorf("bus: seek lock: %w", err) + } + old, err := io.ReadAll(f) + if err != nil { + _ = l.Close() + return nil, fmt.Errorf("bus: read lock: %w", err) + } + + if pid := parsePID(old); pid > 0 && process.Alive(pid) { + // Defensive: flock returned us the lock, but the stored PID is + // alive. This shouldn't normally happen (the live process holds + // the flock), but it's possible across odd kernel/FS edge cases + // (NFS, container restarts). Release and refuse to start. + _ = l.Close() + return nil, ErrStaleOwnerAlive + } + + // Orphan or first-ever acquisition. Rewrite the file with our PID. + if err := truncateAndWritePID(f, os.Getpid()); err != nil { + _ = l.Close() + return nil, fmt.Errorf("bus: write PID: %w", err) + } + return &Lock{inner: l}, nil +} + +// ReadHolderPID returns the PID stored in path, or 0 if the file is missing +// or unreadable. Does NOT attempt to acquire the lock — useful for `event +// status` to display the holder without contention. +func ReadHolderPID(path string) int { + b, err := os.ReadFile(path) + if err != nil { + return 0 + } + return parsePID(b) +} + +// Close releases the flock and best-effort blanks the PID body so a stale +// reader (e.g. `event status` racing our shutdown) does not see our +// long-dead PID and try to signal it. The lock file itself is NOT removed +// — keeping it on disk avoids a race where a competing bus could acquire +// inode-on-create faster than our truncate. +func (l *Lock) Close() error { + if l == nil || l.inner == nil { + return nil + } + // Blank the body before releasing the lock. + f := l.inner.File() + _ = truncateAndWritePID(f, 0) + err := l.inner.Close() + l.inner = nil + return err +} + +// HoldsPath returns the path the lock is held on. +func (l *Lock) HoldsPath() string { + if l == nil || l.inner == nil { + return "" + } + return l.inner.Path() +} + +func truncateAndWritePID(f *os.File, pid int) error { + if err := f.Truncate(0); err != nil { + return err + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + return err + } + if pid > 0 { + if _, err := fmt.Fprintf(f, "%d\n", pid); err != nil { + return err + } + } + return f.Sync() +} + +func parsePID(b []byte) int { + s := strings.TrimSpace(string(b)) + if s == "" { + return 0 + } + n, err := strconv.Atoi(s) + if err != nil || n <= 0 { + return 0 + } + return n +} diff --git a/internal/event/bus/lockfile_test.go b/internal/event/bus/lockfile_test.go new file mode 100644 index 00000000..6ec4e86d --- /dev/null +++ b/internal/event/bus/lockfile_test.go @@ -0,0 +1,147 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bus + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" +) + +func TestAcquire_WritesOurPID(t *testing.T) { + path := filepath.Join(t.TempDir(), LockFileName) + l, err := Acquire(path) + if err != nil { + t.Fatalf("Acquire: %v", err) + } + defer l.Close() + + got := ReadHolderPID(path) + if got != os.Getpid() { + t.Fatalf("ReadHolderPID = %d, want %d", got, os.Getpid()) + } +} + +func TestAcquire_SecondCallerGetsBusy(t *testing.T) { + path := filepath.Join(t.TempDir(), LockFileName) + l1, err := Acquire(path) + if err != nil { + t.Fatalf("first Acquire: %v", err) + } + defer l1.Close() + + _, err = Acquire(path) + if !errors.Is(err, ErrBusy) { + t.Fatalf("second Acquire = %v, want ErrBusy", err) + } +} + +func TestAcquire_StaleOrphanIsReclaimed(t *testing.T) { + path := filepath.Join(t.TempDir(), LockFileName) + // Pre-populate file with a definitely-dead PID (max int32 is unlikely to be alive). + if err := os.WriteFile(path, []byte("2147483646\n"), 0o600); err != nil { + t.Fatalf("pre-populate: %v", err) + } + l, err := Acquire(path) + if err != nil { + t.Fatalf("Acquire on stale orphan: %v", err) + } + defer l.Close() + + if got := ReadHolderPID(path); got != os.Getpid() { + t.Fatalf("after orphan reclaim, ReadHolderPID = %d, want %d", got, os.Getpid()) + } +} + +func TestAcquire_EmptyExistingFileWorks(t *testing.T) { + path := filepath.Join(t.TempDir(), LockFileName) + if err := os.WriteFile(path, []byte{}, 0o600); err != nil { + t.Fatalf("pre-create: %v", err) + } + l, err := Acquire(path) + if err != nil { + t.Fatalf("Acquire on empty file: %v", err) + } + defer l.Close() + if got := ReadHolderPID(path); got != os.Getpid() { + t.Fatalf("ReadHolderPID = %d, want %d", got, os.Getpid()) + } +} + +func TestClose_BlanksPID(t *testing.T) { + path := filepath.Join(t.TempDir(), LockFileName) + l, err := Acquire(path) + if err != nil { + t.Fatalf("Acquire: %v", err) + } + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + // Body should be empty (or at least not our PID anymore). + if got := ReadHolderPID(path); got != 0 { + t.Fatalf("after Close, ReadHolderPID = %d, want 0", got) + } +} + +func TestReadHolderPID_MissingFileReturnsZero(t *testing.T) { + if got := ReadHolderPID(filepath.Join(t.TempDir(), "does-not-exist")); got != 0 { + t.Fatalf("ReadHolderPID(missing) = %d, want 0", got) + } +} + +func TestReadHolderPID_MalformedReturnsZero(t *testing.T) { + path := filepath.Join(t.TempDir(), "junk.lock") + if err := os.WriteFile(path, []byte("not-a-pid"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + if got := ReadHolderPID(path); got != 0 { + t.Fatalf("ReadHolderPID(malformed) = %d, want 0", got) + } +} + +func TestAcquire_AfterReleaseReclaimable(t *testing.T) { + path := filepath.Join(t.TempDir(), LockFileName) + l1, err := Acquire(path) + if err != nil { + t.Fatalf("first: %v", err) + } + if err := l1.Close(); err != nil { + t.Fatalf("close first: %v", err) + } + l2, err := Acquire(path) + if err != nil { + t.Fatalf("reclaim after Close: %v", err) + } + defer l2.Close() +} + +// Sanity check that PID round-trips through truncateAndWritePID. +func TestTruncateAndWritePID_Roundtrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "x") + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o600) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := truncateAndWritePID(f, 9999); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(path) + want := fmt.Sprintf("%d\n", 9999) + if string(b) != want { + t.Fatalf("body = %q, want %q", b, want) + } +} diff --git a/internal/event/bus/meta.go b/internal/event/bus/meta.go new file mode 100644 index 00000000..a43850f2 --- /dev/null +++ b/internal/event/bus/meta.go @@ -0,0 +1,103 @@ +// 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 bus + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" +) + +// MetaFileName is the on-disk name of the bus metadata file. It lives +// alongside bus.lock and bus.sock inside the bus working directory. +const MetaFileName = "bus.meta" + +// Meta is the JSON document written once at bus startup. Its primary +// purpose is to let `dws event list/status --all` reverse-map directory +// names (clientIDHash hex) back to the human-readable ClientID. It also +// records bus identity for protocol-compatibility diagnostics (a future +// consume client built against bus_version="v2" can refuse to dial a +// bus_version="v1" bus, etc.). +// +// The file is overwritten on each bus startup (so a previous bus's stale +// meta does not persist past a fresh boot) and intentionally NOT deleted +// on Close — keeping it on disk helps `event status` diagnose an orphan +// (bus.lock empty + bus.meta present + PID dead = clean orphan). +type Meta struct { + ClientID string `json:"client_id"` + Edition string `json:"edition"` + SourceKind dwsevent.SourceKind `json:"source_kind,omitempty"` + IdentityHash string `json:"identity_hash,omitempty"` + SourceID string `json:"source_id,omitempty"` + StartedAt time.Time `json:"started_at"` + SDKVersion string `json:"sdk_version,omitempty"` + BusVersion string `json:"bus_version"` + BusPID int `json:"bus_pid"` +} + +// CurrentBusVersion identifies the bus wire/storage compatibility level. +// Bumped only on breaking changes (IPC protocol, lockfile shape, meta +// schema). v1 is the initial value; the field is parsed defensively by +// readers (older readers tolerate unknown fields via encoding/json). +const CurrentBusVersion = "v1" + +// WriteMeta atomically writes m to /bus.meta. Atomic via tmp-file + +// rename. Directory permissions are not changed; caller must mkdir the +// containing directory beforehand with pkg/config.DirPerm. +func WriteMeta(dir string, m Meta) error { + if m.BusVersion == "" { + m.BusVersion = CurrentBusVersion + } + if m.BusPID == 0 { + m.BusPID = os.Getpid() + } + if m.StartedAt.IsZero() { + m.StartedAt = time.Now().UTC() + } + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("bus: marshal meta: %w", err) + } + final := filepath.Join(dir, MetaFileName) + tmp := final + ".tmp" + if err := os.WriteFile(tmp, b, config.FilePerm); err != nil { + return fmt.Errorf("bus: write tmp meta: %w", err) + } + if err := os.Rename(tmp, final); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("bus: rename meta: %w", err) + } + return nil +} + +// ReadMeta loads and parses /bus.meta. Returns (nil, error) when the +// file is missing or malformed. Used by `event list/status --all` to +// resolve clientIDHash → original ClientID. +func ReadMeta(dir string) (*Meta, error) { + path := filepath.Join(dir, MetaFileName) + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var m Meta + if err := json.Unmarshal(b, &m); err != nil { + return nil, fmt.Errorf("bus: parse meta %s: %w", path, err) + } + return &m, nil +} diff --git a/internal/event/bus/meta_test.go b/internal/event/bus/meta_test.go new file mode 100644 index 00000000..8c6d0e7c --- /dev/null +++ b/internal/event/bus/meta_test.go @@ -0,0 +1,90 @@ +// 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 bus + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestWriteRead_Roundtrip(t *testing.T) { + dir := t.TempDir() + m := Meta{ + ClientID: "ding_xyz", + Edition: "open", + StartedAt: time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC), + SDKVersion: "v0.9.1", + BusPID: 12345, + } + if err := WriteMeta(dir, m); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + + got, err := ReadMeta(dir) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if got.ClientID != m.ClientID || got.Edition != m.Edition || got.BusPID != m.BusPID { + t.Errorf("roundtrip mismatch: got %+v want %+v", got, m) + } + if got.BusVersion != CurrentBusVersion { + t.Errorf("BusVersion default not applied: %q", got.BusVersion) + } +} + +func TestWriteMeta_DefaultsPID(t *testing.T) { + dir := t.TempDir() + if err := WriteMeta(dir, Meta{ClientID: "x", Edition: "open"}); err != nil { + t.Fatalf("WriteMeta: %v", err) + } + got, err := ReadMeta(dir) + if err != nil { + t.Fatalf("ReadMeta: %v", err) + } + if got.BusPID != os.Getpid() { + t.Errorf("BusPID default = %d, want %d", got.BusPID, os.Getpid()) + } + if got.StartedAt.IsZero() { + t.Error("StartedAt default not applied") + } +} + +func TestWriteMeta_AtomicNoTmpLeft(t *testing.T) { + dir := t.TempDir() + if err := WriteMeta(dir, Meta{ClientID: "x", Edition: "open"}); err != nil { + t.Fatal(err) + } + // .tmp file must not remain after successful rename + if _, err := os.Stat(filepath.Join(dir, MetaFileName+".tmp")); err == nil { + t.Fatal(".tmp file leaked after WriteMeta") + } +} + +func TestReadMeta_MissingFileErrors(t *testing.T) { + if _, err := ReadMeta(t.TempDir()); err == nil { + t.Fatal("ReadMeta on missing file should error") + } +} + +func TestReadMeta_MalformedErrors(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, MetaFileName), []byte("{garbage"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := ReadMeta(dir); err == nil { + t.Fatal("ReadMeta on malformed JSON should error") + } +} diff --git a/internal/event/bus/metrics.go b/internal/event/bus/metrics.go new file mode 100644 index 00000000..8279a8d5 --- /dev/null +++ b/internal/event/bus/metrics.go @@ -0,0 +1,120 @@ +// 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 bus + +import ( + "sort" + "sync" + "sync/atomic" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +// PerTypeCounters is bus-wide and tracks how many events of each event_type +// the bus delivered (or dropped due to backpressure) since startup. Surfaced +// via `dws event status`. Concurrent-safe. +// +// Implementation note: a map of *atomic uint64 pairs lets us avoid taking +// the mu in the hot Add() path; mu is only held when inserting a new +// event_type key. +type PerTypeCounters struct { + mu sync.RWMutex + m map[string]*typeRow +} + +type typeRow struct { + received atomic.Uint64 + dropped atomic.Uint64 +} + +// NewPerTypeCounters returns an empty counter set. +func NewPerTypeCounters() *PerTypeCounters { + return &PerTypeCounters{m: make(map[string]*typeRow)} +} + +// AddReceived increments the received counter for eventType (allocating the +// row on first sight of a new type). +func (c *PerTypeCounters) AddReceived(eventType string) { + c.row(eventType).received.Add(1) +} + +// AddDropped increments the dropped counter for eventType. +func (c *PerTypeCounters) AddDropped(eventType string) { + c.row(eventType).dropped.Add(1) +} + +// Snapshot returns a deterministic point-in-time view, sorted by +// event_type. Used by status RPC encoding. +func (c *PerTypeCounters) Snapshot() map[string]transport.Counters { + c.mu.RLock() + defer c.mu.RUnlock() + out := make(map[string]transport.Counters, len(c.m)) + for k, row := range c.m { + out[k] = transport.Counters{ + Received: row.received.Load(), + Dropped: row.dropped.Load(), + } + } + return out +} + +// SortedTypes returns the known event_types in deterministic order. Useful +// for human-readable formatting (status table). +func (c *PerTypeCounters) SortedTypes() []string { + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]string, 0, len(c.m)) + for k := range c.m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// DropRatePercent returns rounded (dropped / (received + dropped)) * 100 +// for the given event_type, or -1 if the type was never observed. Used by +// the drop-rate stderr warning when crossing a configurable threshold. +func (c *PerTypeCounters) DropRatePercent(eventType string) int { + c.mu.RLock() + row, ok := c.m[eventType] + c.mu.RUnlock() + if !ok { + return -1 + } + r := row.received.Load() + d := row.dropped.Load() + total := r + d + if total == 0 { + return -1 + } + return int((d * 100) / total) +} + +func (c *PerTypeCounters) row(eventType string) *typeRow { + c.mu.RLock() + row, ok := c.m[eventType] + c.mu.RUnlock() + if ok { + return row + } + c.mu.Lock() + defer c.mu.Unlock() + // double-check after acquiring write lock + if row, ok = c.m[eventType]; ok { + return row + } + row = &typeRow{} + c.m[eventType] = row + return row +} diff --git a/internal/event/bus/metrics_test.go b/internal/event/bus/metrics_test.go new file mode 100644 index 00000000..2cffa5aa --- /dev/null +++ b/internal/event/bus/metrics_test.go @@ -0,0 +1,110 @@ +// 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 bus + +import ( + "sync" + "testing" +) + +func TestPerTypeCounters_BasicAdd(t *testing.T) { + c := NewPerTypeCounters() + c.AddReceived("im.message.receive_v1") + c.AddReceived("im.message.receive_v1") + c.AddDropped("im.message.receive_v1") + c.AddReceived("approval.task") + + snap := c.Snapshot() + if snap["im.message.receive_v1"].Received != 2 { + t.Errorf("im received = %d, want 2", snap["im.message.receive_v1"].Received) + } + if snap["im.message.receive_v1"].Dropped != 1 { + t.Errorf("im dropped = %d, want 1", snap["im.message.receive_v1"].Dropped) + } + if snap["approval.task"].Received != 1 { + t.Errorf("approval received = %d, want 1", snap["approval.task"].Received) + } +} + +func TestPerTypeCounters_SortedTypes(t *testing.T) { + c := NewPerTypeCounters() + for _, k := range []string{"z", "a", "m", "b"} { + c.AddReceived(k) + } + got := c.SortedTypes() + want := []string{"a", "b", "m", "z"} + if len(got) != len(want) { + t.Fatalf("len = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("[%d] %q, want %q", i, got[i], want[i]) + } + } +} + +func TestPerTypeCounters_DropRatePercent(t *testing.T) { + c := NewPerTypeCounters() + for i := 0; i < 95; i++ { + c.AddReceived("a") + } + for i := 0; i < 5; i++ { + c.AddDropped("a") + } + if got := c.DropRatePercent("a"); got != 5 { + t.Errorf("DropRatePercent = %d, want 5", got) + } + if got := c.DropRatePercent("never-seen"); got != -1 { + t.Errorf("unseen DropRatePercent = %d, want -1", got) + } +} + +func TestPerTypeCounters_ConcurrentAddSameType(t *testing.T) { + c := NewPerTypeCounters() + const N = 1000 + var wg sync.WaitGroup + wg.Add(N * 2) + for i := 0; i < N; i++ { + go func() { defer wg.Done(); c.AddReceived("hot") }() + go func() { defer wg.Done(); c.AddDropped("hot") }() + } + wg.Wait() + snap := c.Snapshot() + if snap["hot"].Received != N || snap["hot"].Dropped != N { + t.Fatalf("got %+v, want received=%d dropped=%d", snap["hot"], N, N) + } +} + +func TestPerTypeCounters_ConcurrentNewTypes(t *testing.T) { + c := NewPerTypeCounters() + const N = 500 + var wg sync.WaitGroup + wg.Add(N) + for i := 0; i < N; i++ { + i := i + go func() { + defer wg.Done() + c.AddReceived(string(rune('a'+(i%26))) + "_" + string(rune('a'+((i/26)%26)))) + }() + } + wg.Wait() + // Each goroutine adds exactly 1; total received across all types must be N. + var total uint64 + for _, v := range c.Snapshot() { + total += v.Received + } + if total != N { + t.Fatalf("total received = %d, want %d", total, N) + } +} diff --git a/internal/event/bus/tuning.go b/internal/event/bus/tuning.go new file mode 100644 index 00000000..83e80773 --- /dev/null +++ b/internal/event/bus/tuning.go @@ -0,0 +1,81 @@ +// 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 bus + +import ( + "os" + "strconv" + "time" +) + +// Tunable env vars (plan §15 已决项 — surfaced for operators without +// requiring a new CLI flag for each knob). Each lookup is read-once at +// bus startup; runtime changes require a bus restart. +const ( + EnvIdleTimeout = "DWS_EVENT_BUS_IDLE_TIMEOUT" // Go duration, e.g. "10m" + EnvConsumerBuffer = "DWS_EVENT_CONSUMER_BUFFER" // integer + EnvDedupLRU = "DWS_EVENT_DEDUP_LRU" // integer + EnvDropWarnPct = "DWS_EVENT_DROP_WARN_PCT" // integer 1-100 +) + +// ApplyEnvTuning fills in Config defaults from the env vars listed above +// for any fields the caller left at zero. The cobra layer calls this after +// constructing Config so explicit flag values still win. +// +// Defaults (when env is absent or invalid): +// +// IdleTimeout → 5m +// ConsumerBuffer → DefaultSendBuffer +// DedupCapacity → 0 (let dedup package's DefaultCapacity apply) +// DropWarnPercent → DefaultDropWarnPercent +// +// Invalid env values (non-parseable, out of range) are silently ignored +// and the default is used. We deliberately do NOT fail the bus on bad +// env input — operators shouldn't lose a daemon over a typo'd env var. +func ApplyEnvTuning(cfg *Config) { + if cfg.IdleTimeout == 0 { + if v := os.Getenv(EnvIdleTimeout); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + cfg.IdleTimeout = d + } + } + if cfg.IdleTimeout == 0 { + cfg.IdleTimeout = 5 * time.Minute + } + } + if cfg.ConsumerBuffer == 0 { + if v := os.Getenv(EnvConsumerBuffer); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + cfg.ConsumerBuffer = n + } + } + } + if cfg.DedupCapacity == 0 { + if v := os.Getenv(EnvDedupLRU); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + cfg.DedupCapacity = n + } + } + } + if cfg.DropWarnPercent == 0 { + if v := os.Getenv(EnvDropWarnPct); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 100 { + cfg.DropWarnPercent = n + } + } + if cfg.DropWarnPercent == 0 { + cfg.DropWarnPercent = DefaultDropWarnPercent + } + } +} diff --git a/internal/event/bus/tuning_test.go b/internal/event/bus/tuning_test.go new file mode 100644 index 00000000..31d1ecce --- /dev/null +++ b/internal/event/bus/tuning_test.go @@ -0,0 +1,120 @@ +// 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 bus + +import ( + "testing" + "time" +) + +func TestApplyEnvTuning_DefaultsWhenAllUnset(t *testing.T) { + t.Setenv(EnvIdleTimeout, "") + t.Setenv(EnvConsumerBuffer, "") + t.Setenv(EnvDedupLRU, "") + t.Setenv(EnvDropWarnPct, "") + + cfg := Config{} + ApplyEnvTuning(&cfg) + + if cfg.IdleTimeout != 5*time.Minute { + t.Errorf("IdleTimeout default = %s, want 5m", cfg.IdleTimeout) + } + if cfg.DropWarnPercent != DefaultDropWarnPercent { + t.Errorf("DropWarnPercent default = %d, want %d", + cfg.DropWarnPercent, DefaultDropWarnPercent) + } + // ConsumerBuffer / DedupCapacity intentionally left zero — the Hub + // / dedup packages apply their own defaults from that signal. + if cfg.ConsumerBuffer != 0 { + t.Errorf("ConsumerBuffer should remain 0 (Hub picks default), got %d", cfg.ConsumerBuffer) + } + if cfg.DedupCapacity != 0 { + t.Errorf("DedupCapacity should remain 0 (dedup picks default), got %d", cfg.DedupCapacity) + } +} + +func TestApplyEnvTuning_ReadsValidEnv(t *testing.T) { + t.Setenv(EnvIdleTimeout, "10m") + t.Setenv(EnvConsumerBuffer, "200") + t.Setenv(EnvDedupLRU, "16384") + t.Setenv(EnvDropWarnPct, "10") + + cfg := Config{} + ApplyEnvTuning(&cfg) + + if cfg.IdleTimeout != 10*time.Minute { + t.Errorf("IdleTimeout = %s, want 10m", cfg.IdleTimeout) + } + if cfg.ConsumerBuffer != 200 { + t.Errorf("ConsumerBuffer = %d, want 200", cfg.ConsumerBuffer) + } + if cfg.DedupCapacity != 16384 { + t.Errorf("DedupCapacity = %d, want 16384", cfg.DedupCapacity) + } + if cfg.DropWarnPercent != 10 { + t.Errorf("DropWarnPercent = %d, want 10", cfg.DropWarnPercent) + } +} + +func TestApplyEnvTuning_ExplicitConfigWins(t *testing.T) { + t.Setenv(EnvIdleTimeout, "10m") + t.Setenv(EnvDropWarnPct, "20") + + cfg := Config{ + IdleTimeout: 3 * time.Minute, + DropWarnPercent: 7, + } + ApplyEnvTuning(&cfg) + + if cfg.IdleTimeout != 3*time.Minute { + t.Errorf("explicit IdleTimeout overridden by env: %s", cfg.IdleTimeout) + } + if cfg.DropWarnPercent != 7 { + t.Errorf("explicit DropWarnPercent overridden by env: %d", cfg.DropWarnPercent) + } +} + +func TestApplyEnvTuning_IgnoresInvalidValues(t *testing.T) { + t.Setenv(EnvIdleTimeout, "not-a-duration") + t.Setenv(EnvConsumerBuffer, "-50") + t.Setenv(EnvDedupLRU, "notanumber") + t.Setenv(EnvDropWarnPct, "150") // out of 1..100 + + cfg := Config{} + ApplyEnvTuning(&cfg) + + if cfg.IdleTimeout != 5*time.Minute { + t.Errorf("invalid duration → default; got %s", cfg.IdleTimeout) + } + if cfg.ConsumerBuffer != 0 { + t.Errorf("negative buffer ignored → 0; got %d", cfg.ConsumerBuffer) + } + if cfg.DedupCapacity != 0 { + t.Errorf("non-numeric LRU ignored → 0; got %d", cfg.DedupCapacity) + } + if cfg.DropWarnPercent != DefaultDropWarnPercent { + t.Errorf("out-of-range pct → default; got %d", cfg.DropWarnPercent) + } +} + +func TestApplyEnvTuning_IdleTimeoutZeroEnv(t *testing.T) { + t.Setenv(EnvIdleTimeout, "0") + cfg := Config{} + ApplyEnvTuning(&cfg) + // "0" is parseable but <= 0 → use default 5m, not 0 (which would mean + // "disabled" in the daemon — too dangerous as an env-driven default). + if cfg.IdleTimeout != 5*time.Minute { + t.Errorf("zero env should fall through to 5m default, got %s", cfg.IdleTimeout) + } +} diff --git a/internal/event/busctl/discover.go b/internal/event/busctl/discover.go new file mode 100644 index 00000000..b15b1342 --- /dev/null +++ b/internal/event/busctl/discover.go @@ -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 busctl + +import ( + "errors" + "fmt" + "net" + "os" + "path/filepath" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" +) + +// SpawnFunc abstracts the fork-bus operation so tests can inject a fake +// instead of execing a real binary. Production callers pass busctl.Spawn. +type SpawnFunc func(SpawnConfig) (pid int, err error) + +// DiscoverConfig describes one discover attempt. WorkDir holds bus.lock and +// usually (on Unix) bus.sock — see dwsevent.IPCEndpoint for the short-path +// fallback when WorkDir is too deep; the caller must mkdir it with +// pkg/config.DirPerm beforehand. +type DiscoverConfig struct { + WorkDir string + IPCEndpoint string + ClientID string + // Spawn is the fork-bus callback. Default busctl.Spawn. + Spawn SpawnFunc + // SpawnExtraArgs is forwarded to Spawn (for tests). + SpawnExtraArgs []string + // DialBackoff: initial sleep between retry dials when another process + // is spawning. Doubled each attempt up to DialMaxBackoff. + DialBackoff time.Duration + // DialMaxBackoff caps backoff. + DialMaxBackoff time.Duration + // DialDeadline caps total wall-clock time spent discovering. + DialDeadline time.Duration +} + +// Default knobs. Conservative — dial is local and cheap, so retry is cheap. +const ( + defaultDialBackoff = 25 * time.Millisecond + defaultDialMaxBackoff = 250 * time.Millisecond + defaultDialDeadline = 5 * time.Second +) + +// Discover returns a connected net.Conn to the bus for cfg.ClientID. If the +// bus is not running, Discover forks a new one and waits for it to come up. +// +// Race-free three-step (plan §12 P3): +// +// 1. try dial IPC endpoint → success → done +// 2. failed → call Spawn (fork _bus); Spawn blocks until ready pipe says +// 'R' (or returns ErrSpawnFailed if another process won the race and +// our bus startup hit ErrBusy on the lock) +// 3. dial again — should succeed; retry with backoff up to DialDeadline +// in case Spawn succeeded but socket bind has tiny latency +// +// On concurrent Discover by N processes: only one Spawn succeeds (the +// others get ErrBusy via the bus daemon's own lock acquisition). Losers +// fall through to the retry-dial loop in step 3 and connect to the bus +// the winner brought up. +func Discover(cfg DiscoverConfig) (net.Conn, error) { + if cfg.WorkDir == "" { + return nil, errors.New("busctl: WorkDir is required") + } + if cfg.IPCEndpoint == "" { + return nil, errors.New("busctl: IPCEndpoint is required") + } + if cfg.ClientID == "" { + return nil, errors.New("busctl: ClientID is required") + } + if cfg.Spawn == nil { + cfg.Spawn = Spawn + } + if cfg.DialBackoff == 0 { + cfg.DialBackoff = defaultDialBackoff + } + if cfg.DialMaxBackoff == 0 { + cfg.DialMaxBackoff = defaultDialMaxBackoff + } + if cfg.DialDeadline == 0 { + cfg.DialDeadline = defaultDialDeadline + } + + // Step 1: try dial. + if conn, err := transport.Dial(cfg.IPCEndpoint); err == nil { + return conn, nil + } + + // Step 2: ensure WorkDir, then spawn _bus. + if err := os.MkdirAll(cfg.WorkDir, config.DirPerm); err != nil { + return nil, fmt.Errorf("busctl: mkdir workdir: %w", err) + } + _, spawnErr := cfg.Spawn(SpawnConfig{ + ClientID: cfg.ClientID, + ExtraArgs: cfg.SpawnExtraArgs, + }) + if spawnErr != nil && !errors.Is(spawnErr, ErrSpawnFailed) { + // Hard error (couldn't even exec the child). Stop here — no bus + // will come up. + return nil, fmt.Errorf("busctl: spawn bus: %w", spawnErr) + } + // spawnErr == ErrSpawnFailed → child reported it can't start, most + // commonly because another process already holds the lock. Either way + // we fall through to retry-dial — if someone else's bus IS up we'll + // connect to it. + + // Step 3: retry dial until DialDeadline. + deadline := time.Now().Add(cfg.DialDeadline) + backoff := cfg.DialBackoff + var lastDialErr error + for time.Now().Before(deadline) { + conn, err := transport.Dial(cfg.IPCEndpoint) + if err == nil { + return conn, nil + } + lastDialErr = err + time.Sleep(backoff) + backoff *= 2 + if backoff > cfg.DialMaxBackoff { + backoff = cfg.DialMaxBackoff + } + } + return nil, fmt.Errorf("busctl: discover deadline exceeded; last dial error: %w (spawn error: %v)", lastDialErr, spawnErr) +} + +// LockPath returns the canonical bus.lock path for the given working dir. +// Centralised so consume / status / stop all agree on the location. +func LockPath(workDir string) string { + return filepath.Join(workDir, "bus.lock") +} + +// MetaPath returns the canonical bus.meta path. +func MetaPath(workDir string) string { + return filepath.Join(workDir, "bus.meta") +} diff --git a/internal/event/busctl/discover_test.go b/internal/event/busctl/discover_test.go new file mode 100644 index 00000000..65f935f2 --- /dev/null +++ b/internal/event/busctl/discover_test.go @@ -0,0 +1,280 @@ +// 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 busctl + +import ( + "errors" + "net" + "os" + "path/filepath" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +func skipOnWindows(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("uses Unix socket; Windows transport covered by transport_windows_test.go") + } +} + +func shortTempDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("/tmp", "dws-busctl-") + if err != nil { + t.Fatalf("mktemp: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} + +// startStubBus brings up a transport.Listener at sockPath and accepts +// connections in a goroutine, discarding the data. Returns a closer. +// Used as a stand-in for the real bus daemon in discover unit tests. +func startStubBus(t *testing.T, sockPath string) func() { + t.Helper() + l, err := transport.Listen(sockPath) + if err != nil { + t.Fatalf("startStubBus listen: %v", err) + } + done := make(chan struct{}) + go func() { + for { + conn, err := l.Accept() + if err != nil { + close(done) + return + } + go func(c net.Conn) { + buf := make([]byte, 256) + for { + if _, err := c.Read(buf); err != nil { + _ = c.Close() + return + } + } + }(conn) + } + }() + return func() { + _ = l.Close() + <-done + } +} + +func TestDiscover_BusAlreadyRunning_DirectDial(t *testing.T) { + skipOnWindows(t) + dir := shortTempDir(t) + sock := filepath.Join(dir, "bus.sock") + closer := startStubBus(t, sock) + defer closer() + + var spawnCalled atomic.Bool + fakeSpawn := func(SpawnConfig) (int, error) { + spawnCalled.Store(true) + return 0, nil + } + + conn, err := Discover(DiscoverConfig{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_x", + Spawn: fakeSpawn, + }) + if err != nil { + t.Fatalf("Discover: %v", err) + } + defer conn.Close() + if spawnCalled.Load() { + t.Fatal("Spawn must not be called when bus is already running") + } +} + +func TestDiscover_NoBus_SpawnSucceeds(t *testing.T) { + skipOnWindows(t) + dir := shortTempDir(t) + sock := filepath.Join(dir, "bus.sock") + + // fakeSpawn launches the stub bus *during* the spawn call to mirror + // the real flow (bus is up by the time spawn returns). + var closer func() + t.Cleanup(func() { + if closer != nil { + closer() + } + }) + fakeSpawn := func(SpawnConfig) (int, error) { + closer = startStubBus(t, sock) + return 12345, nil + } + + conn, err := Discover(DiscoverConfig{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_x", + Spawn: fakeSpawn, + }) + if err != nil { + t.Fatalf("Discover: %v", err) + } + defer conn.Close() +} + +func TestDiscover_SpawnHardErrorFails(t *testing.T) { + skipOnWindows(t) + dir := shortTempDir(t) + sock := filepath.Join(dir, "bus.sock") + + hardErr := errors.New("exec failed: not found") + fakeSpawn := func(SpawnConfig) (int, error) { return 0, hardErr } + + _, err := Discover(DiscoverConfig{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_x", + Spawn: fakeSpawn, + DialDeadline: 100 * time.Millisecond, + }) + if err == nil { + t.Fatal("Discover should fail when Spawn returns non-ErrSpawnFailed error") + } + if !errors.Is(err, hardErr) { + t.Fatalf("err = %v, want wrap of %v", err, hardErr) + } +} + +func TestDiscover_SpawnReportsFailButPeerBusComesUp(t *testing.T) { + skipOnWindows(t) + dir := shortTempDir(t) + sock := filepath.Join(dir, "bus.sock") + + // Simulates the race: our Spawn loses (returns ErrSpawnFailed) but + // during retry-dial a peer bus shows up. + go func() { + time.Sleep(80 * time.Millisecond) + closer := startStubBus(t, sock) + t.Cleanup(closer) + }() + + fakeSpawn := func(SpawnConfig) (int, error) { return 0, ErrSpawnFailed } + + conn, err := Discover(DiscoverConfig{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_x", + Spawn: fakeSpawn, + DialDeadline: 2 * time.Second, + DialBackoff: 20 * time.Millisecond, + }) + if err != nil { + t.Fatalf("Discover should retry-dial after ErrSpawnFailed: %v", err) + } + defer conn.Close() +} + +func TestDiscover_DeadlineExceeded(t *testing.T) { + skipOnWindows(t) + dir := shortTempDir(t) + sock := filepath.Join(dir, "bus.sock") // never created + + fakeSpawn := func(SpawnConfig) (int, error) { return 0, ErrSpawnFailed } + start := time.Now() + _, err := Discover(DiscoverConfig{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_x", + Spawn: fakeSpawn, + DialDeadline: 150 * time.Millisecond, + DialBackoff: 20 * time.Millisecond, + }) + if err == nil { + t.Fatal("Discover should fail when bus never comes up within deadline") + } + if elapsed := time.Since(start); elapsed < 100*time.Millisecond || elapsed > 1*time.Second { + t.Errorf("deadline-driven exit took %s, expected ~150ms", elapsed) + } +} + +func TestDiscover_ConcurrentCallersOnlyOneSpawn(t *testing.T) { + skipOnWindows(t) + dir := shortTempDir(t) + sock := filepath.Join(dir, "bus.sock") + + // Use a mutex-guarded one-shot Spawn that actually starts the stub bus + // on first call. Concurrent callers may race: the first wins (returns + // success), the rest see ErrSpawnFailed but still retry-dial successfully. + var spawnMu sync.Mutex + var spawnCount atomic.Int32 + var closer func() + t.Cleanup(func() { + if closer != nil { + closer() + } + }) + fakeSpawn := func(SpawnConfig) (int, error) { + spawnMu.Lock() + defer spawnMu.Unlock() + spawnCount.Add(1) + if closer == nil { + closer = startStubBus(t, sock) + return 99, nil + } + return 0, ErrSpawnFailed + } + + const N = 5 + var wg sync.WaitGroup + errs := make(chan error, N) + conns := make([]net.Conn, 0, N) + connsMu := sync.Mutex{} + for i := 0; i < N; i++ { + wg.Add(1) + go func() { + defer wg.Done() + conn, err := Discover(DiscoverConfig{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_x", + Spawn: fakeSpawn, + DialDeadline: 2 * time.Second, + }) + if err != nil { + errs <- err + return + } + connsMu.Lock() + conns = append(conns, conn) + connsMu.Unlock() + }() + } + wg.Wait() + close(errs) + for err := range errs { + t.Errorf("Discover concurrent caller: %v", err) + } + for _, c := range conns { + _ = c.Close() + } + // Note: spawnCount can be 1..N because all goroutines fail dial first + // and call Spawn. The point is they all SUCCESSFULLY connected to the + // single bus that the first Spawn brought up. + if len(conns) != N { + t.Fatalf("only %d/%d callers got a conn", len(conns), N) + } +} diff --git a/internal/event/busctl/doc.go b/internal/event/busctl/doc.go new file mode 100644 index 00000000..72e18d60 --- /dev/null +++ b/internal/event/busctl/doc.go @@ -0,0 +1,25 @@ +// 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 busctl glues the consume client to the bus daemon. It encapsulates +// the three operations a consumer needs at startup and shutdown: +// +// discover: find the running bus or fork a fresh one (race-free, plan §12 +// P3 "try dial → try fork lock → retry dial") +// spawn: exec `dws event _bus --client-id ` as a detached +// background process (stdio detach, setsid, ready pipe handshake) +// stop: gracefully terminate the bus daemon (SIGTERM + IPC fallback) +// +// All three operations are short-lived helpers — they own no long-lived +// goroutines and return whole errors to their caller. +package busctl diff --git a/internal/event/busctl/spawn.go b/internal/event/busctl/spawn.go new file mode 100644 index 00000000..53d663b9 --- /dev/null +++ b/internal/event/busctl/spawn.go @@ -0,0 +1,210 @@ +// 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 busctl + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "strconv" + "strings" + "time" +) + +// ReadyFDEnv is the env var the spawned `event _bus` child inspects to find +// the ready-pipe write end. The parent passes the FD number; child opens it +// via os.NewFile(fd, "ready") and writes 'R' on success or 'E' on failure. +// 3 is the first FD slot beyond stdio in cmd.ExtraFiles. +const ReadyFDEnv = "DWS_EVENT_BUS_READY_FD" + +// ReadyTimeout caps how long Spawn waits for the child to signal readiness. +// 10s is generous — bus startup is local-only work (file I/O + socket bind), +// so 1s would normally suffice; the extra headroom covers cold-start +// keychain prompts and slow CI machines. +const ReadyTimeout = 10 * time.Second + +// ErrSpawnFailed is returned when the child reports startup failure via +// the ready pipe ('E' byte). The child's exit error / log file holds the +// actual cause; this sentinel just lets the caller distinguish "ready +// pipe said no" from "ready pipe timed out / closed early". +var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure on ready pipe") + +// ErrSpawnTimeout is returned when ReadyTimeout elapses without any signal. +var ErrSpawnTimeout = errors.New("busctl: bus child did not signal readiness within deadline") + +// SpawnConfig describes one spawn attempt. ClientID is the only field +// inspected by the child; the rest govern process attributes the parent +// applies before exec. +type SpawnConfig struct { + // ExecPath is the dws binary to exec. Default os.Executable(). + ExecPath string + + // ClientID is passed as `--client-id` to `dws event _bus`. Required. + ClientID string + + // ExtraArgs are appended after `--client-id`. Empty for normal use; tests + // pass `--extra-flag-for-test` etc. + ExtraArgs []string + + // Env to pass to the child. Defaults to os.Environ(). The ReadyFDEnv + // entry is appended automatically. + Env []string +} + +// Spawn forks a detached `dws event _bus --client-id ` child process and +// waits for it to signal readiness via the ready pipe. Returns the child's +// PID on success — the caller can then dial the bus IPC endpoint. +// +// stdio detach (plan invariant #7): +// - cmd.Stdout / cmd.Stderr set to nil so the child's own writes don't +// pollute the parent's NDJSON stream +// - Setsid on Unix so the child survives parent SIGHUP / parent exit +// - CREATE_NEW_PROCESS_GROUP on Windows (set in spawn_windows.go) +// +// Child startup (handled by the eventcmd._bus handler, P6): +// - Opens os.NewFile(, "ready") +// - On startup success → writes 'R' and closes +// - On startup failure → writes 'E' and closes (child exits) +// +// Parent (this function): +// - Holds the read end open until either 1 byte is read or ReadyTimeout +// - Returns ErrSpawnFailed for 'E', ErrSpawnTimeout otherwise +func Spawn(cfg SpawnConfig) (pid int, err error) { + if cfg.ClientID == "" { + return 0, errors.New("busctl: SpawnConfig.ClientID is required") + } + if cfg.ExecPath == "" { + execPath, err := os.Executable() + if err != nil { + return 0, fmt.Errorf("busctl: locate executable: %w", err) + } + cfg.ExecPath = execPath + } + if cfg.Env == nil { + cfg.Env = os.Environ() + } + + pr, pw, err := os.Pipe() + if err != nil { + return 0, fmt.Errorf("busctl: pipe: %w", err) + } + defer pr.Close() + // pw is passed to the child; close in parent after Start so only the + // child holds the write end (so reads return EOF if child dies before + // signalling, helping us distinguish death from slow startup). + + args := append([]string{"event", "_bus", "--client-id", cfg.ClientID}, cfg.ExtraArgs...) + cmd := exec.Command(cfg.ExecPath, args...) + cmd.Env = append(cfg.Env, ReadyFDEnv+"=3") + cmd.ExtraFiles = []*os.File{pw} // child sees fd 3 = pw + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + applyDetach(cmd) // platform-specific Setsid / new process group + + if err := cmd.Start(); err != nil { + _ = pw.Close() + return 0, fmt.Errorf("busctl: start %s: %w", cfg.ExecPath, err) + } + pid = cmd.Process.Pid + + // Close parent's copy of the write end immediately. Now only the child + // holds it; reading on pr will return EOF when the child exits without + // signalling, instead of blocking forever. + _ = pw.Close() + + // The detached child owns its own process group. Wait in the background to + // release its process resources; the caller only retains the numeric PID. + go func() { _ = cmd.Wait() }() + + // Wait for ready byte. + if err := waitReady(pr); err != nil { + return pid, err + } + return pid, nil +} + +// waitReady reads the child's ready signal within ReadyTimeout. The first +// byte is 'R' (ready) or 'E' (error); on 'E' the child may write its real +// error text after the byte and close the pipe, which we surface so the +// caller sees WHY the bus failed to start (instead of an opaque +// ErrSpawnFailed). pr is closed by the caller on return. +func waitReady(pr *os.File) error { + type result struct { + b byte + msg string + err error + } + done := make(chan result, 1) + go func() { + buf := make([]byte, 1) + n, err := io.ReadFull(pr, buf) + if err != nil { + done <- result{err: err} + return + } + if n != 1 { + 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 { + case res := <-done: + if res.err != nil { + if errors.Is(res.err, io.EOF) || errors.Is(res.err, io.ErrUnexpectedEOF) { + return ErrSpawnFailed // child closed pipe without writing + } + return fmt.Errorf("busctl: read ready pipe: %w", res.err) + } + switch res.b { + 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) + } + case <-time.After(ReadyTimeout): + return ErrSpawnTimeout + } +} + +// ReadyFDFromEnv returns the inherited ready pipe (or nil if not set). The +// `event _bus` command handler calls this at startup, passes the returned +// *os.File to bus.Run as Config.ReadyPipe, and the bus signals readiness +// through it. +func ReadyFDFromEnv() *os.File { + v := os.Getenv(ReadyFDEnv) + if v == "" { + return nil + } + fd, err := strconv.Atoi(v) + if err != nil || fd < 3 { + return nil + } + return os.NewFile(uintptr(fd), "dws-bus-ready") +} diff --git a/internal/event/busctl/spawn_test.go b/internal/event/busctl/spawn_test.go new file mode 100644 index 00000000..42a1c532 --- /dev/null +++ b/internal/event/busctl/spawn_test.go @@ -0,0 +1,292 @@ +// 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 busctl + +import ( + "errors" + "io" + "os" + "os/exec" + "runtime" + "strings" + "testing" + "time" +) + +// Test child mode: when DWS_BUSCTL_TEST_CHILD is set, this test binary +// acts as a fake "dws event _bus" child. It reads ReadyFDFromEnv, writes +// the byte specified by the env var, and either exits or sleeps based on +// the second env var. Used by the Spawn tests below to exercise the +// real fork path without requiring a separate test-helper binary. +// +// We piggy-back on the test binary because building a separate helper +// would require either build tags or a `TestMain` two-phase exec; the +// env-marker pattern is what Go's own os/exec tests use and stays +// confined to this file. +const ( + childEnvMarker = "DWS_BUSCTL_TEST_CHILD" + // values: + // "ready" — write 'R' then sleep 30s (parent should see ready) + // "fail" — write 'E' then exit (parent should see ErrSpawnFailed) + // "silent" — exit without writing (parent should see ErrSpawnFailed via EOF) + // "stall" — sleep without writing (parent should see ErrSpawnTimeout) + // "write-stdout" — write 'R' to ready FD AND to stdout (parent verifies stdout was detached) +) + +// TestMain detects the child mode marker and executes the corresponding +// behaviour before delegating to the normal test runner. Production +// invocations never have this env var set so the dispatch is a no-op. +func TestMain(m *testing.M) { + switch os.Getenv(childEnvMarker) { + case "ready": + writeReady('R') + time.Sleep(30 * time.Second) + os.Exit(0) + case "fail": + writeReady('E') + os.Exit(1) + case "silent": + // don't open the ready FD at all — let the pipe close on exec exit + os.Exit(2) + case "stall": + time.Sleep(30 * time.Second) + os.Exit(0) + case "write-stdout": + // Write to stdout BEFORE signalling ready. Parent's Spawn should + // have detached stdout to /dev/null, so the parent's stdout + // buffer (captured separately in the test) must NOT see this. + _, _ = os.Stdout.Write([]byte("POLLUTION-FROM-CHILD\n")) + writeReady('R') + time.Sleep(30 * time.Second) + os.Exit(0) + } + os.Exit(m.Run()) +} + +func writeReady(b byte) { + pipe := ReadyFDFromEnv() + if pipe == nil { + return + } + _, _ = pipe.Write([]byte{b}) + _ = pipe.Close() +} + +// spawnWithMarker invokes Spawn against the current test binary with a +// child-mode env var set. Returns the child's PID + the Spawn error. +func spawnWithMarker(t *testing.T, marker string, opts ...func(*SpawnConfig)) (int, error) { + t.Helper() + cfg := SpawnConfig{ + ExecPath: os.Args[0], + ClientID: "ding_spawn_test", + Env: append(os.Environ(), + childEnvMarker+"="+marker, + ), + } + for _, o := range opts { + o(&cfg) + } + return Spawn(cfg) +} + +func TestSpawn_ReadySuccess(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Spawn uses Unix Setsid; Windows path covered separately") + } + pid, err := spawnWithMarker(t, "ready") + if err != nil { + t.Fatalf("Spawn ready: %v", err) + } + if pid <= 0 { + t.Errorf("Spawn returned non-positive pid %d", pid) + } + // Reap the child so it doesn't outlive the test. + if proc, err := os.FindProcess(pid); err == nil { + _ = proc.Kill() + _, _ = proc.Wait() + } +} + +func TestSpawn_ReadyFailReportsErrSpawnFailed(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Spawn uses Unix Setsid") + } + pid, err := spawnWithMarker(t, "fail") + if !errors.Is(err, ErrSpawnFailed) { + t.Fatalf("err = %v, want ErrSpawnFailed", err) + } + if pid <= 0 { + t.Errorf("pid should still be reported even on fail, got %d", pid) + } +} + +func TestSpawn_ChildSilentExitReportsErrSpawnFailed(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Spawn uses Unix Setsid") + } + _, err := spawnWithMarker(t, "silent") + if !errors.Is(err, ErrSpawnFailed) { + t.Fatalf("err = %v, want ErrSpawnFailed (EOF on pipe)", err) + } +} + +func TestSpawn_StallReportsTimeout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Spawn uses Unix Setsid") + } + // Temporarily shorten ReadyTimeout via a local Spawn variant. The + // production timeout is 10s — too long for a unit test. We exec + // manually with a tiny io-wait wrapper to verify the behaviour. + // + // We can't change the package-level const, so we re-implement the + // timeout part directly using the same primitives the production + // code uses. + cmd := exec.Command(os.Args[0]) + cmd.Env = append(os.Environ(), childEnvMarker+"=stall", ReadyFDEnv+"=3") + pr, pw, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + cmd.ExtraFiles = []*os.File{pw} + if err := cmd.Start(); err != nil { + t.Fatalf("start: %v", err) + } + defer func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }() + _ = pw.Close() + + // Replicate waitReady with a tiny timeout. + done := make(chan error, 1) + go func() { + b := make([]byte, 1) + _, err := pr.Read(b) + done <- err + }() + select { + case <-done: + t.Fatal("child should have stalled; got data on ready pipe") + case <-time.After(200 * time.Millisecond): + // expected — child is stalling, no ready byte arrived + } + _ = pr.Close() +} + +func TestSpawn_StdioDetached(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Spawn uses Unix Setsid; Windows stdio handling differs") + } + // Capture this process's stdout for the duration of the child run. + // If applyDetach is broken and cmd.Stdout would otherwise inherit, + // the child's "POLLUTION-FROM-CHILD" line would land in our pipe. + origStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + defer func() { + os.Stdout = origStdout + _ = w.Close() + _ = r.Close() + }() + + pid, err := spawnWithMarker(t, "write-stdout") + if err != nil { + t.Fatalf("Spawn: %v", err) + } + defer func() { + if proc, err := os.FindProcess(pid); err == nil { + _ = proc.Kill() + _, _ = proc.Wait() + } + }() + // Close the write end on the parent side so reading r will EOF if + // nothing arrived. Give the child a beat to attempt the write. + time.Sleep(150 * time.Millisecond) + _ = w.Close() + + buf := make([]byte, 256) + r.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + n, _ := r.Read(buf) + if n > 0 { + t.Fatalf("child wrote %q to parent's stdout — stdio not detached!", buf[:n]) + } +} + +func TestReadyFDFromEnv_NoEnvReturnsNil(t *testing.T) { + t.Setenv(ReadyFDEnv, "") + if f := ReadyFDFromEnv(); f != nil { + t.Errorf("ReadyFDFromEnv with empty env should be nil, got %v", f) + } +} + +func TestReadyFDFromEnv_InvalidIntReturnsNil(t *testing.T) { + t.Setenv(ReadyFDEnv, "not-an-int") + if f := ReadyFDFromEnv(); f != nil { + t.Errorf("ReadyFDFromEnv with bad value should be nil, got %v", f) + } +} + +func TestReadyFDFromEnv_LowFDRejected(t *testing.T) { + // fd 0/1/2 are stdio — refusing them defends against accidental + // stdin/stdout/stderr corruption if someone misconfigures. + t.Setenv(ReadyFDEnv, "1") + if f := ReadyFDFromEnv(); f != nil { + 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) + } +} diff --git a/internal/event/busctl/spawn_unix.go b/internal/event/busctl/spawn_unix.go new file mode 100644 index 00000000..b568c524 --- /dev/null +++ b/internal/event/busctl/spawn_unix.go @@ -0,0 +1,31 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package busctl + +import ( + "os/exec" + "syscall" +) + +// applyDetach configures the child to live past parent death and not share +// the parent's controlling terminal. Setsid puts the child in a new +// session, so SIGHUP on the parent's controlling tty (e.g. SSH disconnect) +// does not propagate. Setpgid is implied by Setsid. +func applyDetach(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } +} diff --git a/internal/event/busctl/spawn_windows.go b/internal/event/busctl/spawn_windows.go new file mode 100644 index 00000000..ee0174b1 --- /dev/null +++ b/internal/event/busctl/spawn_windows.go @@ -0,0 +1,32 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package busctl + +import ( + "os/exec" + "syscall" +) + +// CREATE_NEW_PROCESS_GROUP (0x00000200) prevents the child from receiving +// the parent's Ctrl+C signal, similar in spirit to Setsid on Unix. +const createNewProcessGroup = 0x00000200 + +func applyDetach(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{ + CreationFlags: createNewProcessGroup, + HideWindow: true, + } +} diff --git a/internal/event/busctl/status.go b/internal/event/busctl/status.go new file mode 100644 index 00000000..87c00175 --- /dev/null +++ b/internal/event/busctl/status.go @@ -0,0 +1,290 @@ +// 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 busctl + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +// BusEntryState classifies a discovered bus directory's runtime state. +// Used by `dws event status/list` to render the table and by +// --fail-on-orphan to drive exit code. +type BusEntryState string + +const ( + // BusStateRunning: bus.lock holds an alive PID — the daemon is up. + BusStateRunning BusEntryState = "running" + // BusStateOrphan: bus.meta exists but bus.lock PID is dead. The user + // should `dws event stop --client-id ` (which detects the dead + // PID and unblocks fresh starts) or rm -rf the working directory. + BusStateOrphan BusEntryState = "orphan" + // BusStateNotRunning: directory exists (e.g. bus.meta retained for + // historic reasons) but bus.lock is missing or empty. Clean state. + BusStateNotRunning BusEntryState = "not_running" +) + +// BusEntry is one bus working directory found on disk plus its detected +// lifecycle state. EnumerateBuses produces these; the cobra layer joins +// them with QueryStatus output to render the full status view. +type BusEntry struct { + WorkDir string `json:"workdir"` + Edition string `json:"edition"` + SourceKind dwsevent.SourceKind `json:"source_kind,omitempty"` + ClientIDHash string `json:"client_id_hash"` + IdentityHash string `json:"identity_hash,omitempty"` + HolderPID int `json:"holder_pid"` + State BusEntryState `json:"state"` + // Meta, if non-nil, lets list/status display the original ClientID + // (reverse-mapped from the hash) and the bus start time. + Meta *bus.Meta `json:"meta,omitempty"` +} + +// IPCEndpoint returns the IPC endpoint for this entry. Delegates to +// dwsevent.IPCEndpoint so status/stop dial exactly where consume and the +// bus daemon bound (including the short-path fallback when WorkDir is too +// deep for sun_path). +func (e BusEntry) IPCEndpoint() string { + hash := e.ClientIDHash + if e.IdentityHash != "" { + hash = e.IdentityHash + } + return dwsevent.IPCEndpoint(e.WorkDir, e.Edition, e.SourceKind, hash) +} + +// EnumerateBuses scans /events//*/ for bus +// working directories. An empty editionFilter scans every edition +// directory found under events/. +// +// Returns a deterministic slice sorted by (edition, source_kind, identity_hash). +// Missing/inaccessible directories are skipped silently — list/status +// commands should still succeed when only some editions have ever run a +// bus. +func EnumerateBuses(configDir string, editionFilter string) ([]BusEntry, error) { + root := filepath.Join(configDir, "events") + editions, err := listSubdirs(root) + if err != nil { + // events/ might not exist if no bus ever ran — that's fine, + // surface an empty list rather than an error. + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("busctl: scan events dir: %w", err) + } + + var out []BusEntry + for _, ed := range editions { + if editionFilter != "" && ed != editionFilter { + continue + } + editionDir := filepath.Join(root, ed) + hashDirs, err := listSubdirs(editionDir) + if err != nil { + continue + } + for _, h := range hashDirs { + candidate := filepath.Join(editionDir, h) + if isSourceKindDir(h) { + identityDirs, err := listSubdirs(candidate) + if err != nil { + continue + } + for _, ih := range identityDirs { + workDir := filepath.Join(candidate, ih) + out = append(out, inspectEntry(workDir, ed, h, ih)) + } + continue + } + // Legacy v1 app-stream layout: events//. + out = append(out, inspectEntry(candidate, ed, "", h)) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Edition != out[j].Edition { + return out[i].Edition < out[j].Edition + } + if out[i].SourceKind != out[j].SourceKind { + return out[i].SourceKind < out[j].SourceKind + } + return out[i].IdentityHash < out[j].IdentityHash + }) + return out, nil +} + +// FindBusByClientID is the "current ClientID" lookup used by `event status` +// (no --all). Returns the entry for the given (edition, clientIDHash) pair +// or nil if no bus has ever started for it. The caller derives the hash +// using event.ClientIDHash. +func FindBusByClientID(configDir, editionName, clientIDHash string) *BusEntry { + workDir := filepath.Join(configDir, "events", editionName, string(dwsevent.SourceKindAppStream), clientIDHash) + if _, err := os.Stat(workDir); err != nil { + legacy := filepath.Join(configDir, "events", editionName, clientIDHash) + if _, legacyErr := os.Stat(legacy); legacyErr != nil { + return nil + } + workDir = legacy + } + e := inspectEntry(workDir, editionName, string(dwsevent.SourceKindAppStream), clientIDHash) + return &e +} + +// FindBusByIdentity looks up a bus in the source-kind-aware layout. +func FindBusByIdentity(configDir, editionName string, sourceKind dwsevent.SourceKind, identityHash string) *BusEntry { + if sourceKind == "" { + sourceKind = dwsevent.SourceKindAppStream + } + workDir := filepath.Join(configDir, "events", editionName, string(sourceKind), identityHash) + if _, err := os.Stat(workDir); err != nil { + return nil + } + e := inspectEntry(workDir, editionName, string(sourceKind), identityHash) + return &e +} + +// listSubdirs returns immediate subdirectories of path, by basename. Ignores +// regular files. Returns the err from ReadDir unchanged (callers handle +// os.IsNotExist). +func listSubdirs(path string) ([]string, error) { + ents, err := os.ReadDir(path) + if err != nil { + return nil, err + } + out := make([]string, 0, len(ents)) + for _, e := range ents { + if e.IsDir() { + out = append(out, e.Name()) + } + } + return out, nil +} + +// inspectEntry reads bus.meta + bus.lock and derives the lifecycle state. +// Never returns an error: any read failure folds into BusStateNotRunning. +func inspectEntry(workDir, editionName, sourceKindRaw, identityHash string) BusEntry { + sourceKind := dwsevent.SourceKind(sourceKindRaw) + if sourceKind == "" { + sourceKind = dwsevent.SourceKindAppStream + } + e := BusEntry{ + WorkDir: workDir, + Edition: editionName, + SourceKind: sourceKind, + ClientIDHash: identityHash, + IdentityHash: identityHash, + State: BusStateNotRunning, + } + if m, err := bus.ReadMeta(workDir); err == nil { + e.Meta = m + if m.SourceKind != "" { + e.SourceKind = m.SourceKind + } + if m.IdentityHash != "" { + e.IdentityHash = m.IdentityHash + e.ClientIDHash = m.IdentityHash + } + } + pid := bus.ReadHolderPID(filepath.Join(workDir, bus.LockFileName)) + e.HolderPID = pid + switch { + case pid > 0 && process.Alive(pid): + e.State = BusStateRunning + case pid > 0 && !process.Alive(pid): + e.State = BusStateOrphan + case pid == 0 && e.Meta != nil: + // meta retained but lock cleared — bus exited cleanly. Render as + // not_running (with the historical meta visible if the user asked + // for --format json). + e.State = BusStateNotRunning + } + return e +} + +func isSourceKindDir(name string) bool { + return name == string(dwsevent.SourceKindAppStream) || name == string(dwsevent.SourceKindPersonalStream) +} + +// DefaultStatusRPCTimeout caps how long QueryStatus waits for the bus to +// reply. 2s is generous — the bus's status_resp is a synchronous +// in-memory snapshot, sub-millisecond in practice; the timeout exists +// only to bound pathological cases (bus stuck in shutdown). +const DefaultStatusRPCTimeout = 2 * time.Second + +// QueryStatus dials the bus IPC, sends Hello with Role=status, sends a +// StatusReq, reads exactly one StatusResp, and closes. Returns the +// decoded response or an error if any step fails. +// +// Used by `dws event status` and `dws event list` to fetch live +// per-consumer / per-event-type counters. The bus's handleStatusRPC +// path (see internal/event/bus/daemon.go) handles this connection +// without registering with the Hub — ad-hoc tooling does not count as +// a consumer in `status.active_consumers`. +func QueryStatus(endpoint string) (*transport.StatusResp, error) { + conn, err := transport.Dial(endpoint) + if err != nil { + return nil, fmt.Errorf("busctl: dial bus for status: %w", err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(DefaultStatusRPCTimeout)) + + w := transport.NewWriter(conn) + r := transport.NewReader(conn) + + if err := w.WriteJSON(transport.Hello{ + Type: transport.FrameTypeHello, + ConsumerPID: os.Getpid(), + Role: transport.HelloRoleStatus, + }); err != nil { + return nil, fmt.Errorf("busctl: write hello: %w", err) + } + if err := w.WriteJSON(transport.StatusReq{Type: transport.FrameTypeStatusReq}); err != nil { + return nil, fmt.Errorf("busctl: write status_req: %w", err) + } + var resp transport.StatusResp + if err := r.ReadJSON(&resp); err != nil { + return nil, fmt.Errorf("busctl: read status_resp: %w", err) + } + return &resp, nil +} + +// EntryStatus combines static FS info (BusEntry) with the live RPC +// snapshot (StatusResp). For not_running / orphan entries Live is nil. +type EntryStatus struct { + Entry BusEntry `json:"entry"` + Live *transport.StatusResp `json:"live,omitempty"` +} + +// QueryEntry fetches the live status for one BusEntry. Returns the entry +// wrapped with a nil Live when state != running (or when the dial fails). +// Errors from QueryStatus are folded into Live=nil so the caller's table +// rendering does not need to surface per-bus dial failures (they are +// already conveyed by State). +func QueryEntry(entry BusEntry) EntryStatus { + out := EntryStatus{Entry: entry} + if entry.State != BusStateRunning { + return out + } + live, err := QueryStatus(entry.IPCEndpoint()) + if err == nil { + out.Live = live + } + return out +} diff --git a/internal/event/busctl/status_test.go b/internal/event/busctl/status_test.go new file mode 100644 index 00000000..f8c12a28 --- /dev/null +++ b/internal/event/busctl/status_test.go @@ -0,0 +1,203 @@ +// 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 busctl + +import ( + "context" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus" +) + +func TestEnumerateBuses_EmptyConfigDir(t *testing.T) { + dir := shortTempDir(t) + got, err := EnumerateBuses(dir, "") + if err != nil { + t.Fatalf("err: %v", err) + } + if got != nil { + t.Fatalf("expected nil for empty configDir, got %v", got) + } +} + +// makeBusDir creates events/// with optional meta + lock +// content. Returns the path. +func makeBusDir(t *testing.T, configDir, ed, hash string, withMeta bool, lockPID int) string { + t.Helper() + workDir := filepath.Join(configDir, "events", ed, hash) + if err := os.MkdirAll(workDir, 0o700); err != nil { + t.Fatal(err) + } + if withMeta { + shortHash := hash + if len(shortHash) > 8 { + shortHash = shortHash[:8] + } + if err := bus.WriteMeta(workDir, bus.Meta{ + ClientID: "ding_" + shortHash, + Edition: ed, + StartedAt: time.Now().UTC(), + }); err != nil { + t.Fatal(err) + } + } + if lockPID != 0 { + if err := os.WriteFile(filepath.Join(workDir, bus.LockFileName), + []byte(strconv.Itoa(lockPID)+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + return workDir +} + +func TestEnumerateBuses_DetectsRunningOrphanNotRunning(t *testing.T) { + dir := shortTempDir(t) + makeBusDir(t, dir, "open", "aaaa1111", true, os.Getpid()) // running (self pid) + makeBusDir(t, dir, "open", "bbbb2222", true, 2147483646) // orphan (dead pid) + makeBusDir(t, dir, "open", "cccc3333", true, 0) // not_running (meta only, no lock content) + makeBusDir(t, dir, "wukong", "dddd4444", true, os.Getpid()) // running, different edition + + all, err := EnumerateBuses(dir, "") + if err != nil { + t.Fatalf("EnumerateBuses all: %v", err) + } + if len(all) != 4 { + t.Fatalf("expected 4 entries, got %d: %+v", len(all), all) + } + + // Filter by edition. + openOnly, err := EnumerateBuses(dir, "open") + if err != nil { + t.Fatal(err) + } + if len(openOnly) != 3 { + t.Fatalf("expected 3 open-edition entries, got %d", len(openOnly)) + } + for _, e := range openOnly { + if e.Edition != "open" { + t.Errorf("editionFilter leak: %+v", e) + } + } + + // State classification (in the all-editions slice the entries are + // sorted by edition,hash so we can index deterministically). + byHash := map[string]BusEntry{} + for _, e := range all { + byHash[e.ClientIDHash] = e + } + if got := byHash["aaaa1111"].State; got != BusStateRunning { + t.Errorf("aaaa1111 state = %s, want running", got) + } + if got := byHash["bbbb2222"].State; got != BusStateOrphan { + t.Errorf("bbbb2222 state = %s, want orphan", got) + } + if got := byHash["cccc3333"].State; got != BusStateNotRunning { + t.Errorf("cccc3333 state = %s, want not_running", got) + } + if got := byHash["dddd4444"].State; got != BusStateRunning { + t.Errorf("dddd4444 state = %s, want running", got) + } +} + +func TestEnumerateBuses_SortedDeterministic(t *testing.T) { + dir := shortTempDir(t) + makeBusDir(t, dir, "open", "zzzz", true, 0) + makeBusDir(t, dir, "open", "aaaa", true, 0) + makeBusDir(t, dir, "wukong", "bbbb", true, 0) + got, _ := EnumerateBuses(dir, "") + if len(got) != 3 { + t.Fatalf("got %d entries", len(got)) + } + // expected order: open/aaaa, open/zzzz, wukong/bbbb + if got[0].ClientIDHash != "aaaa" || got[1].ClientIDHash != "zzzz" || got[2].ClientIDHash != "bbbb" { + t.Fatalf("sort order wrong:\n %+v\n %+v\n %+v", got[0], got[1], got[2]) + } +} + +func TestFindBusByClientID(t *testing.T) { + dir := shortTempDir(t) + makeBusDir(t, dir, "open", "aaaa", true, os.Getpid()) + if e := FindBusByClientID(dir, "open", "aaaa"); e == nil { + t.Fatal("FindBusByClientID returned nil for existing entry") + } else if e.State != BusStateRunning { + t.Errorf("State = %s, want running", e.State) + } + if e := FindBusByClientID(dir, "open", "missing"); e != nil { + t.Errorf("missing entry should return nil, got %+v", e) + } +} + +func TestQueryStatus_RealBusE2E(t *testing.T) { + skipOnWindows(t) + // Bring up a real bus daemon, then query it. + workDir := shortTempDir(t) + sock := filepath.Join(workDir, "bus.sock") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + runDone := make(chan error, 1) + go func() { + runDone <- bus.Run(ctx, bus.Config{ + WorkDir: workDir, + IPCEndpoint: sock, + ClientID: "ding_test_query", + Edition: "open", + Source: &fakeSrc{}, + }) + }() + defer func() { cancel(); <-runDone }() + waitForSocket(t, sock, 2*time.Second) + + resp, err := QueryStatus(sock) + if err != nil { + t.Fatalf("QueryStatus: %v", err) + } + if resp.Bus.ClientID != "ding_test_query" { + t.Errorf("ClientID round-trip = %q", resp.Bus.ClientID) + } + if resp.Bus.Edition != "open" { + t.Errorf("Edition = %q", resp.Bus.Edition) + } + if resp.Bus.PID != os.Getpid() { + t.Errorf("Bus.PID = %d, want %d", resp.Bus.PID, os.Getpid()) + } +} + +// fakeSrc is a no-op SourceAdapter used by QueryStatus E2E. It just +// blocks on ctx so the bus daemon stays up long enough for the test to +// dial it. +type fakeSrc struct{} + +func (fakeSrc) Start(ctx context.Context, _ dwsevent.EmitFn) error { + <-ctx.Done() + return ctx.Err() +} + +// waitForSocket polls for the unix socket file. Reused by tests that +// need to dial a freshly-spawned bus. +func waitForSocket(t *testing.T, path string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("socket %q did not appear within %s", path, timeout) +} diff --git a/internal/event/busctl/stop.go b/internal/event/busctl/stop.go new file mode 100644 index 00000000..d2dd8608 --- /dev/null +++ b/internal/event/busctl/stop.go @@ -0,0 +1,95 @@ +// 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 busctl + +import ( + "errors" + "fmt" + "os" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process" +) + +// DefaultStopTimeout is the wall-clock budget Stop waits for the bus to +// exit after the signal is sent. 5s covers the bus's own graceful tear-down +// (broadcast Bye → consumer goroutines drain → cleanup) with margin. +const DefaultStopTimeout = 5 * time.Second + +// ErrNotRunning indicates bus.lock either does not exist or its recorded +// PID is not alive. Stop returns this as a sentinel so the caller can +// distinguish "nothing to stop" from "failed to stop". +var ErrNotRunning = errors.New("busctl: bus is not running") + +// StopConfig identifies the target bus and tunes timing. +type StopConfig struct { + // WorkDir holds bus.lock; Stop reads the PID from there. + WorkDir string + // Timeout is the total wall-clock budget for graceful exit. After this, + // Stop returns an error; it does NOT escalate to SIGKILL — leave that + // to the operator. + Timeout time.Duration +} + +// Stop signals the bus daemon for cfg.WorkDir to exit gracefully and waits +// for the process to actually die. Returns ErrNotRunning if no bus is +// running for that work dir. +// +// Implementation note: on Unix we send SIGTERM. The bus daemon's Run loop +// watches its parent ctx for cancellation; the cobra `event _bus` command +// wires signal.NotifyContext so SIGTERM triggers ctx.Done() → graceful +// shutdown path. On Windows we use os.Process.Signal(os.Interrupt) which +// the Go runtime maps to TerminateProcess for processes outside our +// console group; for v1 that's acceptable (Windows graceful shutdown is +// future work — plan §16 v2). +func Stop(cfg StopConfig) error { + if cfg.WorkDir == "" { + return errors.New("busctl: StopConfig.WorkDir is required") + } + if cfg.Timeout == 0 { + cfg.Timeout = DefaultStopTimeout + } + + pid := bus.ReadHolderPID(LockPath(cfg.WorkDir)) + if pid <= 0 { + return ErrNotRunning + } + if !process.Alive(pid) { + return ErrNotRunning + } + + proc, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("busctl: find process %d: %w", pid, err) + } + if err := proc.Signal(stopSignal()); err != nil { + // On many Unix platforms Signal returns "process already finished" + // when the bus has just exited on its own — treat that as success. + if errors.Is(err, os.ErrProcessDone) { + return nil + } + return fmt.Errorf("busctl: signal bus pid=%d: %w", pid, err) + } + + // Poll for actual exit. + deadline := time.Now().Add(cfg.Timeout) + for time.Now().Before(deadline) { + if !process.Alive(pid) { + return nil + } + time.Sleep(50 * time.Millisecond) + } + return fmt.Errorf("busctl: bus pid=%d did not exit within %s", pid, cfg.Timeout) +} diff --git a/internal/event/busctl/stop_test.go b/internal/event/busctl/stop_test.go new file mode 100644 index 00000000..e4cd99de --- /dev/null +++ b/internal/event/busctl/stop_test.go @@ -0,0 +1,140 @@ +// 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 busctl + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus" +) + +func TestStop_NotRunningWhenLockMissing(t *testing.T) { + dir := shortTempDir(t) + err := Stop(StopConfig{WorkDir: dir}) + if !errors.Is(err, ErrNotRunning) { + t.Fatalf("Stop on missing lock = %v, want ErrNotRunning", err) + } +} + +func TestStop_NotRunningWhenPIDDead(t *testing.T) { + dir := shortTempDir(t) + // Write a definitely-dead PID into bus.lock. + if err := os.WriteFile(LockPath(dir), []byte("2147483646\n"), 0o600); err != nil { + t.Fatal(err) + } + err := Stop(StopConfig{WorkDir: dir}) + if !errors.Is(err, ErrNotRunning) { + t.Fatalf("Stop on dead PID = %v, want ErrNotRunning", err) + } +} + +func TestStop_SignalsLiveProcess(t *testing.T) { + skipOnWindows(t) + dir := shortTempDir(t) + + // Spawn `sleep 10` to act as the "bus daemon". + cmd := exec.CommandContext(context.Background(), "sh", "-c", "sleep 10") + if err := cmd.Start(); err != nil { + t.Fatalf("start sleep child: %v", err) + } + defer func() { + // Best-effort cleanup if test fails. + if cmd.ProcessState == nil { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + } + }() + pid := cmd.Process.Pid + + // Reap the child in background so Wait doesn't leave a zombie. + waited := make(chan error, 1) + go func() { waited <- cmd.Wait() }() + + // Write PID into bus.lock. + if err := os.WriteFile(LockPath(dir), []byte(strconv.Itoa(pid)+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + // Stop should signal SIGTERM and observe the process exit. + start := time.Now() + if err := Stop(StopConfig{WorkDir: dir, Timeout: 3 * time.Second}); err != nil { + t.Fatalf("Stop: %v", err) + } + elapsed := time.Since(start) + + // sleep should react to SIGTERM almost immediately. + if elapsed > 2*time.Second { + t.Errorf("Stop took %s, expected <2s for SIGTERM-responsive child", elapsed) + } + + // Confirm the child actually exited. + select { + case err := <-waited: + // sh -c "sleep 10" exits with non-zero on signal; either is fine. + _ = err + case <-time.After(2 * time.Second): + t.Fatal("child did not exit after Stop") + } +} + +// TestStop_TimeoutWhenChildIgnoresSignal ensures Stop honours its deadline +// and returns a useful error instead of hanging forever. +func TestStop_TimeoutWhenChildIgnoresSignal(t *testing.T) { + skipOnWindows(t) + dir := shortTempDir(t) + + // shell that traps SIGTERM and ignores it for a long time + cmd := exec.Command("sh", "-c", "trap '' TERM; sleep 30") + if err := cmd.Start(); err != nil { + t.Fatalf("start trap child: %v", err) + } + defer func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }() + pid := cmd.Process.Pid + + if err := os.WriteFile(LockPath(dir), []byte(strconv.Itoa(pid)+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + err := Stop(StopConfig{WorkDir: dir, Timeout: 250 * time.Millisecond}) + if err == nil { + t.Fatal("Stop should error when child ignores SIGTERM") + } +} + +// TestStop_RealBusGracefulShutdown is the integration sanity check: bring +// up a real bus.Run instance, set bus.lock content to its PID, call Stop, +// and verify Run returned cleanly (via ctx done propagation in the test). +// +// NOTE: bus.Run installs its own ctx handler from the caller's ctx; here +// we don't have signal.NotifyContext (we're running in-process), so Stop's +// SIGTERM won't reach bus.Run unless we install a signal handler. Instead, +// we test the underlying primitives: PID-read, signal-send, alive-poll. +func TestStop_BusLockPathHelper(t *testing.T) { + dir := shortTempDir(t) + got := LockPath(dir) + want := filepath.Join(dir, bus.LockFileName) + if got != want { + t.Fatalf("LockPath = %q, want %q", got, want) + } +} diff --git a/internal/event/busctl/stop_unix.go b/internal/event/busctl/stop_unix.go new file mode 100644 index 00000000..f07f0148 --- /dev/null +++ b/internal/event/busctl/stop_unix.go @@ -0,0 +1,26 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package busctl + +import ( + "os" + "syscall" +) + +// stopSignal returns the graceful-shutdown signal for Unix (SIGTERM). The +// `event _bus` command wires signal.NotifyContext on SIGINT/SIGTERM so +// the daemon's Run sees ctx.Done() and runs its shutdown sequence. +func stopSignal() os.Signal { return syscall.SIGTERM } diff --git a/internal/event/busctl/stop_windows.go b/internal/event/busctl/stop_windows.go new file mode 100644 index 00000000..2e7223f6 --- /dev/null +++ b/internal/event/busctl/stop_windows.go @@ -0,0 +1,24 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows + +package busctl + +import "os" + +// stopSignal returns the graceful-shutdown signal for Windows. The Go +// runtime maps os.Interrupt to TerminateProcess for non-console-group +// processes — not truly graceful, but acceptable for v1 (Windows graceful +// shutdown via Ctrl+Break is in the v2 backlog, plan §16). +func stopSignal() os.Signal { return os.Interrupt } diff --git a/internal/event/consume/doc.go b/internal/event/consume/doc.go new file mode 100644 index 00000000..89648682 --- /dev/null +++ b/internal/event/consume/doc.go @@ -0,0 +1,18 @@ +// 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 consume implements the consumer-side process of `dws event +// consume`: dial the bus, send Hello, read Event frames, format them, and +// write them out (stdout / file / dir). v1 (P3) implements the minimal +// path — NDJSON to stdout. P4 adds filter/format/route/compact pipeline. +package consume diff --git a/internal/event/consume/duration_flag_test.go b/internal/event/consume/duration_flag_test.go new file mode 100644 index 00000000..34131669 --- /dev/null +++ b/internal/event/consume/duration_flag_test.go @@ -0,0 +1,123 @@ +// 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 consume + +import ( + "context" + "io" + "path/filepath" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus" +) + +// TestRun_DurationExitsCleanly verifies that --duration triggers a +// graceful exit (nil return, no error surfaced) and does so within a +// small multiple of the requested duration. The contract: --duration +// is a wall-clock budget, not an "abort" — Run wraps the caller's ctx +// with WithTimeout and returns nil rather than DeadlineExceeded so the +// exit code stays 0. +func TestRun_DurationExitsCleanly(t *testing.T) { + skipOnWindows(t) + dir, sock, cancel, runDone, trigger := bringUpBus(t, nil) + defer func() { cancel(); <-runDone }() + close(trigger) // no events to fire — Run will exit on duration alone + + duration := 200 * time.Millisecond + start := time.Now() + err := Run(context.Background(), Config{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_test", + Stdout: io.Discard, + Stderr: io.Discard, + Duration: duration, + }) + if err != nil { + t.Fatalf("Run with --duration should exit nil, got %v", err) + } + elapsed := time.Since(start) + if elapsed < duration { + t.Errorf("Run returned before duration elapsed: %s < %s", elapsed, duration) + } + if elapsed > duration+2*time.Second { + t.Errorf("Run took %s, much longer than duration %s", elapsed, duration) + } +} + +// TestRun_DurationZeroMeansUnlimited verifies the documented "0 = no +// limit" semantic of --duration. We start with a small parent-ctx +// timeout to bound the test runtime; Run should respect that ctx +// instead of having its own (zero) duration trigger. +func TestRun_DurationZeroMeansUnlimited(t *testing.T) { + skipOnWindows(t) + dir, sock, cancel, runDone, trigger := bringUpBus(t, nil) + defer func() { cancel(); <-runDone }() + close(trigger) + + ctx, ctxCancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer ctxCancel() + start := time.Now() + err := Run(ctx, Config{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_test", + Stdout: io.Discard, + Stderr: io.Discard, + Duration: 0, // explicitly unlimited + }) + if err != nil { + t.Fatalf("Run with --duration=0 should exit nil (ctx cancel), got %v", err) + } + // Run should respect the parent ctx — not Duration. So it returns + // at roughly the ctx deadline. + if elapsed := time.Since(start); elapsed < 100*time.Millisecond { + t.Errorf("Run returned too quickly (%s); --duration=0 should defer to parent ctx", elapsed) + } +} + +// TestRun_DryRunPrintsConfigAndExits verifies --dry-run is end-to-end +// observable: Run never dials the bus (so even with a bogus IPC endpoint +// it returns nil) and writes the config block to Stderr. +func TestRun_DryRunDoesNotDial(t *testing.T) { + // We deliberately give a non-existent endpoint to prove Run does + // not try to dial. + dir := shortTempDir(t) + bogusSock := filepath.Join(dir, "no-such.sock") + err := Run(context.Background(), Config{ + WorkDir: dir, + IPCEndpoint: bogusSock, + ClientID: "ding_test", + Stdout: io.Discard, + Stderr: io.Discard, + DryRun: true, + }) + if err != nil { + t.Fatalf("DryRun should bypass dial and return nil, got %v", err) + } +} + +// Sanity check that bus.ApplyEnvTuning is wired the same way Run reads +// from Config.Duration — both should be additive and not interfere. +func TestApplyEnvTuning_DoesNotTouchDuration(t *testing.T) { + // Duration is a consume.Config field, not bus.Config — but we still + // want a smoke test that ApplyEnvTuning doesn't accidentally reach + // into the consume layer. + cfg := bus.Config{} + bus.ApplyEnvTuning(&cfg) + // (no Duration field on bus.Config; this test compiles only if the + // invariant holds — caught by reviewer if someone adds one) + _ = cfg +} diff --git a/internal/event/consume/formatter.go b/internal/event/consume/formatter.go new file mode 100644 index 00000000..d22b8716 --- /dev/null +++ b/internal/event/consume/formatter.go @@ -0,0 +1,157 @@ +// 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 consume + +import ( + "encoding/json" + "fmt" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/registry" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +// Format identifies the wire shape `dws event consume` writes per event. +// Values mirror dws's global -f/--format flag vocabulary (defined in +// internal/output) with the subset that makes sense for streaming. +type Format string + +const ( + // FormatNDJSON is the default: one compact JSON object per line. + // Pipe-friendly; one event per `read` line. Recommended for agents. + FormatNDJSON Format = "ndjson" + // FormatJSON pretty-prints each event as multi-line JSON. NOT a + // JSON array — still NDJSON-style (one document per output unit), + // just with indentation. See plan §3.1 输出约束 note about why we + // do not emit a JSON array for an unbounded stream. + FormatJSON Format = "json" + // FormatPretty is the same as FormatJSON in v1; reserved for future + // human-friendly colorisation. Kept distinct so we never silently + // degrade `--format pretty` to compact-ndjson. + FormatPretty Format = "pretty" + // FormatRaw writes only the SDK's original Data string (one per + // event, newline-terminated). Useful when piping into jq / a tool + // that wants the cloud payload verbatim without our envelope. + FormatRaw Format = "raw" + // FormatCompact runs the per-event-type compact processor (see + // registry.LookupProcessor) and emits one flattened JSON line. + FormatCompact Format = "compact" +) + +// NormalizeFormat maps a raw flag value to a supported Format. Values +// outside the event command's supported set fall back to NDJSON with the +// fallback flag set true — callers SHOULD warn on stderr when fallback is +// true and the original value was non-empty (e.g. user passed +// --format table which has no meaning for an event stream). +// +// Empty input maps to NDJSON without a fallback warning. +func NormalizeFormat(raw string) (f Format, fellback bool) { + switch raw { + case "": + return FormatNDJSON, false + case string(FormatNDJSON): + return FormatNDJSON, false + case string(FormatJSON): + return FormatJSON, false + case string(FormatPretty): + return FormatPretty, false + case string(FormatRaw): + return FormatRaw, false + case string(FormatCompact): + return FormatCompact, false + default: + // Includes table/csv from the global -f vocabulary, plus any + // typo. Fall back to ndjson (the safe stream default) and let + // the caller stderr-WARN. + return FormatNDJSON, true + } +} + +// Formatter renders a transport.Event into the byte stream the sink writes +// out. Implementations append their own line terminator when appropriate +// (NDJSON / Raw add '\n'; Pretty/JSON embed newlines in the JSON itself). +type Formatter interface { + Render(ev transport.Event) ([]byte, error) +} + +// NewFormatter returns a Formatter for the given Format. Compact wraps +// registry.LookupProcessor so adding a new specialised compactor is just +// a registry-side change. Returns an error only if format is an internally +// unsupported value (defensive — NormalizeFormat guarantees the input is +// one of the constants). +func NewFormatter(format Format) (Formatter, error) { + switch format { + case FormatNDJSON: + return &ndjsonFormatter{}, nil + case FormatJSON, FormatPretty: + return &prettyFormatter{}, nil + case FormatRaw: + return &rawFormatter{}, nil + case FormatCompact: + return &compactFormatter{}, nil + default: + return nil, fmt.Errorf("consume: unsupported format %q", format) + } +} + +// ndjsonFormatter encodes each Event as one compact JSON line + '\n'. +type ndjsonFormatter struct{} + +func (ndjsonFormatter) Render(ev transport.Event) ([]byte, error) { + b, err := json.Marshal(ev) + if err != nil { + return nil, err + } + return append(b, '\n'), nil +} + +// prettyFormatter encodes each Event as multi-line indented JSON + '\n'. +// json.MarshalIndent does not append a trailing newline; we add one so +// successive events are visually separated in the output. +type prettyFormatter struct{} + +func (prettyFormatter) Render(ev transport.Event) ([]byte, error) { + b, err := json.MarshalIndent(ev, "", " ") + if err != nil { + return nil, err + } + return append(b, '\n'), nil +} + +// rawFormatter writes ev.Data verbatim. If Data is already JSON it stays +// JSON; if it's some other string format it stays that. A trailing newline +// is appended so successive raw events are separable. +type rawFormatter struct{} + +func (rawFormatter) Render(ev transport.Event) ([]byte, error) { + out := make([]byte, 0, len(ev.Data)+1) + out = append(out, ev.Data...) + if len(ev.Data) == 0 || ev.Data[len(ev.Data)-1] != '\n' { + out = append(out, '\n') + } + return out, nil +} + +// compactFormatter dispatches to the registry per event_type and writes +// the flattened map as one compact JSON line + '\n'. +type compactFormatter struct{} + +func (compactFormatter) Render(ev transport.Event) ([]byte, error) { + p := registry.LookupProcessor(ev.EventType) + v := p(ev) + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + return append(b, '\n'), nil +} diff --git a/internal/event/consume/formatter_test.go b/internal/event/consume/formatter_test.go new file mode 100644 index 00000000..55a16761 --- /dev/null +++ b/internal/event/consume/formatter_test.go @@ -0,0 +1,156 @@ +// 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 consume + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +func sampleEvent() transport.Event { + return transport.Event{ + Type: transport.FrameTypeEvent, + Seq: 42, + EventID: "ev_abc", + EventBornTime: 1700000000123, + EventType: "im.message.receive_v1", + EventCorpID: "corp_x", + EventUnifiedAppID: "app_y", + Data: `{"message":{"message_id":"om_x","chat_id":"oc_y","content":"hi"},"sender":{"sender_id":{"open_id":"ou_z"}}}`, + ReceivedAtUnixMS: 1700000000999, + } +} + +func TestNormalizeFormat(t *testing.T) { + cases := []struct { + in string + want Format + fellback bool + }{ + {"", FormatNDJSON, false}, + {"ndjson", FormatNDJSON, false}, + {"json", FormatJSON, false}, + {"pretty", FormatPretty, false}, + {"raw", FormatRaw, false}, + {"compact", FormatCompact, false}, + {"table", FormatNDJSON, true}, + {"csv", FormatNDJSON, true}, + {"yaml", FormatNDJSON, true}, // typo / unsupported → fallback + } + for _, c := range cases { + got, fb := NormalizeFormat(c.in) + if got != c.want || fb != c.fellback { + t.Errorf("NormalizeFormat(%q) = (%s, %v), want (%s, %v)", c.in, got, fb, c.want, c.fellback) + } + } +} + +func TestNDJSONFormatter_OneLinePerEvent(t *testing.T) { + f, _ := NewFormatter(FormatNDJSON) + out, err := f.Render(sampleEvent()) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(string(out), "\n") { + t.Fatal("ndjson output must end with \\n") + } + lines := strings.Split(strings.TrimRight(string(out), "\n"), "\n") + if len(lines) != 1 { + t.Fatalf("ndjson must be exactly one line, got %d: %s", len(lines), out) + } + // Must be valid JSON + var ev transport.Event + if err := json.Unmarshal([]byte(lines[0]), &ev); err != nil { + t.Fatalf("not valid JSON: %v", err) + } + if ev.EventID != "ev_abc" { + t.Errorf("round-trip lost EventID: %q", ev.EventID) + } +} + +func TestPrettyFormatter_MultilineIndented(t *testing.T) { + f, _ := NewFormatter(FormatPretty) + out, err := f.Render(sampleEvent()) + if err != nil { + t.Fatal(err) + } + s := string(out) + if !strings.Contains(s, "\n ") { + t.Fatal("pretty output should have 2-space indentation") + } + if !strings.HasSuffix(s, "\n") { + t.Fatal("pretty output should end with \\n") + } + // Strip trailing newline and ensure round-trip works (still valid JSON). + var ev transport.Event + if err := json.Unmarshal([]byte(strings.TrimRight(s, "\n")), &ev); err != nil { + t.Fatalf("pretty not valid JSON: %v", err) + } +} + +func TestRawFormatter_WritesDataVerbatim(t *testing.T) { + f, _ := NewFormatter(FormatRaw) + out, err := f.Render(transport.Event{Data: `{"foo":"bar"}`}) + if err != nil { + t.Fatal(err) + } + if string(out) != "{\"foo\":\"bar\"}\n" { + t.Errorf("raw output = %q", out) + } +} + +func TestRawFormatter_PreservesExistingTrailingNewline(t *testing.T) { + f, _ := NewFormatter(FormatRaw) + out, _ := f.Render(transport.Event{Data: "already-ends-newline\n"}) + if string(out) != "already-ends-newline\n" { + t.Errorf("raw should not double the trailing \\n, got %q", out) + } +} + +func TestRawFormatter_EmptyDataYieldsBareNewline(t *testing.T) { + f, _ := NewFormatter(FormatRaw) + out, _ := f.Render(transport.Event{}) + if string(out) != "\n" { + t.Errorf("empty Data → %q, want bare \\n", out) + } +} + +func TestCompactFormatter_DispatchesPerEventType(t *testing.T) { + f, _ := NewFormatter(FormatCompact) + out, err := f.Render(sampleEvent()) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal([]byte(strings.TrimRight(string(out), "\n")), &got); err != nil { + t.Fatalf("compact not valid JSON: %v", err) + } + // IM message processor should have lifted message_id/chat_id/etc. + if got["message_id"] != "om_x" || got["chat_id"] != "oc_y" { + t.Fatalf("compact output missing lifted fields: %+v", got) + } + // Header field `type` must equal event_type. + if got["type"] != "im.message.receive_v1" { + t.Errorf("type = %v", got["type"]) + } +} + +func TestNewFormatter_RejectsUnknown(t *testing.T) { + if _, err := NewFormatter(Format("nope")); err == nil { + t.Fatal("expected error for unknown format") + } +} diff --git a/internal/event/consume/integration_test.go b/internal/event/consume/integration_test.go new file mode 100644 index 00000000..5408a259 --- /dev/null +++ b/internal/event/consume/integration_test.go @@ -0,0 +1,316 @@ +// 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 consume + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +// TestIntegration_HelloPushdownFiltersAtBus verifies the Hello-time +// event_types pushdown contract (plan §4 unsung superpower): a consumer +// subscribing to "im.*" must NOT receive "approval.*" events even when +// bus and source are flowing both. The filter happens at the Hub layer, +// not at the consumer pipeline — saves IPC bytes for narrow consumers. +func TestIntegration_HelloPushdownFiltersAtBus(t *testing.T) { + skipOnWindows(t) + events := []dwsevent.RawEvent{ + {EventID: "1", EventType: "im.message.receive_v1", Data: `{}`}, + {EventID: "2", EventType: "approval.task", Data: `{}`}, + {EventID: "3", EventType: "im.message.at_v1", Data: `{}`}, + {EventID: "4", EventType: "approval.instance.status_changed", Data: `{}`}, + {EventID: "5", EventType: "im.chat.member.user.added_v1", Data: `{}`}, + } + dir, sock, cancel, runDone, trigger := bringUpBus(t, events) + defer func() { cancel(); <-runDone }() + + var imBuf, approvalBuf bytes.Buffer + var wg sync.WaitGroup + wg.Add(2) + + // Consumer A: im.* only + go func() { + defer wg.Done() + _ = Run(context.Background(), Config{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_test", + Stdout: &imBuf, + Stderr: io.Discard, + EventTypes: []string{"im.*"}, + MaxEvents: 3, // 3 im events expected + }) + }() + // Consumer B: approval.* only + go func() { + defer wg.Done() + _ = Run(context.Background(), Config{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_test", + Stdout: &approvalBuf, + Stderr: io.Discard, + EventTypes: []string{"approval.*"}, + MaxEvents: 2, // 2 approval events expected + }) + }() + + // Give both consumers time to Hello + register. + time.Sleep(200 * time.Millisecond) + close(trigger) + wg.Wait() + + // Verify consumer A got exactly the 3 im.* events. + imLines := nonEmptyLines(imBuf.String()) + if len(imLines) != 3 { + t.Fatalf("im consumer got %d events, want 3:\n%s", len(imLines), imBuf.String()) + } + for i, line := range imLines { + var ev transport.Event + if err := json.Unmarshal([]byte(line), &ev); err != nil { + t.Fatalf("im[%d] not valid JSON: %v", i, err) + } + if !strings.HasPrefix(ev.EventType, "im.") { + t.Errorf("im consumer got non-im event: %s", ev.EventType) + } + } + + // Verify consumer B got exactly the 2 approval.* events. + apprLines := nonEmptyLines(approvalBuf.String()) + if len(apprLines) != 2 { + t.Fatalf("approval consumer got %d events, want 2:\n%s", len(apprLines), approvalBuf.String()) + } + for i, line := range apprLines { + var ev transport.Event + if err := json.Unmarshal([]byte(line), &ev); err != nil { + t.Fatalf("approval[%d] not valid JSON: %v", i, err) + } + if !strings.HasPrefix(ev.EventType, "approval.") { + t.Errorf("approval consumer got non-approval event: %s", ev.EventType) + } + } +} + +// TestIntegration_FilterRegexInAdditionToEventTypes verifies the +// regex --filter is applied on top of --event-types (logical AND). +// Both narrow the stream; the test confirms only events matching BOTH +// surface to the consumer. +func TestIntegration_FilterRegexNarrowsFurther(t *testing.T) { + skipOnWindows(t) + events := []dwsevent.RawEvent{ + {EventID: "1", EventType: "im.message.receive_v1", Data: `{}`}, + {EventID: "2", EventType: "im.message.at_v1", Data: `{}`}, + {EventID: "3", EventType: "im.chat.member.user.added_v1", Data: `{}`}, + } + dir, sock, cancel, runDone, trigger := bringUpBus(t, events) + defer func() { cancel(); <-runDone }() + + var buf bytes.Buffer + consumeDone := make(chan struct{}) + go func() { + defer close(consumeDone) + _ = Run(context.Background(), Config{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_test", + Stdout: &buf, + Stderr: io.Discard, + EventTypes: []string{"im.*"}, + Filter: `\.at_v1$`, // only at_v1 events + MaxEvents: 1, + }) + }() + time.Sleep(150 * time.Millisecond) + close(trigger) + <-consumeDone + + lines := nonEmptyLines(buf.String()) + if len(lines) != 1 { + t.Fatalf("expected 1 event after im.* + .at_v1 regex, got %d:\n%s", len(lines), buf.String()) + } + var ev transport.Event + _ = json.Unmarshal([]byte(lines[0]), &ev) + if ev.EventType != "im.message.at_v1" { + t.Errorf("got %q, want im.message.at_v1", ev.EventType) + } +} + +// TestIntegration_BusRestartConsumerReconnects verifies bus death + restart +// scenario: a consumer dialing after the first bus died and was replaced +// should connect to the fresh bus and receive new events. This proves +// stale lock cleanup + fresh-bus startup flow work correctly. +func TestIntegration_BusRestartConsumerReconnects(t *testing.T) { + skipOnWindows(t) + dir := shortTempDir(t) + sock := filepath.Join(dir, "bus.sock") + ctx1, cancel1 := context.WithCancel(context.Background()) + run1Done := make(chan error, 1) + go func() { + run1Done <- bus.Run(ctx1, bus.Config{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_test", + Edition: "open", + Source: &fakeSource{}, + }) + }() + waitForSock(t, sock, 2*time.Second) + // Verify first bus is up by dialing it briefly. + conn, err := transport.Dial(sock) + if err != nil { + t.Fatalf("dial first bus: %v", err) + } + conn.Close() + + // Kill the first bus. + cancel1() + if err := <-run1Done; err != nil && !errors.Is(err, context.Canceled) { + t.Fatalf("first bus exited with: %v", err) + } + + // Start second bus on same workdir (stale lock should be reclaimed). + ctx2, cancel2 := context.WithCancel(context.Background()) + defer cancel2() + run2Done := make(chan error, 1) + events := []dwsevent.RawEvent{ + {EventID: "post_restart", EventType: "im.message.receive_v1", Data: `{}`}, + } + trigger := make(chan struct{}) + src := &fakeSource{events: events, trigger: trigger} + go func() { + run2Done <- bus.Run(ctx2, bus.Config{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_test", + Edition: "open", + Source: src, + }) + }() + waitForSock(t, sock, 2*time.Second) + + // Consumer dials the second bus and receives the new event. + var buf bytes.Buffer + consumeDone := make(chan error, 1) + go func() { + consumeDone <- Run(context.Background(), Config{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_test", + Stdout: &buf, + Stderr: io.Discard, + MaxEvents: 1, + }) + }() + time.Sleep(150 * time.Millisecond) + close(trigger) + if err := <-consumeDone; err != nil { + t.Fatalf("consume on restarted bus: %v", err) + } + + lines := nonEmptyLines(buf.String()) + if len(lines) != 1 { + t.Fatalf("expected 1 event after restart, got %d", len(lines)) + } + var ev transport.Event + _ = json.Unmarshal([]byte(lines[0]), &ev) + if ev.EventID != "post_restart" { + t.Errorf("EventID = %q, want post_restart", ev.EventID) + } + + cancel2() + <-run2Done +} + +// TestIntegration_PipelineWithRouteAndOutputDir is the end-to-end version +// of the unit pipeline tests: a real bus + a real consume.Run process +// configured with --route and --output-dir. Verifies that matched +// events land in route dirs and unmatched events in the fallback dir. +func TestIntegration_PipelineWithRouteAndOutputDir(t *testing.T) { + skipOnWindows(t) + events := []dwsevent.RawEvent{ + {EventID: "im1", EventType: "im.message.receive_v1", Data: `{}`}, + {EventID: "ap1", EventType: "approval.task", Data: `{}`}, + {EventID: "im2", EventType: "im.message.at_v1", Data: `{}`}, + } + dir, sock, cancel, runDone, trigger := bringUpBus(t, events) + defer func() { cancel(); <-runDone }() + + outputRoot := shortTempDir(t) + imDir := filepath.Join(outputRoot, "im") + defaultDir := filepath.Join(outputRoot, "default") + routes, _ := ParseRoutes([]string{`^im\.=dir:` + imDir}) + + consumeDone := make(chan struct{}) + go func() { + defer close(consumeDone) + _ = Run(context.Background(), Config{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_test", + Stdout: io.Discard, + Stderr: io.Discard, + OutputDir: defaultDir, + Routes: routes, + MaxEvents: 3, + }) + }() + time.Sleep(150 * time.Millisecond) + close(trigger) + <-consumeDone + + if entries, _ := os.ReadDir(imDir); len(entries) != 2 { + t.Errorf("imDir got %d files, want 2 (im1+im2)", len(entries)) + } + if entries, _ := os.ReadDir(defaultDir); len(entries) != 1 { + t.Errorf("defaultDir got %d files, want 1 (ap1)", len(entries)) + } +} + +func nonEmptyLines(s string) []string { + parts := strings.Split(strings.TrimRight(s, "\n"), "\n") + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p != "" { + out = append(out, p) + } + } + return out +} + +// waitForSock polls for the unix socket file. Used by bus-restart tests +// that bring up bus.Run twice in the same workdir. +func waitForSock(t *testing.T, path string, timeout time.Duration) { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if _, err := os.Stat(path); err == nil { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("socket %q did not appear within %s", path, timeout) +} diff --git a/internal/event/consume/pipeline.go b/internal/event/consume/pipeline.go new file mode 100644 index 00000000..1417dc70 --- /dev/null +++ b/internal/event/consume/pipeline.go @@ -0,0 +1,86 @@ +// 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 consume + +import ( + "io" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +// Pipeline is the consumer-side delivery chain: format → route → sink. +// Each delivered event goes through formatting once; the routed sink then +// dispatches the formatted bytes to either a route-specific directory or +// the fallback (stdout/file). +// +// A Pipeline is bound to a single Config snapshot. Reconfiguring (changing +// format / routes mid-stream) is out of scope for v1. +type Pipeline struct { + formatter Formatter + sink Sink +} + +// NewPipeline builds a Pipeline for the given formatter and sink. +func NewPipeline(formatter Formatter, sink Sink) *Pipeline { + return &Pipeline{formatter: formatter, sink: sink} +} + +// Deliver renders ev with the configured formatter and hands the result +// to the sink. Returns ErrPipeClosed (re-raised from the sink) when the +// downstream stdout pipe closed; otherwise returns whatever formatting or +// sink error surfaced. +func (p *Pipeline) Deliver(ev transport.Event) error { + body, err := p.formatter.Render(ev) + if err != nil { + return err + } + return p.sink.Write(ev, body) +} + +// Close releases sink resources. Safe to call multiple times because +// underlying Sink Close methods are idempotent. +func (p *Pipeline) Close() error { return p.sink.Close() } + +// BuildPipeline constructs a Pipeline from the cobra-side flag bundle. The +// cobra command first parses --format / --output-dir / --route into the +// derived inputs here so this function stays free of cobra dependencies. +// +// Sink selection rules (plan §3.1 输出约束): +// - --route present → routed sink with per-rule dirs; +// fallback is --output-dir if set, else stdout +// - --output-dir only → file-per-event sink at the dir +// - neither → stdout sink with stdoutW +// +// stdoutW is injected for tests (os.Stdout in production). When nil it +// defaults to io.Discard so a misconfigured pipeline never writes to +// the host process's actual stdout. +func BuildPipeline(format Format, outputDir string, routes []Route, stdoutW io.Writer) (*Pipeline, error) { + fmter, err := NewFormatter(format) + if err != nil { + return nil, err + } + if stdoutW == nil { + stdoutW = io.Discard + } + var fallback Sink + if outputDir != "" { + fallback = NewFileDirSink(outputDir) + } else { + fallback = NewStdoutSink(stdoutW) + } + if len(routes) > 0 { + return NewPipeline(fmter, NewRoutedSink(NewRouter(routes), fallback)), nil + } + return NewPipeline(fmter, fallback), nil +} diff --git a/internal/event/consume/pipeline_test.go b/internal/event/consume/pipeline_test.go new file mode 100644 index 00000000..128c3bf4 --- /dev/null +++ b/internal/event/consume/pipeline_test.go @@ -0,0 +1,124 @@ +// 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 consume + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +func TestPipeline_FormatNDJSONToStdout(t *testing.T) { + var buf bytes.Buffer + p, err := BuildPipeline(FormatNDJSON, "", nil, &buf) + if err != nil { + t.Fatal(err) + } + defer p.Close() + for i := 0; i < 3; i++ { + _ = p.Deliver(transport.Event{Type: transport.FrameTypeEvent, EventID: "x", EventType: "y", Data: "{}"}) + } + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("expected 3 NDJSON lines, got %d", len(lines)) + } + for _, line := range lines { + var ev transport.Event + if err := json.Unmarshal([]byte(line), &ev); err != nil { + t.Errorf("not valid JSON: %v\n%s", err, line) + } + } +} + +func TestPipeline_OutputDirFallback(t *testing.T) { + dir := t.TempDir() + p, err := BuildPipeline(FormatNDJSON, dir, nil, nil) + if err != nil { + t.Fatal(err) + } + defer p.Close() + _ = p.Deliver(transport.Event{EventType: "im.message.receive_v1", EventID: "ev1", ReceivedAtUnixMS: 100}) + _ = p.Deliver(transport.Event{EventType: "im.message.receive_v1", EventID: "ev2", ReceivedAtUnixMS: 200}) + + entries, _ := os.ReadDir(dir) + if len(entries) != 2 { + t.Fatalf("expected 2 files in %s, got %d", dir, len(entries)) + } +} + +func TestPipeline_RouteWithStdoutFallback(t *testing.T) { + root := t.TempDir() + imDir := filepath.Join(root, "im") + var stdoutBuf bytes.Buffer + routes, err := ParseRoutes([]string{`^im\.=dir:` + imDir}) + if err != nil { + t.Fatal(err) + } + p, err := BuildPipeline(FormatNDJSON, "", routes, &stdoutBuf) + if err != nil { + t.Fatal(err) + } + defer p.Close() + + _ = p.Deliver(transport.Event{EventType: "im.message.receive_v1", EventID: "ev1", ReceivedAtUnixMS: 100}) + _ = p.Deliver(transport.Event{EventType: "approval.task", EventID: "ev2", ReceivedAtUnixMS: 200}) + + // im event routed to imDir + if entries, _ := os.ReadDir(imDir); len(entries) != 1 { + t.Errorf("expected 1 file in im dir, got %d", len(entries)) + } + // approval event fell through to stdout + if !strings.Contains(stdoutBuf.String(), `"event_id":"ev2"`) { + t.Errorf("approval event missing from stdout:\n%s", stdoutBuf.String()) + } +} + +func TestPipeline_RouteWithOutputDirFallback(t *testing.T) { + root := t.TempDir() + imDir := filepath.Join(root, "im") + defaultDir := filepath.Join(root, "default") + routes, _ := ParseRoutes([]string{`^im\.=dir:` + imDir}) + p, _ := BuildPipeline(FormatNDJSON, defaultDir, routes, nil) + defer p.Close() + + _ = p.Deliver(transport.Event{EventType: "im.message.receive_v1", EventID: "ev1", ReceivedAtUnixMS: 100}) + _ = p.Deliver(transport.Event{EventType: "approval.task", EventID: "ev2", ReceivedAtUnixMS: 200}) + + if entries, _ := os.ReadDir(imDir); len(entries) != 1 { + t.Errorf("im events should go to imDir, got %d files", len(entries)) + } + if entries, _ := os.ReadDir(defaultDir); len(entries) != 1 { + t.Errorf("unmatched events should go to default dir, got %d files", len(entries)) + } +} + +func TestPipeline_CompactFormat(t *testing.T) { + var buf bytes.Buffer + p, _ := BuildPipeline(FormatCompact, "", nil, &buf) + defer p.Close() + _ = p.Deliver(transport.Event{EventType: "im.message.receive_v1", EventID: "ev1", Data: `{"message":{"chat_id":"oc_x","message_id":"om_y","content":"hi"}}`}) + line := strings.TrimRight(buf.String(), "\n") + var out map[string]any + if err := json.Unmarshal([]byte(line), &out); err != nil { + t.Fatalf("compact output not valid JSON: %v", err) + } + if out["chat_id"] != "oc_x" { + t.Errorf("compact missed chat_id: %+v", out) + } +} diff --git a/internal/event/consume/router.go b/internal/event/consume/router.go new file mode 100644 index 00000000..5179e40a --- /dev/null +++ b/internal/event/consume/router.go @@ -0,0 +1,121 @@ +// 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 consume + +import ( + "fmt" + "regexp" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +// Route describes one --route rule. The CLI accepts the wire form +// `=dir:`; ParseRoute compiles regex once at startup so the +// hot path is just a Match. +// +// Pattern matches against event.EventType (NOT the whole event JSON). +// The first matching rule in CLI order wins; unmatched events fall through +// to the default sink (stdout or --output-dir). +type Route struct { + Pattern *regexp.Regexp + Dir string + // Raw is the original CLI spec; preserved for status / debug output. + Raw string +} + +// ParseRoute parses one `=dir:` spec. Returns a typed error +// for bad inputs so the CLI can render a clear "did you mean" message. +// +// Wire grammar: +// +// spec = regex "=dir:" path +// regex = any chars except literal "=" (use \= to escape) (v1: no escape) +// path = any string (no validation here; sink validates at write time) +// +// Examples: +// +// "^im\\.message=dir:./im/" +// "^approval\\.=dir:./approval/" +func ParseRoute(spec string) (Route, error) { + if spec == "" { + return Route{}, fmt.Errorf("consume: empty route spec") + } + // v1 grammar is intentionally rigid: split on the first "=dir:". + // Earlier proposals supported other sink kinds (=file: / =mcp:), but + // the cobra layer rejects those — keep parsing tight here too. + const sep = "=dir:" + idx := strings.Index(spec, sep) + if idx <= 0 || idx == len(spec)-len(sep) { + return Route{}, fmt.Errorf("consume: route spec must be '=dir:', got %q", spec) + } + pattern := spec[:idx] + path := spec[idx+len(sep):] + re, err := regexp.Compile(pattern) + if err != nil { + return Route{}, fmt.Errorf("consume: route regex %q: %w", pattern, err) + } + if path == "" { + return Route{}, fmt.Errorf("consume: route path is empty in %q", spec) + } + return Route{Pattern: re, Dir: path, Raw: spec}, nil +} + +// ParseRoutes parses many specs in CLI order. On any parse failure returns +// the partial parse so far and the error — the caller decides whether to +// continue. (The cobra layer treats any parse error as fatal validation.) +func ParseRoutes(specs []string) ([]Route, error) { + out := make([]Route, 0, len(specs)) + for _, s := range specs { + r, err := ParseRoute(s) + if err != nil { + return out, err + } + out = append(out, r) + } + return out, nil +} + +// Router decides which sink an event goes to. Match returns the directory +// of the first matching Route, or empty string when no rule matches (fall +// through to default sink). +type Router struct { + rules []Route +} + +// NewRouter constructs a router from pre-parsed rules. +func NewRouter(rules []Route) *Router { return &Router{rules: rules} } + +// Match returns the destination directory for the event's type, or "" if +// no rule matched. Iterates rules in CLI order (first match wins). +func (r *Router) Match(ev transport.Event) string { + if r == nil { + return "" + } + for _, rule := range r.rules { + if rule.Pattern.MatchString(ev.EventType) { + return rule.Dir + } + } + return "" +} + +// Rules returns the parsed routes for status / debug output. Caller MUST +// NOT mutate the returned slice. +func (r *Router) Rules() []Route { + if r == nil { + return nil + } + return r.rules +} diff --git a/internal/event/consume/router_test.go b/internal/event/consume/router_test.go new file mode 100644 index 00000000..58d39aa2 --- /dev/null +++ b/internal/event/consume/router_test.go @@ -0,0 +1,104 @@ +// 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 consume + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +func TestParseRoute_Valid(t *testing.T) { + r, err := ParseRoute(`^im\.message=dir:./im/`) + if err != nil { + t.Fatalf("ParseRoute: %v", err) + } + if !r.Pattern.MatchString("im.message.receive_v1") { + t.Error("regex did not match expected event type") + } + if r.Dir != "./im/" { + t.Errorf("Dir = %q", r.Dir) + } + if r.Raw != `^im\.message=dir:./im/` { + t.Errorf("Raw = %q", r.Raw) + } +} + +func TestParseRoute_Invalid(t *testing.T) { + cases := []string{ + "", // empty + "no-separator", // no =dir: + "=dir:./x/", // empty regex + "^im=dir:", // empty path + "(unclosed=dir:./x/", // invalid regex + "=dir:", // both empty + } + for _, in := range cases { + if _, err := ParseRoute(in); err == nil { + t.Errorf("ParseRoute(%q) should error", in) + } + } +} + +func TestParseRoutes_StopsOnFirstError(t *testing.T) { + good := `^im=dir:./im/` + bad := `(unclosed=dir:./x/` + out, err := ParseRoutes([]string{good, bad, good}) + if err == nil { + t.Fatal("expected parse error") + } + if len(out) != 1 { + t.Errorf("partial parse should have 1 entry, got %d", len(out)) + } +} + +func TestRouter_FirstMatchWins(t *testing.T) { + routes, err := ParseRoutes([]string{ + `^im\.message=dir:./im/`, + `^im\.=dir:./other-im/`, + }) + if err != nil { + t.Fatal(err) + } + r := NewRouter(routes) + + // First rule should win for im.message.* events. + got := r.Match(transport.Event{EventType: "im.message.receive_v1"}) + if got != "./im/" { + t.Errorf("Match im.message = %q, want ./im/", got) + } + // Second rule covers im.chat.* + got = r.Match(transport.Event{EventType: "im.chat.member.bot.added_v1"}) + if got != "./other-im/" { + t.Errorf("Match im.chat = %q, want ./other-im/", got) + } +} + +func TestRouter_NoMatchReturnsEmpty(t *testing.T) { + routes, _ := ParseRoutes([]string{`^im\.=dir:./im/`}) + r := NewRouter(routes) + if got := r.Match(transport.Event{EventType: "approval.task"}); got != "" { + t.Fatalf("no-match should return empty, got %q", got) + } +} + +func TestRouter_NilSafe(t *testing.T) { + var r *Router + if got := r.Match(transport.Event{EventType: "x"}); got != "" { + t.Fatalf("nil Router.Match should return empty, got %q", got) + } + if rules := r.Rules(); rules != nil { + t.Fatalf("nil Router.Rules should return nil, got %v", rules) + } +} diff --git a/internal/event/consume/run.go b/internal/event/consume/run.go new file mode 100644 index 00000000..fa5d049b --- /dev/null +++ b/internal/event/consume/run.go @@ -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. + +package consume + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +// Config holds everything Run needs. Built by the cobra command handler +// (P5) from flag values + strict resolver output. +type Config struct { + // WorkDir is the bus working directory: + // /events//// + WorkDir string + // IPCEndpoint is the Unix socket path / Windows pipe name. Caller + // computes from WorkDir on Unix, from edition+hash on Windows. + IPCEndpoint string + // ClientID is forwarded to busctl.Spawn so it can pass --client-id + // when forking _bus. + ClientID string + // SpawnExtraArgs are forwarded to the hidden _bus process when consume.Run + // needs to start a daemon. Used for source-mode options that must be + // reproduced in the child process, including portal ticket mode and + // personal_stream. + SpawnExtraArgs []string + + // EventTypes / Filter / Compact are forwarded to the bus via Hello + // for server-side pushdown filtering. + EventTypes []string + Filter string + SubscribeID string + Compact bool + + // 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. + // + // Note: this is event-consume specific and intentionally NOT named + // "Timeout" — global dws --timeout is HTTP request timeout (int + // seconds) which would collide if reused. See plan §1 决策 + // "事件运行时长 flag 不复用全局 --timeout". + Duration time.Duration + + // DryRun, when true, prints the resolved configuration to Stderr and + // returns nil without dialing the bus. Used by the cobra layer to + // preview configuration with `--dry-run` (plan §3.1). + DryRun bool + + // Foreground hint, passed through to status output but otherwise has + // no behavioural effect inside consume.Run — the cobra layer decides + // whether to call this Run or to bus.Run directly when --foreground + // is set. + Foreground bool + // Force, like Foreground, is informational at this layer. The cobra + // layer enforces the "--force requires --foreground" rule before + // calling Run. + Force bool + + // --- Output / Sink config (P4) --- + // Format controls the per-event output shape (ndjson/json/pretty/raw/ + // compact). The cobra layer maps --format string → Format via + // NormalizeFormat; an empty Format here defaults to NDJSON inside + // BuildPipeline. + Format Format + // OutputDir, if non-empty, switches the fallback sink from stdout to + // "file per event" under this directory. + OutputDir string + // 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. + // Set to io.Discard when --quiet is in effect. + Stderr io.Writer + + // Quiet suppresses stderr status writes (the HelloAck / bye banners). + Quiet bool +} + +// Run dials the bus (forking one if necessary), sends Hello, and writes +// each received Event frame as one NDJSON line to stdout. Blocks until +// ctx is cancelled, MaxEvents is reached, the bus sends Bye, or the +// stream is interrupted. +// +// Returns nil on graceful exits (ctx done, max-events reached, bye +// received, stdout pipe closed). Returns a non-nil error only for +// connection / protocol failures. +func Run(ctx context.Context, cfg Config) error { + if cfg.WorkDir == "" || cfg.IPCEndpoint == "" || cfg.ClientID == "" { + return errors.New("consume: WorkDir, IPCEndpoint, and ClientID are required") + } + if cfg.Stdout == nil { + cfg.Stdout = os.Stdout + } + if cfg.Stderr == nil { + cfg.Stderr = os.Stderr + } + if cfg.Quiet { + cfg.Stderr = io.Discard + } + if cfg.Format == "" { + cfg.Format = FormatNDJSON + } + + // --dry-run: print resolved config, return without dialing. + if cfg.DryRun { + PrintDryRun(cfg.Stderr, cfg) + return nil + } + + // Distinguish the exit cause for the contract's `exited` line: + // duration deadline → timeout + // parentCtx cancelled → signal (SIGTERM/SIGINT) + // runCtx-only cancelled → signal (stdin EOF) + parentCtx := ctx + + // --duration: layer a deadline on top of caller-provided ctx. Run + // returns nil on deadline (clean exit) rather than surfacing the + // context.DeadlineExceeded as an error to the user. + var timeoutCtx context.Context + if cfg.Duration > 0 { + var cancel context.CancelFunc + timeoutCtx, cancel = context.WithTimeout(ctx, cfg.Duration) + 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) + } + defer pipeline.Close() + + conn, err := busctl.Discover(busctl.DiscoverConfig{ + WorkDir: cfg.WorkDir, + IPCEndpoint: cfg.IPCEndpoint, + ClientID: cfg.ClientID, + SpawnExtraArgs: cfg.SpawnExtraArgs, + }) + if err != nil { + return fmt.Errorf("consume: discover bus: %w", err) + } + defer conn.Close() + + // Ensure the conn closes when ctx cancels so blocked Read returns. + closeOnContext(ctx, conn) + + w := transport.NewWriter(conn) + r := transport.NewReader(conn) + + hello := transport.Hello{ + Type: transport.FrameTypeHello, + ConsumerPID: os.Getpid(), + EventTypes: cfg.EventTypes, + Filter: cfg.Filter, + SubscribeID: cfg.SubscribeID, + Compact: cfg.Compact, + } + if err := w.WriteJSON(hello); err != nil { + return fmt.Errorf("consume: write hello: %w", err) + } + + var ack transport.HelloAck + if err := r.ReadJSON(&ack); err != nil { + return fmt.Errorf("consume: read hello_ack: %w", err) + } + if ack.Type != transport.FrameTypeHelloAck { + return fmt.Errorf("consume: unexpected first frame type %q", ack.Type) + } + if !cfg.Quiet { + // Contract: a fixed ready line on stderr BEFORE any stdout event. + // Parents block on stderr until this appears, then read stdout. + if cfg.EventKey != "" { + fmt.Fprintf(cfg.Stderr, "[event] ready event_key=%s bus_pid=%d\n", cfg.EventKey, ack.BusPID) + } else { + fmt.Fprintf(cfg.Stderr, "[event] ready bus_pid=%d\n", ack.BusPID) + } + // Secondary diagnostic line (source/state/idle); not part of the + // ready contract. + fmt.Fprintf(cfg.Stderr, "[event] bus source=%s state=%s idle_timeout=%ds\n", + ack.StateSource, ack.SourceState, ack.IdleTimeoutSecs) + } + + // 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: ) + // Error returns leave reason empty → no `exited` line (an `Error:` line + // is printed by the cobra layer instead). + received := 0 + start := time.Now() + reason := "" + defer func() { + if !cfg.Quiet && reason != "" { + fmt.Fprintf(cfg.Stderr, "[event] exited — received %d event(s) in %s (reason: %s)\n", + received, time.Since(start).Round(time.Millisecond), reason) + } + }() + + // classifyCancel maps a context-cancelled exit to a contract reason. + classifyCancel := func() string { + if timeoutCtx != nil && errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) && parentCtx.Err() == nil { + return "timeout" + } + return "signal" + } + + for { + raw, err := r.Read() + if err != nil { + if errors.Is(err, io.EOF) { + reason = "bus_shutdown" // peer closed cleanly + return nil + } + if isCtxCancelled(ctx) { + reason = classifyCancel() + return nil + } + return fmt.Errorf("consume: read frame: %w", err) + } + typ, err := transport.PeekType(raw) + if err != nil { + // Malformed frame; skip and continue. + continue + } + switch typ { + case transport.FrameTypeEvent: + var ev transport.Event + if err := json.Unmarshal(raw, &ev); err != nil { + continue + } + if err := pipeline.Deliver(ev); err != nil { + if errors.Is(err, ErrPipeClosed) { + // Downstream stdout consumer closed; exit cleanly. + _ = w.WriteJSON(transport.Bye{ + Type: transport.FrameTypeBye, + Reason: "client_done", + }) + reason = "signal" + return nil + } + return fmt.Errorf("consume: deliver event: %w", err) + } + received++ + if cfg.MaxEvents > 0 && received >= cfg.MaxEvents { + _ = w.WriteJSON(transport.Bye{ + Type: transport.FrameTypeBye, + Reason: "client_done", + }) + reason = "limit" + return nil + } + case transport.FrameTypeBye: + var bye transport.Bye + _ = json.Unmarshal(raw, &bye) + if !cfg.Quiet { + fmt.Fprintf(cfg.Stderr, "[event] bus closing: %s\n", bye.Reason) + } + reason = "bus_shutdown" + return nil + case transport.FrameTypeSourceState: + if !cfg.Quiet { + var s transport.SourceState + _ = json.Unmarshal(raw, &s) + fmt.Fprintf(cfg.Stderr, "source state: %s (source=%s, attempt=%d)\n", s.State, s.StateSource, s.Attempt) + } + case transport.FrameTypeHeartbeat: + // silent + default: + // future frame types: ignored for forward compat + } + } +} + +// closeOnContext spawns a goroutine that closes conn when ctx is done. +// This unblocks any pending Read on conn so the main loop can return. +func closeOnContext(ctx context.Context, conn net.Conn) { + go func() { + <-ctx.Done() + _ = conn.Close() + }() +} + +func isCtxCancelled(ctx context.Context) bool { + select { + case <-ctx.Done(): + return true + default: + 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: + } + } +} diff --git a/internal/event/consume/run_test.go b/internal/event/consume/run_test.go new file mode 100644 index 00000000..a0ed2e68 --- /dev/null +++ b/internal/event/consume/run_test.go @@ -0,0 +1,464 @@ +// 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 consume + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +func skipOnWindows(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("uses Unix socket") + } +} + +func shortTempDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("/tmp", "dws-consume-") + if err != nil { + t.Fatalf("mktemp: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} + +// fakeSource mirrors the one in bus tests; reproduced here to keep the +// integration test self-contained. +type fakeSource struct { + events []dwsevent.RawEvent + trigger <-chan struct{} +} + +func (f *fakeSource) Start(ctx context.Context, emit dwsevent.EmitFn) error { + if f.trigger != nil { + select { + case <-f.trigger: + case <-ctx.Done(): + return ctx.Err() + } + } + for i := range f.events { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + ev := f.events[i] + ev.ReceivedAt = time.Now().UTC() + emit(&ev) + time.Sleep(5 * time.Millisecond) + } + <-ctx.Done() + return ctx.Err() +} + +// bringUpBus starts a bus.Run in a goroutine and waits for its socket. +// Returns (workDir, sockPath, cancelFunc, runDone, fakeSource trigger). +func bringUpBus(t *testing.T, events []dwsevent.RawEvent) (string, string, context.CancelFunc, <-chan error, chan struct{}) { + t.Helper() + dir := shortTempDir(t) + sock := filepath.Join(dir, "bus.sock") + ctx, cancel := context.WithCancel(context.Background()) + trigger := make(chan struct{}) + src := &fakeSource{events: events, trigger: trigger} + done := make(chan error, 1) + go func() { + done <- bus.Run(ctx, bus.Config{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_test", + Edition: "open", + Source: src, + }) + }() + + // Wait for socket. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if _, err := os.Stat(sock); err == nil { + return dir, sock, cancel, done, trigger + } + time.Sleep(10 * time.Millisecond) + } + cancel() + t.Fatalf("bus socket did not appear") + return "", "", nil, nil, nil +} + +// dialOnlyDiscover is a Discover-impl-bypass: tests don't want consume.Run +// to exec a real dws binary, so we sidestep by pre-bringing-up the bus and +// letting Discover succeed on its first dial attempt. No Spawn is required. + +func TestRun_StdoutNDJSON(t *testing.T) { + skipOnWindows(t) + dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{ + {EventID: "1", EventType: "im.message.receive_v1", Data: `{"text":"hi"}`}, + {EventID: "2", EventType: "im.message.at_v1", Data: `{"at":1}`}, + }) + defer func() { cancel(); <-runDone }() + + // Trigger source emission after we've started consume (otherwise events + // race ahead of consumer registration). + go func() { + time.Sleep(150 * time.Millisecond) + close(trigger) + }() + + var stdout bytes.Buffer + var stderr bytes.Buffer + ctx, consumeCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer consumeCancel() + err := Run(ctx, Config{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_test", + Stdout: &stdout, + Stderr: &stderr, + EventTypes: []string{"im.*"}, + MaxEvents: 2, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + + // Verify NDJSON: each non-empty line is a valid Event JSON. + lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 NDJSON lines, got %d:\n%s", len(lines), stdout.String()) + } + for i, line := range lines { + var ev transport.Event + if err := json.Unmarshal([]byte(line), &ev); err != nil { + t.Errorf("line %d not valid JSON: %v\n%s", i, err, line) + } + if ev.Type != transport.FrameTypeEvent { + t.Errorf("line %d type = %s, want event", i, ev.Type) + } + } + + // Stderr should carry the standardized ready marker. + if !strings.Contains(stderr.String(), "[event] ready ") { + t.Errorf("stderr missing ready marker:\n%s", stderr.String()) + } +} + +func TestRun_QuietSuppressesStderr(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, consumeCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer consumeCancel() + err := Run(ctx, Config{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_test", + Stdout: &stdout, + Stderr: &stderr, + Quiet: true, + MaxEvents: 1, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if stderr.Len() != 0 { + t.Errorf("--quiet should suppress all stderr; got: %s", stderr.String()) + } + if stdout.Len() == 0 { + t.Error("stdout should still contain the NDJSON event") + } +} + +func TestRun_CtxCancelReturnsCleanly(t *testing.T) { + skipOnWindows(t) + dir, sock, cancel, runDone, _ := bringUpBus(t, nil) + defer func() { cancel(); <-runDone }() + + ctx, consumeCancel := context.WithCancel(context.Background()) + consumeDone := make(chan error, 1) + go func() { + consumeDone <- Run(ctx, Config{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_test", + Stdout: io.Discard, + Stderr: io.Discard, + }) + }() + // Let consume connect. + time.Sleep(100 * time.Millisecond) + consumeCancel() + + select { + case err := <-consumeDone: + if err != nil && !errors.Is(err, context.Canceled) { + t.Fatalf("Run returned %v, want nil or canceled", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Run did not return after ctx cancel") + } +} + +func TestRun_MaxEventsZeroIsUnlimited(t *testing.T) { + skipOnWindows(t) + dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{ + {EventID: "1", EventType: "x", Data: `{}`}, + {EventID: "2", EventType: "x", Data: `{}`}, + {EventID: "3", EventType: "x", Data: `{}`}, + }) + defer func() { cancel(); <-runDone }() + + var stdout bytes.Buffer + ctx, consumeCancel := context.WithCancel(context.Background()) + consumeDone := make(chan error, 1) + go func() { + consumeDone <- Run(ctx, Config{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_test", + Stdout: &stdout, + Stderr: io.Discard, + MaxEvents: 0, // unlimited + }) + }() + // Wait for consume to dial + Hello (otherwise events fire before + // consumer registers and the Hub drops them silently — no consumer + // to deliver to). + time.Sleep(150 * time.Millisecond) + close(trigger) + // Let all 3 events flow. + time.Sleep(200 * time.Millisecond) + consumeCancel() + <-consumeDone + + lines := strings.Split(strings.TrimRight(stdout.String(), "\n"), "\n") + if len(lines) != 3 { + t.Fatalf("expected 3 events with MaxEvents=0, got %d:\n%s", len(lines), stdout.String()) + } +} + +// TestRun_MultipleConsumersOneBus exercises the daemon's multi-consumer +// fan-out via the real consume.Run path. Both consumers should receive +// every matching event independently. +func TestRun_MultipleConsumersOneBus(t *testing.T) { + skipOnWindows(t) + dir, sock, cancel, runDone, trigger := bringUpBus(t, []dwsevent.RawEvent{ + {EventID: "1", EventType: "im.message.receive_v1", Data: `{}`}, + {EventID: "2", EventType: "im.message.receive_v1", Data: `{}`}, + }) + defer func() { cancel(); <-runDone }() + + var wg sync.WaitGroup + bufs := make([]*bytes.Buffer, 2) + for i := 0; i < 2; i++ { + i := i + bufs[i] = &bytes.Buffer{} + wg.Add(1) + go func() { + defer wg.Done() + _ = Run(context.Background(), Config{ + WorkDir: dir, + IPCEndpoint: sock, + ClientID: "ding_test", + Stdout: bufs[i], + Stderr: io.Discard, + MaxEvents: 2, + }) + }() + } + // Both consumers should be Hello'd before trigger. + time.Sleep(200 * time.Millisecond) + close(trigger) + + wg.Wait() + for i, buf := range bufs { + lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") + if len(lines) != 2 { + t.Errorf("consumer %d: got %d lines, want 2:\n%s", i, len(lines), buf.String()) + } + } +} + +// --- 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()) + } +} diff --git a/internal/event/consume/sigpipe_unix.go b/internal/event/consume/sigpipe_unix.go new file mode 100644 index 00000000..4e5cd28e --- /dev/null +++ b/internal/event/consume/sigpipe_unix.go @@ -0,0 +1,30 @@ +// 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 consume + +import ( + "errors" + "syscall" +) + +// isBrokenPipe reports whether err originates from a closed downstream +// pipe (typical: `dws event consume | head -1`). On Unix this surfaces as +// EPIPE; the Go runtime by default also raises SIGPIPE which would kill +// the process, but Go programs ignore SIGPIPE on stdio writes (since +// Go 1.x). We just need to detect EPIPE and exit cleanly. +func isBrokenPipe(err error) bool { + return errors.Is(err, syscall.EPIPE) +} diff --git a/internal/event/consume/sigpipe_windows.go b/internal/event/consume/sigpipe_windows.go new file mode 100644 index 00000000..413237db --- /dev/null +++ b/internal/event/consume/sigpipe_windows.go @@ -0,0 +1,29 @@ +// 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 consume + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +// isBrokenPipe reports whether err is the Windows equivalent of EPIPE +// (ERROR_BROKEN_PIPE / ERROR_NO_DATA) surfaced when a downstream pipe +// consumer closes its read end. +func isBrokenPipe(err error) bool { + return errors.Is(err, windows.ERROR_BROKEN_PIPE) || errors.Is(err, windows.ERROR_NO_DATA) +} diff --git a/internal/event/consume/sink.go b/internal/event/consume/sink.go new file mode 100644 index 00000000..1a9ae436 --- /dev/null +++ b/internal/event/consume/sink.go @@ -0,0 +1,170 @@ +// 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 consume + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +// Sink is what a Pipeline writes formatted event bytes to. Implementations +// take both the event (for filename derivation in file sinks) and the +// already-formatted bytes (so the same event can be rendered with +// different formats per call without re-running the formatter inside the +// sink). +type Sink interface { + // Write places one event on the sink. Returns an error for IO failures; + // returns ErrPipeClosed when the downstream consumer closed the pipe + // (typical pattern: `dws event consume | head -1`). + Write(ev transport.Event, formatted []byte) error + // Close releases sink-owned resources. Idempotent. + Close() error +} + +// ErrPipeClosed is returned by stdout-style sinks when the downstream +// reader closed its end (SIGPIPE / EPIPE on Unix, ERROR_BROKEN_PIPE on +// Windows). The pipeline catches this sentinel and exits cleanly without +// surfacing it as a fatal error. +var ErrPipeClosed = errors.New("sink: downstream pipe closed") + +// NewStdoutSink wraps the given writer (typically os.Stdout) in a Sink +// that writes formatted bytes verbatim. Detects broken-pipe on the host +// platform and returns ErrPipeClosed so the caller can exit code 0. +func NewStdoutSink(w io.Writer) Sink { return &stdoutSink{w: w} } + +type stdoutSink struct{ w io.Writer } + +func (s *stdoutSink) Write(_ transport.Event, formatted []byte) error { + _, err := s.w.Write(formatted) + if err != nil && isBrokenPipe(err) { + return ErrPipeClosed + } + return err +} +func (s *stdoutSink) Close() error { return nil } + +// NewFileDirSink returns a sink that writes each event to its own file +// under dir, naming files `{type}_{id}_{ts}.json`. The directory is +// mkdir'd on first write so callers don't have to ensure it themselves. +// +// Filename pieces are sanitised: characters that would escape the +// directory (path separators) or break shell globbing are replaced with +// '_'. `ts` is the ReceivedAtUnixMS (or current time if zero) so two +// events with the same id (re-delivery, dedup-defeated edge cases) don't +// collide. +func NewFileDirSink(dir string) Sink { return &fileDirSink{dir: dir} } + +type fileDirSink struct{ dir string } + +func (s *fileDirSink) Write(ev transport.Event, formatted []byte) error { + if err := os.MkdirAll(s.dir, 0o700); err != nil { + return fmt.Errorf("sink: mkdir %s: %w", s.dir, err) + } + name := buildFilename(ev) + full := filepath.Join(s.dir, name) + return atomicWrite(full, formatted) +} +func (s *fileDirSink) Close() error { return nil } + +// NewRoutedSink composes a Router with per-route dir sinks plus a fallback. +// On each Write, Router.Match decides the target dir; if non-empty, the +// event is written there; otherwise the fallback sink handles it. The +// fallback is typically NewStdoutSink (default) or NewFileDirSink +// (--output-dir mode). +func NewRoutedSink(router *Router, fallback Sink) Sink { + return &routedSink{router: router, fallback: fallback} +} + +type routedSink struct { + router *Router + fallback Sink +} + +func (s *routedSink) Write(ev transport.Event, formatted []byte) error { + if dir := s.router.Match(ev); dir != "" { + return NewFileDirSink(dir).Write(ev, formatted) + } + return s.fallback.Write(ev, formatted) +} +func (s *routedSink) Close() error { return s.fallback.Close() } + +// buildFilename produces `{type}_{id}_{ts}.json`. All three pieces are +// sanitised to be safe filesystem path segments — see safePart. +func buildFilename(ev transport.Event) string { + typ := safePart(ev.EventType) + if typ == "" { + typ = "unknown" + } + id := safePart(ev.EventID) + if id == "" { + id = "no-id" + } + ts := ev.ReceivedAtUnixMS + if ts == 0 { + ts = time.Now().UTC().UnixMilli() + } + return fmt.Sprintf("%s_%s_%d.json", typ, id, ts) +} + +// safePart strips path separators, NULs, and leading/trailing whitespace +// from a filename piece. Replaces unsafe chars with '_' instead of +// dropping them so different inputs don't collide. +// +// We DO allow dots and dashes (common in event types like "im.message.at_v1"); +// we just reject path separators and parent-directory traversal. +func safePart(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return "" + } + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch r { + case '/', '\\', 0, ':': + b.WriteRune('_') + default: + b.WriteRune(r) + } + } + return b.String() + // We intentionally do NOT collapse ".." sequences. After path + // separator replacement (above), a bare ".." in the middle of a + // filename cannot perform parent-directory traversal because there + // is no separator to anchor it against. The final filename is + // joined into a known-safe directory with filepath.Join which itself + // will clean any traversal that does sneak through. +} + +// atomicWrite writes content to path via tmp-file + rename, so a concurrent +// reader either sees the previous version or the new version — never a +// half-written file. +func atomicWrite(path string, content []byte) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, content, 0o600); err != nil { + return fmt.Errorf("sink: write tmp %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("sink: rename to %s: %w", path, err) + } + return nil +} diff --git a/internal/event/consume/sink_test.go b/internal/event/consume/sink_test.go new file mode 100644 index 00000000..f89a4e3d --- /dev/null +++ b/internal/event/consume/sink_test.go @@ -0,0 +1,138 @@ +// 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 consume + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +func TestStdoutSink_WritesVerbatim(t *testing.T) { + var buf bytes.Buffer + s := NewStdoutSink(&buf) + if err := s.Write(transport.Event{}, []byte("line one\n")); err != nil { + t.Fatal(err) + } + if err := s.Write(transport.Event{}, []byte("line two\n")); err != nil { + t.Fatal(err) + } + if buf.String() != "line one\nline two\n" { + t.Fatalf("got: %q", buf.String()) + } +} + +func TestFileDirSink_WritesPerEvent(t *testing.T) { + dir := t.TempDir() + s := NewFileDirSink(dir) + ev := transport.Event{ + EventType: "im.message.receive_v1", + EventID: "ev_abc", + ReceivedAtUnixMS: 1700000000123, + } + body := []byte(`{"hello":"world"}`) + if err := s.Write(ev, body); err != nil { + t.Fatalf("Write: %v", err) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 1 { + t.Fatalf("expected 1 file, got %d", len(entries)) + } + got, _ := os.ReadFile(filepath.Join(dir, entries[0].Name())) + if !bytes.Equal(got, body) { + t.Fatalf("body mismatch: %q", got) + } + if entries[0].Name() != "im.message.receive_v1_ev_abc_1700000000123.json" { + t.Errorf("filename = %q", entries[0].Name()) + } +} + +func TestFileDirSink_MkdirAutomatic(t *testing.T) { + dir := filepath.Join(t.TempDir(), "nested", "events") + s := NewFileDirSink(dir) + err := s.Write(transport.Event{EventType: "x", EventID: "1", ReceivedAtUnixMS: 1}, []byte("ok")) + if err != nil { + t.Fatalf("Write should auto-mkdir: %v", err) + } + if _, err := os.Stat(dir); err != nil { + t.Fatalf("dir not created: %v", err) + } +} + +func TestFileDirSink_AtomicWriteNoTmpLeft(t *testing.T) { + dir := t.TempDir() + s := NewFileDirSink(dir) + _ = s.Write(transport.Event{EventType: "x", EventID: "1", ReceivedAtUnixMS: 1}, []byte("ok")) + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".tmp") { + t.Fatalf("tmp file leaked: %s", e.Name()) + } + } +} + +func TestSafePart_StripsPathSeparators(t *testing.T) { + cases := []struct{ in, want string }{ + {"normal", "normal"}, + {"im.message.receive_v1", "im.message.receive_v1"}, // dots OK + {"a/b", "a_b"}, + {"a\\b", "a_b"}, + {"a:b", "a_b"}, + {"../../etc/passwd", ".._.._etc_passwd"}, // slashes → _; dots preserved (safe — no separator anchor) + {" spaces ", "spaces"}, + {"", ""}, + } + for _, c := range cases { + if got := safePart(c.in); got != c.want { + t.Errorf("safePart(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestBuildFilename_DefaultsForMissingFields(t *testing.T) { + got := buildFilename(transport.Event{}) + // Should contain unknown_no-id_.json + if !strings.HasPrefix(got, "unknown_no-id_") || !strings.HasSuffix(got, ".json") { + t.Fatalf("default filename shape unexpected: %q", got) + } +} + +func TestRoutedSink_MatchedGoesToDir(t *testing.T) { + tmp := t.TempDir() + imDir := filepath.Join(tmp, "im") + var fallback bytes.Buffer + routes, _ := ParseRoutes([]string{`^im\.=dir:` + imDir}) + rs := NewRoutedSink(NewRouter(routes), NewStdoutSink(&fallback)) + + // IM event → file in imDir + if err := rs.Write(transport.Event{EventType: "im.message.receive_v1", EventID: "x", ReceivedAtUnixMS: 1}, []byte("body")); err != nil { + t.Fatal(err) + } + if entries, _ := os.ReadDir(imDir); len(entries) != 1 { + t.Errorf("expected 1 file in %s, got %d", imDir, len(entries)) + } + if fallback.Len() != 0 { + t.Errorf("fallback should be empty for matched route") + } + + // Non-IM event → fallback (stdout) + _ = rs.Write(transport.Event{EventType: "approval.task", EventID: "y", ReceivedAtUnixMS: 2}, []byte("body2\n")) + if fallback.String() != "body2\n" { + t.Errorf("fallback got: %q", fallback.String()) + } +} diff --git a/internal/event/consume/validate.go b/internal/event/consume/validate.go new file mode 100644 index 00000000..135fe136 --- /dev/null +++ b/internal/event/consume/validate.go @@ -0,0 +1,151 @@ +// 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 consume + +import ( + "errors" + "fmt" + "io" + "strings" +) + +// ValidationError represents a flag-level user error. It wraps a clear, +// actionable message — the cobra command layer surfaces it to the user +// with exit code 2 (validation error). +type ValidationError struct{ Msg string } + +func (e *ValidationError) Error() string { return e.Msg } + +// validation sentinels (cobra layer uses errors.Is to set the exit code). +var ( + // ErrForceRequiresForeground is the plan §3.1 contract: --force only + // makes sense in foreground mode where the bus runs in the current + // process (then --force skips the single-instance lock so a second + // foreground bus can co-exist for a brief debug window). Outside of + // --foreground, --force would silently produce two daemons writing + // to the same socket — refuse upfront. + ErrForceRequiresForeground = &ValidationError{ + Msg: "--force is only meaningful with --foreground (in daemon mode it would produce multiple bus instances; cloud events would be randomly split across connections). To restart the bus: dws event stop && dws event consume", + } + + // ErrJSONFormatRequiresBounded is the plan §3.1 contract: --format + // json renders each event as a multi-line JSON object suitable for + // human inspection. With an unbounded stream the output mixes events + // without delimiters. Force the user to bound the run. + ErrJSONFormatRequiresBounded = &ValidationError{ + Msg: "--format json requires --max-events or --duration (an unbounded JSON stream is not parseable). Use --format ndjson for unbounded streams.", + } +) + +// ValidateConfig performs all pre-flight validation that does not require +// disk / network I/O. Returns a *ValidationError for any rule violation; +// returns nil if the cfg is launchable. The cobra layer calls this BEFORE +// calling Run so the user gets clear errors at parse time. +// +// Rules implemented: +// 1. WorkDir / IPCEndpoint / ClientID non-empty +// 2. --force requires --foreground (plan §3.1) +// 3. --format json requires --max-events OR --duration (bounded) +// 4. Routes already pre-parsed (any parse error is reported by ParseRoutes) +// 5. --output-dir conflict with global --output (caller-supplied flag — +// we expose ValidateNoOutputConflict separately because global -o is +// a cobra-layer concern) +// +// Rules NOT enforced here (deferred to caller / Run): +// - Credentials presence (auth.ResolveAppCredentialsStrict already +// reports a typed error) +// - bus availability (busctl.Discover handles) +func ValidateConfig(cfg Config) error { + if strings.TrimSpace(cfg.WorkDir) == "" { + return &ValidationError{Msg: "consume: WorkDir is required"} + } + if strings.TrimSpace(cfg.IPCEndpoint) == "" { + return &ValidationError{Msg: "consume: IPCEndpoint is required"} + } + if strings.TrimSpace(cfg.ClientID) == "" { + return &ValidationError{Msg: "consume: ClientID is required"} + } + if cfg.Force && !cfg.Foreground { + return ErrForceRequiresForeground + } + if cfg.Format == FormatJSON && cfg.MaxEvents <= 0 && cfg.Duration <= 0 { + return ErrJSONFormatRequiresBounded + } + return nil +} + +// ValidateNoOutputConflict ensures --output-dir / --route (event-stream +// sinks) are not combined with the dws global hidden -o/--output flag +// (request-output to file). The cobra layer reads the global output flag +// from inherited flags and passes its value here; an empty globalOutput +// means the flag was unset. +func ValidateNoOutputConflict(cfg Config, globalOutput string) error { + if globalOutput == "" { + return nil + } + if cfg.OutputDir != "" || len(cfg.Routes) > 0 { + return &ValidationError{ + Msg: fmt.Sprintf("--output-dir/--route cannot be combined with global -o/--output=%q (event stream sinks are mutually exclusive with single-file output capture)", globalOutput), + } + } + return nil +} + +// IsValidationError reports whether err is a flag-level user error. +// Cobra command handlers use this to map validation errors to exit code 2. +func IsValidationError(err error) bool { + var v *ValidationError + return errors.As(err, &v) +} + +// PrintDryRun writes the resolved configuration to w in a single +// human-readable block. Called by Run when cfg.DryRun is true. Format +// avoids JSON so users can `dws event consume --dry-run | head` cleanly. +// +// Secret-bearing fields are never present in Config (credentials never +// reach this layer), so no redaction is required here. +func PrintDryRun(w io.Writer, cfg Config) { + if w == nil { + return + } + fmt.Fprintln(w, "dws event consume — dry run (no bus connection will be made)") + fmt.Fprintf(w, " client_id : %s\n", cfg.ClientID) + fmt.Fprintf(w, " workdir : %s\n", cfg.WorkDir) + fmt.Fprintf(w, " ipc_endpoint : %s\n", cfg.IPCEndpoint) + if len(cfg.EventTypes) > 0 { + fmt.Fprintf(w, " event_types : %s\n", strings.Join(cfg.EventTypes, ",")) + } else { + fmt.Fprintln(w, " event_types : (catch-all)") + } + if cfg.Filter != "" { + fmt.Fprintf(w, " filter : %s\n", cfg.Filter) + } + fmt.Fprintf(w, " format : %s\n", cfg.Format) + if cfg.OutputDir != "" { + fmt.Fprintf(w, " output_dir : %s\n", cfg.OutputDir) + } + for i, r := range cfg.Routes { + fmt.Fprintf(w, " route[%d] : %s\n", i, r.Raw) + } + if cfg.MaxEvents > 0 { + fmt.Fprintf(w, " max_events : %d\n", cfg.MaxEvents) + } + if cfg.Duration > 0 { + fmt.Fprintf(w, " duration : %s\n", cfg.Duration) + } + fmt.Fprintf(w, " compact : %v\n", cfg.Compact) + fmt.Fprintf(w, " quiet : %v\n", cfg.Quiet) + fmt.Fprintf(w, " foreground : %v\n", cfg.Foreground) + fmt.Fprintf(w, " force : %v\n", cfg.Force) +} diff --git a/internal/event/consume/validate_test.go b/internal/event/consume/validate_test.go new file mode 100644 index 00000000..525e9250 --- /dev/null +++ b/internal/event/consume/validate_test.go @@ -0,0 +1,166 @@ +// 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 consume + +import ( + "bytes" + "errors" + "strings" + "testing" + "time" +) + +func base() Config { + return Config{ + WorkDir: "/tmp/x", + IPCEndpoint: "/tmp/x/bus.sock", + ClientID: "ding_abc", + } +} + +func TestValidate_Happy(t *testing.T) { + if err := ValidateConfig(base()); err != nil { + t.Fatalf("baseline should be valid, got %v", err) + } +} + +func TestValidate_RequiredFields(t *testing.T) { + cases := []struct { + name string + mut func(*Config) + }{ + {"empty WorkDir", func(c *Config) { c.WorkDir = "" }}, + {"whitespace WorkDir", func(c *Config) { c.WorkDir = " " }}, + {"empty IPCEndpoint", func(c *Config) { c.IPCEndpoint = "" }}, + {"empty ClientID", func(c *Config) { c.ClientID = "" }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := base() + tc.mut(&c) + err := ValidateConfig(c) + if !IsValidationError(err) { + t.Fatalf("expected ValidationError, got %v", err) + } + }) + } +} + +func TestValidate_ForceRequiresForeground(t *testing.T) { + c := base() + c.Force = true + c.Foreground = false + err := ValidateConfig(c) + if !errors.Is(err, ErrForceRequiresForeground) { + t.Fatalf("err = %v, want ErrForceRequiresForeground", err) + } + if !strings.Contains(err.Error(), "event stop && dws event consume") { + t.Errorf("error message must include the recovery hint, got: %s", err.Error()) + } + + // With --foreground it's fine. + c.Foreground = true + if err := ValidateConfig(c); err != nil { + t.Fatalf("--force + --foreground should be valid, got %v", err) + } +} + +func TestValidate_FormatJSONRequiresBounded(t *testing.T) { + c := base() + c.Format = FormatJSON + c.MaxEvents = 0 + c.Duration = 0 + err := ValidateConfig(c) + if !errors.Is(err, ErrJSONFormatRequiresBounded) { + t.Fatalf("err = %v, want ErrJSONFormatRequiresBounded", err) + } + + // With --max-events it passes. + c.MaxEvents = 10 + if err := ValidateConfig(c); err != nil { + t.Fatalf("--format json + --max-events should be valid: %v", err) + } + c.MaxEvents = 0 + c.Duration = 30 * time.Second + if err := ValidateConfig(c); err != nil { + t.Fatalf("--format json + --duration should be valid: %v", err) + } + + // NDJSON has no such requirement. + c.Format = FormatNDJSON + c.MaxEvents = 0 + c.Duration = 0 + if err := ValidateConfig(c); err != nil { + t.Fatalf("ndjson unbounded should be valid: %v", err) + } +} + +func TestValidateNoOutputConflict(t *testing.T) { + c := base() + c.OutputDir = "/tmp/events" + if err := ValidateNoOutputConflict(c, ""); err != nil { + t.Fatalf("no global -o → ok, got %v", err) + } + if err := ValidateNoOutputConflict(c, "/tmp/out.json"); !IsValidationError(err) { + t.Fatalf("--output-dir + global -o should be ValidationError, got %v", err) + } + + c2 := base() + c2.Routes, _ = ParseRoutes([]string{`^im=dir:./im/`}) + if err := ValidateNoOutputConflict(c2, "/tmp/out.json"); !IsValidationError(err) { + t.Fatalf("--route + global -o should be ValidationError, got %v", err) + } +} + +func TestPrintDryRun_NilWriterSafe(t *testing.T) { + // Must not panic. + PrintDryRun(nil, base()) +} + +func TestPrintDryRun_RendersAllSetFields(t *testing.T) { + var buf bytes.Buffer + c := base() + c.EventTypes = []string{"im.*", "approval.*"} + c.Filter = "^im\\." + c.Format = FormatCompact + c.OutputDir = "/tmp/events" + c.Routes, _ = ParseRoutes([]string{`^im\.=dir:/tmp/im/`}) + c.MaxEvents = 5 + c.Duration = 30 * time.Second + c.Compact = true + c.Quiet = true + c.Foreground = true + c.Force = true + + PrintDryRun(&buf, c) + out := buf.String() + wants := []string{ + "client_id", "workdir", "ipc_endpoint", "im.*,approval.*", + "^im\\.", "compact", "/tmp/events", "route[0]", "max_events : 5", + "duration", "true", + } + for _, w := range wants { + if !strings.Contains(out, w) { + t.Errorf("dry-run missing %q in output:\n%s", w, out) + } + } +} + +func TestPrintDryRun_CatchAllWhenEventTypesEmpty(t *testing.T) { + var buf bytes.Buffer + PrintDryRun(&buf, base()) + if !strings.Contains(buf.String(), "(catch-all)") { + t.Errorf("expected '(catch-all)' for empty event_types:\n%s", buf.String()) + } +} diff --git a/internal/event/dedup/lru.go b/internal/event/dedup/lru.go new file mode 100644 index 00000000..5a4f620e --- /dev/null +++ b/internal/event/dedup/lru.go @@ -0,0 +1,94 @@ +// 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 dedup implements a fixed-capacity LRU set used by the bus to +// suppress duplicate events that the DingTalk Stream SDK redelivers on +// reconnect (see plan invariant #2). The set stores only keys, never values, +// and is safe for concurrent use by multiple goroutines. +package dedup + +import ( + "container/list" + "sync" +) + +// DefaultCapacity is the default LRU size when LRU is constructed without an +// explicit capacity. 8192 is sized to absorb a typical reconnect-storm window +// (~5 min × 30 events/s) while staying memory-cheap (~256 KB at 32 bytes/key). +const DefaultCapacity = 8192 + +// LRU is a fixed-capacity LRU set of string keys. Zero value is not usable; +// call New or NewWithCapacity. +type LRU struct { + mu sync.Mutex + cap int + keys map[string]*list.Element + eviction *list.List // back = newest, front = oldest +} + +// New returns an LRU with DefaultCapacity. +func New() *LRU { return NewWithCapacity(DefaultCapacity) } + +// NewWithCapacity returns an LRU sized to hold up to cap keys. cap must be > 0. +func NewWithCapacity(cap int) *LRU { + if cap <= 0 { + cap = DefaultCapacity + } + return &LRU{ + cap: cap, + keys: make(map[string]*list.Element, cap), + eviction: list.New(), + } +} + +// Seen reports whether key was already present and inserts it if not. The +// return value is true when the caller should treat the event as a duplicate +// (drop it) and false when this is the first occurrence. +// +// Empty keys are never considered duplicates and are not stored — callers +// without a stable identifier should use RawEvent.DedupKey() which falls back +// to a content hash. +func (l *LRU) Seen(key string) bool { + if key == "" { + return false + } + l.mu.Lock() + defer l.mu.Unlock() + + if el, ok := l.keys[key]; ok { + l.eviction.MoveToBack(el) + return true + } + + if len(l.keys) >= l.cap { + oldest := l.eviction.Front() + if oldest != nil { + delete(l.keys, oldest.Value.(string)) + l.eviction.Remove(oldest) + } + } + + el := l.eviction.PushBack(key) + l.keys[key] = el + return false +} + +// Len returns the current number of stored keys. +func (l *LRU) Len() int { + l.mu.Lock() + defer l.mu.Unlock() + return len(l.keys) +} + +// Cap returns the configured capacity. +func (l *LRU) Cap() int { return l.cap } diff --git a/internal/event/dedup/lru_test.go b/internal/event/dedup/lru_test.go new file mode 100644 index 00000000..7ae83cde --- /dev/null +++ b/internal/event/dedup/lru_test.go @@ -0,0 +1,111 @@ +// 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 dedup + +import ( + "strconv" + "sync" + "testing" +) + +func TestLRU_FirstSeenIsFalse(t *testing.T) { + l := New() + if l.Seen("a") { + t.Fatal("first occurrence should not be reported as seen") + } + if !l.Seen("a") { + t.Fatal("second occurrence should be reported as seen") + } +} + +func TestLRU_EmptyKeyNeverSeen(t *testing.T) { + l := New() + if l.Seen("") { + t.Fatal("empty key must never be reported as seen") + } + if l.Seen("") { + t.Fatal("empty key must never be stored / reported as seen on second call either") + } + if l.Len() != 0 { + t.Fatalf("empty key must not be stored, got Len=%d", l.Len()) + } +} + +func TestLRU_EvictsOldestAtCapacity(t *testing.T) { + l := NewWithCapacity(3) + for _, k := range []string{"a", "b", "c"} { + if l.Seen(k) { + t.Fatalf("unexpected seen for %s", k) + } + } + // d inserts → a should be evicted + if l.Seen("d") { + t.Fatal("d is new, should not be seen") + } + if l.Seen("a") { + t.Fatal("a should have been evicted; second insert returns not-seen") + } + // Now b should be the oldest. After re-querying "c" (refreshes c), + // inserting "e" should evict b not c. + if !l.Seen("c") { + t.Fatal("c is still in set, should be seen") + } + if l.Seen("e") { + t.Fatal("e is new") + } + if l.Seen("b") { + t.Fatal("b should have been evicted by e (c was just refreshed)") + } +} + +func TestLRU_LenAndCap(t *testing.T) { + l := NewWithCapacity(5) + if l.Cap() != 5 { + t.Fatalf("Cap = %d, want 5", l.Cap()) + } + if l.Len() != 0 { + t.Fatalf("initial Len = %d, want 0", l.Len()) + } + _ = l.Seen("x") + _ = l.Seen("y") + if l.Len() != 2 { + t.Fatalf("after 2 inserts Len = %d, want 2", l.Len()) + } +} + +func TestLRU_ZeroCapacityUsesDefault(t *testing.T) { + l := NewWithCapacity(0) + if l.Cap() != DefaultCapacity { + t.Fatalf("zero cap should use DefaultCapacity, got %d", l.Cap()) + } +} + +func TestLRU_ConcurrentSafety(t *testing.T) { + l := NewWithCapacity(1000) + const N = 200 + var wg sync.WaitGroup + wg.Add(N) + for i := 0; i < N; i++ { + i := i + go func() { + defer wg.Done() + _ = l.Seen(strconv.Itoa(i)) + _ = l.Seen(strconv.Itoa(i)) // duplicate + }() + } + wg.Wait() + if l.Len() != N { + t.Fatalf("Len after concurrent insert = %d, want %d", l.Len(), N) + } +} diff --git a/internal/event/doc.go b/internal/event/doc.go new file mode 100644 index 00000000..d4f18043 --- /dev/null +++ b/internal/event/doc.go @@ -0,0 +1,36 @@ +// 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 event implements the DingTalk Stream event subscription pipeline for +// dws. The architecture is a single-cloud-connection bus daemon (one per +// ClientID) plus N local consumer processes communicating over Unix socket / +// Windows Named Pipe. The bus keeps one cloud connection per identity while +// exposing observable connection state, per-event-type metrics, and Hello-time +// filter pushdown to local consumers. +// +// Package layout: +// +// event/ // top-level types (RawEvent, EmitFn, hash helpers) +// event/dedup/ // event_id LRU dedup +// event/registry/ // catch-all event types + compact processor registry +// event/source/ // wrap dingtalk-stream-sdk-go + connection state machine +// event/bus/ // daemon loop, hub, metrics, lockfile, meta +// event/transport/ // UDS/Pipe abstraction, frame protocol +// event/busctl/ // discover, spawn, stop helpers +// event/consume/ // consumer-side pipeline, formatter, router, sink +// event/lock/ // cross-platform flock primitive (Unix flock / Windows LockFileEx) +// event/process/ // cross-platform process-alive check (Unix signal 0 / Windows OpenProcess) +// +// See plans/2026-05-28_event_capability_v1.plan.md for the full design, +// invariants, and protocol. +package event diff --git a/internal/event/endpoint.go b/internal/event/endpoint.go new file mode 100644 index 00000000..c0d03747 --- /dev/null +++ b/internal/event/endpoint.go @@ -0,0 +1,60 @@ +// 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 event + +import ( + "os" + "path/filepath" + "runtime" +) + +// MaxUnixSocketPath returns the longest Unix socket path accepted by +// bind/connect on this OS (Go rejects longer names with EINVAL before +// the syscall). sockaddr_un.sun_path is 104 bytes on darwin and the +// BSDs and 108 on Linux; the usable budget is one less. +func MaxUnixSocketPath() int { + if runtime.GOOS == "linux" { + return 107 + } + return 103 +} + +// IPCEndpoint returns the bus IPC endpoint for one identity: a Named Pipe +// name on Windows, otherwise bus.sock inside workDir. +// +// The canonical Unix location is /bus.sock, but workDir derives +// from the config dir, which can be arbitrarily deep (e.g. dwssb sandboxes +// use ~/.dwssb/sandboxes//config/...). When the canonical path would +// exceed the OS sun_path limit, the socket falls back to a short +// deterministic path under os.TempDir keyed by a hash of workDir, so every +// process (consume parent, forked _bus child, status/stop tooling) that +// derives the endpoint from the same workDir agrees on the location. +// bus.lock / bus.meta / bus.log always stay in workDir — only the socket +// moves. +// +// This is the single source of truth for endpoint derivation; the cobra +// layer and busctl must not re-implement the shape. +func IPCEndpoint(workDir, editionName string, sourceKind SourceKind, identityHash string) string { + if sourceKind == "" { + sourceKind = SourceKindAppStream + } + if runtime.GOOS == "windows" { + return `\\.\pipe\dws-event-` + editionName + "-" + string(sourceKind) + "-" + identityHash + } + sock := filepath.Join(workDir, "bus.sock") + if len(sock) <= MaxUnixSocketPath() { + return sock + } + return filepath.Join(os.TempDir(), "dws-evt-"+IdentityHash(workDir)+".sock") +} diff --git a/internal/event/endpoint_test.go b/internal/event/endpoint_test.go new file mode 100644 index 00000000..53b684fc --- /dev/null +++ b/internal/event/endpoint_test.go @@ -0,0 +1,61 @@ +// 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 event + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestIPCEndpointShortWorkDirUsesCanonicalPath(t *testing.T) { + workDir := "/tmp/dws/events/open/app_stream/aabbccdd00112233" + got := IPCEndpoint(workDir, "open", SourceKindAppStream, "aabbccdd00112233") + want := filepath.Join(workDir, "bus.sock") + if got != want { + t.Fatalf("IPCEndpoint = %q, want %q", got, want) + } +} + +func TestIPCEndpointLongWorkDirFallsBackUnderTempDir(t *testing.T) { + // Mirrors the dwssb sandbox layout that produced a 111-byte socket + // path — over macOS's 103-byte usable sun_path budget. + workDir := "/Users/zhengyubai/.dwssb/sandboxes/event-subscribe/config/events/open/personal_stream/3928ce0fb4860a52" + got := IPCEndpoint(workDir, "open", SourceKindPersonalStream, "3928ce0fb4860a52") + if strings.HasPrefix(got, workDir) { + t.Fatalf("IPCEndpoint = %q, want fallback outside workDir", got) + } + if !strings.HasPrefix(got, os.TempDir()) { + t.Fatalf("IPCEndpoint = %q, want fallback under os.TempDir %q", got, os.TempDir()) + } + if len(got) > MaxUnixSocketPath() { + t.Fatalf("fallback path still too long: %d > %d (%q)", len(got), MaxUnixSocketPath(), got) + } +} + +func TestIPCEndpointFallbackIsDeterministicPerWorkDir(t *testing.T) { + long := strings.Repeat("x", 120) + a := IPCEndpoint("/base/"+long+"/one", "open", SourceKindPersonalStream, "hash") + b := IPCEndpoint("/base/"+long+"/one", "open", SourceKindPersonalStream, "hash") + c := IPCEndpoint("/base/"+long+"/two", "open", SourceKindPersonalStream, "hash") + if a != b { + t.Fatalf("same workDir produced different endpoints: %q vs %q", a, b) + } + if a == c { + t.Fatalf("different workDirs collided on endpoint %q", a) + } +} diff --git a/internal/event/lock/doc.go b/internal/event/lock/doc.go new file mode 100644 index 00000000..5b56434d --- /dev/null +++ b/internal/event/lock/doc.go @@ -0,0 +1,24 @@ +// 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 lock implements a cross-platform exclusive file lock primitive used +// by the bus daemon to enforce the "single bus per ClientID" invariant +// (plan invariant #3). The primitive is intentionally tiny — it acquires and +// releases a non-blocking exclusive lock on an opened file handle, with no +// knowledge of PID files or business semantics. Higher layers (bus/lockfile.go) +// combine this primitive with PID content read/write to provide the full +// single-file bus.lock design. +// +// Unix: syscall.Flock(LOCK_EX|LOCK_NB). +// Windows: windows.LockFileEx with LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY. +package lock diff --git a/internal/event/lock/lock.go b/internal/event/lock/lock.go new file mode 100644 index 00000000..97eb3bee --- /dev/null +++ b/internal/event/lock/lock.go @@ -0,0 +1,79 @@ +// 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 lock + +import ( + "errors" + "fmt" + "os" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" +) + +// ErrBusy indicates the lock is currently held by another process. Callers +// distinguish "actually busy" (another live bus) from "lock file +// inaccessible" (FS error) by checking for this sentinel. +var ErrBusy = errors.New("lock: file is held by another process") + +// File represents a held exclusive file lock. Close releases the lock and +// closes the underlying file handle. A zero File is not usable. +type File struct { + f *os.File +} + +// TryAcquire opens path (creating it if absent, mode 0600) and attempts to +// take an exclusive non-blocking lock. Returns ErrBusy when the lock is held +// by another process; any other error wraps the underlying I/O failure. +// +// The directory containing path must already exist; callers should mkdir +// with pkg/config.DirPerm beforehand. +func TryAcquire(path string) (*File, error) { + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, config.FilePerm) + if err != nil { + return nil, fmt.Errorf("lock: open %s: %w", path, err) + } + if err := lockFile(f); err != nil { + _ = f.Close() + if isBusy(err) { + return nil, ErrBusy + } + return nil, fmt.Errorf("lock: flock %s: %w", path, err) + } + return &File{f: f}, nil +} + +// File returns the underlying *os.File so callers can Read/Write content +// while holding the lock. The handle MUST NOT be closed by the caller — +// use Close on the lock File instead. +func (l *File) File() *os.File { return l.f } + +// Path returns the file path the lock is held on. +func (l *File) Path() string { + if l == nil || l.f == nil { + return "" + } + return l.f.Name() +} + +// Close releases the lock and closes the file handle. Safe to call on a nil +// receiver. Subsequent calls are no-ops. +func (l *File) Close() error { + if l == nil || l.f == nil { + return nil + } + unlockFile(l.f) + err := l.f.Close() + l.f = nil + return err +} diff --git a/internal/event/lock/lock_test.go b/internal/event/lock/lock_test.go new file mode 100644 index 00000000..a33b15ca --- /dev/null +++ b/internal/event/lock/lock_test.go @@ -0,0 +1,101 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package lock + +import ( + "errors" + "io" + "path/filepath" + "testing" +) + +func TestTryAcquire_FirstCallerWins(t *testing.T) { + path := filepath.Join(t.TempDir(), "bus.lock") + l, err := TryAcquire(path) + if err != nil { + t.Fatalf("first TryAcquire: %v", err) + } + defer l.Close() + if l.Path() != path { + t.Fatalf("Path() = %q, want %q", l.Path(), path) + } +} + +func TestTryAcquire_SecondCallerGetsBusy(t *testing.T) { + path := filepath.Join(t.TempDir(), "bus.lock") + l1, err := TryAcquire(path) + if err != nil { + t.Fatalf("first TryAcquire: %v", err) + } + defer l1.Close() + + l2, err := TryAcquire(path) + if !errors.Is(err, ErrBusy) { + t.Fatalf("second TryAcquire: err = %v, want ErrBusy", err) + } + if l2 != nil { + t.Fatal("on ErrBusy the returned lock must be nil") + } +} + +func TestTryAcquire_ReleasedLockIsReacquirable(t *testing.T) { + path := filepath.Join(t.TempDir(), "bus.lock") + l1, err := TryAcquire(path) + if err != nil { + t.Fatalf("first TryAcquire: %v", err) + } + if err := l1.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + l2, err := TryAcquire(path) + if err != nil { + t.Fatalf("re-acquire after close: %v", err) + } + defer l2.Close() +} + +func TestTryAcquire_ContentReadWriteWhileHeld(t *testing.T) { + path := filepath.Join(t.TempDir(), "bus.lock") + l, err := TryAcquire(path) + if err != nil { + t.Fatalf("acquire: %v", err) + } + defer l.Close() + + // Write PID-like content through the underlying handle + const pid = "12345\n" + if _, err := l.File().WriteString(pid); err != nil { + t.Fatalf("write: %v", err) + } + + // Rewind and read back + if _, err := l.File().Seek(0, io.SeekStart); err != nil { + t.Fatalf("seek: %v", err) + } + buf := make([]byte, len(pid)) + if _, err := io.ReadFull(l.File(), buf); err != nil { + t.Fatalf("read: %v", err) + } + if string(buf) != pid { + t.Fatalf("read back = %q, want %q", buf, pid) + } +} + +func TestClose_NilSafe(t *testing.T) { + var l *File + if err := l.Close(); err != nil { + t.Fatalf("nil Close should be no-op, got %v", err) + } +} diff --git a/internal/event/lock/lock_unix.go b/internal/event/lock/lock_unix.go new file mode 100644 index 00000000..8e2f4cf3 --- /dev/null +++ b/internal/event/lock/lock_unix.go @@ -0,0 +1,35 @@ +// 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 lock + +import ( + "errors" + "os" + "syscall" +) + +func lockFile(f *os.File) error { + return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) +} + +func unlockFile(f *os.File) { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) +} + +func isBusy(err error) bool { + // Linux returns EWOULDBLOCK (==EAGAIN); macOS returns EWOULDBLOCK too. + return errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) +} diff --git a/internal/event/lock/lock_windows.go b/internal/event/lock/lock_windows.go new file mode 100644 index 00000000..3ae1c147 --- /dev/null +++ b/internal/event/lock/lock_windows.go @@ -0,0 +1,60 @@ +// 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 lock + +import ( + "errors" + "os" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + lockfileExclusiveLock = 0x00000002 + lockfileFailImmediately = 0x00000001 +) + +func lockFile(f *os.File) error { + handle := windows.Handle(f.Fd()) + ol := new(windows.Overlapped) + return windows.LockFileEx( + handle, + lockfileExclusiveLock|lockfileFailImmediately, + 0, + 1, + 0, + (*windows.Overlapped)(unsafe.Pointer(ol)), + ) +} + +func unlockFile(f *os.File) { + handle := windows.Handle(f.Fd()) + ol := new(windows.Overlapped) + _ = windows.UnlockFileEx( + handle, + 0, + 1, + 0, + (*windows.Overlapped)(unsafe.Pointer(ol)), + ) +} + +func isBusy(err error) bool { + // LockFileEx with LOCKFILE_FAIL_IMMEDIATELY returns ERROR_LOCK_VIOLATION + // when the region is already locked. + return errors.Is(err, windows.ERROR_LOCK_VIOLATION) || errors.Is(err, windows.ERROR_IO_PENDING) +} diff --git a/internal/event/personal/client.go b/internal/event/personal/client.go new file mode 100644 index 00000000..f7228236 --- /dev/null +++ b/internal/event/personal/client.go @@ -0,0 +1,681 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package personal + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" +) + +const DefaultBasePath = "/dws" + +const ( + controlLogPayloadLimit = 8192 + subscriptionListPageSize = 100 + subscriptionListMaxPageGuard = 10000 +) + +type Identity struct { + AccessToken string `json:"-"` + LocalSubject string `json:"-"` + CorpID string `json:"corp_id"` + UserID string `json:"user_id"` + ClientID string `json:"client_id"` + SourceID string `json:"source_id"` +} + +func (i Identity) Key() string { + corpID := strings.TrimSpace(i.CorpID) + userID := strings.TrimSpace(i.UserID) + clientID := strings.TrimSpace(i.ClientID) + sourceID := strings.TrimSpace(i.SourceID) + if corpID != "" && userID != "" { + return strings.Join([]string{"corp_user", corpID, userID, clientID, sourceID}, "\x00") + } + if localSubject := strings.TrimSpace(i.LocalSubject); localSubject != "" { + return strings.Join([]string{"local_subject", localSubject, clientID, sourceID}, "\x00") + } + return strings.Join([]string{"unknown", corpID, userID, clientID, sourceID}, "\x00") +} + +type Client struct { + BaseURL string + HTTPClient *http.Client + Identity Identity +} + +type CreateSubscriptionRequest struct { + EventKey string `json:"event_key"` + RuleType string `json:"rule_type"` + Name string `json:"name,omitempty"` + RuleParam map[string]any `json:"rule_param"` + Filter any `json:"filter,omitempty"` + Delivery map[string]any `json:"delivery"` + TTLSeconds int64 `json:"ttl_seconds,omitempty"` + IdempotencyKey string `json:"idempotency_key,omitempty"` +} + +type Subscription struct { + SubscribeID string `json:"subscribe_id"` + EventKey string `json:"event_key,omitempty"` + RuleType string `json:"rule_type,omitempty"` + Status string `json:"status,omitempty"` + SourceID string `json:"source_id,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +type ListOptions struct { + Status string + EventKey string + SubscribeID string +} + +type dwsCreateSubscriptionRequest struct { + ClientID string `json:"clientId"` + SourceID string `json:"sourceId,omitempty"` + EventKey string `json:"eventKey"` + FilterRule string `json:"filterRule,omitempty"` + DeliveryPref string `json:"deliveryPref,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + Ext map[string]any `json:"ext,omitempty"` +} + +type dwsSubListResult struct { + Total int `json:"total,omitempty"` + PageNo int `json:"pageNo,omitempty"` + PageSize int `json:"pageSize,omitempty"` + Items []dwsSubscription `json:"items"` +} + +type dwsSubscription struct { + SubID string `json:"subId"` + SubscribeID string `json:"subscribe_id"` + EventKey string `json:"eventKey"` + EventKeySnake string `json:"event_key"` + RuleType string `json:"ruleType,omitempty"` + RuleTypeSnake string `json:"rule_type,omitempty"` + ClientID string `json:"clientId,omitempty"` + SourceID string `json:"sourceId"` + SourceIDSnake string `json:"source_id"` + DeliveryPref string `json:"deliveryPref,omitempty"` + Status json.RawMessage `json:"status,omitempty"` + GmtCreate string `json:"gmtCreate,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +func (s dwsSubscription) toSubscription() Subscription { + return Subscription{ + SubscribeID: firstNonEmpty(s.SubID, s.SubscribeID), + EventKey: firstNonEmpty(s.EventKey, s.EventKeySnake), + RuleType: firstNonEmpty(s.RuleType, s.RuleTypeSnake), + Status: dwsStatusString(s.Status), + SourceID: firstNonEmpty(s.SourceID, s.SourceIDSnake), + CreatedAt: firstNonEmpty(s.GmtCreate, s.CreatedAt), + } +} + +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` + Retryable bool `json:"retryable,omitempty"` + Details map[string]any `json:"details,omitempty"` +} + +func (e *APIError) Error() string { + if e == nil { + return "" + } + if e.Code != "" && e.Message != "" { + return e.Code + ": " + e.Message + } + if e.Code != "" { + return e.Code + } + return e.Message +} + +func NewClient(baseURL string, identity Identity) *Client { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + baseURL = strings.TrimRight(config.GetMCPBaseURL(), "/") + DefaultBasePath + } + return &Client{ + BaseURL: baseURL, + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + Identity: identity, + } +} + +func (c *Client) CreateSubscription(ctx context.Context, req CreateSubscriptionRequest) (*Subscription, error) { + if req.EventKey == "" || req.RuleType == "" { + return nil, errors.New("personal event: event_key and rule_type are required") + } + var sub Subscription + if err := c.do(ctx, http.MethodPost, "/subscription/user", nil, c.buildCreateRequest(req), &sub); err != nil { + var apiErr *APIError + if errors.As(err, &apiErr) { + if subID, ok := apiErr.Details["subscribe_id"].(string); ok && subID != "" { + return &Subscription{ + SubscribeID: subID, + EventKey: req.EventKey, + RuleType: req.RuleType, + Status: "active", + SourceID: c.Identity.SourceID, + }, nil + } + } + return nil, err + } + if sub.EventKey == "" { + sub.EventKey = req.EventKey + } + if sub.RuleType == "" { + sub.RuleType = req.RuleType + } + if sub.Status == "" { + sub.Status = "active" + } + if sub.SourceID == "" { + sub.SourceID = c.Identity.SourceID + } + return &sub, nil +} + +func (c *Client) GetSubscription(ctx context.Context, subscribeID string) (*Subscription, error) { + subscribeID = strings.TrimSpace(subscribeID) + if subscribeID == "" { + return nil, errors.New("personal event: subscribe_id is required") + } + subs, err := c.ListSubscriptions(ctx, ListOptions{SubscribeID: subscribeID}) + if err != nil { + return nil, err + } + if len(subs) == 0 { + return nil, &APIError{Code: "PERSONAL_EVENT_NOT_FOUND", Message: "subscription not found"} + } + return &subs[0], nil +} + +func (c *Client) ListSubscriptions(ctx context.Context, opts ListOptions) ([]Subscription, error) { + q := make(url.Values) + if clientID := strings.TrimSpace(c.Identity.ClientID); clientID != "" { + q.Set("clientId", clientID) + } + if sourceID := strings.TrimSpace(c.Identity.SourceID); sourceID != "" { + q.Set("sourceId", sourceID) + } + q.Set("pageSize", fmt.Sprintf("%d", subscriptionListPageSize)) + all := make([]Subscription, 0, subscriptionListPageSize) + seen := make(map[string]struct{}, subscriptionListPageSize) + for pageNo := 1; pageNo <= subscriptionListMaxPageGuard; pageNo++ { + q.Set("pageNo", fmt.Sprintf("%d", pageNo)) + var result dwsSubListResult + if err := c.do(ctx, http.MethodGet, "/event/sublist", q, nil, &result); err != nil { + return nil, err + } + if len(result.Items) == 0 { + break + } + effectivePageSize := subscriptionListPageSize + if result.PageSize > 0 { + effectivePageSize = result.PageSize + } + + added := 0 + for _, item := range result.Items { + sub := item.toSubscription() + if sub.SubscribeID != "" { + if _, ok := seen[sub.SubscribeID]; ok { + continue + } + seen[sub.SubscribeID] = struct{}{} + } + all = append(all, sub) + added++ + } + if added == 0 && (result.Total > len(all) || len(result.Items) >= effectivePageSize) { + return nil, fmt.Errorf("personal event: subscription pagination made no progress at page %d", pageNo) + } + if result.Total > 0 && len(all) >= result.Total { + break + } + if len(result.Items) < effectivePageSize { + break + } + if pageNo == subscriptionListMaxPageGuard { + return nil, fmt.Errorf("personal event: subscription pagination exceeded %d pages", subscriptionListMaxPageGuard) + } + } + + items := make([]Subscription, 0, len(all)) + for _, sub := range all { + if opts.Status != "" && opts.Status != "all" && sub.Status != opts.Status { + continue + } + if opts.EventKey != "" && sub.EventKey != opts.EventKey { + continue + } + if opts.SubscribeID != "" && sub.SubscribeID != opts.SubscribeID { + continue + } + items = append(items, sub) + } + return items, nil +} + +func (c *Client) DeleteSubscription(ctx context.Context, subscribeID string) error { + subscribeID = strings.TrimSpace(subscribeID) + if subscribeID == "" { + return errors.New("personal event: subscribe_id is required") + } + err := c.do(ctx, http.MethodPost, "/subscription/cancel", nil, map[string]string{"subId": subscribeID}, nil) + if isNotFound(err) { + return nil + } + return err +} + +func (c *Client) buildCreateRequest(req CreateSubscriptionRequest) dwsCreateSubscriptionRequest { + filterRule := "" + if req.RuleParam != nil { + if b, err := json.Marshal(req.RuleParam); err == nil { + filterRule = string(b) + } + } + ext := map[string]any{ + "ruleType": req.RuleType, + } + if req.Name != "" { + ext["name"] = req.Name + } + if req.Filter != nil { + ext["filter"] = req.Filter + } + if req.IdempotencyKey != "" { + ext["idempotencyKey"] = req.IdempotencyKey + } + out := dwsCreateSubscriptionRequest{ + ClientID: c.Identity.ClientID, + SourceID: c.Identity.SourceID, + EventKey: req.EventKey, + FilterRule: filterRule, + DeliveryPref: "realtime", + Ext: ext, + } + if req.TTLSeconds > 0 { + out.ExpiresAt = time.Now().UTC().Add(time.Duration(req.TTLSeconds) * time.Second).Format(time.RFC3339) + } + return out +} + +func (c *Client) do(ctx context.Context, method, path string, q url.Values, body any, out any) error { + if c == nil { + return errors.New("personal event: nil client") + } + if c.Identity.AccessToken == "" { + return errors.New("personal event: access token is required") + } + u := strings.TrimRight(c.BaseURL, "/") + path + if len(q) > 0 { + u += "?" + q.Encode() + } + var r io.Reader + requestLog := "" + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("personal event: encode request: %w", err) + } + requestLog = sanitizeLogPayload(b) + r = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, u, r) + if err != nil { + return fmt.Errorf("personal event: create request: %w", err) + } + c.decorate(req) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + hc := c.HTTPClient + if hc == nil { + hc = http.DefaultClient + } + resp, err := hc.Do(req) + if err != nil { + return fmt.Errorf("personal event: send request: %w", err) + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if err != nil { + return fmt.Errorf("personal event: read response: %w", err) + } + responseLog := sanitizeLogPayload(data) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if apiErr := decodeAPIError(data); apiErr != nil { + apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, responseRequestID(data)) + logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), apiErr) + return apiErr + } + logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil) + return fmt.Errorf("personal event: HTTP %d", resp.StatusCode) + } + if len(bytes.TrimSpace(data)) == 0 { + logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, "", nil) + return nil + } + var env responseEnvelope + if err := json.Unmarshal(data, &env); err == nil && (env.Success != nil || env.Error != nil || env.Result != nil || env.ErrorCode != "" || env.ErrorMsg != "") { + if env.Success == nil { + if apiErr := env.apiError(); apiErr != nil { + apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, env.requestID()) + logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), apiErr) + return apiErr + } + } + if env.Success != nil && !*env.Success { + if apiErr := env.apiError(); apiErr != nil { + apiErr = withRequestDetails(apiErr, method, path, resp.StatusCode, env.requestID()) + logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), apiErr) + return apiErr + } + logControlRequest("personal event control request failed", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), nil) + return errors.New("personal event: request failed") + } + logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, env.requestID(), nil) + if env.Result == nil { + return nil + } + if out == nil { + return nil + } + return decodeResult(env.Result, out) + } + if out == nil { + logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil) + return nil + } + logControlRequest("personal event control request", method, path, q, resp.StatusCode, requestLog, responseLog, responseRequestID(data), nil) + return json.Unmarshal(data, out) +} + +func (c *Client) decorate(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+c.Identity.AccessToken) + req.Header.Set("x-user-access-token", c.Identity.AccessToken) + req.Header.Set("X-DWS-Client-Id", c.Identity.ClientID) + req.Header.Set("X-DWS-Source-Id", c.Identity.SourceID) + if c.Identity.CorpID != "" { + req.Header.Set("X-DWS-Corp-Id", c.Identity.CorpID) + } + req.Header.Set("Accept", "application/json") +} + +type responseEnvelope struct { + Success *bool `json:"success"` + RequestID string `json:"request_id,omitempty"` + RequestID2 string `json:"requestId,omitempty"` + Result json.RawMessage `json:"result"` + Error *APIError `json:"error"` + ErrorCode string `json:"errorCode,omitempty"` + ErrorMsg string `json:"errorMsg,omitempty"` +} + +func (e responseEnvelope) apiError() *APIError { + if e.Error != nil { + return e.Error + } + if e.ErrorCode != "" || e.ErrorMsg != "" { + return &APIError{Code: e.ErrorCode, Message: e.ErrorMsg} + } + return nil +} + +func (e responseEnvelope) requestID() string { + return firstNonEmpty(e.RequestID, e.RequestID2) +} + +func decodeResult(raw json.RawMessage, out any) error { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + if sub, ok := out.(*Subscription); ok { + if decoded, ok, err := decodeSubscriptionResult(raw); ok || err != nil { + if err != nil { + return err + } + *sub = decoded + return nil + } + } + if err := json.Unmarshal(raw, out); err == nil { + return nil + } + return json.Unmarshal(raw, out) +} + +func decodeSubscriptionResult(raw json.RawMessage) (Subscription, bool, error) { + var ids []string + if err := json.Unmarshal(raw, &ids); err == nil { + if len(ids) == 0 { + return Subscription{}, true, nil + } + return Subscription{SubscribeID: ids[0]}, true, nil + } + var item dwsSubscription + if err := json.Unmarshal(raw, &item); err == nil && + (firstNonEmpty(item.SubID, item.SubscribeID) != "" || + firstNonEmpty(item.EventKey, item.EventKeySnake) != "") { + return item.toSubscription(), true, nil + } + return Subscription{}, false, nil +} + +func decodeAPIError(data []byte) *APIError { + var env responseEnvelope + if err := json.Unmarshal(data, &env); err == nil { + if env.Error != nil { + return env.Error + } + if env.ErrorCode != "" || env.ErrorMsg != "" { + return &APIError{Code: env.ErrorCode, Message: env.ErrorMsg} + } + } + var apiErr APIError + if err := json.Unmarshal(data, &apiErr); err == nil && (apiErr.Code != "" || apiErr.Message != "") { + return &apiErr + } + return nil +} + +func responseRequestID(data []byte) string { + var env responseEnvelope + if err := json.Unmarshal(data, &env); err == nil { + return env.requestID() + } + return "" +} + +func withRequestDetails(apiErr *APIError, method, path string, status int, requestID string) *APIError { + if apiErr == nil { + return nil + } + if apiErr.Details == nil { + apiErr.Details = make(map[string]any, 4) + } + apiErr.Details["method"] = method + apiErr.Details["path"] = path + apiErr.Details["http_status"] = status + if requestID != "" { + apiErr.Details["request_id"] = requestID + } + return apiErr +} + +func logControlRequest(message, method, path string, q url.Values, status int, requestPayload, responsePayload, requestID string, apiErr *APIError) { + attrs := []any{ + "method", method, + "path", path, + "http_status", status, + } + if query := redactedQueryString(q); query != "" { + attrs = append(attrs, "query", query) + } + if requestPayload != "" { + attrs = append(attrs, "request", requestPayload) + } + if responsePayload != "" { + attrs = append(attrs, "response", responsePayload) + } + if requestID != "" { + attrs = append(attrs, "request_id", requestID) + } + if apiErr != nil { + if apiErr.Code != "" { + attrs = append(attrs, "error_code", apiErr.Code) + } + if apiErr.Message != "" { + attrs = append(attrs, "error_msg", apiErr.Message) + } + } + slog.Debug(message, attrs...) +} + +func sanitizeLogPayload(data []byte) string { + data = bytes.TrimSpace(data) + if len(data) == 0 { + return "" + } + var parsed any + if err := json.Unmarshal(data, &parsed); err == nil { + redacted := redactJSONValue(parsed) + if s, err := marshalLogJSON(redacted); err == nil { + return truncateLogPayload(s) + } + } + return truncateLogPayload(string(data)) +} + +func marshalLogJSON(v any) (string, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return "", err + } + return strings.TrimSpace(buf.String()), nil +} + +func redactJSONValue(v any) any { + switch x := v.(type) { + case map[string]any: + out := make(map[string]any, len(x)) + for k, value := range x { + if sensitiveLogKey(k) { + out[k] = "" + continue + } + out[k] = redactJSONValue(value) + } + return out + case []any: + out := make([]any, len(x)) + for i, value := range x { + out[i] = redactJSONValue(value) + } + return out + default: + return v + } +} + +func sensitiveLogKey(key string) bool { + key = strings.ToLower(strings.TrimSpace(key)) + return strings.Contains(key, "token") || + strings.Contains(key, "secret") || + strings.Contains(key, "ticket") || + strings.Contains(key, "authorization") +} + +func redactedQueryString(q url.Values) string { + if len(q) == 0 { + return "" + } + clone := make(url.Values, len(q)) + for key, values := range q { + if sensitiveLogKey(key) { + clone[key] = []string{""} + continue + } + clone[key] = append([]string(nil), values...) + } + return clone.Encode() +} + +func truncateLogPayload(s string) string { + if len(s) <= controlLogPayloadLimit { + return s + } + return s[:controlLogPayloadLimit] + "..." +} + +func isNotFound(err error) bool { + var apiErr *APIError + return errors.As(err, &apiErr) && (apiErr.Code == "PERSONAL_EVENT_NOT_FOUND" || apiErr.Code == "NOT_FOUND") +} + +func dwsStatusString(raw json.RawMessage) string { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || string(raw) == "null" { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + var n int + if err := json.Unmarshal(raw, &n); err == nil { + switch n { + case 1: + return "active" + case 2: + return "paused" + case 3: + return "deleted" + default: + return fmt.Sprintf("%d", n) + } + } + return string(raw) +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} diff --git a/internal/event/personal/client_test.go b/internal/event/personal/client_test.go new file mode 100644 index 00000000..1bb71d90 --- /dev/null +++ b/internal/event/personal/client_test.go @@ -0,0 +1,646 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package personal + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +func TestClientCreateSubscriptionDWSRequestAndArrayResponse(t *testing.T) { + var gotPath string + var gotReq dwsCreateSubscriptionRequest + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s", r.Method) + } + gotPath = r.URL.Path + if got := r.Header.Get("Authorization"); got != "Bearer token-1" { + t.Fatalf("Authorization = %q", got) + } + if got := r.Header.Get("x-user-access-token"); got != "token-1" { + t.Fatalf("x-user-access-token = %q", got) + } + if got := r.Header.Get("X-DWS-Client-Id"); got != "client-1" { + t.Fatalf("X-DWS-Client-Id = %q", got) + } + if got := r.Header.Get("X-DWS-Source-Id"); got != "open" { + t.Fatalf("X-DWS-Source-Id = %q", got) + } + if got := r.Header.Get("X-DWS-Corp-Id"); got != "corp-1" { + t.Fatalf("X-DWS-Corp-Id = %q", got) + } + if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil { + t.Fatalf("Decode body: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": []string{"sub-1"}, + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{ + AccessToken: "token-1", + CorpID: "corp-1", + UserID: "user-1", + ClientID: "client-1", + SourceID: "open", + }) + sub, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{ + EventKey: EventSingleChat, + RuleType: "singleChat", + Name: "test-o2o", + RuleParam: map[string]any{ + "targetUid": "507971", + "targetUidType": "staffId", + }, + Filter: map[string]any{"field": "payload.body.content", "op": "contains", "value": "P0"}, + IdempotencyKey: "idem-1", + }) + if err != nil { + t.Fatalf("CreateSubscription() error = %v", err) + } + if gotPath != "/subscription/user" { + t.Fatalf("path = %q, want /subscription/user", gotPath) + } + if gotReq.ClientID != "client-1" || gotReq.SourceID != "open" || gotReq.EventKey != EventSingleChat { + t.Fatalf("request identity/event = %#v", gotReq) + } + if gotReq.DeliveryPref != "realtime" { + t.Fatalf("deliveryPref = %q, want realtime", gotReq.DeliveryPref) + } + var filterRule map[string]any + if err := json.Unmarshal([]byte(gotReq.FilterRule), &filterRule); err != nil { + t.Fatalf("filterRule is not JSON: %q: %v", gotReq.FilterRule, err) + } + if filterRule["targetUid"] != "507971" || filterRule["targetUidType"] != "staffId" { + t.Fatalf("filterRule = %#v", filterRule) + } + if gotReq.Ext["ruleType"] != "singleChat" || gotReq.Ext["name"] != "test-o2o" || gotReq.Ext["idempotencyKey"] != "idem-1" { + t.Fatalf("ext = %#v", gotReq.Ext) + } + if sub.SubscribeID != "sub-1" { + t.Fatalf("subscribe_id = %q", sub.SubscribeID) + } + if sub.EventKey != EventSingleChat || sub.RuleType != "singleChat" || sub.Status != "active" || sub.SourceID != "open" { + t.Fatalf("subscription = %#v", sub) + } +} + +func TestClientCreateSubscriptionObjectResponses(t *testing.T) { + cases := []map[string]any{ + {"subId": "sub-camel", "eventKey": EventMention, "sourceId": "open", "status": 1}, + {"subscribe_id": "sub-snake", "event_key": EventMention, "source_id": "open", "status": "active"}, + } + for _, result := range cases { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": result, + }) + })) + c := NewClient(srv.URL, Identity{AccessToken: "token-1", ClientID: "client-1", SourceID: "open"}) + sub, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{ + EventKey: EventMention, + RuleType: "at", + RuleParam: map[string]any{}, + }) + srv.Close() + if err != nil { + t.Fatalf("CreateSubscription() error = %v", err) + } + if sub.SubscribeID == "" || !strings.HasPrefix(sub.SubscribeID, "sub-") { + t.Fatalf("subscription = %#v", sub) + } + } +} + +func TestClientDebugLogCreateSubscriptionRequestResponse(t *testing.T) { + logs := captureClientDebugLogs(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "requestId": "req-ok", + "result": []string{"sub-1"}, + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{AccessToken: "secret-token", ClientID: "client-1", SourceID: "pre_open_source"}) + if _, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{ + EventKey: EventSingleChat, + RuleType: "singleChat", + RuleParam: map[string]any{ + "targetUid": "507971", + "targetUidType": "staffId", + }, + IdempotencyKey: "idem-1", + }); err != nil { + t.Fatalf("CreateSubscription() error = %v", err) + } + out := logs.String() + for _, want := range []string{ + "personal event control request", + "/subscription/user", + "client-1", + "pre_open_source", + EventSingleChat, + "filterRule", + "targetUid", + "507971", + "sub-1", + "req-ok", + } { + if !strings.Contains(out, want) { + t.Fatalf("debug log missing %q: %s", want, out) + } + } + if strings.Contains(out, "secret-token") { + t.Fatalf("debug log leaked access token: %s", out) + } +} + +func TestClientBusinessErrorHTTP200(t *testing.T) { + logs := captureClientDebugLogs(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": false, + "requestId": "req-1", + "errorCode": "INVALID_PARAM", + "errorMsg": "clientId is empty", + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{AccessToken: "token-1", ClientID: "client-1", SourceID: "open"}) + _, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{ + EventKey: EventMention, + RuleType: "at", + RuleParam: map[string]any{}, + }) + if err == nil || !strings.Contains(err.Error(), "INVALID_PARAM") || !strings.Contains(err.Error(), "clientId is empty") { + t.Fatalf("error = %v, want INVALID_PARAM business error", err) + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error type = %T, want *APIError", err) + } + if apiErr.Details["method"] != http.MethodPost || apiErr.Details["path"] != "/subscription/user" || + apiErr.Details["http_status"] != http.StatusOK || apiErr.Details["request_id"] != "req-1" { + t.Fatalf("details = %#v", apiErr.Details) + } + out := logs.String() + for _, want := range []string{"/subscription/user", "INVALID_PARAM", "clientId is empty", "req-1", "request", "response"} { + if !strings.Contains(out, want) { + t.Fatalf("debug log missing %q: %s", want, out) + } + } +} + +func TestClientOmitsCorpHeaderWhenUnknown(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-DWS-Corp-Id"); got != "" { + t.Fatalf("X-DWS-Corp-Id = %q, want empty", got) + } + if got := r.Header.Get("Authorization"); got != "Bearer token-1" { + t.Fatalf("Authorization = %q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": []string{"sub-1"}, + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{AccessToken: "token-1", ClientID: "client-1", SourceID: "open"}) + if _, err := c.CreateSubscription(t.Context(), CreateSubscriptionRequest{ + EventKey: EventMention, + RuleType: "at", + RuleParam: map[string]any{}, + }); err != nil { + t.Fatalf("CreateSubscription() error = %v", err) + } +} + +func TestIdentityKeyUsesLocalSubjectFallback(t *testing.T) { + withCorpUser := Identity{CorpID: "corp-1", UserID: "user-1", ClientID: "client-1", SourceID: "open"} + if got := withCorpUser.Key(); got != "corp_user\x00corp-1\x00user-1\x00client-1\x00open" { + t.Fatalf("corp/user key = %q", got) + } + fallback := Identity{LocalSubject: "refresh:abc", ClientID: "client-1", SourceID: "open"} + if got := fallback.Key(); got != "local_subject\x00refresh:abc\x00client-1\x00open" { + t.Fatalf("fallback key = %q", got) + } +} + +func TestClientDeleteSubscriptionTreatsNotFoundAsSuccess(t *testing.T) { + var gotBody map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method = %s", r.Method) + } + if r.URL.Path != "/subscription/cancel" { + t.Fatalf("path = %q", r.URL.Path) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": false, + "error": map[string]any{"code": "PERSONAL_EVENT_NOT_FOUND", "message": "not found"}, + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}) + if err := c.DeleteSubscription(t.Context(), "sub-404"); err != nil { + t.Fatalf("DeleteSubscription() error = %v", err) + } + if gotBody["subId"] != "sub-404" { + t.Fatalf("cancel body = %#v", gotBody) + } +} + +func TestClientDeleteSubscriptionBusinessError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": false, + "requestId": "req-cancel", + "errorCode": "INVALID_STATE", + "errorMsg": "subscription cannot be cancelled", + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{AccessToken: "token-1", ClientID: "client-1", SourceID: "open"}) + err := c.DeleteSubscription(t.Context(), "sub-1") + if err == nil || !strings.Contains(err.Error(), "INVALID_STATE") { + t.Fatalf("DeleteSubscription() error = %v, want INVALID_STATE", err) + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error type = %T, want *APIError", err) + } + if apiErr.Details["path"] != "/subscription/cancel" || apiErr.Details["request_id"] != "req-cancel" { + t.Fatalf("details = %#v", apiErr.Details) + } +} + +func TestClientDebugLogListAndDeleteSubscription(t *testing.T) { + logs := captureClientDebugLogs(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/event/sublist": + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{"items": []map[string]any{}}, + }) + case "/subscription/cancel": + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}) + if _, err := c.ListSubscriptions(t.Context(), ListOptions{Status: "active"}); err != nil { + t.Fatalf("ListSubscriptions() error = %v", err) + } + if err := c.DeleteSubscription(t.Context(), "sub-1"); err != nil { + t.Fatalf("DeleteSubscription() error = %v", err) + } + out := logs.String() + for _, want := range []string{"/event/sublist", "clientId=client", "sourceId=open", "/subscription/cancel", "sub-1"} { + if !strings.Contains(out, want) { + t.Fatalf("debug log missing %q: %s", want, out) + } + } +} + +func TestClientDebugLogRedactsSensitivePayloadFields(t *testing.T) { + logs := captureClientDebugLogs(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "requestId": "req-secret", + "result": map[string]any{ + "access_token": "resp-access-token", + "client_secret": "resp-client-secret", + "ticket": "resp-ticket", + "Authorization": "Bearer resp-auth", + "safe": "ok", + }, + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{AccessToken: "header-token-secret", ClientID: "client", SourceID: "open"}) + err := c.do(t.Context(), http.MethodPost, "/subscription/user", nil, map[string]any{ + "access_token": "req-access-token", + "client_secret": "req-client-secret", + "ticket": "req-ticket", + "Authorization": "Bearer req-auth", + "safe": "ok", + }, nil) + if err != nil { + t.Fatalf("do() error = %v", err) + } + out := logs.String() + for _, leaked := range []string{ + "header-token-secret", + "req-access-token", + "req-client-secret", + "req-ticket", + "Bearer req-auth", + "resp-access-token", + "resp-client-secret", + "resp-ticket", + "Bearer resp-auth", + } { + if strings.Contains(out, leaked) { + t.Fatalf("debug log leaked %q: %s", leaked, out) + } + } + for _, want := range []string{"", "safe", "ok", "req-secret"} { + if !strings.Contains(out, want) { + t.Fatalf("debug log missing %q: %s", want, out) + } + } +} + +func TestClientListSubscriptionsDWSSublist(t *testing.T) { + var gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/event/sublist" { + t.Fatalf("path = %q", r.URL.Path) + } + gotQuery = r.URL.RawQuery + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{ + "total": 2, + "pageNo": 1, + "pageSize": 20, + "items": []map[string]any{ + { + "subId": "sub-1", + "eventKey": EventSingleChat, + "sourceId": "open", + "deliveryPref": "realtime", + "status": 1, + "gmtCreate": "2026-06-29T10:00:00Z", + }, + { + "subId": "sub-2", + "eventKey": EventMention, + "sourceId": "open", + "status": 3, + }, + }, + }, + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}) + subs, err := c.ListSubscriptions(t.Context(), ListOptions{Status: "active", EventKey: EventSingleChat}) + if err != nil { + t.Fatalf("ListSubscriptions() error = %v", err) + } + if !strings.Contains(gotQuery, "clientId=client") || !strings.Contains(gotQuery, "sourceId=open") || + !strings.Contains(gotQuery, "pageNo=1") || !strings.Contains(gotQuery, "pageSize=100") { + t.Fatalf("query = %q", gotQuery) + } + if len(subs) != 1 || subs[0].SubscribeID != "sub-1" || subs[0].Status != "active" || subs[0].CreatedAt == "" { + t.Fatalf("subs = %#v", subs) + } +} + +func TestClientGetSubscriptionFiltersSublist(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{ + "items": []map[string]any{ + {"subId": "sub-1", "eventKey": EventMention, "sourceId": "open", "status": 1}, + {"subId": "sub-2", "eventKey": EventSingleChat, "sourceId": "open", "status": 1}, + }, + }, + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}) + sub, err := c.GetSubscription(t.Context(), "sub-2") + if err != nil { + t.Fatalf("GetSubscription() error = %v", err) + } + if sub.SubscribeID != "sub-2" || sub.EventKey != EventSingleChat { + t.Fatalf("subscription = %#v", sub) + } +} + +func TestClientListSubscriptionsPaginatesAllResults(t *testing.T) { + var pages []int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pageNo, err := strconv.Atoi(r.URL.Query().Get("pageNo")) + if err != nil { + t.Fatalf("pageNo = %q", r.URL.Query().Get("pageNo")) + } + pages = append(pages, pageNo) + start := (pageNo - 1) * subscriptionListPageSize + end := start + subscriptionListPageSize + if end > 205 { + end = 205 + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{ + "total": 205, + "items": dwsSubscriptionTestItems(start, end), + }, + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}) + subs, err := c.ListSubscriptions(t.Context(), ListOptions{}) + if err != nil { + t.Fatalf("ListSubscriptions() error = %v", err) + } + if len(subs) != 205 || subs[204].SubscribeID != "sub-204" { + t.Fatalf("subscriptions = %d, last = %#v", len(subs), subs[len(subs)-1]) + } + if fmt.Sprint(pages) != "[1 2 3]" { + t.Fatalf("pages = %v, want [1 2 3]", pages) + } +} + +func TestClientGetSubscriptionFindsLaterPage(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + pageNo, _ := strconv.Atoi(r.URL.Query().Get("pageNo")) + items := dwsSubscriptionTestItems(0, 100) + if pageNo == 2 { + items = dwsSubscriptionTestItems(100, 101) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{"total": 101, "items": items}, + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}) + sub, err := c.GetSubscription(t.Context(), "sub-100") + if err != nil { + t.Fatalf("GetSubscription() error = %v", err) + } + if sub.SubscribeID != "sub-100" || calls != 2 { + t.Fatalf("subscription = %#v, calls = %d", sub, calls) + } +} + +func TestClientListSubscriptionsWithoutTotalStopsOnEmptyPage(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + items := dwsSubscriptionTestItems(0, 100) + if r.URL.Query().Get("pageNo") == "2" { + items = nil + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{"items": items}, + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}) + subs, err := c.ListSubscriptions(t.Context(), ListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(subs) != 100 || calls != 2 { + t.Fatalf("subscriptions = %d, calls = %d", len(subs), calls) + } +} + +func TestClientListSubscriptionsUsesServerPageSize(t *testing.T) { + var calls int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + pageNo, _ := strconv.Atoi(r.URL.Query().Get("pageNo")) + start := (pageNo - 1) * 20 + end := start + 20 + if end > 45 { + end = 45 + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{ + "total": 45, + "pageSize": 20, + "items": dwsSubscriptionTestItems(start, end), + }, + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}) + subs, err := c.ListSubscriptions(t.Context(), ListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(subs) != 45 || calls != 3 { + t.Fatalf("subscriptions = %d, calls = %d", len(subs), calls) + } +} + +func TestClientListSubscriptionsDeduplicatesSubscribeID(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + items := dwsSubscriptionTestItems(0, 100) + if r.URL.Query().Get("pageNo") == "2" { + items = append(dwsSubscriptionTestItems(99, 100), dwsSubscriptionTestItems(100, 101)...) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{"total": 101, "items": items}, + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}) + subs, err := c.ListSubscriptions(t.Context(), ListOptions{}) + if err != nil { + t.Fatal(err) + } + if len(subs) != 101 || subs[100].SubscribeID != "sub-100" { + t.Fatalf("subscriptions = %#v", subs) + } +} + +func TestClientListSubscriptionsRejectsRepeatedPage(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{ + "total": 200, + "items": dwsSubscriptionTestItems(0, 100), + }, + }) + })) + defer srv.Close() + + c := NewClient(srv.URL, Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}) + _, err := c.ListSubscriptions(t.Context(), ListOptions{}) + if err == nil || !strings.Contains(err.Error(), "pagination made no progress") { + t.Fatalf("ListSubscriptions() error = %v", err) + } +} + +func dwsSubscriptionTestItems(start, end int) []map[string]any { + items := make([]map[string]any, 0, end-start) + for i := start; i < end; i++ { + items = append(items, map[string]any{ + "subId": fmt.Sprintf("sub-%d", i), + "eventKey": EventSingleChat, + "sourceId": "open", + "status": 1, + }) + } + return items +} + +func captureClientDebugLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { + slog.SetDefault(previous) + }) + return &buf +} diff --git a/internal/event/personal/registry.go b/internal/event/personal/registry.go new file mode 100644 index 00000000..fa2e1488 --- /dev/null +++ b/internal/event/personal/registry.go @@ -0,0 +1,401 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package personal + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" +) + +const ( + EventMention = "user_im_message_receive_at" + EventSingleChat = "user_im_message_receive_o2o" + EventInChat = "user_im_message_receive_group" + EventFromUser = "user_im_message_receive_user" +) + +const ( + StatusEnabled = "enabled" + StatusPending = "pending" +) + +type Definition struct { + EventKey string `json:"event_key"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Category string `json:"category"` + RuleType string `json:"rule_type"` + Status string `json:"status"` + RequiredParams []string `json:"required_params"` + Auth map[string]any `json:"auth,omitempty"` + Public bool `json:"-"` +} + +type SchemaDocument struct { + EventKey string `json:"event_key"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Category string `json:"category"` + RuleType string `json:"rule_type"` + RequiredParams []string `json:"required_params"` + JQRootPath string `json:"jq_root_path"` + Schema map[string]any `json:"schema"` +} + +type RuleOptions struct { + RuleType string + UserID string + GroupID string +} + +type SchemaPendingError struct { + EventKey string +} + +func (e *SchemaPendingError) Error() string { + return fmt.Sprintf("%s schema is pending; try user_im_message_receive_at or user_im_message_receive_o2o first", e.EventKey) +} + +var definitions = []Definition{ + { + EventKey: EventMention, + DisplayName: "@我的消息", + Description: "当前用户被 @ 的消息", + Category: "im", + RuleType: "at", + Status: StatusEnabled, + RequiredParams: nil, + Auth: map[string]any{"identity": "user"}, + Public: true, + }, + { + EventKey: EventSingleChat, + DisplayName: "指定单聊消息", + Description: "当前用户与指定用户的单聊消息", + Category: "im", + RuleType: "singleChat", + Status: StatusEnabled, + RequiredParams: []string{"user"}, + Auth: map[string]any{"identity": "user"}, + Public: true, + }, + { + EventKey: EventInChat, + DisplayName: "指定群消息", + Description: "当前用户所在指定会话的消息", + Category: "im", + RuleType: "group", + Status: StatusEnabled, + RequiredParams: []string{"group"}, + Auth: map[string]any{"identity": "user"}, + Public: true, + }, + { + EventKey: EventFromUser, + DisplayName: "指定发送人消息", + Description: "当前用户收到的特别关注用户的消息", + Category: "im", + RuleType: "sender", + Status: StatusEnabled, + RequiredParams: []string{"user"}, + Auth: map[string]any{"identity": "user"}, + Public: false, + }, +} + +func Definitions() []Definition { + out := append([]Definition(nil), definitions...) + return out +} + +func Lookup(eventKey string) (Definition, bool) { + for _, def := range definitions { + if def.EventKey == eventKey { + return def, true + } + } + return Definition{}, false +} + +func IsPublic(eventKey string) bool { + def, ok := Lookup(eventKey) + return ok && def.Public +} + +func PublicAvailabilityError(eventKey string) error { + return fmt.Errorf("event %s is not publicly available yet", eventKey) +} + +func Catalog(category string, enabledOnly, includePending bool) []Definition { + category = strings.TrimSpace(category) + var out []Definition + for _, def := range definitions { + if !def.Public { + continue + } + if category != "" && def.Category != category { + continue + } + if enabledOnly && def.Status != StatusEnabled { + continue + } + if !includePending && def.Status == StatusPending { + continue + } + out = append(out, def) + } + return out +} + +func BuildSchemaDocument(def Definition) SchemaDocument { + requiredParams := make([]string, 0, len(def.RequiredParams)) + requiredParams = append(requiredParams, def.RequiredParams...) + return SchemaDocument{ + EventKey: def.EventKey, + DisplayName: def.DisplayName, + Description: def.Description, + Category: def.Category, + RuleType: def.RuleType, + RequiredParams: requiredParams, + JQRootPath: ".data | fromjson", + Schema: personalMessageSchema(def.EventKey), + } +} + +func BuildRuleParam(eventKey string, opts RuleOptions) (ruleType string, ruleParam map[string]any, err error) { + def, ok := Lookup(eventKey) + if !ok { + return "", nil, fmt.Errorf("unknown personal event key %q", eventKey) + } + if opts.RuleType != "" && opts.RuleType != def.RuleType { + return "", nil, fmt.Errorf("--rule %q does not match %s rule %q", opts.RuleType, eventKey, def.RuleType) + } + if def.Status == StatusPending { + return "", nil, &SchemaPendingError{EventKey: eventKey} + } + userID := strings.TrimSpace(opts.UserID) + groupID := strings.TrimSpace(opts.GroupID) + switch def.RuleType { + case "at": + if userID != "" { + return "", nil, fmt.Errorf("--user is only supported for %s", EventSingleChat) + } + if groupID != "" { + return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat) + } + return def.RuleType, map[string]any{}, nil + case "singleChat": + if groupID != "" { + return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat) + } + if userID == "" { + return "", nil, fmt.Errorf("--user is required") + } + return def.RuleType, map[string]any{ + "targetUid": userID, + "targetUidType": "staffId", + }, nil + case "sender": + if groupID != "" { + return "", nil, fmt.Errorf("--group is only supported for %s", EventInChat) + } + if userID == "" { + return "", nil, fmt.Errorf("--user is required") + } + return def.RuleType, map[string]any{ + "targetUid": userID, + "targetUidType": "staffId", + }, nil + case "group": + if userID != "" { + return "", nil, fmt.Errorf("--user is only supported for %s", EventSingleChat) + } + if groupID == "" { + return "", nil, fmt.Errorf("--group is required") + } + return def.RuleType, map[string]any{ + "openConversationId": groupID, + }, nil + default: + return "", nil, &SchemaPendingError{EventKey: eventKey} + } +} + +func BuildFilter(filterJSON string, queryCSV string) (any, string, error) { + var parts []any + filterJSON = strings.TrimSpace(filterJSON) + if filterJSON != "" { + var v any + if err := json.Unmarshal([]byte(filterJSON), &v); err != nil { + return nil, "", fmt.Errorf("--filter-json must be valid JSON: %w", err) + } + v = normalizeFilterAliases(v) + parts = append(parts, v) + } + queries := splitCSV(queryCSV) + if len(queries) > 0 { + parts = append(parts, map[string]any{ + "field": "payload.body.content", + "op": "contains_any", + "value": queries, + }) + } + switch len(parts) { + case 0: + return nil, "", nil + case 1: + canon, err := CanonicalJSON(parts[0]) + return parts[0], canon, err + default: + v := map[string]any{"and": parts} + canon, err := CanonicalJSON(v) + return v, canon, err + } +} + +func IdempotencyKey(identity Identity, eventKey, ruleType string, ruleParam map[string]any, filterCanonical string) string { + ruleCanonical, _ := CanonicalJSON(ruleParam) + sum := sha256.Sum256([]byte(strings.Join([]string{ + identity.Key(), + eventKey, + ruleType, + ruleCanonical, + filterCanonical, + }, "\x00"))) + return "dws-cli-" + hex.EncodeToString(sum[:8]) +} + +func CanonicalJSON(v any) (string, error) { + if v == nil { + return "", nil + } + b, err := json.Marshal(v) + if err != nil { + return "", err + } + return string(b), nil +} + +func splitCSV(raw string) []string { + var out []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +var filterFieldAliases = map[string]string{ + "content": "payload.body.content", + "conversation_id": "payload.body.openConversationId", + "sender": "payload.body.sender", + "sender_open_dingtalk_id": "payload.body.senderOpenDingTalkId", +} + +func normalizeFilterAliases(v any) any { + switch x := v.(type) { + case map[string]any: + out := make(map[string]any, len(x)) + for k, value := range x { + if k == "field" { + if raw, ok := value.(string); ok { + if mapped, ok := filterFieldAliases[raw]; ok { + value = mapped + } + } + } else { + value = normalizeFilterAliases(value) + } + out[k] = value + } + return out + case []any: + out := make([]any, len(x)) + for i, value := range x { + out[i] = normalizeFilterAliases(value) + } + return out + default: + return v + } +} + +func personalMessageSchema(eventKey string) map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "type": map[string]any{ + "type": "string", + "description": "事件类型,固定为当前 event_key", + "enum": []string{eventKey}, + }, + "event_id": map[string]any{ + "type": "string", + "description": "事件 ID,可用于去重", + }, + "timestamp": map[string]any{ + "type": "integer", + "description": "事件发生时间戳,对应 occurredAtMs", + "format": "timestamp_ms", + }, + "subscribe_id": map[string]any{ + "type": "string", + "description": "订阅 ID,对应 subId", + }, + "message_id": map[string]any{ + "type": "string", + "description": "开放消息 ID,对应 payload.body.openMessageId", + "format": "open_message_id", + }, + "conversation_id": map[string]any{ + "type": "string", + "description": "会话 ID,对应 payload.body.openConversationId", + "format": "open_conversation_id", + }, + "sender": map[string]any{ + "type": "string", + "description": "发送人展示名,对应 payload.body.sender", + }, + "sender_open_dingtalk_id": map[string]any{ + "type": "string", + "description": "发送人开放 ID,对应 payload.body.senderOpenDingTalkId", + "format": "open_dingtalk_id", + }, + "content": map[string]any{ + "type": "string", + "description": "消息正文,对应 payload.body.content", + }, + "create_time": map[string]any{ + "type": "string", + "description": "消息创建时间,对应 payload.body.createTime", + }, + "event_time": map[string]any{ + "type": "integer", + "description": "消息事件时间戳,对应 payload.event_time", + "format": "timestamp_ms", + }, + }, + } +} + +func IsSchemaPending(err error) bool { + var pending *SchemaPendingError + return errors.As(err, &pending) +} diff --git a/internal/event/personal/registry_test.go b/internal/event/personal/registry_test.go new file mode 100644 index 00000000..1cb64b88 --- /dev/null +++ b/internal/event/personal/registry_test.go @@ -0,0 +1,271 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package personal + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +func TestCatalogEnabledEvents(t *testing.T) { + items := Catalog("", true, false) + keys := make([]string, 0, len(items)) + for _, item := range items { + keys = append(keys, item.EventKey) + if item.Status != StatusEnabled { + t.Fatalf("%s status = %q, want enabled", item.EventKey, item.Status) + } + } + want := []string{ + EventMention, + EventSingleChat, + EventInChat, + } + if !reflect.DeepEqual(keys, want) { + t.Fatalf("keys = %#v, want %#v", keys, want) + } +} + +func TestEventFromUserRemainsInternalButNotPublic(t *testing.T) { + if _, ok := Lookup(EventFromUser); !ok { + t.Fatalf("Lookup(%q) failed, want internal definition retained", EventFromUser) + } + if IsPublic(EventFromUser) { + t.Fatalf("IsPublic(%q) = true, want hidden", EventFromUser) + } +} + +func TestLegacyEventKeysAreUnknown(t *testing.T) { + legacyKeys := []string{ + "im_message_receive_at", + "im_message_receive_o2o", + "im_message_receive_group", + "im_message_receive_user", + } + for _, key := range legacyKeys { + if _, ok := Lookup(key); ok { + t.Fatalf("Lookup(%q) succeeded, want unknown", key) + } + if _, _, err := BuildRuleParam(key, RuleOptions{}); err == nil || !strings.Contains(err.Error(), "unknown personal event key") { + t.Fatalf("BuildRuleParam(%q) error = %v, want unknown personal event key", key, err) + } + } +} + +func TestDefinitionJSONHidesInternalSchemaIDs(t *testing.T) { + raw, err := json.Marshal(Definitions()) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + out := string(raw) + for _, leaked := range []string{"schema_ids", "im_msg_23", "im_msg_29"} { + if strings.Contains(out, leaked) { + t.Fatalf("definitions JSON leaked %q: %s", leaked, out) + } + } +} + +func TestSchemaDocumentsUseSingleJSONSchema(t *testing.T) { + for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} { + t.Run(eventKey, func(t *testing.T) { + def, ok := Lookup(eventKey) + if !ok { + t.Fatalf("Lookup(%q) failed", eventKey) + } + doc := BuildSchemaDocument(def) + raw, err := json.Marshal(doc) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + out := string(raw) + for _, want := range []string{ + "event_key", + "display_name", + "description", + "category", + "rule_type", + "required_params", + "jq_root_path", + "schema", + "event_id", + "timestamp", + "subscribe_id", + "content", + "sender", + "sender_open_dingtalk_id", + "conversation_id", + "message_id", + "create_time", + "event_time", + } { + if !strings.Contains(out, want) { + t.Fatalf("schema for %s missing %q: %s", eventKey, want, out) + } + } + for _, leaked := range []string{ + "message.text", + "chat.openConversationId", + "sender.userId", + "sender.unionId", + "auth", + "resolved_output_schema", + "decoded_data_schema", + "filter_schema", + "payload_schema", + "output_schema", + "data_json_path", + "headers", + "audit", + "tenant", + "subject", + "traceId", + "msgIdMetaq", + "at_users", + "sender_user_id", + } { + if strings.Contains(out, leaked) { + t.Fatalf("schema for %s leaked %q: %s", eventKey, leaked, out) + } + } + if doc.JQRootPath != ".data | fromjson" { + t.Fatalf("jq_root_path = %q, want .data | fromjson", doc.JQRootPath) + } + if doc.RequiredParams == nil { + t.Fatalf("required_params = nil, want empty slice") + } + props, ok := doc.Schema["properties"].(map[string]any) + if !ok { + t.Fatalf("schema.properties = %#v, want object", doc.Schema["properties"]) + } + if _, ok := props["content"].(map[string]any); !ok { + t.Fatalf("schema.properties.content = %#v, want object", props["content"]) + } + }) + } +} + +func TestBuildRuleParamMention(t *testing.T) { + rule, param, err := BuildRuleParam(EventMention, RuleOptions{}) + if err != nil { + t.Fatalf("BuildRuleParam() error = %v", err) + } + if rule != "at" { + t.Fatalf("rule = %q, want at", rule) + } + if len(param) != 0 { + t.Fatalf("param = %#v, want empty map", param) + } +} + +func TestBuildRuleParamSingleChatRequiresPeer(t *testing.T) { + _, _, err := BuildRuleParam(EventSingleChat, RuleOptions{}) + if err == nil || !strings.Contains(err.Error(), "--user is required") { + t.Fatalf("error = %v, want user requirement", err) + } +} + +func TestBuildRuleParamSingleChatUserIDMapsToStaffID(t *testing.T) { + rule, param, err := BuildRuleParam(EventSingleChat, RuleOptions{UserID: "staff-1"}) + if err != nil { + t.Fatalf("BuildRuleParam() error = %v", err) + } + if rule != "singleChat" { + t.Fatalf("rule = %q, want singleChat", rule) + } + if param["targetUidType"] != "staffId" || param["targetUid"] != "staff-1" { + t.Fatalf("param = %#v", param) + } +} + +func TestBuildRuleParamSender(t *testing.T) { + _, _, err := BuildRuleParam(EventFromUser, RuleOptions{}) + if err == nil || !strings.Contains(err.Error(), "--user is required") { + t.Fatalf("error = %v, want sender requirement", err) + } + + rule, param, err := BuildRuleParam(EventFromUser, RuleOptions{UserID: "staff-1"}) + if err != nil { + t.Fatalf("BuildRuleParam() error = %v", err) + } + if rule != "sender" { + t.Fatalf("rule = %q, want sender", rule) + } + if param["targetUidType"] != "staffId" || param["targetUid"] != "staff-1" { + t.Fatalf("param = %#v", param) + } +} + +func TestBuildRuleParamGroup(t *testing.T) { + _, _, err := BuildRuleParam(EventInChat, RuleOptions{}) + if err == nil || !strings.Contains(err.Error(), "--group is required") { + t.Fatalf("error = %v, want group requirement", err) + } + + rule, param, err := BuildRuleParam(EventInChat, RuleOptions{GroupID: "cid-1"}) + if err != nil { + t.Fatalf("BuildRuleParam() error = %v", err) + } + if rule != "group" { + t.Fatalf("rule = %q, want group", rule) + } + if param["openConversationId"] != "cid-1" { + t.Fatalf("param = %#v", param) + } +} + +func TestBuildRuleParamRejectsWrongScopedFlags(t *testing.T) { + if _, _, err := BuildRuleParam(EventMention, RuleOptions{UserID: "staff-1"}); err == nil || !strings.Contains(err.Error(), "--user is only supported") { + t.Fatalf("mention with user error = %v, want unsupported user", err) + } + if _, _, err := BuildRuleParam(EventSingleChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--group is only supported") { + t.Fatalf("singleChat with group error = %v, want unsupported group", err) + } + if _, _, err := BuildRuleParam(EventInChat, RuleOptions{UserID: "staff-1", GroupID: "cid-1"}); err == nil || !strings.Contains(err.Error(), "--user is only supported") { + t.Fatalf("group with user error = %v, want unsupported user", err) + } +} + +func TestBuildFilterQueryAndJSON(t *testing.T) { + filter, canonical, err := BuildFilter(`{"field":"conversation_id","op":"eq","value":"cid1"}`, "P0, 故障") + if err != nil { + t.Fatalf("BuildFilter() error = %v", err) + } + m := filter.(map[string]any) + parts := m["and"].([]any) + if len(parts) != 2 { + t.Fatalf("parts len = %d, want 2", len(parts)) + } + if !strings.Contains(canonical, "contains_any") { + t.Fatalf("canonical = %s, want contains_any", canonical) + } + if !strings.Contains(canonical, "payload.body.content") || strings.Contains(canonical, "message.text") { + t.Fatalf("canonical = %s, want query filter on payload.body.content only", canonical) + } + if !strings.Contains(canonical, "payload.body.openConversationId") || strings.Contains(canonical, "conversation_id") { + t.Fatalf("canonical = %s, want conversation_id alias mapped to payload.body.openConversationId", canonical) + } +} + +func TestIdempotencyKeyUsesLocalIdentityKey(t *testing.T) { + left := Identity{LocalSubject: "refresh:left", ClientID: "client-1", SourceID: "open"} + right := Identity{LocalSubject: "refresh:right", ClientID: "client-1", SourceID: "open"} + ruleParam := map[string]any{"targetUid": "507971", "targetUidType": "staffId"} + leftKey := IdempotencyKey(left, EventSingleChat, "singleChat", ruleParam, "") + rightKey := IdempotencyKey(right, EventSingleChat, "singleChat", ruleParam, "") + if leftKey == rightKey { + t.Fatalf("idempotency key collapsed for different local subjects: %s", leftKey) + } +} diff --git a/internal/event/personal/state.go b/internal/event/personal/state.go new file mode 100644 index 00000000..fa74fc07 --- /dev/null +++ b/internal/event/personal/state.go @@ -0,0 +1,172 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package personal + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" +) + +const ( + StateFileName = "personal_subscriptions.json" + StateLockFileName = "personal_subscriptions.lock" + stateLockWaitTimeout = 5 * time.Second + stateLockRetryDelay = 25 * time.Millisecond +) + +type RunState struct { + SubscribeID string `json:"subscribe_id"` + EventKey string `json:"event_key,omitempty"` + RuleType string `json:"rule_type,omitempty"` + ClientID string `json:"client_id,omitempty"` + SourceID string `json:"source_id,omitempty"` + IdentityHash string `json:"identity_hash,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func LoadRunStates(workDir string) ([]RunState, error) { + path := filepath.Join(workDir, StateFileName) + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + var states []RunState + if err := json.Unmarshal(b, &states); err != nil { + return nil, err + } + sort.Slice(states, func(i, j int) bool { + return states[i].SubscribeID < states[j].SubscribeID + }) + return states, nil +} + +func UpsertRunState(workDir string, st RunState) error { + if st.SubscribeID == "" { + return nil + } + if st.CreatedAt.IsZero() { + st.CreatedAt = time.Now().UTC() + } + return withRunStateLock(workDir, stateLockWaitTimeout, func() error { + states, err := LoadRunStates(workDir) + if err != nil { + return err + } + replaced := false + for i := range states { + if states[i].SubscribeID == st.SubscribeID { + states[i] = st + replaced = true + break + } + } + if !replaced { + states = append(states, st) + } + return writeRunStates(workDir, states) + }) +} + +func RemoveRunStates(workDir string, subscribeIDs []string) error { + if len(subscribeIDs) == 0 { + return nil + } + remove := make(map[string]struct{}, len(subscribeIDs)) + for _, id := range subscribeIDs { + if id != "" { + remove[id] = struct{}{} + } + } + return withRunStateLock(workDir, stateLockWaitTimeout, func() error { + states, err := LoadRunStates(workDir) + if err != nil { + return err + } + filtered := states[:0] + for _, st := range states { + if _, ok := remove[st.SubscribeID]; !ok { + filtered = append(filtered, st) + } + } + return writeRunStates(workDir, filtered) + }) +} + +func withRunStateLock(workDir string, wait time.Duration, fn func() error) error { + if err := os.MkdirAll(workDir, config.DirPerm); err != nil { + return err + } + lockPath := filepath.Join(workDir, StateLockFileName) + deadline := time.Now().Add(wait) + for { + held, err := eventlock.TryAcquire(lockPath) + if err == nil { + defer held.Close() + return fn() + } + if !errors.Is(err, eventlock.ErrBusy) { + return err + } + remaining := time.Until(deadline) + if remaining <= 0 { + return fmt.Errorf("personal event: timed out waiting for run-state lock after %s", wait) + } + delay := stateLockRetryDelay + if remaining < delay { + delay = remaining + } + time.Sleep(delay) + } +} + +func writeRunStates(workDir string, states []RunState) error { + if err := os.MkdirAll(workDir, config.DirPerm); err != nil { + return err + } + sort.Slice(states, func(i, j int) bool { + return states[i].SubscribeID < states[j].SubscribeID + }) + path := filepath.Join(workDir, StateFileName) + if len(states) == 0 { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil + } + b, err := json.MarshalIndent(states, "", " ") + if err != nil { + return err + } + b = append(b, '\n') + tmp := path + ".tmp" + if err := os.WriteFile(tmp, b, config.FilePerm); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} diff --git a/internal/event/personal/state_test.go b/internal/event/personal/state_test.go new file mode 100644 index 00000000..88d8713d --- /dev/null +++ b/internal/event/personal/state_test.go @@ -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 personal + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock" +) + +func TestRunStatesConcurrentUpsertPreservesEverySubscription(t *testing.T) { + workDir := t.TempDir() + const count = 64 + var wg sync.WaitGroup + errs := make(chan error, count) + for i := 0; i < count; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs <- UpsertRunState(workDir, RunState{SubscribeID: fmt.Sprintf("sub-%03d", i)}) + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("UpsertRunState() error = %v", err) + } + } + + states, err := LoadRunStates(workDir) + if err != nil { + t.Fatal(err) + } + if len(states) != count { + t.Fatalf("run states = %d, want %d", len(states), count) + } + for i, st := range states { + if want := fmt.Sprintf("sub-%03d", i); st.SubscribeID != want { + t.Fatalf("state %d = %q, want %q", i, st.SubscribeID, want) + } + } +} + +func TestRunStatesConcurrentUpsertAndRemoveRemainConsistent(t *testing.T) { + workDir := t.TempDir() + for i := 0; i < 50; i++ { + if err := UpsertRunState(workDir, RunState{SubscribeID: fmt.Sprintf("base-%02d", i)}); err != nil { + t.Fatal(err) + } + } + + var wg sync.WaitGroup + errs := make(chan error, 75) + for i := 0; i < 50; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs <- UpsertRunState(workDir, RunState{SubscribeID: fmt.Sprintf("new-%02d", i)}) + }(i) + } + for i := 0; i < 25; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + errs <- RemoveRunStates(workDir, []string{fmt.Sprintf("base-%02d", i)}) + }(i) + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatalf("run-state mutation error = %v", err) + } + } + + raw, err := os.ReadFile(filepath.Join(workDir, StateFileName)) + if err != nil { + t.Fatal(err) + } + var decoded []RunState + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("state file is invalid JSON: %v", err) + } + if len(decoded) != 75 { + t.Fatalf("run states = %d, want 75", len(decoded)) + } + present := make(map[string]bool, len(decoded)) + for _, st := range decoded { + present[st.SubscribeID] = true + } + for i := 25; i < 50; i++ { + if !present[fmt.Sprintf("base-%02d", i)] { + t.Fatalf("unremoved base subscription %d was lost", i) + } + } + for i := 0; i < 50; i++ { + if !present[fmt.Sprintf("new-%02d", i)] { + t.Fatalf("new subscription %d was lost", i) + } + } +} + +func TestWithRunStateLockTimesOut(t *testing.T) { + workDir := t.TempDir() + held, err := eventlock.TryAcquire(filepath.Join(workDir, StateLockFileName)) + if err != nil { + t.Fatal(err) + } + defer held.Close() + + called := false + err = withRunStateLock(workDir, 20*time.Millisecond, func() error { + called = true + return nil + }) + if err == nil || !strings.Contains(err.Error(), "timed out waiting for run-state lock") { + t.Fatalf("withRunStateLock() error = %v, want timeout", err) + } + if called { + t.Fatal("locked callback was called") + } +} diff --git a/internal/event/process/doc.go b/internal/event/process/doc.go new file mode 100644 index 00000000..a2802b35 --- /dev/null +++ b/internal/event/process/doc.go @@ -0,0 +1,23 @@ +// 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 process provides cross-platform process-existence checks. Used by +// the bus daemon's stale-lock recovery path: bus.lock holds the daemon's PID; +// on startup a competing process reads that PID and calls Alive() to decide +// whether to abort (the holder is still running) or steal the lock (orphan). +// +// On Unix this is the standard signal-0 trick (syscall.Kill(pid, 0)) which +// errors with ESRCH if the process is gone. On Windows it uses OpenProcess +// with PROCESS_QUERY_LIMITED_INFORMATION and GetExitCodeProcess to detect +// STILL_ACTIVE; this avoids the Unix-only assumption in plan §1. +package process diff --git a/internal/event/process/process_alive_test.go b/internal/event/process/process_alive_test.go new file mode 100644 index 00000000..ca92f6ee --- /dev/null +++ b/internal/event/process/process_alive_test.go @@ -0,0 +1,63 @@ +// 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 process + +import ( + "os" + "os/exec" + "runtime" + "testing" +) + +func TestAlive_SelfPID(t *testing.T) { + if !Alive(os.Getpid()) { + t.Fatal("Alive(self pid) should be true") + } +} + +func TestAlive_NonPositivePID(t *testing.T) { + for _, pid := range []int{0, -1, -42} { + if Alive(pid) { + t.Fatalf("Alive(%d) should be false (non-positive)", pid) + } + } +} + +func TestAlive_DeadChild(t *testing.T) { + // Spawn a short-lived child and wait for it to exit, then verify Alive + // returns false. We use sleep 0 which exits immediately on both Unix + // and Windows (Windows treats `sleep 0` as a built-in via cmd.exe but + // to avoid Windows shell quirks we fall back to `cmd /c exit 0`). + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("cmd", "/c", "exit", "0") + } else { + cmd = exec.Command("sh", "-c", "exit 0") + } + if err := cmd.Start(); err != nil { + t.Fatalf("start child: %v", err) + } + pid := cmd.Process.Pid + if err := cmd.Wait(); err != nil { + t.Fatalf("wait child: %v", err) + } + + // On Windows, even after Wait, the kernel may keep the handle table + // entry briefly; GetExitCodeProcess should still report non-STILL_ACTIVE + // so Alive returns false. On Unix, the parent has reaped via Wait so + // signal 0 returns ESRCH. + if Alive(pid) { + t.Fatalf("Alive(%d) should be false after child exit", pid) + } +} diff --git a/internal/event/process/process_alive_unix.go b/internal/event/process/process_alive_unix.go new file mode 100644 index 00000000..c42526e3 --- /dev/null +++ b/internal/event/process/process_alive_unix.go @@ -0,0 +1,52 @@ +// 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 process + +import ( + "errors" + "syscall" +) + +// Alive reports whether a process with the given PID is currently running. +// Returns false for non-positive PIDs without performing any syscall. +// +// On Unix this uses signal 0 (syscall.Kill(pid, 0)), which performs the +// normal permission/existence check without delivering a signal: +// - nil error → process exists and we have permission +// - syscall.EPERM → process exists but owned by another user +// (still counts as alive for our purpose) +// - syscall.ESRCH → no such process +// +// Any other error is treated as "alive" defensively, so we never steal a +// lock based on an ambiguous syscall failure. +func Alive(pid int) bool { + if pid <= 0 { + return false + } + err := syscall.Kill(pid, 0) + if err == nil { + return true + } + if errors.Is(err, syscall.EPERM) { + // Process exists, we just can't signal it. + return true + } + if errors.Is(err, syscall.ESRCH) { + return false + } + // Unknown error: be conservative, treat as alive. + return true +} diff --git a/internal/event/process/process_alive_windows.go b/internal/event/process/process_alive_windows.go new file mode 100644 index 00000000..e84f2be3 --- /dev/null +++ b/internal/event/process/process_alive_windows.go @@ -0,0 +1,60 @@ +// 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 process + +import ( + "golang.org/x/sys/windows" +) + +// stillActive is the WinAPI sentinel returned by GetExitCodeProcess for +// running processes (STILL_ACTIVE = 259). +const stillActive uint32 = 259 + +// Alive reports whether a process with the given PID is currently running. +// Returns false for non-positive PIDs without performing any syscall. +// +// On Windows this opens the process with PROCESS_QUERY_LIMITED_INFORMATION +// (least privilege required) and calls GetExitCodeProcess. A return code of +// STILL_ACTIVE means the process is running; any other value means it has +// exited. If OpenProcess fails with ERROR_INVALID_PARAMETER the PID is +// unknown to the system (process never existed or has been recycled). +// +// Any other unexpected error is treated as "alive" defensively, so we never +// steal a lock based on an ambiguous syscall failure. +func Alive(pid int) bool { + if pid <= 0 { + return false + } + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + if err == windows.ERROR_INVALID_PARAMETER { + return false + } + // Access denied means the process exists but we can't query it. + if err == windows.ERROR_ACCESS_DENIED { + return true + } + // Other errors: be conservative, treat as alive. + return true + } + defer windows.CloseHandle(h) + + var code uint32 + if err := windows.GetExitCodeProcess(h, &code); err != nil { + return true + } + return code == stillActive +} diff --git a/internal/event/registry/catchall.go b/internal/event/registry/catchall.go new file mode 100644 index 00000000..8a7faec6 --- /dev/null +++ b/internal/event/registry/catchall.go @@ -0,0 +1,54 @@ +// 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 registry + +// CatchAllEventTypes returns the default event-type wildcards passed in +// the Hello frame when `dws event consume` is invoked without +// --event-types. +// +// v1 returns nil = "bus-wide catch-all" — the bus already subscribes +// upstream to everything the open-platform UI ticks. The Hub's matcher +// treats nil/empty as match-everything, so the consumer gets every event +// the open platform pushes without needing an explicit list. +// +// Why we don't ship a curated default (yet): the exact event_type +// strings DingTalk emits over Stream are not yet confirmed by a P0 +// run against a real app (escape-hatch row #3). Shipping a curated +// list that differs by one character from what DingTalk actually sends +// would silently filter out events users expect to see. The bus-wide +// catch-all behaviour is conservative — too inclusive rather than too +// exclusive — and avoids that failure mode. +// +// Known/expected event type strings (from open-platform docs; pending +// P0 SDK confirmation — DO NOT enable until verified): +// +// im.message.receive_v1 receive any IM message +// im.message.read_v1 message read receipt +// im.message.reaction.created_v1 reaction added +// im.message.reaction.deleted_v1 reaction removed +// im.chat.member.bot.added_v1 bot added to a chat +// im.chat.member.bot.deleted_v1 bot removed from a chat +// im.chat.member.user.added_v1 user added to a chat +// im.chat.disbanded_v1 chat disbanded +// contact.user.created_v3 / updated / deleted +// contact.department.created_v3 / updated / deleted +// cal.event.created_v1 / updated / deleted +// approval.instance.status_changed +// approval.task.created +// attendance.check_v1 +// +// Once verified, switch CatchAllEventTypes to return the slice above (and +// document the change so users can override with --event-types '*' to +// regain literal "everything the open platform sends"). +func CatchAllEventTypes() []string { return nil } diff --git a/internal/event/registry/compact_processors.go b/internal/event/registry/compact_processors.go new file mode 100644 index 00000000..1197fbf2 --- /dev/null +++ b/internal/event/registry/compact_processors.go @@ -0,0 +1,243 @@ +// 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 registry + +import ( + "encoding/json" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +// CompactView is the JSON-encoded shape `dws event consume --compact` +// writes per event. Implementations populate the common header fields +// then layer event-type-specific semantic fields on top via "extra". +type CompactView struct { + // Type echoes EventType for routing/filtering at the agent layer. + Type string `json:"type"` + // EventID is the SDK-assigned identifier (DataFrameHeader "eventId"). + EventID string `json:"event_id,omitempty"` + // Timestamp is the event-born-time milliseconds value, surfaced under a + // shorter name agents typically expect. + Timestamp int64 `json:"timestamp,omitempty"` + // CorpID propagates the SDK's eventCorpId when present. + CorpID string `json:"corp_id,omitempty"` + // AppID propagates eventUnifiedAppId when present. + AppID string `json:"app_id,omitempty"` + // Extra holds the event-type-specific flattened fields (e.g. + // chat_id/sender_id for IM messages). Marshalled inline. + Extra map[string]any `json:"-"` +} + +// MarshalJSON serialises CompactView with the Extra fields lifted to the +// top level, dropping the wrapping "extra" key. This produces the flat +// map output that agents prefer. +func (v CompactView) MarshalJSON() ([]byte, error) { + out := make(map[string]any, 6+len(v.Extra)) + out["type"] = v.Type + if v.EventID != "" { + out["event_id"] = v.EventID + } + if v.Timestamp != 0 { + out["timestamp"] = v.Timestamp + } + if v.CorpID != "" { + out["corp_id"] = v.CorpID + } + if v.AppID != "" { + out["app_id"] = v.AppID + } + for k, val := range v.Extra { + // Header fields win over Extra on collision — we never let a + // processor accidentally overwrite the documented top-level shape. + if _, taken := out[k]; taken { + continue + } + out[k] = val + } + return json.Marshal(out) +} + +// Processor transforms one Event frame into a CompactView. Implementations +// MUST NOT modify the input. Returning a zero CompactView is allowed and +// falls back to the generic processor at the caller's discretion. +type Processor func(ev transport.Event) CompactView + +// processors holds the registered specialised processors. Lookup is by +// exact EventType match; wildcard support is intentionally absent because +// compact rendering is identity-specific (per event_type schema). +// +// To register more event types, add entries here. Each processor receives +// the full transport.Event and returns a CompactView with the per-type +// semantic fields lifted into Extra. See GenericProcessor for the +// fall-through behaviour applied to unregistered types. +var processors = map[string]Processor{ + // IM + "im.message.receive_v1": compactIMMessage, + "im.message.read_v1": compactIMMessageRead, + // Approval + "approval.instance.status_changed": compactApprovalInstance, + "approval.task.created": compactApprovalTask, + // Contact + "contact.user.created_v3": compactContactUser, + "contact.user.updated_v3": compactContactUser, + "contact.user.deleted_v3": compactContactUser, + // Calendar + "cal.event.created_v1": compactCalendarEvent, + "cal.event.updated_v1": compactCalendarEvent, + "cal.event.deleted_v1": compactCalendarEvent, + // Attendance + "attendance.check_v1": compactAttendanceCheck, +} + +// LookupProcessor returns the registered processor for eventType, or +// GenericProcessor when no specialised one exists. +func LookupProcessor(eventType string) Processor { + if p, ok := processors[eventType]; ok { + return p + } + return GenericProcessor +} + +// GenericProcessor is the fallback used when no specialised processor is +// registered for the event type. It surfaces the 5 SDK header fields and +// tries to parse the JSON payload — on parse failure it embeds the raw +// payload string under "data" so nothing is lost. +func GenericProcessor(ev transport.Event) CompactView { + v := CompactView{ + Type: ev.EventType, + EventID: ev.EventID, + Timestamp: ev.EventBornTime, + CorpID: ev.EventCorpID, + AppID: ev.EventUnifiedAppID, + Extra: map[string]any{}, + } + var parsed map[string]any + if err := json.Unmarshal([]byte(ev.Data), &parsed); err == nil { + for k, val := range parsed { + v.Extra[k] = val + } + } else if ev.Data != "" { + v.Extra["data"] = ev.Data + } + return v +} + +// liftFromNested copies named keys from a nested map at top level on the +// CompactView. No-op when nested is missing. Used by the per-type +// processors to lift "sender.sender_id.open_id" style nested fields. +func liftFromNested(v *CompactView, nestedKey string, keys ...string) { + nested, ok := v.Extra[nestedKey].(map[string]any) + if !ok { + return + } + for _, k := range keys { + if val, ok := nested[k]; ok { + if _, taken := v.Extra[k]; !taken { + v.Extra[k] = val + } + } + } +} + +// compactIMMessage is the specialised processor for IM message receipts +// (event_type: im.message.receive_v1). +// +// The SDK payload for IM message events is a nested JSON document. The +// canonical agent fields we expose are: message_id, chat_id, chat_type, +// message_type, content (already a string for text messages), sender_id. +// Any field DingTalk adds later flows through via the generic merge — +// we never drop unknown fields, we just promote the common ones. +func compactIMMessage(ev transport.Event) CompactView { + v := GenericProcessor(ev) + liftFromNested(&v, "message", "message_id", "chat_id", "chat_type", "message_type", "content") + // Sender open_id may be doubly nested: sender.sender_id.open_id. + if sender, ok := v.Extra["sender"].(map[string]any); ok { + if sid, ok := sender["sender_id"].(map[string]any); ok { + if open, ok := sid["open_id"]; ok { + if _, taken := v.Extra["sender_id"]; !taken { + v.Extra["sender_id"] = open + } + } + } + } + return v +} + +// compactIMMessageRead lifts message read receipts (open_id of the reader +// + the message_ids they acknowledged). +func compactIMMessageRead(ev transport.Event) CompactView { + v := GenericProcessor(ev) + liftFromNested(&v, "reader", "open_id", "read_time") + liftFromNested(&v, "context", "open_message_ids") + return v +} + +// compactApprovalInstance promotes the approval-instance-level identity +// fields (instance_id / status / business_id / process_code) and the +// requestor name. +func compactApprovalInstance(ev transport.Event) CompactView { + v := GenericProcessor(ev) + liftFromNested(&v, "instance", "instance_id", "process_code", "business_id", "title") + liftFromNested(&v, "status_change", "from_status", "to_status", "operate_time") + if op, ok := v.Extra["operator"].(map[string]any); ok { + if id, ok := op["userid"]; ok { + v.Extra["operator_userid"] = id + } + } + return v +} + +// compactApprovalTask lifts task assignment info (task_id, assignee user_id). +func compactApprovalTask(ev transport.Event) CompactView { + v := GenericProcessor(ev) + liftFromNested(&v, "task", "task_id", "instance_id", "status", "action_type") + if assignee, ok := v.Extra["assignee"].(map[string]any); ok { + if id, ok := assignee["userid"]; ok { + v.Extra["assignee_userid"] = id + } + } + return v +} + +// compactContactUser lifts the user identity for user lifecycle events. +func compactContactUser(ev transport.Event) CompactView { + v := GenericProcessor(ev) + liftFromNested(&v, "user", "open_id", "userid", "name", "active") + if v.Extra["open_id"] == nil { + // older payload may put open_id at top level under "open_ids" array + if arr, ok := v.Extra["open_ids"].([]any); ok && len(arr) > 0 { + v.Extra["open_id"] = arr[0] + } + } + return v +} + +// compactCalendarEvent lifts calendar event identity + organiser. +func compactCalendarEvent(ev transport.Event) CompactView { + v := GenericProcessor(ev) + liftFromNested(&v, "event", "event_id", "calendar_id", "title", "start_time", "end_time", "location") + if org, ok := v.Extra["organizer"].(map[string]any); ok { + if id, ok := org["userid"]; ok { + v.Extra["organizer_userid"] = id + } + } + return v +} + +// compactAttendanceCheck lifts the punch info (userid, check_time, location). +func compactAttendanceCheck(ev transport.Event) CompactView { + v := GenericProcessor(ev) + liftFromNested(&v, "punch", "userid", "check_time", "check_type", "location_method") + return v +} diff --git a/internal/event/registry/compact_processors_test.go b/internal/event/registry/compact_processors_test.go new file mode 100644 index 00000000..d40c919a --- /dev/null +++ b/internal/event/registry/compact_processors_test.go @@ -0,0 +1,238 @@ +// 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 registry + +import ( + "encoding/json" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" +) + +func mustJSON(t *testing.T, v any) map[string]any { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return out +} + +func TestGenericProcessor_HeaderFieldsPromoted(t *testing.T) { + ev := transport.Event{ + EventID: "ev_1", + EventBornTime: 1700000000123, + EventCorpID: "corp_x", + EventType: "approval.task", + EventUnifiedAppID: "app_y", + Data: `{"task_id":"t1","status":"approved"}`, + } + cv := GenericProcessor(ev) + out := mustJSON(t, cv) + wants := map[string]any{ + "type": "approval.task", + "event_id": "ev_1", + "corp_id": "corp_x", + "app_id": "app_y", + "task_id": "t1", + "status": "approved", + "timestamp": float64(1700000000123), + } + for k, want := range wants { + if got := out[k]; got != want { + t.Errorf("compact[%q] = %v, want %v", k, got, want) + } + } +} + +func TestGenericProcessor_PayloadParseFailEmbedsRaw(t *testing.T) { + ev := transport.Event{ + EventType: "x", + Data: "not json", + } + out := mustJSON(t, GenericProcessor(ev)) + if out["data"] != "not json" { + t.Fatalf("non-JSON Data should land under 'data', got: %v", out["data"]) + } +} + +func TestGenericProcessor_EmptyDataNoExtraData(t *testing.T) { + ev := transport.Event{EventType: "x", Data: ""} + out := mustJSON(t, GenericProcessor(ev)) + if _, ok := out["data"]; ok { + t.Errorf("empty Data should not produce a 'data' key; got: %v", out) + } +} + +func TestCompactView_HeaderFieldsWinOverExtra(t *testing.T) { + // A malicious or buggy processor that tries to overwrite "type" must + // be ignored — header fields are the documented contract. + cv := CompactView{ + Type: "the_real_type", + Extra: map[string]any{"type": "fake"}, + } + out := mustJSON(t, cv) + if out["type"] != "the_real_type" { + t.Fatalf("Extra 'type' must not override header; got %v", out["type"]) + } +} + +func TestLookupProcessor_FallsBackToGeneric(t *testing.T) { + p := LookupProcessor("never.registered.event") + if p == nil { + t.Fatal("LookupProcessor should never return nil") + } + cv := p(transport.Event{EventType: "never.registered.event", Data: "{}"}) + if cv.Type != "never.registered.event" { + t.Fatalf("fallback processor type = %q", cv.Type) + } +} + +func TestCompactIMMessage_LiftsNestedFields(t *testing.T) { + // Simulate the nested SDK shape: {"message": {...}, "sender": {...}}. + ev := transport.Event{ + EventType: "im.message.receive_v1", + EventID: "ev_abc", + Data: `{ + "message": { + "message_id": "om_x", + "chat_id": "oc_y", + "chat_type": "p2p", + "message_type": "text", + "content": "{\"text\":\"hello\"}" + }, + "sender": { + "sender_id": {"open_id": "ou_z"}, + "sender_type": "user" + } + }`, + } + out := mustJSON(t, compactIMMessage(ev)) + if out["message_id"] != "om_x" { + t.Errorf("message_id = %v", out["message_id"]) + } + if out["chat_id"] != "oc_y" { + t.Errorf("chat_id = %v", out["chat_id"]) + } + if out["chat_type"] != "p2p" { + t.Errorf("chat_type = %v", out["chat_type"]) + } + if out["sender_id"] != "ou_z" { + t.Errorf("sender_id = %v", out["sender_id"]) + } + // Original nested fields should still be present too (generic merge). + if _, ok := out["message"]; !ok { + t.Error("nested 'message' should also be retained for downstream consumers") + } +} + +func TestCompactIMMessage_FlatPayloadAlsoWorks(t *testing.T) { + // If the SDK ever flattens these to the top level, we should still + // surface them — generic merge already does this. + ev := transport.Event{ + EventType: "im.message.receive_v1", + Data: `{"message_id":"om_x","chat_id":"oc_y","content":"hi"}`, + } + out := mustJSON(t, compactIMMessage(ev)) + if out["message_id"] != "om_x" || out["chat_id"] != "oc_y" || out["content"] != "hi" { + t.Fatalf("flat fields lost: %+v", out) + } +} + +func TestCatchAllEventTypes_EmptyForV1(t *testing.T) { + if got := CatchAllEventTypes(); got != nil { + t.Fatalf("CatchAllEventTypes v1 should be nil, got %v", got) + } +} + +func TestSpecialisedProcessorsRegistered(t *testing.T) { + // Sentinel: every specialised event type should resolve to a non- + // GenericProcessor function. If a P7 entry was accidentally dropped + // from the registry map this catches it before runtime users do. + wantRegistered := []string{ + "im.message.receive_v1", + "im.message.read_v1", + "approval.instance.status_changed", + "approval.task.created", + "contact.user.created_v3", + "contact.user.updated_v3", + "contact.user.deleted_v3", + "cal.event.created_v1", + "cal.event.updated_v1", + "cal.event.deleted_v1", + "attendance.check_v1", + } + for _, et := range wantRegistered { + if _, ok := processors[et]; !ok { + t.Errorf("event type %q should have a registered processor", et) + } + } +} + +func TestCompactApprovalInstance(t *testing.T) { + ev := transport.Event{ + EventType: "approval.instance.status_changed", + Data: `{ + "instance": { + "instance_id": "inst_123", + "process_code": "proc_abc", + "title": "请假申请" + }, + "status_change": { + "from_status": "PENDING", + "to_status": "APPROVED" + }, + "operator": {"userid": "user_xyz"} + }`, + } + out := mustJSON(t, compactApprovalInstance(ev)) + for k, want := range map[string]any{ + "instance_id": "inst_123", + "process_code": "proc_abc", + "title": "请假申请", + "from_status": "PENDING", + "to_status": "APPROVED", + "operator_userid": "user_xyz", + } { + if out[k] != want { + t.Errorf("approval[%q] = %v, want %v", k, out[k], want) + } + } +} + +func TestCompactContactUser(t *testing.T) { + ev := transport.Event{ + EventType: "contact.user.created_v3", + Data: `{"user":{"open_id":"ou_x","userid":"u_x","name":"Alice","active":true}}`, + } + out := mustJSON(t, compactContactUser(ev)) + if out["open_id"] != "ou_x" || out["userid"] != "u_x" || out["name"] != "Alice" { + t.Errorf("contact user fields missing: %+v", out) + } +} + +func TestCompactCalendarEvent(t *testing.T) { + ev := transport.Event{ + EventType: "cal.event.created_v1", + Data: `{"event":{"event_id":"e1","title":"Standup","start_time":"2026-01-01T09:00:00Z"},"organizer":{"userid":"u_x"}}`, + } + out := mustJSON(t, compactCalendarEvent(ev)) + if out["event_id"] != "e1" || out["title"] != "Standup" || out["organizer_userid"] != "u_x" { + t.Errorf("calendar event fields missing: %+v", out) + } +} diff --git a/internal/event/registry/doc.go b/internal/event/registry/doc.go new file mode 100644 index 00000000..ac6c811e --- /dev/null +++ b/internal/event/registry/doc.go @@ -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 registry holds two cross-cutting reference data sets used by +// daemon, consume, and the cobra command layer: +// +// - CatchAll: the default event_type list passed to `consume`'s Hello +// when --event-types is omitted. v1 (P4) ships an empty list so the +// consumer falls back to bus-wide catch-all; P7 fills in the curated +// DingTalk default set after P0 SDK verification confirms the exact +// event_type string values. +// +// - CompactProcessors: per-event_type formatters that flatten the SDK +// RawEvent into an agent-friendly map[string]any. A generic processor +// handles unknown types by surfacing the top-level header fields plus +// a parsed payload; specialised processors (im.message.* etc.) extract +// semantically meaningful fields. +package registry diff --git a/internal/event/source/dingtalk.go b/internal/event/source/dingtalk.go new file mode 100644 index 00000000..37d11c93 --- /dev/null +++ b/internal/event/source/dingtalk.go @@ -0,0 +1,178 @@ +// 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 source wraps the open-dingtalk Stream SDK and exposes a small +// blocking Start interface plus a connection state machine. The SDK is the +// only place in the bus that talks to the cloud; the rest of the bus stays +// vendor-agnostic and would be drop-in replaceable for a different Stream +// provider. +package source + +import ( + "context" + "errors" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/event" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/payload" +) + +// Config carries the credentials and behavioural knobs needed to construct a +// DingtalkSource. ClientID/ClientSecret are required for app-credential SDK +// mode. Portal ticket normal mode uses portal-side managed credentials, so it +// does not require local app credentials. +type Config struct { + ClientID string + ClientSecret string + // PortalTicket switches the source to the portal-managed user Stream + // ticket flow. When set, Start fetches endpoint+ticket over HTTP and + // dials the returned WebSocket directly instead of asking the SDK to + // open an app-credential connection. + PortalTicket *PortalTicketConfig + // Now is injected for tests. Defaults to time.Now when nil. + Now func() time.Time +} + +// SourceOptions is the functional-option type reserved for future overlay +// extensions (e.g. inject trace IDs into emit, swap the underlying client +// for a fake). v1 takes no options; the type exists so callers can write +// `New(cfg)` today and `New(cfg, WithFoo())` tomorrow without an API break. +// See plan §7 Edition 扩展点. +type SourceOption func(*sourceConfig) + +type sourceConfig struct { + // reserved for v2 hooks (pre-emit interceptor, etc.) +} + +// DingtalkSource is the cloud-side adapter. It owns one StreamClient and one +// state Machine; lifecycle is bounded by the context passed to Start. +type DingtalkSource struct { + cfg Config + machine *Machine + cli *client.StreamClient +} + +// New constructs a DingtalkSource. Returns an error if required Config +// fields are missing — keep the boundary tight so misconfiguration fails +// loudly rather than at first-event time. +func New(cfg Config, _ ...SourceOption) (*DingtalkSource, error) { + if cfg.PortalTicket != nil { + if err := cfg.PortalTicket.Valid(); err != nil { + return nil, err + } + if cfg.PortalTicket.normalizedMode() == PortalTicketModeCustom && cfg.ClientID == "" { + return nil, errors.New("source: ClientID is required") + } + } else { + if cfg.ClientID == "" { + return nil, errors.New("source: ClientID is required") + } + if cfg.ClientSecret == "" { + return nil, errors.New("source: ClientSecret is required") + } + } + if cfg.Now == nil { + cfg.Now = time.Now + } + m := NewMachine() + m.now = cfg.Now + return &DingtalkSource{cfg: cfg, machine: m}, nil +} + +// State returns the current Snapshot of the connection state machine. +func (s *DingtalkSource) State() Snapshot { return s.machine.Snapshot() } + +// Start opens the Stream WebSocket and blocks until ctx is cancelled or a +// fatal SDK error occurs. Events are delivered to emit synchronously from +// the SDK callback goroutine — emit MUST return immediately (the SDK's +// processLoop is single-goroutine; see plan invariant #1 and P0 §1 row 2). +// +// Blocking semantics: the underlying StreamClient.Start returns as soon as +// dial succeeds (it spawns its own processLoop goroutine), so we wait on +// ctx.Done() before returning. cli.Close() is always called on exit; ctx.Err +// is returned for context cancellation, otherwise the underlying error. +func (s *DingtalkSource) Start(ctx context.Context, emit dwsevent.EmitFn) error { + if emit == nil { + return errors.New("source: emit is required") + } + if s.cli != nil { + return errors.New("source: Start called twice") + } + if s.cfg.PortalTicket != nil { + return s.startPortalTicket(ctx, emit) + } + + options := []client.ClientOption{ + client.WithAppCredential(client.NewAppCredentialConfig(s.cfg.ClientID, s.cfg.ClientSecret)), + } + s.cli = client.NewStreamClient(options...) + s.cli.RegisterAllEventRouter(s.makeHandler(emit)) + + s.machine.OnConnecting() + if err := s.cli.Start(ctx); err != nil { + s.machine.OnStopped() + return err + } + s.machine.OnConnected() + + <-ctx.Done() + s.cli.Close() + s.machine.OnStopped() + return ctx.Err() +} + +// makeHandler returns the IFrameHandler closure the SDK invokes for every +// inbound event. The closure: +// 1. parses the EventHeader from the raw DataFrame, +// 2. builds a RawEvent with all 5 SDK header fields + payload + receive +// time + full header map (passthrough), +// 3. calls emit (non-blocking by contract), +// 4. updates the state machine, +// 5. returns SUCCESS (LATER is reserved for future explicit retry policy; +// v1 always Success and relies on dedup, see P0 §1 row 6). +func (s *DingtalkSource) makeHandler(emit dwsevent.EmitFn) func(context.Context, *payload.DataFrame) (*payload.DataFrameResponse, error) { + return func(_ context.Context, df *payload.DataFrame) (*payload.DataFrameResponse, error) { + hdr := event.NewEventHeaderFromDataFrame(df) + raw := &dwsevent.RawEvent{ + EventID: hdr.EventId, + EventBornTime: hdr.EventBornTime, + EventCorpID: hdr.EventCorpId, + EventType: hdr.EventType, + EventUnifiedAppID: hdr.EventUnifiedAppId, + Data: df.Data, + Headers: copyHeaders(df.Headers), + ReceivedAt: s.cfg.Now().UTC(), + } + emit(raw) + s.machine.OnEvent() + + resp := payload.NewSuccessDataFrameResponse() + if err := resp.SetJson(event.NewEventProcessResultSuccess()); err != nil { + return nil, err + } + return resp, nil + } +} + +func copyHeaders(h payload.DataFrameHeader) map[string]string { + if len(h) == 0 { + return nil + } + out := make(map[string]string, len(h)) + for k, v := range h { + out[k] = v + } + return out +} diff --git a/internal/event/source/dingtalk_test.go b/internal/event/source/dingtalk_test.go new file mode 100644 index 00000000..6d3b83eb --- /dev/null +++ b/internal/event/source/dingtalk_test.go @@ -0,0 +1,235 @@ +// 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 source + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/event" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/payload" +) + +func TestNew_RequiresClientID(t *testing.T) { + if _, err := New(Config{ClientSecret: "secret"}); err == nil { + t.Fatal("expected error when ClientID empty") + } +} + +func TestNew_RequiresClientSecret(t *testing.T) { + if _, err := New(Config{ClientID: "id"}); err == nil { + t.Fatal("expected error when ClientSecret empty") + } +} + +func TestNew_DefaultsNow(t *testing.T) { + s, err := New(Config{ClientID: "id", ClientSecret: "secret"}) + if err != nil { + t.Fatalf("New: %v", err) + } + if s.cfg.Now == nil { + t.Fatal("Now should default to time.Now") + } +} + +func TestNew_AcceptsPortalTicketNormalWithoutClientSecret(t *testing.T) { + if _, err := New(Config{ + PortalTicket: &PortalTicketConfig{ + TicketURL: "https://example.com/stream/connections/ticket", + AccessToken: "token", + SourceID: "pre_open_source", + Mode: "normal", + }, + }); err != nil { + t.Fatalf("New: %v", err) + } +} + +func TestNew_RejectsPortalTicketCustomWithoutSecret(t *testing.T) { + if _, err := New(Config{ + ClientID: "id", + PortalTicket: &PortalTicketConfig{ + TicketURL: "https://example.com/stream/connections/ticket", + AccessToken: "token", + SourceID: "pre_open_source", + Mode: "custom", + ClientID: "custom_client", + }, + }); err == nil { + t.Fatal("expected error when custom portal ticket secret is empty") + } +} + +func TestRequestPortalTicketCustomBody(t *testing.T) { + var got struct { + SourceID string `json:"sourceId"` + ChannelType string `json:"channelType"` + Mode string `json:"mode"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if token := r.Header.Get("x-user-access-token"); token != "token-123" { + t.Fatalf("x-user-access-token = %q", token) + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode request: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]string{ + "endpoint": "wss://pre-wss-open-connection.dingtalk.com/connect", + "ticket": "ticket-123", + }, + }) + })) + defer srv.Close() + + ticket, err := requestPortalTicket(context.Background(), &PortalTicketConfig{ + TicketURL: srv.URL, + AccessToken: "token-123", + SourceID: "pre_open_source", + Mode: "custom", + ClientID: "custom_client", + ClientSecret: "custom_secret", + }) + if err != nil { + t.Fatalf("requestPortalTicket: %v", err) + } + if ticket.Endpoint == "" || ticket.Ticket == "" { + t.Fatalf("ticket = %#v", ticket) + } + if got.SourceID != "pre_open_source" || got.ChannelType != "pre_open_source" { + t.Fatalf("source fields = %#v", got) + } + if got.Mode != "custom" || got.ClientID != "custom_client" || got.ClientSecret != "custom_secret" { + t.Fatalf("custom fields = %#v", got) + } +} + +func TestState_InitiallyDisconnected(t *testing.T) { + s, _ := New(Config{ClientID: "id", ClientSecret: "secret"}) + if got := s.State().State; got != StateDisconnected { + t.Fatalf("initial state = %s, want %s", got, StateDisconnected) + } +} + +func TestMakeHandler_TranslatesDataFrameToRawEvent(t *testing.T) { + fixed := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + s, err := New(Config{ + ClientID: "id", + ClientSecret: "secret", + Now: func() time.Time { return fixed }, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + var got *dwsevent.RawEvent + handler := s.makeHandler(func(e *dwsevent.RawEvent) { got = e }) + + df := &payload.DataFrame{ + SpecVersion: "1.0", + Type: "EVENT", + Time: 1700000000, + Headers: payload.DataFrameHeader{ + "eventId": "ev_abc", + "eventBornTime": "1700000000123", + "eventCorpId": "corp_x", + "eventType": "im.message.receive_v1", + "eventUnifiedAppId": "app_y", + "extra": "passthrough", + }, + Data: `{"chat":"hello"}`, + } + + resp, err := handler(context.Background(), df) + if err != nil { + t.Fatalf("handler: %v", err) + } + if resp == nil { + t.Fatal("handler returned nil resp") + } + + if got == nil { + t.Fatal("emit was not called") + } + if got.EventID != "ev_abc" { + t.Errorf("EventID = %q, want %q", got.EventID, "ev_abc") + } + if got.EventType != "im.message.receive_v1" { + t.Errorf("EventType = %q", got.EventType) + } + if got.EventCorpID != "corp_x" { + t.Errorf("EventCorpID = %q", got.EventCorpID) + } + if got.EventUnifiedAppID != "app_y" { + t.Errorf("EventUnifiedAppID = %q", got.EventUnifiedAppID) + } + if got.EventBornTime != 1700000000123 { + t.Errorf("EventBornTime = %d", got.EventBornTime) + } + if got.Data != `{"chat":"hello"}` { + t.Errorf("Data = %q", got.Data) + } + if !got.ReceivedAt.Equal(fixed) { + t.Errorf("ReceivedAt = %v, want %v (injected Now)", got.ReceivedAt, fixed) + } + if got.Headers["extra"] != "passthrough" { + t.Error("passthrough header lost") + } + + // Also verify the connection state machine ticked: handler should have + // called OnEvent, but since we never called OnConnected the visible + // state remains disconnected. We only verify the lastEventAt timestamp. + snap := s.State() + if snap.LastEventAt.IsZero() { + t.Error("OnEvent should have updated lastEventAt") + } +} + +func TestStart_RejectsNilEmit(t *testing.T) { + s, _ := New(Config{ClientID: "id", ClientSecret: "secret"}) + err := s.Start(context.Background(), nil) + if err == nil || err.Error() == "" { + t.Fatalf("Start with nil emit should error, got %v", err) + } +} + +func TestMakeHandler_NilHeaders(t *testing.T) { + s, _ := New(Config{ClientID: "id", ClientSecret: "secret"}) + handler := s.makeHandler(func(*dwsevent.RawEvent) {}) + + df := &payload.DataFrame{ + // no Headers at all + Data: "{}", + } + resp, err := handler(context.Background(), df) + if err != nil { + t.Fatalf("handler: %v", err) + } + if resp == nil { + t.Fatal("nil resp") + } +} + +// Compile-time guard: the EventProcessResult we return must be the SDK's +// EventProcessResultSuccess type. If the SDK ever changes the shape, this +// will fail to compile. +var _ = event.NewEventProcessResultSuccess diff --git a/internal/event/source/personal.go b/internal/event/source/personal.go new file mode 100644 index 00000000..78575f11 --- /dev/null +++ b/internal/event/source/personal.go @@ -0,0 +1,627 @@ +// 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 source + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "sync/atomic" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" + "github.com/gorilla/websocket" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/event" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/payload" +) + +const ( + personalRawDebugPayloadLimit = 8192 + personalReconnectMinBackoff = time.Second + personalReconnectMaxBackoff = 30 * time.Second +) + +type PersonalConfig struct { + AccessToken string + ClientID string + ClientSecret string + SourceID string + TicketURL string + TicketMode string + HTTPClient *http.Client + WebSocketDialer *websocket.Dialer + Now func() time.Time + ReconnectMin time.Duration + ReconnectMax time.Duration +} + +type PersonalSource struct { + cfg PersonalConfig + machine *Machine + started atomic.Bool +} + +type retryablePersonalError struct { + err error +} + +func (e *retryablePersonalError) Error() string { return e.err.Error() } +func (e *retryablePersonalError) Unwrap() error { return e.err } + +type ticketResponse struct { + Endpoint string `json:"endpoint"` + Ticket string `json:"ticket"` +} + +func NewPersonal(cfg PersonalConfig) (*PersonalSource, error) { + if strings.TrimSpace(cfg.AccessToken) == "" { + return nil, errors.New("personal source: AccessToken is required") + } + if strings.TrimSpace(cfg.ClientID) == "" { + return nil, errors.New("personal source: ClientID is required") + } + if strings.TrimSpace(cfg.SourceID) == "" { + return nil, errors.New("personal source: SourceID is required") + } + if strings.TrimSpace(cfg.TicketURL) == "" { + return nil, errors.New("personal source: TicketURL is required") + } + if cfg.TicketMode == "" { + cfg.TicketMode = "normal" + } + if cfg.TicketMode == "custom" && strings.TrimSpace(cfg.ClientSecret) == "" { + return nil, errors.New("personal source: ClientSecret is required when stream ticket mode is custom") + } + if cfg.HTTPClient == nil { + cfg.HTTPClient = &http.Client{Timeout: 30 * time.Second} + } + if cfg.WebSocketDialer == nil { + cfg.WebSocketDialer = websocket.DefaultDialer + } + if cfg.Now == nil { + cfg.Now = time.Now + } + if cfg.ReconnectMin <= 0 { + cfg.ReconnectMin = personalReconnectMinBackoff + } + if cfg.ReconnectMax <= 0 { + cfg.ReconnectMax = personalReconnectMaxBackoff + } + if cfg.ReconnectMax < cfg.ReconnectMin { + cfg.ReconnectMax = cfg.ReconnectMin + } + m := NewMachine() + m.now = cfg.Now + return &PersonalSource{cfg: cfg, machine: m}, nil +} + +func (s *PersonalSource) State() Snapshot { return s.machine.Snapshot() } + +func (s *PersonalSource) Start(ctx context.Context, emit dwsevent.EmitFn) error { + if emit == nil { + return errors.New("personal source: emit is required") + } + if !s.started.CompareAndSwap(false, true) { + return errors.New("personal source: Start called twice") + } + s.machine.OnConnecting() + defer s.machine.OnStopped() + + backoff := s.cfg.ReconnectMin + for { + acked, err := s.runAttempt(ctx, emit) + if err == nil { + return nil + } + if ctx.Err() != nil { + return ctx.Err() + } + if !isRetryablePersonalError(err) { + return err + } + if acked { + backoff = s.cfg.ReconnectMin + } + s.machine.OnReconnect() + slog.Warn("personal source reconnecting", + "error", personalRetryLogError(err), + "retry_in", backoff, + "reconnect_count", s.machine.Snapshot().ReconnectCount, + ) + if err := waitPersonalReconnect(ctx, backoff); err != nil { + return err + } + backoff = nextPersonalBackoff(backoff, s.cfg.ReconnectMax) + } +} + +func (s *PersonalSource) runAttempt(ctx context.Context, emit dwsevent.EmitFn) (bool, error) { + ticket, err := s.fetchTicket(ctx) + if err != nil { + return false, err + } + wsURL, err := endpointWithTicket(ticket.Endpoint, ticket.Ticket) + if err != nil { + return false, err + } + conn, _, err := s.cfg.WebSocketDialer.DialContext(ctx, wsURL, nil) + if err != nil { + return false, retryPersonal(fmt.Errorf("personal source: dial websocket: %w", err)) + } + attemptCtx, cancel := context.WithCancel(ctx) + defer func() { + cancel() + _ = conn.Close() + }() + closePersonalWebSocketOnContext(attemptCtx, conn) + s.machine.OnConnected() + + acked := false + for { + messageType, data, err := conn.ReadMessage() + if err != nil { + if ctx.Err() != nil { + return acked, ctx.Err() + } + return acked, retryPersonal(fmt.Errorf("personal source: read websocket: %w", err)) + } + if messageType != websocket.TextMessage { + continue + } + if err := s.handleFrame(conn, data, emit); err != nil { + return acked, err + } + acked = true + } +} + +func (s *PersonalSource) fetchTicket(ctx context.Context) (*ticketResponse, error) { + body := map[string]any{ + "sourceId": s.cfg.SourceID, + "mode": s.cfg.TicketMode, + } + if s.cfg.TicketMode == "custom" { + body["clientId"] = s.cfg.ClientID + body["clientSecret"] = s.cfg.ClientSecret + } + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.cfg.TicketURL, bytes.NewReader(b)) + if err != nil { + return nil, fmt.Errorf("personal source: create ticket request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("x-user-access-token", s.cfg.AccessToken) + req.Header.Set("Authorization", "Bearer "+s.cfg.AccessToken) + req.Header.Set("X-DWS-Client-Id", s.cfg.ClientID) + req.Header.Set("X-DWS-Source-Id", s.cfg.SourceID) + + resp, err := s.cfg.HTTPClient.Do(req) + if err != nil { + return nil, retryPersonal(fmt.Errorf("personal source: fetch ticket: %w", err)) + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize)) + if err != nil { + return nil, retryPersonal(fmt.Errorf("personal source: read ticket response: %w", err)) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + err := fmt.Errorf("personal source: ticket HTTP %d", resp.StatusCode) + if retryableTicketStatus(resp.StatusCode) { + return nil, retryPersonal(err) + } + return nil, err + } + ticket, err := decodeTicket(data) + if err != nil { + return nil, err + } + if ticket.Endpoint == "" || ticket.Ticket == "" { + return nil, errors.New("personal source: ticket response missing endpoint or ticket") + } + return ticket, nil +} + +func (s *PersonalSource) handleFrame(conn *websocket.Conn, data []byte, emit dwsevent.EmitFn) error { + df, err := payload.DecodeDataFrame(data) + if err != nil { + return fmt.Errorf("personal source: decode dataframe: %w", err) + } + raw := s.rawEventFromDataFrame(df) + logPersonalDataFrame(raw, df.Data) + emit(raw) + resp := payload.NewSuccessDataFrameResponse() + resp.SetHeader(payload.DataFrameHeaderKMessageId, df.GetMessageId()) + resp.SetHeader(payload.DataFrameHeaderKContentType, payload.DataFrameContentTypeKJson) + if err := resp.SetJson(event.NewEventProcessResultSuccess()); err != nil { + return err + } + if err := conn.WriteJSON(resp); err != nil { + return retryPersonal(fmt.Errorf("personal source: write ack: %w", err)) + } + s.machine.OnEvent() + return nil +} + +func (s *PersonalSource) rawEventFromDataFrame(df *payload.DataFrame) *dwsevent.RawEvent { + hdr := event.NewEventHeaderFromDataFrame(df) + dataFields := parsePersonalDataFields(df.Data) + now := time.Now + if s != nil && s.cfg.Now != nil { + now = s.cfg.Now + } + sourceID := "" + if s != nil { + sourceID = s.cfg.SourceID + } + raw := &dwsevent.RawEvent{ + EventID: firstNonEmpty(hdr.EventId, df.GetMessageId(), headerAny(df.Headers, "MESSAGE_ID", "messageId", "event_id", "eventId"), dataFields.string("eventId", "event_id")), + EventBornTime: firstInt64(hdr.EventBornTime, df.GetTimestamp(), df.Time), + EventCorpID: hdr.EventCorpId, + EventType: firstNonEmpty(hdr.EventType, eventTypeFromHeaders(df.Headers), dataFields.string("eventKey", "event_key"), dataFields.nestedString([]string{"source", "tag"})), + EventUnifiedAppID: hdr.EventUnifiedAppId, + EventScope: firstNonEmpty(headerAny(df.Headers, "event_scope", "eventScope"), "personal"), + SubscribeID: firstNonEmpty(headerAny(df.Headers, "SUB_ID", "subscribe_id", "subscribeId", "sub_id", "subId"), dataFields.string("subId", "sub_id", "subscribeId", "subscribe_id")), + SourceID: firstNonEmpty(headerAny(df.Headers, "SOURCE_ID", "source_id", "sourceId"), sourceID), + RuleType: firstNonEmpty(headerAny(df.Headers, "RULE_TYPE", "rule_type", "ruleType"), dataFields.string("ruleType", "rule_type"), dataFields.nestedString([]string{"ext", "ruleType"}, []string{"ext", "rule_type"})), + Data: df.Data, + Headers: copyHeaders(df.Headers), + ReceivedAt: now().UTC(), + } + return raw +} + +func logPersonalDataFrame(raw *dwsevent.RawEvent, data string) { + if raw == nil { + return + } + slog.Debug("personal source received dataframe", + "event_type", raw.EventType, + "event_key", raw.EventType, + "event_id", raw.EventID, + "subscribe_id", raw.SubscribeID, + "source_id", raw.SourceID, + "rule_type", raw.RuleType, + "headers", redactPersonalRawStringMap(raw.Headers), + "data", sanitizePersonalRawPayload([]byte(data)), + ) +} + +func decodeTicket(data []byte) (*ticketResponse, error) { + var env struct { + Success bool `json:"success"` + Result json.RawMessage `json:"result"` + Data json.RawMessage `json:"data"` + Error any `json:"error"` + } + if err := json.Unmarshal(data, &env); err == nil && (env.Result != nil || env.Data != nil || env.Error != nil) { + raw := env.Result + if len(raw) == 0 || string(raw) == "null" { + raw = env.Data + } + var tr ticketResponse + if err := json.Unmarshal(raw, &tr); err != nil { + return nil, fmt.Errorf("personal source: parse ticket result: %w", err) + } + return &tr, nil + } + var tr ticketResponse + if err := json.Unmarshal(data, &tr); err != nil { + return nil, fmt.Errorf("personal source: parse ticket response: %w", err) + } + return &tr, nil +} + +func endpointWithTicket(endpoint, ticket string) (string, error) { + u, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil { + return "", fmt.Errorf("personal source: parse endpoint: %w", err) + } + if (u.Scheme != "ws" && u.Scheme != "wss") || u.Host == "" { + return "", errors.New("personal source: ticket response contains invalid websocket endpoint") + } + q := u.Query() + if q.Get("ticket") == "" { + q.Set("ticket", ticket) + u.RawQuery = q.Encode() + } + return u.String(), nil +} + +func retryPersonal(err error) error { + if err == nil { + return nil + } + return &retryablePersonalError{err: err} +} + +func isRetryablePersonalError(err error) bool { + var retryable *retryablePersonalError + return errors.As(err, &retryable) +} + +func personalRetryLogError(err error) string { + message := err.Error() + switch { + case strings.Contains(message, "ticket HTTP"): + return message + case strings.Contains(message, "fetch ticket"): + return "personal source: fetch ticket: network error" + case strings.Contains(message, "read ticket response"): + return "personal source: read ticket response: network error" + case strings.Contains(message, "dial websocket"): + return "personal source: dial websocket: network error" + case strings.Contains(message, "read websocket"): + return "personal source: read websocket: connection closed" + case strings.Contains(message, "write ack"): + return "personal source: write ack: connection error" + default: + return "personal source: retryable stream error" + } +} + +func retryableTicketStatus(status int) bool { + return status == http.StatusRequestTimeout || + status == http.StatusTooManyRequests || + status >= http.StatusInternalServerError +} + +func waitPersonalReconnect(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func nextPersonalBackoff(current, maximum time.Duration) time.Duration { + if current >= maximum || current > maximum/2 { + return maximum + } + return current * 2 +} + +func closePersonalWebSocketOnContext(ctx context.Context, conn *websocket.Conn) { + go func() { + <-ctx.Done() + _ = conn.Close() + }() +} + +func headerAny(h payload.DataFrameHeader, names ...string) string { + for _, name := range names { + if v := strings.TrimSpace(headerValue(h, name)); v != "" { + return v + } + } + return "" +} + +func headerValue(h payload.DataFrameHeader, name string) string { + if len(h) == 0 { + return "" + } + if v := h.Get(name); strings.TrimSpace(v) != "" { + return v + } + for k, v := range h { + if strings.EqualFold(k, name) { + return v + } + } + return "" +} + +func eventTypeFromHeaders(h payload.DataFrameHeader) string { + if v := headerAny(h, "EVENT_TYPE", "event_type", "eventType", "EVENT_KEY", "event_key", "eventKey"); v != "" { + return v + } + if v := headerAny(h, "TOPIC", "topic"); v != "" && v != "*" { + return v + } + return "" +} + +type personalDataFields map[string]any + +func parsePersonalDataFields(raw string) personalDataFields { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var value any + if err := json.Unmarshal([]byte(raw), &value); err != nil { + return nil + } + if s, ok := value.(string); ok { + var nested any + if err := json.Unmarshal([]byte(strings.TrimSpace(s)), &nested); err == nil { + value = nested + } + } + m, ok := value.(map[string]any) + if !ok { + return nil + } + return personalDataFields(m) +} + +func (f personalDataFields) string(names ...string) string { + if len(f) == 0 { + return "" + } + for _, name := range names { + if v, ok := f[name].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + for k, raw := range f { + if strings.EqualFold(k, name) { + if v, ok := raw.(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + } + } + return "" +} + +func (f personalDataFields) nestedString(paths ...[]string) string { + if len(f) == 0 { + return "" + } + for _, path := range paths { + if v := nestedStringCI(map[string]any(f), path...); v != "" { + return v + } + } + return "" +} + +func nestedStringCI(m map[string]any, path ...string) string { + var cur any = m + for _, segment := range path { + obj, ok := cur.(map[string]any) + if !ok { + return "" + } + next, ok := obj[segment] + if !ok { + for k, v := range obj { + if strings.EqualFold(k, segment) { + next = v + ok = true + break + } + } + } + if !ok { + return "" + } + cur = next + } + if v, ok := cur.(string); ok { + return strings.TrimSpace(v) + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func firstInt64(values ...int64) int64 { + for _, v := range values { + if v != 0 { + return v + } + } + return 0 +} + +func redactPersonalRawStringMap(in map[string]string) map[string]string { + if len(in) == 0 { + return nil + } + out := make(map[string]string, len(in)) + for k, v := range in { + if personalRawSensitiveKey(k) { + out[k] = "" + continue + } + out[k] = v + } + return out +} + +func sanitizePersonalRawPayload(data []byte) string { + data = bytes.TrimSpace(data) + if len(data) == 0 { + return "" + } + var parsed any + if err := json.Unmarshal(data, &parsed); err == nil { + redacted := redactPersonalRawJSONValue(parsed) + if s, err := marshalPersonalRawJSON(redacted); err == nil { + return truncatePersonalRawLog(s) + } + } + return truncatePersonalRawLog(string(data)) +} + +func redactPersonalRawJSONValue(v any) any { + switch x := v.(type) { + case map[string]any: + out := make(map[string]any, len(x)) + for k, value := range x { + if personalRawSensitiveKey(k) { + out[k] = "" + continue + } + out[k] = redactPersonalRawJSONValue(value) + } + return out + case []any: + out := make([]any, len(x)) + for i, value := range x { + out[i] = redactPersonalRawJSONValue(value) + } + return out + default: + return v + } +} + +func marshalPersonalRawJSON(v any) (string, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + return "", err + } + return strings.TrimSpace(buf.String()), nil +} + +func personalRawSensitiveKey(key string) bool { + key = strings.ToLower(strings.TrimSpace(key)) + return strings.Contains(key, "token") || + strings.Contains(key, "secret") || + strings.Contains(key, "ticket") || + strings.Contains(key, "authorization") +} + +func truncatePersonalRawLog(s string) string { + if len(s) <= personalRawDebugPayloadLimit { + return s + } + return s[:personalRawDebugPayloadLimit] + "..." +} diff --git a/internal/event/source/personal_test.go b/internal/event/source/personal_test.go new file mode 100644 index 00000000..a5a083d7 --- /dev/null +++ b/internal/event/source/personal_test.go @@ -0,0 +1,649 @@ +// 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 source + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport" + "github.com/gorilla/websocket" + streamevent "github.com/open-dingtalk/dingtalk-stream-sdk-go/event" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/payload" +) + +func TestPersonalSourceFetchTicketConnectsAndACKs(t *testing.T) { + logs := capturePersonalSourceDebugLogs(t) + var wsEndpoint string + ackCh := make(chan payload.DataFrameResponse, 1) + upgrader := websocket.Upgrader{} + mux := http.NewServeMux() + mux.HandleFunc("/ticket", func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("x-user-access-token"); got != "token-1" { + t.Fatalf("x-user-access-token = %q", got) + } + var req map[string]any + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode ticket request: %v", err) + } + if req["sourceId"] != "open" || req["mode"] != "normal" { + t.Fatalf("ticket request = %#v", req) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{ + "endpoint": wsEndpoint, + "ticket": "ticket-1", + }, + }) + }) + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("ticket"); got != "ticket-1" { + t.Fatalf("ticket query = %q", got) + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("upgrade: %v", err) + } + defer conn.Close() + df := payload.DataFrame{ + Type: "event", + Headers: payload.DataFrameHeader{ + payload.DataFrameHeaderKMessageId: "msg-1", + streamevent.DataFrameHeaderKEventId: "evt-1", + streamevent.DataFrameHeaderKEventBornTime: "1234", + streamevent.DataFrameHeaderKEventCorpId: "corp-1", + streamevent.DataFrameHeaderKEventType: "user_im_message_receive_at", + "subscribeId": "sub-1", + "ruleType": "at", + "sourceId": "open", + "accessToken": "header-secret-token", + }, + Data: `{"message":{"text":"hi"},"access_token":"data-secret-token","client_secret":"data-secret","ticket":"data-ticket","Authorization":"Bearer data-auth"}`, + } + if err := conn.WriteJSON(df); err != nil { + t.Fatalf("write dataframe: %v", err) + } + var ack payload.DataFrameResponse + if err := conn.ReadJSON(&ack); err != nil { + t.Fatalf("read ack: %v", err) + } + ackCh <- ack + }) + srv := httptest.NewServer(mux) + defer srv.Close() + wsEndpoint = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws" + + src, err := NewPersonal(PersonalConfig{ + AccessToken: "token-1", + ClientID: "client-1", + SourceID: "open", + TicketURL: srv.URL + "/ticket", + TicketMode: "normal", + HTTPClient: srv.Client(), + Now: func() time.Time { return time.Unix(10, 0) }, + }) + if err != nil { + t.Fatalf("NewPersonal() error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + evCh := make(chan *dwsevent.RawEvent, 1) + done := make(chan error, 1) + go func() { + done <- src.Start(ctx, func(ev *dwsevent.RawEvent) { evCh <- ev }) + }() + + select { + case ev := <-evCh: + if ev.SubscribeID != "sub-1" || ev.RuleType != "at" || ev.SourceID != "open" { + t.Fatalf("event personal fields = %#v", ev) + } + if ev.EventType != "user_im_message_receive_at" || ev.EventID != "evt-1" { + t.Fatalf("event = %#v", ev) + } + out := logs.String() + for _, want := range []string{"personal source received dataframe", "user_im_message_receive_at", "evt-1", "sub-1", "sourceId", "message", ""} { + if !strings.Contains(out, want) { + t.Fatalf("debug log missing %q: %s", want, out) + } + } + for _, leaked := range []string{"header-secret-token", "data-secret-token", "data-secret", "data-ticket", "Bearer data-auth"} { + if strings.Contains(out, leaked) { + t.Fatalf("debug log leaked %q: %s", leaked, out) + } + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for event") + } + select { + case ack := <-ackCh: + if ack.Code != payload.DataFrameResponseStatusCodeKOK { + t.Fatalf("ack code = %d", ack.Code) + } + if ack.GetHeader(payload.DataFrameHeaderKMessageId) != "msg-1" { + t.Fatalf("ack message id = %q", ack.GetHeader(payload.DataFrameHeaderKMessageId)) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for ack") + } + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("source did not stop after cancel") + } +} + +func TestPersonalSourceReconnectsWithFreshTicket(t *testing.T) { + var wsEndpoint string + var ticketCalls atomic.Int32 + ackCh := make(chan int, 2) + holdSecond := make(chan struct{}) + var releaseSecondOnce sync.Once + releaseSecond := func() { releaseSecondOnce.Do(func() { close(holdSecond) }) } + upgrader := websocket.Upgrader{} + mux := http.NewServeMux() + mux.HandleFunc("/ticket", func(w http.ResponseWriter, _ *http.Request) { + attempt := int(ticketCalls.Add(1)) + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{ + "endpoint": wsEndpoint, + "ticket": fmt.Sprintf("ticket-%d", attempt), + }, + }) + }) + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + attempt, err := strconv.Atoi(strings.TrimPrefix(r.URL.Query().Get("ticket"), "ticket-")) + if err != nil { + http.Error(w, "bad ticket", http.StatusBadRequest) + return + } + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + if err := conn.WriteJSON(personalTestDataFrame(attempt)); err != nil { + return + } + var ack payload.DataFrameResponse + if err := conn.ReadJSON(&ack); err != nil { + return + } + ackCh <- attempt + if attempt == 2 { + <-holdSecond + } + }) + srv := httptest.NewServer(mux) + defer srv.Close() + defer releaseSecond() + wsEndpoint = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws" + + src, err := NewPersonal(PersonalConfig{ + AccessToken: "token", + ClientID: "client", + SourceID: "open", + TicketURL: srv.URL + "/ticket", + HTTPClient: srv.Client(), + ReconnectMin: 5 * time.Millisecond, + ReconnectMax: 10 * time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + events := make(chan *dwsevent.RawEvent, 2) + done := make(chan error, 1) + go func() { done <- src.Start(ctx, func(ev *dwsevent.RawEvent) { events <- ev }) }() + + for i := 1; i <= 2; i++ { + select { + case ev := <-events: + if ev.EventID != fmt.Sprintf("evt-%d", i) { + t.Fatalf("event %d ID = %q", i, ev.EventID) + } + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for event %d", i) + } + select { + case attempt := <-ackCh: + if attempt != i { + t.Fatalf("ack attempt = %d, want %d", attempt, i) + } + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for ACK %d", i) + } + } + if got := ticketCalls.Load(); got != 2 { + t.Fatalf("ticket calls = %d, want 2", got) + } + if got := src.State().ReconnectCount; got != 1 { + t.Fatalf("reconnect count = %d, want 1", got) + } + cancel() + releaseSecond() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("Start() error = %v, want context canceled", err) + } + case <-time.After(2 * time.Second): + t.Fatal("source did not stop after cancel") + } +} + +func TestPersonalSourceRetriesTicket500And429(t *testing.T) { + var wsEndpoint string + var ticketCalls atomic.Int32 + acked := make(chan struct{}, 1) + hold := make(chan struct{}) + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(hold) }) } + upgrader := websocket.Upgrader{} + mux := http.NewServeMux() + mux.HandleFunc("/ticket", func(w http.ResponseWriter, _ *http.Request) { + switch ticketCalls.Add(1) { + case 1: + w.WriteHeader(http.StatusInternalServerError) + return + case 2: + w.WriteHeader(http.StatusTooManyRequests) + return + default: + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "result": map[string]any{"endpoint": wsEndpoint, "ticket": "ticket-ok"}, + }) + } + }) + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer conn.Close() + if err := conn.WriteJSON(personalTestDataFrame(1)); err != nil { + return + } + var ack payload.DataFrameResponse + if err := conn.ReadJSON(&ack); err != nil { + return + } + acked <- struct{}{} + <-hold + }) + srv := httptest.NewServer(mux) + defer srv.Close() + defer release() + wsEndpoint = "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws" + + src, err := NewPersonal(PersonalConfig{ + AccessToken: "token", + ClientID: "client", + SourceID: "open", + TicketURL: srv.URL + "/ticket", + HTTPClient: srv.Client(), + ReconnectMin: time.Millisecond, + ReconnectMax: 2 * time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- src.Start(ctx, func(*dwsevent.RawEvent) {}) }() + select { + case <-acked: + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for recovered connection") + } + if got := ticketCalls.Load(); got != 3 { + t.Fatalf("ticket calls = %d, want 3", got) + } + if got := src.State().ReconnectCount; got != 2 { + t.Fatalf("reconnect count = %d, want 2", got) + } + cancel() + release() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("source did not stop after cancel") + } +} + +func TestPersonalSourceTicketFatalResponsesDoNotRetry(t *testing.T) { + tests := []struct { + name string + status int + body string + }{ + {name: "unauthorized", status: http.StatusUnauthorized}, + {name: "forbidden", status: http.StatusForbidden}, + {name: "malformed success", status: http.StatusOK, body: `{"success":true,"result":{"endpoint":"","ticket":""}}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var calls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls.Add(1) + w.WriteHeader(tt.status) + _, _ = w.Write([]byte(tt.body)) + })) + defer srv.Close() + src, err := NewPersonal(PersonalConfig{ + AccessToken: "token", + ClientID: "client", + SourceID: "open", + TicketURL: srv.URL, + HTTPClient: srv.Client(), + ReconnectMin: time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + if err := src.Start(t.Context(), func(*dwsevent.RawEvent) {}); err == nil { + t.Fatal("Start() error = nil, want fatal ticket error") + } + if got := calls.Load(); got != 1 { + t.Fatalf("ticket calls = %d, want 1", got) + } + }) + } +} + +func TestPersonalSourceRetriesTicketNetworkError(t *testing.T) { + var calls atomic.Int32 + secondCall := make(chan struct{}, 1) + httpClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + if calls.Add(1) == 2 { + secondCall <- struct{}{} + } + return nil, errors.New("network unavailable") + })} + src, err := NewPersonal(PersonalConfig{ + AccessToken: "token", + ClientID: "client", + SourceID: "open", + TicketURL: "https://ticket.invalid", + HTTPClient: httpClient, + ReconnectMin: time.Millisecond, + ReconnectMax: time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- src.Start(ctx, func(*dwsevent.RawEvent) {}) }() + select { + case <-secondCall: + case <-time.After(time.Second): + t.Fatal("ticket network error was not retried") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("Start() error = %v, want context canceled", err) + } + case <-time.After(time.Second): + t.Fatal("source did not stop after cancel") + } +} + +func TestPersonalSourceCancelDuringReconnectBackoff(t *testing.T) { + requestDone := make(chan struct{}, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + requestDone <- struct{}{} + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + src, err := NewPersonal(PersonalConfig{ + AccessToken: "token", + ClientID: "client", + SourceID: "open", + TicketURL: srv.URL, + HTTPClient: srv.Client(), + ReconnectMin: 5 * time.Second, + ReconnectMax: 5 * time.Second, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- src.Start(ctx, func(*dwsevent.RawEvent) {}) }() + select { + case <-requestDone: + case <-time.After(time.Second): + t.Fatal("ticket request did not arrive") + } + deadline := time.Now().Add(time.Second) + for src.State().ReconnectCount == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if src.State().ReconnectCount == 0 { + t.Fatal("source did not enter reconnect backoff") + } + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("Start() error = %v, want context canceled", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("source did not exit promptly during reconnect backoff") + } +} + +func TestNextPersonalBackoffCapsAtMaximum(t *testing.T) { + if got := nextPersonalBackoff(time.Second, 30*time.Second); got != 2*time.Second { + t.Fatalf("next backoff = %s, want 2s", got) + } + if got := nextPersonalBackoff(20*time.Second, 30*time.Second); got != 30*time.Second { + t.Fatalf("capped backoff = %s, want 30s", got) + } +} + +func TestPersonalRetryLogErrorDoesNotExposeWebSocketURL(t *testing.T) { + err := retryPersonal(errors.New("personal source: dial websocket: wss://example.test/ws?ticket=secret-ticket")) + got := personalRetryLogError(err) + if strings.Contains(got, "secret-ticket") || strings.Contains(got, "example.test") { + t.Fatalf("retry log exposed websocket details: %q", got) + } +} + +func TestPersonalSourceParsesUppercaseHeaders(t *testing.T) { + src := personalSourceForRawEventTests() + data := `{"payload":true}` + raw := src.rawEventFromDataFrame(&payload.DataFrame{ + Time: 12345, + Headers: payload.DataFrameHeader{ + "EVENT_TYPE": "user_im_message_receive_o2o", + "SUB_ID": "sub-1", + "SOURCE_ID": "pre_open_source", + "MESSAGE_ID": "evt-1", + }, + Data: data, + }) + + if raw.EventType != "user_im_message_receive_o2o" { + t.Fatalf("EventType = %q", raw.EventType) + } + if raw.SubscribeID != "sub-1" { + t.Fatalf("SubscribeID = %q", raw.SubscribeID) + } + if raw.SourceID != "pre_open_source" { + t.Fatalf("SourceID = %q", raw.SourceID) + } + if raw.EventID != "evt-1" { + t.Fatalf("EventID = %q", raw.EventID) + } + if raw.EventScope != "personal" { + t.Fatalf("EventScope = %q", raw.EventScope) + } + if raw.Data != data { + t.Fatalf("Data changed: %q", raw.Data) + } +} + +func TestPersonalSourceParsesTopicFallbackAndIgnoresWildcard(t *testing.T) { + src := personalSourceForRawEventTests() + raw := src.rawEventFromDataFrame(&payload.DataFrame{ + Headers: payload.DataFrameHeader{ + "TOPIC": "user_im_message_receive_o2o", + "topic": "*", + }, + }) + if raw.EventType != "user_im_message_receive_o2o" { + t.Fatalf("EventType from TOPIC = %q", raw.EventType) + } + + raw = src.rawEventFromDataFrame(&payload.DataFrame{ + Headers: payload.DataFrameHeader{"topic": "*"}, + }) + if raw.EventType != "" { + t.Fatalf("EventType from wildcard topic = %q, want empty", raw.EventType) + } +} + +func TestPersonalSourceParsesDataFallbackWithoutChangingData(t *testing.T) { + src := personalSourceForRawEventTests() + payloadJSON := `{"eventKey":"user_im_message_receive_o2o","subId":"sub-data","eventId":"evt-data","ext":{"ruleType":"singleChat"}}` + encodedPayload, err := json.Marshal(payloadJSON) + if err != nil { + t.Fatal(err) + } + data := string(encodedPayload) + + raw := src.rawEventFromDataFrame(&payload.DataFrame{Data: data}) + if raw.EventType != "user_im_message_receive_o2o" { + t.Fatalf("EventType = %q", raw.EventType) + } + if raw.SubscribeID != "sub-data" { + t.Fatalf("SubscribeID = %q", raw.SubscribeID) + } + if raw.EventID != "evt-data" { + t.Fatalf("EventID = %q", raw.EventID) + } + if raw.RuleType != "singleChat" { + t.Fatalf("RuleType = %q", raw.RuleType) + } + if raw.Data != data { + t.Fatalf("Data changed: %q", raw.Data) + } +} + +func TestPersonalSourceParsesSourceTagDataFallback(t *testing.T) { + src := personalSourceForRawEventTests() + data := `{"source":{"tag":"user_im_message_receive_group"},"subId":"sub-group"}` + + raw := src.rawEventFromDataFrame(&payload.DataFrame{Data: data}) + if raw.EventType != "user_im_message_receive_group" { + t.Fatalf("EventType = %q", raw.EventType) + } + if raw.SubscribeID != "sub-group" { + t.Fatalf("SubscribeID = %q", raw.SubscribeID) + } +} + +func TestPersonalSourceParsedHeadersPassNormalBusFilter(t *testing.T) { + src := personalSourceForRawEventTests() + raw := src.rawEventFromDataFrame(&payload.DataFrame{ + Headers: payload.DataFrameHeader{ + "EVENT_TYPE": "user_im_message_receive_o2o", + "SUB_ID": "sub-1", + }, + }) + h := bus.NewHub(10) + c, err := h.Register(transport.Hello{ + EventTypes: []string{"user_im_message_receive_o2o"}, + SubscribeID: "sub-1", + }) + if err != nil { + t.Fatal(err) + } + + h.Deliver(raw) + + select { + case frame := <-c.SendCh: + ev, ok := frame.(transport.Event) + if !ok { + t.Fatalf("frame = %T, want transport.Event", frame) + } + if ev.EventType != "user_im_message_receive_o2o" || ev.SubscribeID != "sub-1" { + t.Fatalf("event = %#v", ev) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for filtered event") + } +} + +func personalSourceForRawEventTests() *PersonalSource { + return &PersonalSource{cfg: PersonalConfig{ + SourceID: "fallback_source", + Now: func() time.Time { return time.Unix(20, 0) }, + }} +} + +func personalTestDataFrame(attempt int) payload.DataFrame { + return payload.DataFrame{ + Type: "event", + Headers: payload.DataFrameHeader{ + payload.DataFrameHeaderKMessageId: fmt.Sprintf("msg-%d", attempt), + streamevent.DataFrameHeaderKEventId: fmt.Sprintf("evt-%d", attempt), + streamevent.DataFrameHeaderKEventType: "user_im_message_receive_o2o", + "SUB_ID": fmt.Sprintf("sub-%d", attempt), + }, + Data: fmt.Sprintf(`{"eventKey":"user_im_message_receive_o2o","eventId":"evt-%d"}`, attempt), + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func capturePersonalSourceDebugLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { + slog.SetDefault(previous) + }) + return &buf +} diff --git a/internal/event/source/portal_ticket.go b/internal/event/source/portal_ticket.go new file mode 100644 index 00000000..98d20d86 --- /dev/null +++ b/internal/event/source/portal_ticket.go @@ -0,0 +1,275 @@ +// 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 source + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/gorilla/websocket" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/payload" +) + +const ( + PortalTicketModeNormal = "normal" + PortalTicketModeCustom = "custom" +) + +// PortalTicketConfig describes the portal-managed user Stream ticket flow. +// normal mode uses portal-side managed credentials; custom mode asks portal to +// open the user connection with the caller-provided clientId/clientSecret. +type PortalTicketConfig struct { + TicketURL string + AccessToken string + SourceID string + Mode string + ClientID string + ClientSecret string + UserAgent string + HTTPClient *http.Client +} + +func (c *PortalTicketConfig) Valid() error { + if c == nil { + return errors.New("source: PortalTicketConfig is nil") + } + if strings.TrimSpace(c.TicketURL) == "" { + return errors.New("source: portal ticket URL is required") + } + if strings.TrimSpace(c.AccessToken) == "" { + return errors.New("source: portal access token is required") + } + if strings.TrimSpace(c.SourceID) == "" { + return errors.New("source: portal sourceId is required") + } + mode := normalizePortalTicketMode(c.Mode) + if mode == "" { + return fmt.Errorf("source: unsupported portal ticket mode %q", c.Mode) + } + if mode == PortalTicketModeCustom && + (strings.TrimSpace(c.ClientID) == "" || strings.TrimSpace(c.ClientSecret) == "") { + return errors.New("source: custom portal ticket mode requires clientId/clientSecret") + } + return nil +} + +func (c *PortalTicketConfig) normalizedMode() string { + return normalizePortalTicketMode(c.Mode) +} + +func normalizePortalTicketMode(mode string) string { + mode = strings.ToLower(strings.TrimSpace(mode)) + if mode == "" { + return PortalTicketModeNormal + } + switch mode { + case PortalTicketModeNormal, PortalTicketModeCustom: + return mode + default: + return "" + } +} + +func (s *DingtalkSource) startPortalTicket(ctx context.Context, emit dwsevent.EmitFn) error { + s.machine.OnConnecting() + + ticket, err := requestPortalTicket(ctx, s.cfg.PortalTicket) + if err != nil { + s.machine.OnStopped() + return err + } + wsURL, err := websocketURL(ticket) + if err != nil { + s.machine.OnStopped() + return err + } + + userAgent := strings.TrimSpace(s.cfg.PortalTicket.UserAgent) + if userAgent == "" { + userAgent = "dws-event-consume" + } + conn, resp, err := (&websocket.Dialer{HandshakeTimeout: 20 * time.Second}).DialContext(ctx, wsURL, http.Header{ + "User-Agent": []string{userAgent}, + }) + if err != nil { + s.machine.OnStopped() + if resp != nil { + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return fmt.Errorf("source: portal stream connect HTTP %d: %s: %w", + resp.StatusCode, truncatePortalTicketLog(string(raw), 300), err) + } + return fmt.Errorf("source: portal stream connect: %w", err) + } + defer conn.Close() + s.machine.OnConnected() + + closeOnContext(ctx, conn) + handler := s.makeHandler(emit) + for { + messageType, message, err := conn.ReadMessage() + if err != nil { + s.machine.OnStopped() + if isContextDone(ctx) { + return ctx.Err() + } + return fmt.Errorf("source: portal stream read: %w", err) + } + if messageType != websocket.TextMessage && messageType != websocket.BinaryMessage { + continue + } + df, err := payload.DecodeDataFrame(message) + if err != nil { + continue + } + resp, err := handler(ctx, df) + if err != nil { + s.machine.OnStopped() + return err + } + if resp == nil { + continue + } + ensurePortalAckHeaders(resp, df) + if err := conn.WriteMessage(websocket.TextMessage, resp.Encode()); err != nil { + s.machine.OnStopped() + if isContextDone(ctx) { + return ctx.Err() + } + return fmt.Errorf("source: portal stream ack: %w", err) + } + } +} + +type portalStreamTicket struct { + Endpoint string `json:"endpoint"` + Ticket string `json:"ticket"` +} + +func requestPortalTicket(ctx context.Context, cfg *PortalTicketConfig) (portalStreamTicket, error) { + httpClient := cfg.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: 20 * time.Second} + } + body := map[string]string{ + "sourceId": strings.TrimSpace(cfg.SourceID), + "channelType": strings.TrimSpace(cfg.SourceID), + "mode": cfg.normalizedMode(), + } + if cfg.normalizedMode() == PortalTicketModeCustom { + body["clientId"] = strings.TrimSpace(cfg.ClientID) + body["clientSecret"] = strings.TrimSpace(cfg.ClientSecret) + } + rawBody, err := json.Marshal(body) + if err != nil { + return portalStreamTicket{}, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(cfg.TicketURL), bytes.NewReader(rawBody)) + if err != nil { + return portalStreamTicket{}, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if ua := strings.TrimSpace(cfg.UserAgent); ua != "" { + req.Header.Set("User-Agent", ua) + } + req.Header.Set("x-user-access-token", cfg.AccessToken) + + resp, err := httpClient.Do(req) + if err != nil { + return portalStreamTicket{}, fmt.Errorf("source: portal ticket request: %w", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode >= 400 { + return portalStreamTicket{}, fmt.Errorf("source: portal ticket HTTP %d: %s", + resp.StatusCode, truncatePortalTicketLog(string(raw), 300)) + } + + var direct portalStreamTicket + if err := json.Unmarshal(raw, &direct); err == nil && direct.Endpoint != "" && direct.Ticket != "" { + return direct, nil + } + + var envelope struct { + Success bool `json:"success"` + Result portalStreamTicket `json:"result"` + ErrorCode string `json:"errorCode"` + ErrorMsg string `json:"errorMsg"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + return portalStreamTicket{}, fmt.Errorf("source: portal ticket parse: %w", err) + } + if !envelope.Success { + return portalStreamTicket{}, fmt.Errorf("source: portal ticket failed: %s %s", + envelope.ErrorCode, envelope.ErrorMsg) + } + if envelope.Result.Endpoint == "" || envelope.Result.Ticket == "" { + return portalStreamTicket{}, errors.New("source: portal ticket result missing endpoint/ticket") + } + return envelope.Result, nil +} + +func websocketURL(ticket portalStreamTicket) (string, error) { + u, err := url.Parse(ticket.Endpoint) + if err != nil { + return "", err + } + q := u.Query() + q.Set("ticket", ticket.Ticket) + u.RawQuery = q.Encode() + return u.String(), nil +} + +func ensurePortalAckHeaders(resp *payload.DataFrameResponse, df *payload.DataFrame) { + if resp.GetHeader(payload.DataFrameHeaderKMessageId) == "" { + resp.SetHeader(payload.DataFrameHeaderKMessageId, df.GetMessageId()) + } + if resp.GetHeader(payload.DataFrameHeaderKContentType) == "" { + resp.SetHeader(payload.DataFrameHeaderKContentType, payload.DataFrameContentTypeKJson) + } +} + +func closeOnContext(ctx context.Context, conn *websocket.Conn) { + go func() { + <-ctx.Done() + _ = conn.Close() + }() +} + +func isContextDone(ctx context.Context) bool { + select { + case <-ctx.Done(): + return true + default: + return false + } +} + +func truncatePortalTicketLog(s string, max int) string { + s = strings.TrimSpace(s) + if len(s) <= max { + return s + } + return s[:max] + "..." +} diff --git a/internal/event/source/state.go b/internal/event/source/state.go new file mode 100644 index 00000000..3879c733 --- /dev/null +++ b/internal/event/source/state.go @@ -0,0 +1,181 @@ +// 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 source + +import ( + "sync" + "time" +) + +// State enumerates the connection states surfaced by `dws event status`. +// +// Stream SDK v0.9.1 does not expose a user-registerable ConnectionState +// hook (verified P0 §1 escape-hatch table row #4), so the state machine +// runs in "inferred" mode: it derives state from observable signals — +// Start() return value, last-event-received timestamp, last-error timestamp, +// SDK reconnect log lines parsed by the source layer. The Source field +// `state_source` (env-style annotation) is emitted alongside the state so +// downstream tools can distinguish authoritative from inferred values when +// SDK hook support arrives in a future release. +type State string + +const ( + // StateDisconnected is the zero state before Start has been called. + StateDisconnected State = "disconnected" + // StateConnecting is set on entry to Start, before SDK dial completes. + StateConnecting State = "connecting" + // StateConnected is set when Start returns nil (dial succeeded). + StateConnected State = "connected" + // StateIdle is set when the connection is still up but no event has been + // observed for a quiet window (default 60s). Distinguishes "everything + // fine, just no traffic" from "looks connected but broken". + StateIdle State = "idle" + // StateReconnecting is set when the SDK has signalled a reconnect (via + // log line parse or future hook). Cleared on next event received. + StateReconnecting State = "reconnecting" + // StateDegraded is set when reconnects keep happening but no events flow. + // Useful to surface "looks alive, actually broken" cases via status. + StateDegraded State = "degraded" + // StateStopped is set after Close() / context cancellation. + StateStopped State = "stopped" +) + +// SourceKind identifies whether the state was reported by an authoritative +// SDK hook or inferred from observable side-channels. +type SourceKind string + +const ( + // SourceHook is reserved for future SDK versions that expose + // ConnectionState / keepalive callbacks. v1 does not emit this. + SourceHook SourceKind = "hook" + // SourceInferred is the only value emitted by v1. + SourceInferred SourceKind = "inferred" +) + +// IdleAfter is the duration of no events after which StateConnected +// transitions to StateIdle. Exposed for tests to bypass real-time waits. +var IdleAfter = 60 * time.Second + +// Machine tracks the connection state. Methods are safe for concurrent use. +type Machine struct { + mu sync.RWMutex + state State + source SourceKind + lastEventAt time.Time + lastReconnectAt time.Time + reconnectCount int + now func() time.Time +} + +// NewMachine returns a Machine in the StateDisconnected initial state. +func NewMachine() *Machine { + return &Machine{ + state: StateDisconnected, + source: SourceInferred, + now: time.Now, + } +} + +// State returns the current effective state, applying time-based idle +// detection on top of the stored state. This is the public read accessor +// used by `event status` formatting. +func (m *Machine) State() State { + m.mu.RLock() + defer m.mu.RUnlock() + return m.effectiveStateLocked() +} + +// Snapshot returns the full state view for status rendering: state + +// source + last event/reconnect timestamps + reconnect count. +type Snapshot struct { + State State `json:"state"` + StateSource SourceKind `json:"state_source"` + LastEventAt time.Time `json:"last_event_at,omitempty"` + LastReconnectAt time.Time `json:"last_reconnect_at,omitempty"` + ReconnectCount int `json:"reconnect_count"` +} + +// Snapshot returns the current state view atomically. +func (m *Machine) Snapshot() Snapshot { + m.mu.RLock() + defer m.mu.RUnlock() + return Snapshot{ + State: m.effectiveStateLocked(), + StateSource: m.source, + LastEventAt: m.lastEventAt, + LastReconnectAt: m.lastReconnectAt, + ReconnectCount: m.reconnectCount, + } +} + +// OnConnecting moves to StateConnecting. Called by Source.Start before the +// SDK dial completes. +func (m *Machine) OnConnecting() { + m.mu.Lock() + defer m.mu.Unlock() + m.state = StateConnecting +} + +// OnConnected moves to StateConnected. Called when SDK Start returns nil. +func (m *Machine) OnConnected() { + m.mu.Lock() + defer m.mu.Unlock() + m.state = StateConnected +} + +// OnEvent records the timestamp of the most recently received event. Resets +// any Reconnecting or Idle state back to Connected (the connection is +// demonstrably alive). +func (m *Machine) OnEvent() { + m.mu.Lock() + defer m.mu.Unlock() + m.lastEventAt = m.now() + if m.state == StateReconnecting || m.state == StateIdle || m.state == StateDegraded { + m.state = StateConnected + } +} + +// OnReconnect marks a reconnect signal observed (from SDK log or future +// hook). Increments the counter and stamps the time. +func (m *Machine) OnReconnect() { + m.mu.Lock() + defer m.mu.Unlock() + m.lastReconnectAt = m.now() + m.reconnectCount++ + m.state = StateReconnecting +} + +// OnStopped marks the SDK as closed (graceful exit or Close()). +func (m *Machine) OnStopped() { + m.mu.Lock() + defer m.mu.Unlock() + m.state = StateStopped +} + +// effectiveStateLocked applies idle-detection on top of the stored state. +// Caller must hold m.mu (read or write). +func (m *Machine) effectiveStateLocked() State { + switch m.state { + case StateConnected: + // Connected → Idle after IdleAfter with no events. If we've also + // seen recent reconnects with no recovered events, surface Degraded. + if !m.lastEventAt.IsZero() && m.now().Sub(m.lastEventAt) >= IdleAfter { + if m.reconnectCount > 0 && m.lastReconnectAt.After(m.lastEventAt) { + return StateDegraded + } + return StateIdle + } + } + return m.state +} diff --git a/internal/event/source/state_test.go b/internal/event/source/state_test.go new file mode 100644 index 00000000..46b2f5a9 --- /dev/null +++ b/internal/event/source/state_test.go @@ -0,0 +1,131 @@ +// 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 source + +import ( + "testing" + "time" +) + +func newMachineAt(now time.Time) *Machine { + m := NewMachine() + m.now = func() time.Time { return now } + return m +} + +func TestMachine_InitialState(t *testing.T) { + m := NewMachine() + if m.State() != StateDisconnected { + t.Fatalf("initial state = %s, want %s", m.State(), StateDisconnected) + } + snap := m.Snapshot() + if snap.StateSource != SourceInferred { + t.Fatalf("v1 must emit StateSource=inferred, got %s", snap.StateSource) + } +} + +func TestMachine_TransitionsHappyPath(t *testing.T) { + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + m := newMachineAt(now) + + m.OnConnecting() + if m.State() != StateConnecting { + t.Fatalf("after OnConnecting: %s", m.State()) + } + + m.OnConnected() + if m.State() != StateConnected { + t.Fatalf("after OnConnected: %s", m.State()) + } + + m.OnEvent() + if m.State() != StateConnected { + t.Fatalf("after OnEvent: %s (should remain connected)", m.State()) + } + + m.OnStopped() + if m.State() != StateStopped { + t.Fatalf("after OnStopped: %s", m.State()) + } +} + +func TestMachine_IdleAfterQuietWindow(t *testing.T) { + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + m := newMachineAt(now) + m.OnConnected() + m.OnEvent() // lastEventAt = now + + // Advance time past IdleAfter + m.now = func() time.Time { return now.Add(IdleAfter + 5*time.Second) } + if got := m.State(); got != StateIdle { + t.Fatalf("state after quiet window: %s, want %s", got, StateIdle) + } + + // New event resets to connected + m.OnEvent() + if got := m.State(); got != StateConnected { + t.Fatalf("state after new event: %s, want %s", got, StateConnected) + } +} + +func TestMachine_DegradedWhenReconnectsButNoEvents(t *testing.T) { + now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC) + m := newMachineAt(now) + m.OnConnected() + m.OnEvent() // baseline + + // Advance + reconnect signal (no event recovery) + later := now.Add(IdleAfter + 30*time.Second) + m.now = func() time.Time { return later } + m.OnReconnect() // sets state=reconnecting, lastReconnectAt=later + + // State while reconnecting + if got := m.State(); got != StateReconnecting { + t.Fatalf("after OnReconnect: %s, want %s", got, StateReconnecting) + } + + // Push state back to connected (as if reconnect succeeded but no traffic) + m.OnConnected() + + // Now idle window passes with no events but a recent reconnect → degraded + muchLater := later.Add(IdleAfter + 5*time.Second) + m.now = func() time.Time { return muchLater } + if got := m.State(); got != StateDegraded { + t.Fatalf("state after reconnect-but-no-events: %s, want %s", got, StateDegraded) + } +} + +func TestMachine_ReconnectCount(t *testing.T) { + m := NewMachine() + m.OnConnected() + m.OnReconnect() + m.OnReconnect() + m.OnReconnect() + if snap := m.Snapshot(); snap.ReconnectCount != 3 { + t.Fatalf("ReconnectCount = %d, want 3", snap.ReconnectCount) + } +} + +func TestMachine_OnEventClearsReconnecting(t *testing.T) { + m := NewMachine() + m.OnConnected() + m.OnReconnect() + if m.State() != StateReconnecting { + t.Fatal("expected StateReconnecting after OnReconnect") + } + m.OnEvent() + if m.State() != StateConnected { + t.Fatalf("OnEvent should clear Reconnecting, got %s", m.State()) + } +} diff --git a/internal/event/transport/doc.go b/internal/event/transport/doc.go new file mode 100644 index 00000000..22859482 --- /dev/null +++ b/internal/event/transport/doc.go @@ -0,0 +1,29 @@ +// 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 transport implements the cross-platform local IPC between the bus +// daemon and consume clients. The wire is \n-delimited JSON frames over a +// Unix domain socket (darwin/linux) or Windows Named Pipe; the abstraction +// keeps both endpoints (bus side / consume side) and both platforms behind +// one Listener/Dialer interface. +// +// Frame protocol (see plan §9): +// +// consume → bus hello, bye(reason=client_done), heartbeat, status_req +// bus → consume hello_ack, event, source_state, bye(reason=shutdown), +// heartbeat, status_resp +// +// Frames are independent JSON objects; no length prefix, no checksum. +// Maximum single-frame size is enforced by the Reader to bound memory and +// prevent a buggy/malicious peer from causing OOM (MaxFrameBytes). +package transport diff --git a/internal/event/transport/frame.go b/internal/event/transport/frame.go new file mode 100644 index 00000000..8e2dbe58 --- /dev/null +++ b/internal/event/transport/frame.go @@ -0,0 +1,117 @@ +// 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 transport + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" +) + +// MaxFrameBytes caps a single frame's wire size. 1 MiB accommodates large +// event payloads (kanban cards, doc snapshots) with headroom while still +// preventing a buggy/malicious peer from causing OOM via an unbounded line. +// Callers writing frames larger than this should split their work, not bump +// the cap — the SDK enforces its own cloud-side cap that is well below this. +const MaxFrameBytes = 1 << 20 // 1 MiB + +// ErrFrameTooLarge is returned by Reader.Read when an incoming frame exceeds +// MaxFrameBytes. The underlying connection is left in an indeterminate +// state (the rest of the oversized frame is NOT drained) — callers should +// close the connection on receipt of this error. +var ErrFrameTooLarge = errors.New("transport: frame exceeds MaxFrameBytes") + +// Reader reads \n-delimited JSON frames from an underlying byte stream. +// Wrap each connection (one per direction) in a Reader; Reader is not +// safe for concurrent use. +type Reader struct { + br *bufio.Reader +} + +// NewReader returns a Reader with a buffer sized to MaxFrameBytes so a +// single frame can fit in the buffer without growing. +func NewReader(r io.Reader) *Reader { + return &Reader{br: bufio.NewReaderSize(r, MaxFrameBytes)} +} + +// Read returns the next frame's raw bytes (the terminating \n is stripped). +// Returns io.EOF cleanly when the peer closes the connection between +// frames. Returns ErrFrameTooLarge when a single frame exceeds the cap. +func (r *Reader) Read() ([]byte, error) { + line, err := r.br.ReadSlice('\n') + if err == nil { + // Strip trailing \n. Safe because ReadSlice always includes the + // delimiter when err == nil. + out := make([]byte, len(line)-1) + copy(out, line[:len(line)-1]) + return out, nil + } + if errors.Is(err, bufio.ErrBufferFull) { + // Read past current contents (without retaining) to skip the + // remainder of the oversize frame would be the friendly path, + // but plan says callers should close the connection — keep it + // simple and surface ErrFrameTooLarge immediately. + return nil, ErrFrameTooLarge + } + // EOF on a partial line: report it as EOF (clean close between frames + // returns a fresh EOF with an empty line, already handled above). + if errors.Is(err, io.EOF) && len(line) == 0 { + return nil, io.EOF + } + if err != nil { + return nil, err + } + return nil, nil // unreachable +} + +// ReadJSON decodes the next frame into dst. dst must be a non-nil pointer. +func (r *Reader) ReadJSON(dst any) error { + raw, err := r.Read() + if err != nil { + return err + } + if err := json.Unmarshal(raw, dst); err != nil { + return fmt.Errorf("transport: decode frame: %w", err) + } + return nil +} + +// Writer marshals values to JSON and writes them as \n-terminated frames. +// Writer is not safe for concurrent use; callers wanting a fan-in writer +// must serialise externally. +type Writer struct { + w io.Writer +} + +// NewWriter returns a Writer wrapping w. The Writer does NOT buffer — +// every WriteJSON call performs a single Write to the underlying stream +// so backpressure is immediate. +func NewWriter(w io.Writer) *Writer { return &Writer{w: w} } + +// WriteJSON serialises v and writes it as one frame. Refuses to write +// frames larger than MaxFrameBytes with ErrFrameTooLarge. +func (w *Writer) WriteJSON(v any) error { + buf, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("transport: encode frame: %w", err) + } + if len(buf)+1 > MaxFrameBytes { + return ErrFrameTooLarge + } + buf = append(buf, '\n') + _, err = w.w.Write(buf) + return err +} diff --git a/internal/event/transport/frame_test.go b/internal/event/transport/frame_test.go new file mode 100644 index 00000000..d5b31228 --- /dev/null +++ b/internal/event/transport/frame_test.go @@ -0,0 +1,114 @@ +// 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 transport + +import ( + "bytes" + "errors" + "io" + "strings" + "testing" +) + +func TestWriterReader_Roundtrip(t *testing.T) { + var buf bytes.Buffer + w := NewWriter(&buf) + if err := w.WriteJSON(Hello{Type: FrameTypeHello, ConsumerPID: 42, EventTypes: []string{"im.*"}}); err != nil { + t.Fatalf("WriteJSON: %v", err) + } + if err := w.WriteJSON(Heartbeat{Type: FrameTypeHeartbeat}); err != nil { + t.Fatalf("WriteJSON 2: %v", err) + } + + r := NewReader(&buf) + var h Hello + if err := r.ReadJSON(&h); err != nil { + t.Fatalf("ReadJSON 1: %v", err) + } + if h.ConsumerPID != 42 || h.Type != FrameTypeHello || len(h.EventTypes) != 1 || h.EventTypes[0] != "im.*" { + t.Fatalf("decoded Hello = %+v", h) + } + var hb Heartbeat + if err := r.ReadJSON(&hb); err != nil { + t.Fatalf("ReadJSON 2: %v", err) + } + if hb.Type != FrameTypeHeartbeat { + t.Fatalf("hb type = %s", hb.Type) + } +} + +func TestReader_CleanEOFBetweenFrames(t *testing.T) { + var buf bytes.Buffer + NewWriter(&buf).WriteJSON(Heartbeat{Type: FrameTypeHeartbeat}) + + r := NewReader(&buf) + if _, err := r.Read(); err != nil { + t.Fatalf("first read: %v", err) + } + _, err := r.Read() + if !errors.Is(err, io.EOF) { + t.Fatalf("second read after clean close = %v, want EOF", err) + } +} + +func TestReader_FrameTooLarge(t *testing.T) { + // Synthesise a > MaxFrameBytes line with no \n + big := strings.Repeat("A", MaxFrameBytes+10) + r := NewReader(strings.NewReader(big + "\n")) + _, err := r.Read() + if !errors.Is(err, ErrFrameTooLarge) { + t.Fatalf("Read oversized = %v, want ErrFrameTooLarge", err) + } +} + +func TestWriter_FrameTooLarge(t *testing.T) { + // Build a Hello with a filter string that would marshal larger than the cap. + huge := strings.Repeat("x", MaxFrameBytes) + w := NewWriter(io.Discard) + err := w.WriteJSON(Hello{Type: FrameTypeHello, Filter: huge}) + if !errors.Is(err, ErrFrameTooLarge) { + t.Fatalf("WriteJSON oversized = %v, want ErrFrameTooLarge", err) + } +} + +func TestReader_MalformedJSONErrors(t *testing.T) { + r := NewReader(strings.NewReader("{not json}\n")) + var x Hello + if err := r.ReadJSON(&x); err == nil { + t.Fatal("expected decode error for malformed JSON") + } +} + +func TestPeekType(t *testing.T) { + cases := []struct { + raw string + want FrameType + err bool + }{ + {`{"type":"hello"}`, FrameTypeHello, false}, + {`{"type":"event","seq":1}`, FrameTypeEvent, false}, + {`{"foo":"bar"}`, "", true}, + {`not json`, "", true}, + } + for _, tc := range cases { + got, err := PeekType([]byte(tc.raw)) + if (err != nil) != tc.err { + t.Errorf("PeekType(%q) err = %v, wantErr=%v", tc.raw, err, tc.err) + continue + } + if got != tc.want { + t.Errorf("PeekType(%q) = %q, want %q", tc.raw, got, tc.want) + } + } +} diff --git a/internal/event/transport/protocol.go b/internal/event/transport/protocol.go new file mode 100644 index 00000000..fdcb98bd --- /dev/null +++ b/internal/event/transport/protocol.go @@ -0,0 +1,199 @@ +// 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 transport + +import ( + "encoding/json" + "fmt" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" +) + +// FrameType discriminates the JSON frames flowing over the IPC channel. +// Wire values are stable strings — bumping any of these is a protocol +// breaking change requiring a coordinated bus + consume rollout. +type FrameType string + +const ( + FrameTypeHello FrameType = "hello" // consume → bus + FrameTypeHelloAck FrameType = "hello_ack" // bus → consume + FrameTypeEvent FrameType = "event" // bus → consume + FrameTypeHeartbeat FrameType = "heartbeat" // bidirectional + FrameTypeSourceState FrameType = "source_state" // bus → consume + FrameTypeBye FrameType = "bye" // bidirectional + FrameTypeStatusReq FrameType = "status_req" // consume/ad-hoc → bus + FrameTypeStatusResp FrameType = "status_resp" // bus → consume/ad-hoc +) + +// Hello is the first frame a consumer sends after dialing the bus. The bus +// uses event_types + filter to do server-side pushdown (plan §1 decision: +// only events matching this filter are written to this consumer's sendCh). +type Hello struct { + Type FrameType `json:"type"` + ConsumerPID int `json:"consumer_pid"` + EventTypes []string `json:"event_types,omitempty"` // wildcard list ("im.*", "approval.*"); empty = catch-all + Filter string `json:"filter,omitempty"` // optional regex over event_type + SubscribeID string `json:"subscribe_id,omitempty"` // optional personal subscription isolation key; empty = no subscribe_id filter + Compact bool `json:"compact,omitempty"` // hint to status output; bus does not transform payloads + // Role distinguishes a real consumer (registered for events) from an + // ad-hoc tooling connection (status/list/stop). Ad-hoc connections do + // NOT register with the Hub. + Role HelloRole `json:"role,omitempty"` +} + +// HelloRole tags the purpose of a Hello connection. +type HelloRole string + +const ( + HelloRoleConsumer HelloRole = "" // default + HelloRoleStatus HelloRole = "status" // event list / event status + HelloRoleStop HelloRole = "stop" // event stop (graceful trigger) +) + +// HelloAck is the bus's reply on accepted Hello. SourceState/StateSource +// mirror the source.Machine snapshot at connect time; CredentialsSource +// fields tell users which channel actually supplied the credentials in use +// (env / app_config / keychain / plain_config), see plan "凭证来源拆字段". +type HelloAck struct { + Type FrameType `json:"type"` + BusPID int `json:"bus_pid"` + SourceState string `json:"source_state"` // mirrors source.State string value + StateSource string `json:"state_source"` // "hook" | "inferred" + ClientIDSource string `json:"client_id_source"` // auth.CredentialSource string + ClientSecretSource string `json:"client_secret_source"` // auth.CredentialSource string + IdleTimeoutSecs int `json:"idle_timeout_secs,omitempty"` // bus's IdleTimeout for diagnostics +} + +// Event wraps one delivered RawEvent for the wire. We keep the payload as +// a string (not nested JSON) so the bus does not need to parse / re-encode. +type Event struct { + Type FrameType `json:"type"` + Seq uint64 `json:"seq"` // per-consumer monotonic, restarts at 1 on reconnect + EventID string `json:"event_id"` + EventBornTime int64 `json:"event_born_time"` + EventCorpID string `json:"event_corp_id,omitempty"` + EventType string `json:"event_type"` + EventUnifiedAppID string `json:"event_unified_app_id,omitempty"` + EventScope string `json:"event_scope,omitempty"` + SubscribeID string `json:"subscribe_id,omitempty"` + SourceID string `json:"source_id,omitempty"` + RuleType string `json:"rule_type,omitempty"` + Data string `json:"data"` + Headers map[string]string `json:"headers,omitempty"` + ReceivedAtUnixMS int64 `json:"received_at_unix_ms"` +} + +// Heartbeat is bidirectional and stateless. It exists only to give both +// endpoints a chance to notice a dead peer via Read failure. +type Heartbeat struct { + Type FrameType `json:"type"` +} + +// SourceState is pushed bus → consume whenever the connection state machine +// transitions to / from connected. Consumers may render it; v1 they just +// forward to stderr when not --quiet. +type SourceState struct { + Type FrameType `json:"type"` + State string `json:"state"` // source.State string + StateSource string `json:"state_source"` // hook | inferred + Attempt int `json:"attempt,omitempty"` +} + +// Bye is sent by either side at graceful shutdown. Reason is free-form +// for logs; structured shutdown causes are encoded in the value: +// +// "client_done" — consume reached --max-events/--duration or SIGINT +// "shutdown" — bus SIGTERM/SIGINT +// "idle_timeout" — bus IdleTimeout fired with no consumers +// "stop_request" — bus received explicit Stop RPC +type Bye struct { + Type FrameType `json:"type"` + Reason string `json:"reason"` +} + +// StatusReq is an empty JSON frame ad-hoc tooling sends after Hello to +// request a full StatusResp. Bus replies with one StatusResp then closes +// the connection. +type StatusReq struct { + Type FrameType `json:"type"` +} + +// StatusResp is the bus's snapshot view for `dws event status` rendering. +// Counts are accumulated since bus start; per-consumer entries are sorted +// by PID for deterministic output. +type StatusResp struct { + Type FrameType `json:"type"` + Bus StatusBus `json:"bus"` + SourceState StatusSource `json:"source_state"` + Consumers []StatusConsumer `json:"consumers"` + PerEventTypeCounters map[string]Counters `json:"per_event_type"` +} + +// StatusBus is the bus daemon's identity / lifecycle view. +type StatusBus struct { + PID int `json:"pid"` + UptimeSecs int64 `json:"uptime_secs"` + IdleTimeoutSec int `json:"idle_timeout_secs"` + ClientID string `json:"client_id"` + Edition string `json:"edition"` + SourceKind dwsevent.SourceKind `json:"source_kind,omitempty"` + IdentityHash string `json:"identity_hash,omitempty"` + SourceID string `json:"source_id,omitempty"` +} + +// StatusSource is the source.Machine snapshot at status RPC time. +type StatusSource struct { + State string `json:"state"` + Source string `json:"source"` + LastEventAtMS int64 `json:"last_event_at_ms,omitempty"` + LastReconnectMS int64 `json:"last_reconnect_at_ms,omitempty"` + ReconnectCount int `json:"reconnect_count"` +} + +// StatusConsumer is one consumer's per-IPC-connection view. +type StatusConsumer struct { + PID int `json:"pid"` + EventTypes []string `json:"event_types,omitempty"` + Filter string `json:"filter,omitempty"` + SubscribeID string `json:"subscribe_id,omitempty"` + SubscribedAtMS int64 `json:"subscribed_at_ms"` + Received uint64 `json:"received"` + Dropped uint64 `json:"dropped"` +} + +// Counters is the shared "received / dropped" pair used by per-consumer +// and per-event-type rollups. +type Counters struct { + Received uint64 `json:"received"` + Dropped uint64 `json:"dropped"` +} + +// typeOnly is the minimal envelope used by DecodeFrame to peek at the +// "type" field before deciding which concrete struct to unmarshal into. +type typeOnly struct { + Type FrameType `json:"type"` +} + +// PeekType returns the FrameType of a raw frame without fully decoding it. +// Returns an error when the JSON is malformed or the type field is missing. +func PeekType(raw []byte) (FrameType, error) { + var t typeOnly + if err := json.Unmarshal(raw, &t); err != nil { + return "", fmt.Errorf("transport: peek frame type: %w", err) + } + if t.Type == "" { + return "", fmt.Errorf("transport: frame missing 'type' field") + } + return t.Type, nil +} diff --git a/internal/event/transport/protocol_test.go b/internal/event/transport/protocol_test.go new file mode 100644 index 00000000..62d02b7c --- /dev/null +++ b/internal/event/transport/protocol_test.go @@ -0,0 +1,237 @@ +// 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 transport + +import ( + "encoding/json" + "strings" + "testing" +) + +// Each frame type is round-tripped through encoding/json then PeekType +// to guard against accidental wire-format changes (the JSON field tags +// double as part of the cross-version protocol contract). + +func TestFrameType_StableWireValues(t *testing.T) { + // If any of these strings change we've made a protocol-breaking + // change. The test value list is duplicated here on purpose so a + // reviewer renaming a constant is forced to also update the test. + wants := map[FrameType]string{ + FrameTypeHello: "hello", + FrameTypeHelloAck: "hello_ack", + FrameTypeEvent: "event", + FrameTypeHeartbeat: "heartbeat", + FrameTypeSourceState: "source_state", + FrameTypeBye: "bye", + FrameTypeStatusReq: "status_req", + FrameTypeStatusResp: "status_resp", + } + for ft, want := range wants { + if string(ft) != want { + t.Errorf("FrameType wire value drift: %v != %q", ft, want) + } + } +} + +func TestHelloRole_StableWireValues(t *testing.T) { + wants := map[HelloRole]string{ + HelloRoleConsumer: "", + HelloRoleStatus: "status", + HelloRoleStop: "stop", + } + for r, want := range wants { + if string(r) != want { + t.Errorf("HelloRole wire value drift: %v != %q", r, want) + } + } +} + +func roundTrip(t *testing.T, in any, dst any) { + t.Helper() + b, err := json.Marshal(in) + if err != nil { + t.Fatalf("marshal %T: %v", in, err) + } + if err := json.Unmarshal(b, dst); err != nil { + t.Fatalf("unmarshal %T: %v", in, err) + } +} + +func TestHello_Roundtrip(t *testing.T) { + in := Hello{ + Type: FrameTypeHello, + ConsumerPID: 42, + EventTypes: []string{"im.*", "approval.task"}, + Filter: `^im\.`, + Compact: true, + Role: HelloRoleStatus, + } + var out Hello + roundTrip(t, in, &out) + if out.Type != in.Type || out.ConsumerPID != in.ConsumerPID || out.Filter != in.Filter || + out.Compact != in.Compact || out.Role != in.Role || len(out.EventTypes) != len(in.EventTypes) { + t.Fatalf("roundtrip mismatch: %+v != %+v", out, in) + } +} + +func TestHello_OmitemptyForDefaults(t *testing.T) { + // Default values should NOT appear in the wire form so old/new readers + // stay tolerant of each other (each new field comes in with its + // zero value by default). + in := Hello{Type: FrameTypeHello, ConsumerPID: 1} + b, _ := json.Marshal(in) + s := string(b) + for _, k := range []string{`"event_types"`, `"filter"`, `"compact"`, `"role"`} { + if strings.Contains(s, k) { + t.Errorf("zero-value field %s leaked into wire form: %s", k, s) + } + } +} + +func TestHelloAck_Roundtrip(t *testing.T) { + in := HelloAck{ + Type: FrameTypeHelloAck, + BusPID: 12345, + SourceState: "connected", + StateSource: "inferred", + ClientIDSource: "env", + ClientSecretSource: "env", + IdleTimeoutSecs: 300, + } + var out HelloAck + roundTrip(t, in, &out) + if out != in { + t.Fatalf("HelloAck roundtrip: %+v != %+v", out, in) + } +} + +func TestEvent_Roundtrip(t *testing.T) { + in := Event{ + Type: FrameTypeEvent, + Seq: 7, + EventID: "ev_x", + EventBornTime: 1700000000123, + EventCorpID: "corp_x", + EventType: "im.message.receive_v1", + EventUnifiedAppID: "app_y", + Data: `{"text":"hi"}`, + Headers: map[string]string{"extra": "v"}, + ReceivedAtUnixMS: 1700000000999, + } + var out Event + roundTrip(t, in, &out) + if out.EventID != in.EventID || out.Seq != in.Seq || out.Data != in.Data || + out.Headers["extra"] != "v" { + t.Fatalf("Event roundtrip: %+v != %+v", out, in) + } +} + +func TestEvent_OmitsZeroOptionalHeader(t *testing.T) { + in := Event{Type: FrameTypeEvent, EventID: "ev_1", EventType: "x", Data: "{}"} + b, _ := json.Marshal(in) + if strings.Contains(string(b), `"headers"`) { + t.Errorf("nil Headers should not appear: %s", b) + } +} + +func TestHeartbeat_Roundtrip(t *testing.T) { + in := Heartbeat{Type: FrameTypeHeartbeat} + var out Heartbeat + roundTrip(t, in, &out) + if out.Type != FrameTypeHeartbeat { + t.Fatalf("Heartbeat type lost: %v", out) + } +} + +func TestSourceState_Roundtrip(t *testing.T) { + in := SourceState{ + Type: FrameTypeSourceState, + State: "reconnecting", + StateSource: "inferred", + Attempt: 3, + } + var out SourceState + roundTrip(t, in, &out) + if out != in { + t.Fatalf("SourceState mismatch: %+v != %+v", out, in) + } +} + +func TestBye_Roundtrip(t *testing.T) { + for _, reason := range []string{"client_done", "shutdown", "idle_timeout", "stop_request"} { + in := Bye{Type: FrameTypeBye, Reason: reason} + var out Bye + roundTrip(t, in, &out) + if out.Reason != reason { + t.Errorf("Bye reason %q lost: %+v", reason, out) + } + } +} + +func TestStatusResp_Roundtrip(t *testing.T) { + in := StatusResp{ + Type: FrameTypeStatusResp, + Bus: StatusBus{ + PID: 99, UptimeSecs: 600, IdleTimeoutSec: 300, + ClientID: "ding_x", Edition: "open", + }, + SourceState: StatusSource{ + State: "connected", Source: "inferred", ReconnectCount: 1, + LastEventAtMS: 1700000000000, LastReconnectMS: 1700000001000, + }, + Consumers: []StatusConsumer{ + {PID: 12350, EventTypes: []string{"im.*"}, Filter: `^im\.`, + SubscribedAtMS: 1700000000000, Received: 100, Dropped: 2}, + }, + PerEventTypeCounters: map[string]Counters{ + "im.message.receive_v1": {Received: 100, Dropped: 2}, + }, + } + var out StatusResp + roundTrip(t, in, &out) + if out.Bus.ClientID != in.Bus.ClientID || len(out.Consumers) != 1 || + out.PerEventTypeCounters["im.message.receive_v1"].Received != 100 { + t.Fatalf("StatusResp roundtrip mismatch: %+v", out) + } +} + +// PeekType is used by the daemon to dispatch incoming frames before +// fully decoding into the typed struct. Behaviour at boundaries matters. + +func TestPeekType_EachFrameVariant(t *testing.T) { + cases := []struct { + v any + typ FrameType + }{ + {Hello{Type: FrameTypeHello}, FrameTypeHello}, + {HelloAck{Type: FrameTypeHelloAck}, FrameTypeHelloAck}, + {Event{Type: FrameTypeEvent, EventID: "x"}, FrameTypeEvent}, + {Heartbeat{Type: FrameTypeHeartbeat}, FrameTypeHeartbeat}, + {SourceState{Type: FrameTypeSourceState, State: "connected"}, FrameTypeSourceState}, + {Bye{Type: FrameTypeBye, Reason: "x"}, FrameTypeBye}, + {StatusReq{Type: FrameTypeStatusReq}, FrameTypeStatusReq}, + {StatusResp{Type: FrameTypeStatusResp}, FrameTypeStatusResp}, + } + for _, c := range cases { + b, _ := json.Marshal(c.v) + got, err := PeekType(b) + if err != nil { + t.Errorf("PeekType(%T): %v", c.v, err) + continue + } + if got != c.typ { + t.Errorf("PeekType(%T) = %q, want %q", c.v, got, c.typ) + } + } +} diff --git a/internal/event/transport/transport.go b/internal/event/transport/transport.go new file mode 100644 index 00000000..8b2b0c05 --- /dev/null +++ b/internal/event/transport/transport.go @@ -0,0 +1,47 @@ +// 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 transport + +import "net" + +// Listener is the bus-side accept loop. Implementations bind a Unix socket +// (darwin/linux) or Windows Named Pipe at the given Endpoint; calling Close +// removes the underlying socket file on Unix. +type Listener interface { + // Accept blocks until a peer connects or the listener is closed. + Accept() (net.Conn, error) + // Close unbinds the endpoint and unblocks pending Accept calls. + Close() error + // Endpoint returns the human-readable address (Unix path or Windows pipe + // name) — only used in log messages and status output. + Endpoint() string +} + +// Listen binds an IPC endpoint at path. On Unix path is a filesystem socket +// path (caller must ensure the parent directory exists with mode 0700). +// On Windows path is a Named Pipe name like `\\.\pipe\dws-event--`. +// +// Stale Unix sockets (left behind by a crashed bus) are unlinked +// automatically before bind. Caller MUST hold the bus.lock before calling +// Listen so this unlink is race-safe against a still-running bus. +func Listen(path string) (Listener, error) { + return listen(path) +} + +// Dial connects to an IPC endpoint at path. Returns a standard net.Conn +// the caller can wrap with NewReader/NewWriter. Closes are independent — +// closing the conn does not close the listener. +func Dial(path string) (net.Conn, error) { + return dial(path) +} diff --git a/internal/event/transport/transport_unix.go b/internal/event/transport/transport_unix.go new file mode 100644 index 00000000..69a0e1f9 --- /dev/null +++ b/internal/event/transport/transport_unix.go @@ -0,0 +1,79 @@ +// 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 transport + +import ( + "fmt" + "net" + "os" + + dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config" +) + +type unixListener struct { + l net.Listener + path string +} + +func (u *unixListener) Accept() (net.Conn, error) { return u.l.Accept() } +func (u *unixListener) Endpoint() string { return u.path } +func (u *unixListener) Close() error { + err := u.l.Close() + // Best-effort unlink. Ignored errors here because the bus may have + // already been replaced by a competing bus that unlinked first. + _ = os.Remove(u.path) + return err +} + +// checkSocketPath rejects paths over the sun_path budget up front so the +// caller sees the actual problem instead of the syscall's bare EINVAL +// ("invalid argument"). +func checkSocketPath(path string) error { + if max := dwsevent.MaxUnixSocketPath(); len(path) > max { + return fmt.Errorf("transport: unix socket path too long (%d > %d bytes): %s", len(path), max, path) + } + return nil +} + +func listen(path string) (Listener, error) { + if err := checkSocketPath(path); err != nil { + return nil, err + } + // Stale socket cleanup. Caller holds bus.lock so this is race-safe. + if _, err := os.Stat(path); err == nil { + if err := os.Remove(path); err != nil { + return nil, fmt.Errorf("transport: remove stale socket %s: %w", path, err) + } + } + l, err := net.Listen("unix", path) + if err != nil { + return nil, fmt.Errorf("transport: listen %s: %w", path, err) + } + if err := os.Chmod(path, config.FilePerm); err != nil { + _ = l.Close() + _ = os.Remove(path) + return nil, fmt.Errorf("transport: chmod %s: %w", path, err) + } + return &unixListener{l: l, path: path}, nil +} + +func dial(path string) (net.Conn, error) { + if err := checkSocketPath(path); err != nil { + return nil, err + } + return net.Dial("unix", path) +} diff --git a/internal/event/transport/transport_unix_test.go b/internal/event/transport/transport_unix_test.go new file mode 100644 index 00000000..6de5af4a --- /dev/null +++ b/internal/event/transport/transport_unix_test.go @@ -0,0 +1,158 @@ +// 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 transport + +import ( + "errors" + "io" + "net" + "os" + "path/filepath" + "sync" + "testing" +) + +func TestListen_DialRoundtrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "bus.sock") + l, err := Listen(path) + if err != nil { + t.Fatalf("Listen: %v", err) + } + defer l.Close() + + if l.Endpoint() != path { + t.Errorf("Endpoint = %q, want %q", l.Endpoint(), path) + } + + // Verify socket file exists with expected mode (0600). + st, err := os.Stat(path) + if err != nil { + t.Fatalf("stat socket: %v", err) + } + if mode := st.Mode().Perm(); mode != 0o600 { + t.Errorf("socket mode = %v, want 0600", mode) + } + + var serverConn net.Conn + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + c, err := l.Accept() + if err != nil { + t.Errorf("Accept: %v", err) + return + } + serverConn = c + }() + + clientConn, err := Dial(path) + if err != nil { + t.Fatalf("Dial: %v", err) + } + defer clientConn.Close() + + wg.Wait() + if serverConn == nil { + t.Fatal("server did not accept") + } + defer serverConn.Close() + + // Roundtrip a frame + w := NewWriter(clientConn) + if err := w.WriteJSON(Hello{Type: FrameTypeHello, ConsumerPID: 123}); err != nil { + t.Fatalf("WriteJSON: %v", err) + } + r := NewReader(serverConn) + var got Hello + if err := r.ReadJSON(&got); err != nil { + t.Fatalf("ReadJSON: %v", err) + } + if got.ConsumerPID != 123 { + t.Errorf("PID = %d", got.ConsumerPID) + } +} + +func TestListen_StaleSocketCleanup(t *testing.T) { + path := filepath.Join(t.TempDir(), "bus.sock") + // Pre-create a stale file at path (not a valid socket). + if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil { + t.Fatalf("pre-create: %v", err) + } + l, err := Listen(path) + if err != nil { + t.Fatalf("Listen should clean stale file, got: %v", err) + } + defer l.Close() +} + +func TestListen_CloseUnlinksSocket(t *testing.T) { + path := filepath.Join(t.TempDir(), "bus.sock") + l, err := Listen(path) + if err != nil { + t.Fatalf("Listen: %v", err) + } + if err := l.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("socket should be unlinked after Close, stat err = %v", err) + } +} + +func TestDial_NoServerReturnsError(t *testing.T) { + path := filepath.Join(t.TempDir(), "nonexistent.sock") + if _, err := Dial(path); err == nil { + t.Fatal("Dial to nonexistent socket should error") + } +} + +// TestReader_HandlesPeerCloseEOF asserts that a clean peer close mid-stream +// surfaces as io.EOF to the server's Reader — the EOF signal is what bus +// uses to unregister dead consumers (plan invariant #5). +func TestReader_HandlesPeerCloseEOF(t *testing.T) { + path := filepath.Join(t.TempDir(), "bus.sock") + l, err := Listen(path) + if err != nil { + t.Fatalf("Listen: %v", err) + } + defer l.Close() + + done := make(chan error, 1) + go func() { + c, err := l.Accept() + if err != nil { + done <- err + return + } + defer c.Close() + r := NewReader(c) + _, err = r.Read() + done <- err + }() + + clientConn, err := Dial(path) + if err != nil { + t.Fatalf("Dial: %v", err) + } + // Close immediately — server should see EOF on first Read. + _ = clientConn.Close() + + err = <-done + if !errors.Is(err, io.EOF) { + t.Fatalf("server Read after peer close = %v, want EOF", err) + } +} diff --git a/internal/event/transport/transport_windows.go b/internal/event/transport/transport_windows.go new file mode 100644 index 00000000..e9cbb53f --- /dev/null +++ b/internal/event/transport/transport_windows.go @@ -0,0 +1,60 @@ +// 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 transport + +import ( + "fmt" + "net" + + winio "github.com/Microsoft/go-winio" +) + +type pipeListener struct { + l net.Listener + name string +} + +func (p *pipeListener) Accept() (net.Conn, error) { return p.l.Accept() } +func (p *pipeListener) Endpoint() string { return p.name } +func (p *pipeListener) Close() error { return p.l.Close() } + +// listen binds a Windows Named Pipe at the given name. Stale-cleanup is +// unnecessary on Windows because pipe names don't outlive the server +// process (no inode in the FS); a fresh ListenPipe just takes the name. +// +// SecurityDescriptor pinned to "D:P(A;;GA;;;OW)" (Discretionary ACL, +// Protected, Generic All for OWNER) — only the current user can connect. +func listen(name string) (Listener, error) { + cfg := &winio.PipeConfig{ + SecurityDescriptor: "D:P(A;;GA;;;OW)", + MessageMode: false, + InputBufferSize: int32(MaxFrameBytes), + OutputBufferSize: int32(MaxFrameBytes), + } + l, err := winio.ListenPipe(name, cfg) + if err != nil { + return nil, fmt.Errorf("transport: ListenPipe %s: %w", name, err) + } + return &pipeListener{l: l, name: name}, nil +} + +func dial(name string) (net.Conn, error) { + c, err := winio.DialPipe(name, nil) + if err != nil { + return nil, fmt.Errorf("transport: DialPipe %s: %w", name, err) + } + return c, nil +} diff --git a/internal/event/types.go b/internal/event/types.go new file mode 100644 index 00000000..e36d6c69 --- /dev/null +++ b/internal/event/types.go @@ -0,0 +1,129 @@ +// 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 event + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "time" +) + +// SourceKind identifies the upstream event connection family. +type SourceKind string + +const ( + SourceKindAppStream SourceKind = "app_stream" + SourceKindPersonalStream SourceKind = "personal_stream" +) + +// RawEvent is one event delivered by the Source layer to the Hub. It mirrors +// the DingTalk Stream SDK's EventHeader (5 fields) plus the raw JSON payload +// from DataFrame.Data, plus the local receive timestamp used for ordering and +// dedup fallback keys. +// +// Field naming follows Go conventions (camelCase capitalised); JSON tags +// follow the SDK's wire format so that NDJSON output is consistent with what +// DingTalk documents on its open platform. +type RawEvent struct { + EventID string `json:"event_id"` // DataFrameHeader "eventId" + EventBornTime int64 `json:"event_born_time"` // milliseconds; verified P0 + EventCorpID string `json:"event_corp_id"` // tenant corp id + EventType string `json:"event_type"` // catch-all/filter routing key + EventUnifiedAppID string `json:"event_unified_app_id"` // app id + EventScope string `json:"event_scope,omitempty"` + SubscribeID string `json:"subscribe_id,omitempty"` + SourceID string `json:"source_id,omitempty"` + RuleType string `json:"rule_type,omitempty"` + Data string `json:"data"` // raw JSON payload from SDK + Headers map[string]string `json:"headers,omitempty"` // full DataFrame.Headers, passthrough + ReceivedAt time.Time `json:"received_at"` // bus receive time (UTC) +} + +// EmitFn is the non-blocking handoff from Source to Hub. Implementations MUST +// return immediately (drop-oldest on the consumer's sendCh) so the SDK's +// callback can ACK without delay — see plan invariant #1. +type EmitFn func(*RawEvent) + +// DedupKey returns the LRU dedup key for this event. Prefers EventID (the +// SDK-provided identifier); falls back to a content-derived hash when the +// SDK does not populate EventID (rare, but observed on legacy event types +// per the P0 escape-hatch verification). +func (e *RawEvent) DedupKey() string { + if e == nil { + return "" + } + if e.EventID != "" { + return e.EventID + } + // Fallback: type + born_time + sha256(data)[:16] + h := sha256.Sum256([]byte(e.Data)) + return e.EventType + ":" + itoa(e.EventBornTime) + ":" + hex.EncodeToString(h[:8]) +} + +func itoa(n int64) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + neg := n < 0 + if neg { + n = -n + } + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} + +// ClientIDHash returns the path-safe identifier derived from the (possibly +// unsafe) ClientID string. We use sha256(clientID)[:8] hex-encoded (16 chars); +// this is the value placed in filesystem paths and Named Pipe names. 8 bytes +// (64 bits) is sufficient to distinguish a handful of ClientIDs on one machine +// while keeping the Unix socket path under macOS's 104-byte sun_path limit. +// The original ClientID is stored in bus.meta and shown in status output. +func ClientIDHash(clientID string) string { + return IdentityHash(clientID) +} + +// IdentityHash returns the path-safe identifier derived from any event +// identity string. It intentionally uses the same algorithm as ClientIDHash +// so existing app-stream paths remain stable when the identity is client_id. +func IdentityHash(identity string) string { + if strings.TrimSpace(identity) == "" { + return "" + } + h := sha256.Sum256([]byte(identity)) + return hex.EncodeToString(h[:8]) +} + +// RedactSecret returns a redacted form of a secret string for logging and +// error messages: first 3 + "***" + last 3 characters. Short secrets +// (<= 6 chars) are fully masked. Empty input yields empty output. +func RedactSecret(s string) string { + if s == "" { + return "" + } + if len(s) <= 6 { + return "***" + } + return s[:3] + "***" + s[len(s)-3:] +} diff --git a/internal/event/types_test.go b/internal/event/types_test.go new file mode 100644 index 00000000..6f2a315a --- /dev/null +++ b/internal/event/types_test.go @@ -0,0 +1,124 @@ +// 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 event + +import ( + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestRawEvent_DedupKey_UsesEventID(t *testing.T) { + e := &RawEvent{EventID: "ev_abc"} + if got := e.DedupKey(); got != "ev_abc" { + t.Fatalf("DedupKey = %q, want %q", got, "ev_abc") + } +} + +func TestRawEvent_DedupKey_FallbackHashWhenIDEmpty(t *testing.T) { + e := &RawEvent{ + EventType: "im.message.receive_v1", + EventBornTime: 1700000000000, + Data: `{"chat":"x"}`, + } + got := e.DedupKey() + if got == "" { + t.Fatal("fallback DedupKey should not be empty") + } + if !strings.HasPrefix(got, "im.message.receive_v1:1700000000000:") { + t.Fatalf("fallback DedupKey = %q, want prefix type:born_time:", got) + } + // Same content → same key + if got2 := e.DedupKey(); got2 != got { + t.Fatalf("DedupKey not deterministic: %q vs %q", got, got2) + } + // Different Data → different key + e2 := *e + e2.Data = `{"chat":"y"}` + if got3 := e2.DedupKey(); got3 == got { + t.Fatalf("different Data should yield different key, got same: %q", got) + } +} + +func TestRawEvent_DedupKey_NilSafe(t *testing.T) { + var e *RawEvent + if e.DedupKey() != "" { + t.Fatal("nil RawEvent DedupKey should be empty") + } +} + +func TestClientIDHash_StableAndPathSafe(t *testing.T) { + pathSafe := regexp.MustCompile(`^[0-9a-f]{16}$`) + cases := []string{ + "ding_abc123", + "ding_with_special!@#", + "../../escape", + "with/slash", + "with whitespace", + "unicode-工号-123", + } + for _, in := range cases { + got := ClientIDHash(in) + if !pathSafe.MatchString(got) { + t.Fatalf("ClientIDHash(%q) = %q, not path-safe hex8(16 chars)", in, got) + } + // Stable + if got2 := ClientIDHash(in); got2 != got { + t.Fatalf("ClientIDHash not deterministic for %q: %q vs %q", in, got, got2) + } + // Path traversal proof: the hash, used as a path segment, must not + // contain any "..", "/", or "\". + if strings.Contains(got, "..") || strings.ContainsAny(got, `/\`) { + t.Fatalf("ClientIDHash(%q) = %q contains path-unsafe chars", in, got) + } + // And actually joining doesn't escape parent. + joined := filepath.Join("/tmp/events", got) + if !strings.HasPrefix(joined, "/tmp/events/") { + t.Fatalf("path escape: %q", joined) + } + } +} + +func TestClientIDHash_DifferentInputsDifferentHashes(t *testing.T) { + a := ClientIDHash("ding_a") + b := ClientIDHash("ding_b") + if a == b { + t.Fatal("different ClientIDs must produce different hashes") + } +} + +func TestClientIDHash_EmptyReturnsEmpty(t *testing.T) { + if got := ClientIDHash(""); got != "" { + t.Fatalf("ClientIDHash(\"\") = %q, want empty", got) + } +} + +func TestRedactSecret(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"", ""}, + {"abc", "***"}, + {"abcdef", "***"}, + {"abcdefg", "abc***efg"}, + {"this-is-a-very-long-secret-string", "thi***ing"}, + } + for _, tc := range cases { + if got := RedactSecret(tc.in); got != tc.want { + t.Errorf("RedactSecret(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} diff --git a/internal/helpers/agoal.go b/internal/helpers/agoal.go new file mode 100644 index 00000000..e3171f8a --- /dev/null +++ b/internal/helpers/agoal.go @@ -0,0 +1,603 @@ +package helpers + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/spf13/cobra" +) + +// ────────────────────────────────────────────────────────── +// dws agoal — Agoal 管理 +// ────────────────────────────────────────────────────────── + +func newAgoalCommand() *cobra.Command { + root := &cobra.Command{ + Use: "agoal", + Short: "Agoal 管理", + Long: `管理钉钉 Agoal:战略解码、经营合约、计分卡、用户目标、周月报。 + +命令结构: + dws agoal strategy list 获取战略解码列表 + dws agoal strategy detail 获取战略解码详情 + dws agoal strategy update 更新战略解码 + dws agoal contract list 获取经营合约列表 + dws agoal contract fields 获取经营合约字段列表 + dws agoal contract detail 获取经营合约详情 + dws agoal contract update 更新经营合约 + dws agoal scorecard detail 获取计分卡详情 + dws agoal scorecard entity-detail 获取计分卡实体详情 + dws agoal scorecard update 更新计分卡 + dws agoal user rules 获取用户规则 + dws agoal user objectives 查询用户目标列表 + dws agoal report list-statistics 获取周月报数据跟催列表 + dws agoal report submit-detail 获取周月报规则提交详情 + dws agoal obj-template list 获取目标模板列表 + dws agoal obj-template create-or-update 新增或更新目标模板`, + RunE: groupRunE, + } + + // ── strategy: 战略解码管理 ────────────────────────────────── + + strategyCmd := &cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE} + + strategyListCmd := &cobra.Command{ + Use: "list", + Short: "获取战略解码列表", + Long: `按部门或个人维度查询战略解码列表。 +scopeType 支持: + DEPT — 按部门查询 + PERSONAL — 按个人查询 + +--scope-id 为对应维度的钉钉部门 id 或用户 id。`, + Example: ` dws agoal strategy list --scope-type PERSONAL --scope-id USER_ID + dws agoal strategy list --scope-type DEPT --scope-id DEPT_ID`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "scope-type", "scope-id"); err != nil { + return err + } + toolArgs := map[string]any{ + "scopeType": mustGetFlag(cmd, "scope-type"), + "openId": mustGetFlag(cmd, "scope-id"), + } + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + return callMCPTool("list_strategy_decodings", toolArgs) + }, + } + strategyListCmd.Flags().String("scope-type", "", "解码范围类型: DEPT/PERSONAL (必填)") + strategyListCmd.Flags().String("scope-id", "", "scope-type 对应的钉钉部门 id 或用户 id (必填)") + strategyListCmd.Flags().String("request-id", "", "requestId (可选)") + + strategyDetailCmd := &cobra.Command{ + Use: "detail", + Short: "获取战略解码详情", + Long: `根据战略解码 id (profileId) 获取战略解码的详细信息。`, + Example: ` dws agoal strategy detail --profile-id PROFILE_ID`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "profile-id"); err != nil { + return err + } + toolArgs := map[string]any{ + "profileId": mustGetFlag(cmd, "profile-id"), + } + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + return callMCPTool("get_strategy_decoding_detail", toolArgs) + }, + } + strategyDetailCmd.Flags().String("profile-id", "", "战略解码 id (必填)") + strategyDetailCmd.Flags().String("request-id", "", "requestId (可选)") + + strategyUpdateCmd := &cobra.Command{ + Use: "update", + Short: "更新战略解码", + Long: `谨慎操作,基于查询接口返回的老数据进行修改,本接口是覆盖逻辑,会根据战略解码id进行对应的修改。 +--content 为 JSON 数组,每个实体对象包含: + id — 实体 id + title — 标题对象 {"title":"标题文本"} + linkEntityId — 所属的实体 ID(查询接口中此字段有值一定要传进来) + entityType — 类型: OGSM_OBJECTIVE/目的、OGSM_GOAL/目标、OGSM_STRATEGY/策略、OGSM_MEASUREMENT/衡量标准、OGSM_TACTICS/行动方案 + status — 状态: NORMAL/正常、PRE_PUBLISH_THEN_UPDATE/预发布更新、PRE_PUBLISH_THEN_CREATE/预发布新增、PRE_PUBLISH_THEN_DELETE/预发布删除 + supporters — 承接人数组 [{type, dingId, staffId}],type: USER/个人、DEPARTMENT/部门,staffId 类型为 USER 时必填 + indicators — 关键指标 id 字符串数组,如 ["id1","id2"] + linkSources — 资源关联数组 [{id, linkType, linkId, source, objectId, keyResultId}] + linkType: project/项目、task/任务、campaign/战役空间、product/产品空间、doc/文档、standard/其它 + source: teambition + executors — 人员 dingId 字符串数组,如 ["dingId1","dingId2"] + teams — 部门 dingId 字符串数组,如 ["dingId1","dingId2"]`, + Example: ` dws agoal strategy update --profile-id PROFILE_ID --content '[{"id":"entity1","title":{"title":"新目标"},"entityType":"OGSM_OBJECTIVE","status":"NORMAL","executors":["dingId1"],"teams":["deptDingId1"]}]'`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "profile-id", "content"); err != nil { + return err + } + var contentArr []any + if err := json.Unmarshal([]byte(mustGetFlag(cmd, "content")), &contentArr); err != nil { + return fmt.Errorf("--content must be a valid JSON array: %w", err) + } + toolArgs := map[string]any{ + "profileId": mustGetFlag(cmd, "profile-id"), + "content": contentArr, + } + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + return callMCPTool("update_strategy_decoding", toolArgs) + }, + } + strategyUpdateCmd.Flags().String("profile-id", "", "战略解码 id (必填)") + strategyUpdateCmd.Flags().String("content", "", "实体列表 JSON 数组 (必填)") + strategyUpdateCmd.Flags().String("request-id", "", "requestId (可选)") + + strategyCmd.AddCommand(strategyListCmd, strategyDetailCmd, strategyUpdateCmd) + + // ── contract: 经营合约管理 ────────────────────────────────── + + contractCmd := &cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE} + + contractListCmd := &cobra.Command{ + Use: "list", + Short: "获取经营合约列表", + Long: `按部门或个人维度查询经营合约列表。 +scopeType 支持: + DEPT — 按部门查询 + PERSONAL — 按个人查询 + +--scope-id 为通讯录里的部门 id 或用户 id。`, + Example: ` dws agoal contract list --scope-type PERSONAL --scope-id USER_ID + dws agoal contract list --scope-type DEPT --scope-id DEPT_ID`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "scope-type", "scope-id"); err != nil { + return err + } + toolArgs := map[string]any{ + "scopeType": mustGetFlag(cmd, "scope-type"), + "openId": mustGetFlag(cmd, "scope-id"), + } + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + return callMCPTool("list_op_contracts", toolArgs) + }, + } + contractListCmd.Flags().String("scope-type", "", "合约范围类型: DEPT/PERSONAL (必填)") + contractListCmd.Flags().String("scope-id", "", "scope-type 对应的钉钉部门 id 或用户 id (必填)") + contractListCmd.Flags().String("request-id", "", "requestId (可选)") + + contractFieldsCmd := &cobra.Command{ + Use: "fields", + Short: "获取经营合约字段列表", + Long: `获取指定组织下经营合约的字段配置信息。`, + Example: ` dws agoal contract fields`, + RunE: func(cmd *cobra.Command, args []string) error { + toolArgs := map[string]any{} + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + return callMCPTool("list_op_contract_fields", toolArgs) + }, + } + contractFieldsCmd.Flags().String("request-id", "", "requestId (可选)") + + contractDetailCmd := &cobra.Command{ + Use: "detail", + Short: "获取经营合约详情", + Long: `根据经营合约 id 获取经营合约的详细信息。`, + Example: ` dws agoal contract detail --contract-id CONTRACT_ID`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "contract-id"); err != nil { + return err + } + toolArgs := map[string]any{ + "contractId": mustGetFlag(cmd, "contract-id"), + } + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + return callMCPTool("get_op_contract_detail", toolArgs) + }, + } + contractDetailCmd.Flags().String("contract-id", "", "经营合约 id (必填)") + contractDetailCmd.Flags().String("request-id", "", "requestId (可选)") + + contractUpdateCmd := &cobra.Command{ + Use: "update", + Short: "更新经营合约", + Long: `谨慎操作,一定要基于查询接口返回的老数据进行修改,然后给本接口使用,因为本接口是覆盖逻辑,会使用传入的数据去覆盖已存在的数据。 +--dimensions 为 JSON 数组,每个维度对象包含: + id — 维度 id + title — 维度名称 + description — 维度描述 + weight — 维度权重 + objectives — 目标列表 + dimensionConfig — 维度配置 + children — 子维度列表 + +可选参数: + --audit-config — 审批配置 JSON,如 {"needAudit":true,"processTemplateId":"TPL_ID"} + --objective-template — 合约模板 JSON,如 {"id":"TPL_ID","title":"模板名称"}`, + Example: ` dws agoal contract update --contract-id CONTRACT_ID \ + --dimensions '[{"id":"dim1","title":"业绩","weight":60,"objectives":[{"id":"obj1"}]}]'`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "contract-id", "dimensions"); err != nil { + return err + } + var dimensionsArr []any + if err := json.Unmarshal([]byte(mustGetFlag(cmd, "dimensions")), &dimensionsArr); err != nil { + return fmt.Errorf("--dimensions must be a valid JSON array: %w", err) + } + toolArgs := map[string]any{ + "contractId": mustGetFlag(cmd, "contract-id"), + "dimensions": dimensionsArr, + } + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + if v, _ := cmd.Flags().GetString("audit-config"); v != "" { + toolArgs["auditConfig"] = v + } + if v, _ := cmd.Flags().GetString("objective-template"); v != "" { + toolArgs["objectiveTemplate"] = v + } + return callMCPTool("update_op_contract", toolArgs) + }, + } + contractUpdateCmd.Flags().String("contract-id", "", "经营合约 id (必填)") + contractUpdateCmd.Flags().String("request-id", "", "requestId (可选)") + contractUpdateCmd.Flags().String("audit-config", "", "审批配置 JSON (可选)") + contractUpdateCmd.Flags().String("objective-template", "", "合约模板 JSON (可选)") + contractUpdateCmd.Flags().String("dimensions", "", "维度内容列表 JSON 数组 (必填)") + + contractCmd.AddCommand(contractListCmd, contractFieldsCmd, contractDetailCmd, contractUpdateCmd) + + // ── scorecard: 计分卡管理 ─────────────────────────────────── + + scorecardCmd := &cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE} + + scorecardDetailCmd := &cobra.Command{ + Use: "detail", + Short: "获取计分卡详情", + Long: `根据部门 id 和时间获取计分卡详情。 +--selected-time 接受 ISO-8601 字符串,传入对应周期起始时刻: + 2026年 → "2026-01-01T00:00:00+08:00" + 2026年5月 → "2026-05-01T00:00:00+08:00"`, + Example: ` dws agoal scorecard detail --selected-time "2026-01-01T00:00:00+08:00" --dept-id DEPT_ID`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "selected-time", "dept-id"); err != nil { + return err + } + selectedTimeMs, err := parseISO8601ToMillis(mustGetFlag(cmd, "selected-time")) + if err != nil { + return fmt.Errorf("--selected-time: %w", err) + } + toolArgs := map[string]any{ + "selectedTime": selectedTimeMs, + "deptId": mustGetFlag(cmd, "dept-id"), + } + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + return callMCPTool("get_score_card_detail", toolArgs) + }, + } + scorecardDetailCmd.Flags().String("selected-time", "", "ISO-8601 时间字符串,如 \"2026-01-01T00:00:00+08:00\" (必填)") + scorecardDetailCmd.Flags().String("dept-id", "", "部门 id (必填)") + scorecardDetailCmd.Flags().String("request-id", "", "requestId (可选)") + + scorecardEntityDetailCmd := &cobra.Command{ + Use: "entity-detail", + Short: "获取计分卡实体详情", + Long: `根据计分卡 id 和实体 id 获取计分卡实体的详细信息。`, + Example: ` dws agoal scorecard entity-detail --sc-id SC_ID --entity-id ENTITY_ID`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "sc-id", "entity-id"); err != nil { + return err + } + toolArgs := map[string]any{ + "scId": mustGetFlag(cmd, "sc-id"), + "entityId": mustGetFlag(cmd, "entity-id"), + } + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + return callMCPTool("get_score_card_entity_detail", toolArgs) + }, + } + scorecardEntityDetailCmd.Flags().String("sc-id", "", "计分卡 id (必填)") + scorecardEntityDetailCmd.Flags().String("entity-id", "", "计分卡实体 id (必填)") + scorecardEntityDetailCmd.Flags().String("request-id", "", "requestId (可选)") + + scorecardUpdateCmd := &cobra.Command{ + Use: "update", + Short: "更新计分卡", + Long: `更新指定计分卡的内容。 +--content 为 JSON 数组,每个维度对象包含: + id — 维度 id + title — 维度名称 + items — 指标或关键事项列表,每项包含: + id — 实体 id + title — 名称 + reference — 参考信息 + start — 起始值 + target — 目标值 + executors — 负责人列表,每项包含 openId`, + Example: ` dws agoal scorecard update --dept-id DEPT_ID --selected-time "2025-01-01T00:00:00+08:00" --id SC_ID --tracking-period-type MONTHLY --content '[{"id":"dim1","title":"业绩","items":[{"id":"item1","title":"收入","target":"100"}]}]'`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "dept-id", "selected-time", "id", "tracking-period-type", "content"); err != nil { + return err + } + selectedTimeMs, err := parseISO8601ToMillis(mustGetFlag(cmd, "selected-time")) + if err != nil { + return fmt.Errorf("--selected-time: %w", err) + } + var contentArr []any + if err := json.Unmarshal([]byte(mustGetFlag(cmd, "content")), &contentArr); err != nil { + return fmt.Errorf("--content must be a valid JSON array: %w", err) + } + toolArgs := map[string]any{ + "deptId": mustGetFlag(cmd, "dept-id"), + "selectedTime": selectedTimeMs, + "id": mustGetFlag(cmd, "id"), + "trackingPeriodType": mustGetFlag(cmd, "tracking-period-type"), + "content": contentArr, + } + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + return callMCPTool("update_score_card", toolArgs) + }, + } + scorecardUpdateCmd.Flags().String("dept-id", "", "部门 id (必填)") + scorecardUpdateCmd.Flags().String("selected-time", "", "ISO-8601 时间字符串,如 \"2026-01-01T00:00:00+08:00\" (必填)") + scorecardUpdateCmd.Flags().String("id", "", "计分卡 id (必填)") + scorecardUpdateCmd.Flags().String("tracking-period-type", "", "跟踪周期类型: MONTHLY/月度追踪、QUARTERLY/季度追踪 (必填)") + scorecardUpdateCmd.Flags().String("content", "", "内容 JSON 数组 (必填)") + scorecardUpdateCmd.Flags().String("request-id", "", "requestId (可选)") + + scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd) + + // ── user: 用户目标管理 ────────────────────────────────────── + + userCmd := &cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE} + + userRulesCmd := &cobra.Command{ + Use: "rules", + Short: "获取用户的规则周期列表", + Long: `获取用户的规则周期列表。不传 dingUserId 时默认取操作人自己的规则。`, + Example: ` dws agoal user rules + dws agoal user rules --user-id USER_ID`, + RunE: func(cmd *cobra.Command, args []string) error { + toolArgs := map[string]any{} + if v, _ := cmd.Flags().GetString("user-id"); v != "" { + toolArgs["dingUserId"] = v + } + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + return callMCPTool("get_user_rules", toolArgs) + }, + } + userRulesCmd.Flags().String("user-id", "", "要查询的人员钉钉 id (可选,默认取操作人)") + userRulesCmd.Flags().String("request-id", "", "requestId (可选)") + + userObjectivesCmd := &cobra.Command{ + Use: "objectives", + Short: "查询用户目标列表", + Long: `根据用户、规则 id 和周期列表查询用户的目标。`, + Example: ` dws agoal user objectives --user-id USER_ID --rule-id RULE_ID --period-ids "period1,period2"`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "user-id", "rule-id", "period-ids"); err != nil { + return err + } + toolArgs := map[string]any{ + "dingUserId": mustGetFlag(cmd, "user-id"), + "objectiveRuleId": mustGetFlag(cmd, "rule-id"), + "periodIds": parseCSVValues(mustGetFlag(cmd, "period-ids")), + } + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + return callMCPTool("list_user_objectives", toolArgs) + }, + } + userObjectivesCmd.Flags().String("user-id", "", "要查询的人员钉钉 id (必填)") + userObjectivesCmd.Flags().String("rule-id", "", "规则 id (必填)") + userObjectivesCmd.Flags().String("period-ids", "", "周期 id 列表,逗号分隔 (必填)") + userObjectivesCmd.Flags().String("request-id", "", "requestId (可选)") + + userCmd.AddCommand(userRulesCmd, userObjectivesCmd) + + // ── report: 周月报管理 ────────────────────────────────────── + + reportCmd := &cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE} + + reportListStatisticsCmd := &cobra.Command{ + Use: "list-statistics", + Short: "获取周月报数据跟催列表", + Long: `获取周月报规则的人员提交情况列表,包含按时提交、迟交、未提交的人员数量统计。`, + Example: ` dws agoal report list-statistics + dws agoal report list-statistics --keyword "周报规则"`, + RunE: func(cmd *cobra.Command, args []string) error { + toolArgs := map[string]any{} + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + if v, _ := cmd.Flags().GetString("keyword"); v != "" { + toolArgs["keyword"] = v + } + return callMCPTool("list_report_statistics", toolArgs) + }, + } + reportListStatisticsCmd.Flags().String("request-id", "", "requestId (可选)") + reportListStatisticsCmd.Flags().String("keyword", "", "搜索规则名称 (可选)") + + reportSubmitDetailCmd := &cobra.Command{ + Use: "submit-detail", + Short: "获取周月报规则提交详情", + Long: `获取周月报规则提交详情,包含按时提交、迟交、未提交中的具体人员以及提交的时间、周报id等。 +--submit-state 支持: + ON_TIME — 按时提交 + LATE — 迟交 + NOT_SUBMITTED — 未提交 +--query-date 接受 ISO-8601 字符串,如 "2026-06-18T00:00:00+08:00"`, + Example: ` dws agoal report submit-detail --template-id TPL_ID --submit-state ON_TIME + dws agoal report submit-detail --template-id TPL_ID --submit-state LATE --query-date "2026-06-18T00:00:00+08:00" --page 1 --page-size 20`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "template-id", "submit-state"); err != nil { + return err + } + toolArgs := map[string]any{ + "templateId": mustGetFlag(cmd, "template-id"), + "submitState": mustGetFlag(cmd, "submit-state"), + } + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + if v, _ := cmd.Flags().GetString("query-date"); v != "" { + queryDateMs, err := parseISO8601ToMillis(v) + if err != nil { + return fmt.Errorf("--query-date: %w", err) + } + toolArgs["queryDate"] = time.UnixMilli(queryDateMs).In(shanghaiLocation()).Format("2006-01-02") + } + if v, _ := cmd.Flags().GetInt("page"); v != 0 { + toolArgs["page"] = v + } + if v, _ := cmd.Flags().GetInt("page-size"); v != 0 { + toolArgs["pageSize"] = v + } + if v, _ := cmd.Flags().GetString("keyword"); v != "" { + toolArgs["keyword"] = v + } + return callMCPTool("get_submit_detail", toolArgs) + }, + } + reportSubmitDetailCmd.Flags().String("template-id", "", "规则模板 id (必填)") + reportSubmitDetailCmd.Flags().String("submit-state", "", "提交状态: ON_TIME(按时提交)/LATE(迟交)/NOT_SUBMITTED(未提交) (必填)") + reportSubmitDetailCmd.Flags().String("request-id", "", "requestId (可选)") + reportSubmitDetailCmd.Flags().String("query-date", "", "查询日期,ISO-8601 格式(如 \"2026-06-18T00:00:00+08:00\"),默认为当天 (可选)") + reportSubmitDetailCmd.Flags().Int("page", 0, "分页参数,默认为 1 (可选)") + reportSubmitDetailCmd.Flags().Int("page-size", 0, "分页参数,默认为 10 (可选)") + reportSubmitDetailCmd.Flags().String("keyword", "", "搜索员工名称 (可选)") + + reportCmd.AddCommand(reportListStatisticsCmd, reportSubmitDetailCmd) + + // ── template: 目标模板管理 ────────────────────────────────── + + objTemplateCmd := &cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE} + + objTemplateListCmd := &cobra.Command{ + Use: "list", + Short: "获取目标模板列表", + Long: `获取目标模板列表,支持关键词搜索和分页。`, + Example: ` dws agoal obj-template list + dws agoal obj-template list --keyword "业绩" --page 1 --page-size 10`, + RunE: func(cmd *cobra.Command, args []string) error { + toolArgs := map[string]any{} + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + if v, _ := cmd.Flags().GetInt("page"); v != 0 { + toolArgs["page"] = v + } + if v, _ := cmd.Flags().GetInt("page-size"); v != 0 { + toolArgs["pageSize"] = v + } + if v, _ := cmd.Flags().GetString("keyword"); v != "" { + toolArgs["keyword"] = v + } + return callMCPTool("list_obj_template", toolArgs) + }, + } + objTemplateListCmd.Flags().String("request-id", "", "requestId (可选)") + objTemplateListCmd.Flags().Int("page", 0, "页码,默认 1 (可选)") + objTemplateListCmd.Flags().Int("page-size", 0, "每页数量,默认 10 (可选)") + objTemplateListCmd.Flags().String("keyword", "", "搜索关键词 (可选)") + + objTemplateCreateOrUpdateCmd := &cobra.Command{ + Use: "create-or-update", + Short: "新增或更新目标模板", + Long: `新增或更新目标模板。覆盖逻辑,更新时一定要基于查询接口返回的老数据进行修改,新增时建议先参考已存在的模板数据。 +--dimensions 为必填参数,JSON 字符串,包含目标维度、维度配置、目标内容。 +--objective-weight 启用目标权重 +--dimension-weight 启用维度权重 +--compute-by-weight 维度是否参与计算`, + Example: ` dws agoal obj-template create-or-update --title "业绩模板" --dimensions '[{"title":"维度1","weight":100}]' + dws agoal obj-template create-or-update --template-id TPL_ID --dimensions '[...基于老数据修改...]'`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "dimensions"); err != nil { + return err + } + toolArgs := map[string]any{} + if v, _ := cmd.Flags().GetString("request-id"); v != "" { + toolArgs["requestId"] = v + } + if v, _ := cmd.Flags().GetString("template-id"); v != "" { + toolArgs["templateId"] = v + } + if v, _ := cmd.Flags().GetString("title"); v != "" { + toolArgs["title"] = v + } + if cmd.Flags().Changed("objective-weight") { + v, _ := cmd.Flags().GetBool("objective-weight") + toolArgs["objectiveWeight"] = v + } + if cmd.Flags().Changed("dimension-weight") { + v, _ := cmd.Flags().GetBool("dimension-weight") + toolArgs["dimensionWeight"] = v + } + if cmd.Flags().Changed("compute-by-weight") { + v, _ := cmd.Flags().GetBool("compute-by-weight") + toolArgs["computeByWeight"] = v + } + toolArgs["dimensions"] = mustGetFlag(cmd, "dimensions") + return callMCPTool("create_or_update_obj_template", toolArgs) + }, + } + objTemplateCreateOrUpdateCmd.Flags().String("request-id", "", "requestId (可选)") + objTemplateCreateOrUpdateCmd.Flags().String("template-id", "", "模板 id (更新时必填)") + objTemplateCreateOrUpdateCmd.Flags().String("title", "", "模板标题 (新增时必填)") + objTemplateCreateOrUpdateCmd.Flags().Bool("objective-weight", false, "是否启用目标权重") + objTemplateCreateOrUpdateCmd.Flags().Bool("dimension-weight", false, "是否启用维度权重") + objTemplateCreateOrUpdateCmd.Flags().Bool("compute-by-weight", false, "维度是否参与计算") + objTemplateCreateOrUpdateCmd.Flags().String("dimensions", "", "模板关联的维度 JSON 字符串 (必填,更新时基于老数据修改,新增时建议参考已有模板)") + + objTemplateCmd.AddCommand(objTemplateListCmd, objTemplateCreateOrUpdateCmd) + + root.AddCommand(strategyCmd, contractCmd, scorecardCmd, userCmd, reportCmd, objTemplateCmd) + + return root +} + +// parseISO8601ToMillis 将 ISO-8601 时间字符串解析为毫秒时间戳。 +// 支持格式:RFC3339(含时区)、无时区(默认 Asia/Shanghai)、仅日期。 +func parseISO8601ToMillis(value string) (int64, error) { + if t, err := time.Parse(time.RFC3339, value); err == nil { + return t.UnixMilli(), nil + } + + shanghaiLoc := shanghaiLocation() + + if t, err := time.ParseInLocation("2006-01-02T15:04:05", value, shanghaiLoc); err == nil { + return t.UnixMilli(), nil + } + if t, err := time.ParseInLocation("2006-01-02 15:04:05", value, shanghaiLoc); err == nil { + return t.UnixMilli(), nil + } + if t, err := time.ParseInLocation("2006-01-02", value, shanghaiLoc); err == nil { + return t.UnixMilli(), nil + } + + return 0, fmt.Errorf("invalid ISO-8601 time format %q, expected e.g. \"2026-01-01T00:00:00+08:00\"", value) +} + +func shanghaiLocation() *time.Location { + loc, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + return time.FixedZone("CST", 8*3600) + } + return loc +} diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index 05e25e4a..a2ce15b5 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -4290,7 +4290,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。 baseGetPrimaryDocIdCmd.Flags().String("table-id", "", "Table ID,可通过 list_tables 或 get_base 获取 (必填)") baseGetPrimaryDocIdCmd.Flags().String("record-id", "", "记录 ID (必填)") baseCopyCmd.Flags().String("base-id", "", "源 Base ID (必填)") - baseCopyCmd.Flags().String("target-folder-id", "", "目标文件夹 ID (必填), 默认为当前 Base 的父节点") + baseCopyCmd.Flags().String("target-folder-id", "", "目标文件夹 ID (必填, 不传会复制失败)") baseCopyCmd.Flags().Bool("only-struct", false, "是否仅复制结构(不含数据),默认 false 表示完整复制") baseCmd.AddCommand( baseListCmd, baseSearchCmd, baseGetCmd, diff --git a/internal/helpers/attendance.go b/internal/helpers/attendance.go index 1bf2239c..9b4c890c 100644 --- a/internal/helpers/attendance.go +++ b/internal/helpers/attendance.go @@ -2333,7 +2333,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, 子命令: types 查询当前用户假期规则列表 - save-type 创建或更新假期规则(仅支持无额度模式) + update-type 更新假期规则(仅支持无额度模式) balance 查询指定员工假期余额 save-balance 更新员工假期余额 records 查询指定员工假期余额变更记录`, @@ -3136,11 +3136,11 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, // vacation balance (get_leave_balance_quota) vacationBalanceCmd.Flags().String("users", "", "目标员工 ID 列表,逗号分隔 (必填)") - vacationBalanceCmd.Flags().String("leave-code", "", "假期规则 code (选填,不传则查询所有假期)") + vacationBalanceCmd.Flags().String("leave-code", "", "假期规则 code (必填,服务端要求非空,不传返回 INVALID_PARAMS)") // vacation records (get_leave_balance_records) vacationRecordsCmd.Flags().String("user", "", "指定查询员工 ID (必填)") - vacationRecordsCmd.Flags().String("leave-code", "", "假期规则 code (选填,不传则查询所有假期)") + vacationRecordsCmd.Flags().String("leave-code", "", "假期规则 code (必填,服务端要求非空,不传返回 INVALID_PARAMS)") vacationRecordsCmd.Flags().String("start", "", "查询开始日期,格式 YYYY-MM-DD (必填)") vacationRecordsCmd.Flags().String("end", "", "查询结束日期,格式 YYYY-MM-DD (必填)") diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 8cb9ff1c..7ed720b3 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -15,6 +15,7 @@ import ( "strings" "time" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -1039,7 +1040,7 @@ func newChatCommand() *cobra.Command { Use: "chat", Aliases: []string{"im"}, Short: "群聊 / 消息 / 机器人", - Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取会话消息、发送群消息、机器人消息与 Webhook。`, + Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取/发送/收藏会话消息、机器人消息与 Webhook。`, RunE: groupRunE, } @@ -1325,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", @@ -3069,7 +3075,7 @@ func newChatCommand() *cobra.Command { chatMessageCreateTextEmotionCmd := &cobra.Command{ Use: "create-text-emotion", Short: "创建文字表情(获取 emotionId)", - Long: `创建一个新的文字表情模板。当 list-emotions 中没有所需表情时,使用此命令创建并获取 emotionId,随后可用于 add-text-emotion。`, + Long: `创建一个新的文字表情模板。当内置表情(见 chat-emoji-list.md)中没有所需表情时,使用此命令创建并获取 emotionId,随后可用于 add-text-emotion。`, Example: ` dws chat message create-text-emotion --emotion-name "赞" --text "nice" dws chat message create-text-emotion --emotion-name "感谢" --text "感谢" --background-id im_bg_5`, RunE: func(cmd *cobra.Command, args []string) error { @@ -3228,12 +3234,21 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 return err } - // 解析输出路径:如果是目录,从 URL 推断文件名 + // 解析输出路径:目录(已存在的目录,或以分隔符结尾的意图目录)→ 追加推断文件名。 fi, statErr := os.Stat(outputPath) - if statErr == nil && fi.IsDir() { + isDir := (statErr == nil && fi.IsDir()) || + strings.HasSuffix(outputPath, string(os.PathSeparator)) || + strings.HasSuffix(outputPath, "/") + if isDir { filename := inferFilename(resourceURL) outputPath = filepath.Join(outputPath, filename) } + // 确保目标父目录存在,否则 httpGetFile 打开文件会因目录缺失失败。 + if dir := filepath.Dir(outputPath); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("创建输出目录失败: %w", err) + } + } // Step 2: HTTP GET 下载文件 deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载资源到 %s ...", outputPath)) @@ -4234,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 --open-conversation-id `, + 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 --open-conversation-id `, + 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{ @@ -4316,16 +4405,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupAuditJoinValidationCmd := &cobra.Command{ Use: "audit-join-validation", Short: "审批入群验证(通过、拒绝、删除)", - Long: `审批入群验证。支持通过、拒绝、删除、忽略、拒绝并拉黑等操作。 + Long: `审批入群验证。真机实测服务端仅接受 AuditApprove / AuditDelete,其余状态会被拒绝(unsupported audit status)。 status 可选值: - AuditApprove — 通过 - AuditDelete — 删除 - AuditIgnore — 忽略 - AuditRefuse — 拒绝 - AuditBlock — 拒绝且不再接受该用户的申请`, + AuditApprove — 通过(可用) + AuditDelete — 删除(可用) + AuditIgnore — 忽略(服务端拒绝,不可用) + AuditRefuse — 拒绝(服务端拒绝,不可用) + AuditBlock — 拒绝且不再接受该用户的申请(服务端拒绝,不可用)`, Example: ` dws chat group audit-join-validation --group --record-id 123456 --applicant --inviter --status AuditApprove - dws chat group audit-join-validation --group --record-id 123456 --applicant --inviter --status AuditRefuse --description "不符合入群条件" + dws chat group audit-join-validation --group --record-id 123456 --applicant --inviter --status AuditDelete --description "不符合入群条件" # 查询入群验证记录: dws chat group list-join-validations`, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "group", "record-id", "applicant", "inviter", "status"); err != nil { @@ -4352,7 +4441,7 @@ status 可选值: _ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("group") chatGroupAuditJoinValidationCmd.Flags().String("record-id", "", "申请记录 ID (必填)") _ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("record-id") - chatGroupAuditJoinValidationCmd.Flags().String("status", "", "审批动作: AuditApprove/AuditDelete/AuditIgnore/AuditRefuse/AuditBlock (必填)") + chatGroupAuditJoinValidationCmd.Flags().String("status", "", "审批动作,真机仅 AuditApprove/AuditDelete 可用;AuditIgnore/AuditRefuse/AuditBlock 服务端拒绝 (必填)") _ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("status") chatGroupAuditJoinValidationCmd.Flags().String("applicant", "", "申请人 openDingTalkId (必填)") _ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("applicant") @@ -4732,12 +4821,286 @@ status 可选值: chatGroupMembersListByIdsCmd.Flags().String("users", "", "成员 openDingTalkId 列表,逗号分隔 (必填)") _ = chatGroupMembersListByIdsCmd.MarkFlagRequired("users") - chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd, chatGroupUpdateNickCmd, chatGroupUpdateAliasCmd, chatGroupListAllCmd, chatGroupListJoinValidationsCmd, chatGroupAuditJoinValidationCmd) + // ── group notice: 群公告管理 ──────────────────────────────── + chatGroupNoticeCmd := &cobra.Command{Use: "notice", Short: "群公告管理", RunE: groupRunE} + + chatGroupNoticeCreateCmd := &cobra.Command{ + Use: "create", + Short: "发布群公告", + Long: `在指定群聊中发布群公告,正文为 Markdown 格式。 +支持标题、加粗、斜体、删除线、行内代码、链接、代码块、有序/无序/任务列表、表格、引用、分割线、图片、段落、换行。 +定时发布:传 --run-at 指定执行时间点,ISO-8601 格式(建议带时区偏移,不带时按北京时区处理)。`, + Example: ` dws chat group notice create --group --content "今晚 22 点系统维护,请提前保存工作内容" + dws chat group notice create --group --content "# 重要通知\n请大家查收" --sticky --send-ding + dws chat group notice create --group --content "明早九点例会" --run-at "2026-07-03T09:00:00+08:00" + # 查询群 ID: dws chat search --query "群名"`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "group", "content"); err != nil { + return err + } + toolArgs := map[string]any{ + "openConversationId": mustGetFlag(cmd, "group"), + "content": mustGetFlag(cmd, "content"), + } + if v, _ := cmd.Flags().GetBool("sticky"); v { + toolArgs["sticky"] = true + } + if v, _ := cmd.Flags().GetBool("send-ding"); v { + toolArgs["sendDing"] = true + } + if v, _ := cmd.Flags().GetString("run-at"); v != "" { + toolArgs["scheduled"] = true + toolArgs["runAtText"] = v + } + return callMCPToolOnServer("im", "create_group_notice", toolArgs) + }, + } + chatGroupNoticeCreateCmd.Flags().String("group", "", "群聊 openConversationId (必填)") + _ = chatGroupNoticeCreateCmd.MarkFlagRequired("group") + chatGroupNoticeCreateCmd.Flags().String("content", "", "公告正文,Markdown 格式 (必填)") + _ = chatGroupNoticeCreateCmd.MarkFlagRequired("content") + chatGroupNoticeCreateCmd.Flags().Bool("sticky", false, "是否吊顶置顶(默认 false)") + chatGroupNoticeCreateCmd.Flags().Bool("send-ding", false, "是否发 DING 提醒(默认 false)") + chatGroupNoticeCreateCmd.Flags().String("run-at", "", "定时发布时间 ISO-8601(如 2026-07-03T09:00:00+08:00,传入则定时发布)") + + chatGroupNoticeEditCmd := &cobra.Command{ + Use: "edit", + Short: "修改群公告", + Long: `修改指定群聊中的群公告,正文为 Markdown 格式,会整体替换原公告内容。`, + Example: ` dws chat group notice edit --group --notice-id --content "更新后的公告内容" + dws chat group notice edit --group --notice-id --content "更新后的公告内容" --sticky --send-ding + # 查询公告 ID: dws chat group notice list --group `, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "group", "notice-id", "content"); err != nil { + return err + } + toolArgs := map[string]any{ + "openConversationId": mustGetFlag(cmd, "group"), + "dataId": mustGetFlag(cmd, "notice-id"), + "content": mustGetFlag(cmd, "content"), + } + if v, _ := cmd.Flags().GetBool("sticky"); v { + toolArgs["sticky"] = true + } + if v, _ := cmd.Flags().GetBool("send-ding"); v { + toolArgs["sendDing"] = true + } + return callMCPToolOnServer("im", "edit_group_notice", toolArgs) + }, + } + chatGroupNoticeEditCmd.Flags().String("group", "", "群聊 openConversationId (必填)") + _ = chatGroupNoticeEditCmd.MarkFlagRequired("group") + chatGroupNoticeEditCmd.Flags().String("notice-id", "", "群公告 dataId (必填)") + _ = chatGroupNoticeEditCmd.MarkFlagRequired("notice-id") + chatGroupNoticeEditCmd.Flags().String("content", "", "公告新正文,Markdown 格式 (必填)") + _ = chatGroupNoticeEditCmd.MarkFlagRequired("content") + chatGroupNoticeEditCmd.Flags().Bool("sticky", false, "是否吊顶置顶(不传按 false 处理)") + chatGroupNoticeEditCmd.Flags().Bool("send-ding", false, "是否发 DING 提醒(默认 false)") + + chatGroupNoticeGetCmd := &cobra.Command{ + Use: "get", + Short: "查看群公告详情", + Long: `查看指定群公告的详情,包含正文摘要、吊顶状态、发布者、已读人数、点赞/评论数等信息。`, + Example: ` dws chat group notice get --group --notice-id + # 查询公告 ID: dws chat group notice list --group `, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "group", "notice-id"); err != nil { + return err + } + return callMCPToolOnServer("im", "get_group_notice", map[string]any{ + "openConversationId": mustGetFlag(cmd, "group"), + "dataId": mustGetFlag(cmd, "notice-id"), + }) + }, + } + chatGroupNoticeGetCmd.Flags().String("group", "", "群聊 openConversationId (必填)") + _ = chatGroupNoticeGetCmd.MarkFlagRequired("group") + chatGroupNoticeGetCmd.Flags().String("notice-id", "", "群公告 dataId (必填)") + _ = chatGroupNoticeGetCmd.MarkFlagRequired("notice-id") + + chatGroupNoticeListCmd := &cobra.Command{ + Use: "list", + Short: "查看群公告列表", + Long: `分页查看指定群聊的群公告列表。默认查询已发布公告,传 --scheduled 查询定时公告列表。 +支持游标分页,hasMore=true 时用返回的 nextPageCursor 作为下次 --cursor。`, + Example: ` dws chat group notice list --group + dws chat group notice list --group --limit 20 --cursor + dws chat group notice list --group --scheduled + # 查询群 ID: dws chat search --query "群名"`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "group"); err != nil { + return err + } + toolArgs := map[string]any{ + "openConversationId": mustGetFlag(cmd, "group"), + } + if v, _ := cmd.Flags().GetInt("limit"); v > 0 { + toolArgs["limit"] = v + } + if v, _ := cmd.Flags().GetString("cursor"); v != "" { + toolArgs["cursor"] = v + } + if v, _ := cmd.Flags().GetBool("scheduled"); v { + toolArgs["scheduled"] = true + } + return callMCPToolOnServer("im", "list_group_notices", toolArgs) + }, + } + chatGroupNoticeListCmd.Flags().String("group", "", "群聊 openConversationId (必填)") + _ = chatGroupNoticeListCmd.MarkFlagRequired("group") + chatGroupNoticeListCmd.Flags().Int("limit", 10, "每页返回数量(默认 10,最大 100)") + chatGroupNoticeListCmd.Flags().String("cursor", "", "分页游标(首次不传,翻页传返回的 nextPageCursor)") + chatGroupNoticeListCmd.Flags().Bool("scheduled", false, "是否查询定时公告列表(默认 false,查询已发布公告)") + chatGroupNoticeCmd.AddCommand(chatGroupNoticeCreateCmd, chatGroupNoticeEditCmd, chatGroupNoticeGetCmd, chatGroupNoticeListCmd) + + chatGroupShareInviteCmd := &cobra.Command{ + Use: "share-invite", + Short: "分享群聊链接到会话", + Long: `将指定群的邀请链接分享到另一个会话或单聊用户。--target 和 --receiver 二选一:--target 指定目标会话,--receiver 指定单聊用户。`, + Example: ` dws chat group share-invite --source <被分享群openConversationId> --target <目标会话openConversationId> + dws chat group share-invite --source <被分享群openConversationId> --receiver <接收者openDingTalkId> + dws chat group share-invite --source --target --expires-seconds 86400 + # 查询群 ID: dws chat search --query "群名"`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "source"); err != nil { + return err + } + target, _ := cmd.Flags().GetString("target") + receiver, _ := cmd.Flags().GetString("receiver") + if target == "" && receiver == "" { + return fmt.Errorf("--target or --receiver is required") + } + if target != "" && receiver != "" { + return fmt.Errorf("--target and --receiver are mutually exclusive") + } + toolArgs := map[string]any{ + "sourceOpenConversationId": mustGetFlag(cmd, "source"), + } + if target != "" { + toolArgs["targetOpenConversationId"] = target + } + if receiver != "" { + toolArgs["receiverOpenDingTalkId"] = receiver + } + if v, _ := cmd.Flags().GetInt64("expires-seconds"); v > 0 || cmd.Flags().Changed("expires-seconds") { + toolArgs["expiresSeconds"] = v + } + if v, _ := cmd.Flags().GetString("uuid"); v != "" { + toolArgs["uuid"] = v + } + return callMCPToolOnServer("im", "share_group_invite_url", toolArgs) + }, + } + chatGroupShareInviteCmd.Flags().String("source", "", "被分享群的 openConversationId (必填)") + _ = chatGroupShareInviteCmd.MarkFlagRequired("source") + chatGroupShareInviteCmd.Flags().String("target", "", "接收分享消息的会话 openConversationId(与 --receiver 二选一)") + chatGroupShareInviteCmd.Flags().String("receiver", "", "接收分享消息的单聊用户 openDingTalkId(与 --target 二选一)") + chatGroupShareInviteCmd.Flags().Int64("expires-seconds", 0, "链接有效期(秒),0 表示永久有效,不传使用服务端默认值") + chatGroupShareInviteCmd.Flags().String("uuid", "", "消息幂等键(可选)") + + chatCategoryCreateSmartCmd := &cobra.Command{ + Use: "create-smart", + Short: "创建智能会话分组", + Long: `创建智能会话分组,可指定群名称关键词和群内成员 openDingTalkId 作为匹配规则。`, + Example: ` dws chat category create-smart --name "工作群" + dws chat category create-smart --name "项目组" --keywords "项目,开发" + dws chat category create-smart --name "团队群" --members openDingTalkId1,openDingTalkId2 + dws chat category create-smart --name "重点群" --keywords "重点" --members openDingTalkId1`, + RunE: func(cmd *cobra.Command, args []string) error { + name := strings.TrimSpace(mustGetFlag(cmd, "name")) + if name == "" { + return apperrors.NewValidation("--name must not be blank") + } + toolArgs := map[string]any{ + "categoryName": name, + } + if cmd.Flags().Changed("keywords") { + values := parseCSVValues(mustGetFlag(cmd, "keywords")) + if len(values) == 0 { + return apperrors.NewValidation("--keywords must contain at least one non-empty value") + } + toolArgs["groupNameKeywords"] = values + } + if cmd.Flags().Changed("members") { + values := parseCSVValues(mustGetFlag(cmd, "members")) + if len(values) == 0 { + return apperrors.NewValidation("--members must contain at least one non-empty value") + } + toolArgs["memberOpenDingTalkIds"] = values + } + return callMCPToolOnServer("im", "create_smart_conv_category", toolArgs) + }, + } + chatCategoryCreateSmartCmd.Flags().String("name", "", "分组名称 (必填)") + _ = chatCategoryCreateSmartCmd.MarkFlagRequired("name") + chatCategoryCreateSmartCmd.Flags().String("keywords", "", "群名称关键词列表,逗号分隔(可选)") + chatCategoryCreateSmartCmd.Flags().String("members", "", "群内成员 openDingTalkId 列表,逗号分隔(可选)") + + chatMessageListEmotionRepliesCmd := &cobra.Command{ + Use: "list-emotion-replies", + Short: "批量拉取消息的表情回复和文字回复", + Example: ` dws chat message list-emotion-replies --msg-ids msgId1,msgId2,msgId3 + # 消息 ID 可通过 dws chat message list 获取`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "msg-ids"); err != nil { + return err + } + msgIds := parseCSVValues(mustGetFlag(cmd, "msg-ids")) + return callMCPToolOnServer("im", "list_message_emotion_replies", map[string]any{ + "openMessageIds": msgIds, + }) + }, + } + chatMessageListEmotionRepliesCmd.Flags().String("msg-ids", "", "消息 ID 列表,逗号分隔 (必填)") + _ = chatMessageListEmotionRepliesCmd.MarkFlagRequired("msg-ids") + + supportedTranslateLanguages := map[string]bool{ + "en_US": true, "zh_CN": true, "zh_TW": true, "zh_HK": true, + "ja_JP": true, "ko_KR": true, "vi_VN": true, "th_TH": true, + "id_ID": true, "ms_MY": true, "es_419": true, "fr_FR": true, + "pt_BR": true, "tr_TR": true, "ru_RU": true, "de_DE": true, + "hi_IN": true, "hu_HU": true, "pl_PL": true, "sv_SE": true, + "fi_FI": true, "cs_CZ": true, "ar_SA": true, "tl_PH": true, + "he_IL": true, "nl_NL": true, "lo_LA": true, "it_IT": true, + } + chatTextCmd := &cobra.Command{Use: "text", Short: "文本内容处理", RunE: groupRunE} + chatTextTranslateCmd := &cobra.Command{ + Use: "translate", + Short: "翻译文本内容", + Long: `将指定文本翻译成目标语言。 +支持的目标语言代码: en_US, zh_CN, zh_TW, zh_HK, ja_JP, ko_KR, vi_VN, th_TH, +id_ID, ms_MY, es_419, fr_FR, pt_BR, tr_TR, ru_RU, de_DE, hi_IN, hu_HU, +pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`, + Example: ` dws chat text translate --query "你好世界" --to en_US + dws chat text translate --query "Hello World" --to zh_CN + dws chat text translate --query "Bonjour" --to ja_JP`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "query", "to"); err != nil { + return err + } + toLang := mustGetFlag(cmd, "to") + if !supportedTranslateLanguages[toLang] { + return fmt.Errorf("unsupported target language: %s", toLang) + } + return callMCPToolOnServer("im", "translate", map[string]any{ + "query": mustGetFlag(cmd, "query"), + "to": toLang, + }) + }, + } + chatTextTranslateCmd.Flags().String("query", "", "待翻译的文本内容 (必填)") + _ = chatTextTranslateCmd.MarkFlagRequired("query") + chatTextTranslateCmd.Flags().String("to", "en_US", "目标语言代码 (必填,默认 en_US)") + _ = chatTextTranslateCmd.MarkFlagRequired("to") + chatTextCmd.AddCommand(chatTextTranslateCmd) + + chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd, chatGroupUpdateNickCmd, chatGroupUpdateAliasCmd, chatGroupListAllCmd, chatGroupListJoinValidationsCmd, chatGroupAuditJoinValidationCmd, chatGroupNoticeCmd, chatGroupShareInviteCmd) chatGroupMembersCmd.AddCommand(chatGroupMembersRemoveBotCmd, chatGroupMembersListByIdsCmd) chatBotCmd.AddCommand(chatBotFindCmd) - chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd) + chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd) + 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) + 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) // hint: dws chat send → dws chat message send root.AddCommand(hintSubCmd("send", "use: dws chat message send")) diff --git a/internal/helpers/chat_favorites_test.go b/internal/helpers/chat_favorites_test.go new file mode 100644 index 00000000..e13d9fd6 --- /dev/null +++ b/internal/helpers/chat_favorites_test.go @@ -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)) + } +} diff --git a/internal/helpers/chat_message_search_test.go b/internal/helpers/chat_message_search_test.go new file mode 100644 index 00000000..f036ef71 --- /dev/null +++ b/internal/helpers/chat_message_search_test.go @@ -0,0 +1,129 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "context" + "io" + "os" + "reflect" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type chatMessageSearchCall struct { + productID string + toolName string + args map[string]any +} + +type chatMessageSearchCaller struct { + calls []chatMessageSearchCall +} + +func (c *chatMessageSearchCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, chatMessageSearchCall{productID: productID, toolName: toolName, args: args}) + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil +} + +func (*chatMessageSearchCaller) Format() string { return "json" } +func (*chatMessageSearchCaller) DryRun() bool { return false } +func (*chatMessageSearchCaller) Fields() string { return "" } +func (*chatMessageSearchCaller) JQ() string { return "" } + +func TestChatMessageSearchUsesMCPContracts(t *testing.T) { + previousDeps := deps + previousArgs := os.Args + t.Cleanup(func() { + deps = previousDeps + os.Args = previousArgs + }) + + start := "2026-07-09T00:00:00+08:00" + end := "2026-07-11T00:00:00+08:00" + startTime, err := time.Parse(time.RFC3339, start) + if err != nil { + t.Fatalf("parse start time: %v", err) + } + endTime, err := time.Parse(time.RFC3339, end) + if err != nil { + t.Fatalf("parse end time: %v", err) + } + + tests := []struct { + name string + args []string + productID string + toolName string + wantToolArg map[string]any + }{ + { + name: "keyword search", + args: []string{"message", "search", "--query", "categoryName", "--group", "cid-1", "--start", start, "--end", end, "--limit", "100", "--cursor", "0"}, + productID: "chat", + toolName: "search_messages_by_keyword", + wantToolArg: map[string]any{ + "keyword": "categoryName", + "openConversationId": "cid-1", + "startTime": startTime.UnixMilli(), + "endTime": endTime.UnixMilli(), + "limit": 100, + "cursor": "0", + }, + }, + { + name: "advanced search", + args: []string{"message", "search-advanced", "--query", "categoryName", "--conversation-ids", "cid-1,cid-2", "--start", start, "--end", end, "--limit", "100", "--cursor", "0"}, + productID: "im", + toolName: "search_messages", + wantToolArg: map[string]any{ + "keyword": "categoryName", + "openConversationIds": []string{"cid-1", "cid-2"}, + "startTime": startTime.UnixMilli(), + "endTime": endTime.UnixMilli(), + "limit": 100, + "cursor": "0", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caller := &chatMessageSearchCaller{} + InitDeps(caller) + deps.Out.w = io.Discard + os.Args = []string{"dws", "chat"} + + cmd := newChatCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs(tt.args) + if err := cmd.Execute(); err != nil { + t.Fatalf("chat search returned error: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("tool call count = %d, want 1", len(caller.calls)) + } + call := caller.calls[0] + if call.productID != tt.productID || call.toolName != tt.toolName { + t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tt.productID, tt.toolName) + } + if !reflect.DeepEqual(call.args, tt.wantToolArg) { + t.Fatalf("tool args = %#v, want %#v", call.args, tt.wantToolArg) + } + }) + } +} diff --git a/internal/helpers/chat_smart_category_test.go b/internal/helpers/chat_smart_category_test.go new file mode 100644 index 00000000..abb7d499 --- /dev/null +++ b/internal/helpers/chat_smart_category_test.go @@ -0,0 +1,187 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "context" + "io" + "reflect" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type chatSmartCategoryCall struct { + productID string + toolName string + args map[string]any +} + +type chatSmartCategoryCaller struct { + calls []chatSmartCategoryCall +} + +func (c *chatSmartCategoryCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, chatSmartCategoryCall{ + productID: productID, + toolName: toolName, + args: args, + }) + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil +} + +func (*chatSmartCategoryCaller) Format() string { return "json" } +func (*chatSmartCategoryCaller) DryRun() bool { return false } +func (*chatSmartCategoryCaller) Fields() string { return "" } +func (*chatSmartCategoryCaller) JQ() string { return "" } + +func TestChatCategoryCreateSmartUsesMCPParameterNames(t *testing.T) { + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + caller := &chatSmartCategoryCaller{} + InitDeps(caller) + deps.Out.w = io.Discard + + cmd := newChatCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{ + "category", "create-smart", + "--name", "priority", + "--keywords", "alpha, ,beta,", + "--members", "open-id-1, ,open-id-2,", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("chat category create-smart returned error: %v", err) + } + + if len(caller.calls) != 1 { + t.Fatalf("tool call count = %d, want 1", len(caller.calls)) + } + call := caller.calls[0] + if call.productID != "im" || call.toolName != "create_smart_conv_category" { + t.Fatalf("tool call = %s/%s, want im/create_smart_conv_category", call.productID, call.toolName) + } + want := map[string]any{ + "categoryName": "priority", + "groupNameKeywords": []string{"alpha", "beta"}, + "memberOpenDingTalkIds": []string{"open-id-1", "open-id-2"}, + } + if !reflect.DeepEqual(call.args, want) { + t.Fatalf("tool args = %#v, want %#v", call.args, want) + } +} + +func TestChatCategoryCreateSmartRejectsBlankInputs(t *testing.T) { + tests := []struct { + name string + args []string + wantErr string + }{ + { + name: "empty name", + args: []string{"--name", ""}, + wantErr: "--name must not be blank", + }, + { + name: "blank name", + args: []string{"--name", " \t "}, + wantErr: "--name must not be blank", + }, + { + name: "empty keywords", + args: []string{"--name", "priority", "--keywords", ""}, + wantErr: "--keywords must contain at least one non-empty value", + }, + { + name: "blank keywords CSV", + args: []string{"--name", "priority", "--keywords", " , , "}, + wantErr: "--keywords must contain at least one non-empty value", + }, + { + name: "empty keywords JSON array", + args: []string{"--name", "priority", "--keywords", "[]"}, + wantErr: "--keywords must contain at least one non-empty value", + }, + { + name: "empty members", + args: []string{"--name", "priority", "--members", ""}, + wantErr: "--members must contain at least one non-empty value", + }, + { + name: "blank members CSV", + args: []string{"--name", "priority", "--members", " , , "}, + wantErr: "--members must contain at least one non-empty value", + }, + { + name: "blank members JSON array", + args: []string{"--name", "priority", "--members", `["", " "]`}, + wantErr: "--members must contain at least one non-empty value", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + caller := &chatSmartCategoryCaller{} + InitDeps(caller) + deps.Out.w = io.Discard + + cmd := newChatCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs(append([]string{"category", "create-smart"}, tt.args...)) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want containing %q", err, tt.wantErr) + } + if got := apperrors.ExitCode(err); got != 3 { + t.Fatalf("exit code = %d, want validation code 3", got) + } + if len(caller.calls) != 0 { + t.Fatalf("tool call count = %d, want 0", len(caller.calls)) + } + }) + } +} + +func TestChatCategoryCreateSmartAllowsOmittedRulesAndTrimsName(t *testing.T) { + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + caller := &chatSmartCategoryCaller{} + InitDeps(caller) + deps.Out.w = io.Discard + + cmd := newChatCommand() + cmd.SilenceErrors = true + cmd.SilenceUsage = true + cmd.SetArgs([]string{"category", "create-smart", "--name", " priority "}) + if err := cmd.Execute(); err != nil { + t.Fatalf("chat category create-smart returned error: %v", err) + } + + if len(caller.calls) != 1 { + t.Fatalf("tool call count = %d, want 1", len(caller.calls)) + } + want := map[string]any{"categoryName": "priority"} + if !reflect.DeepEqual(caller.calls[0].args, want) { + t.Fatalf("tool args = %#v, want %#v", caller.calls[0].args, want) + } +} diff --git a/internal/helpers/connect_agent_options_test.go b/internal/helpers/connect_agent_options_test.go index 1c8653a4..d7a2b3ff 100644 --- a/internal/helpers/connect_agent_options_test.go +++ b/internal/helpers/connect_agent_options_test.go @@ -57,8 +57,8 @@ func TestConvSessions(t *testing.T) { } // TestApplyModelArg covers both shapes: replacing an existing model pin -// (claudecode's built-in haiku) and inserting before the tail (gemini-style -// tails that end with -p and need the prompt to stay trailing). +// (claudecode's built-in haiku) and inserting before a prompt tail that must +// stay trailing. func TestApplyModelArg(t *testing.T) { replaced := applyModelArg( []string{"claude", "-p", "--model", "claude-haiku-4-5-20251001", "--strict-mcp-config"}, @@ -147,6 +147,128 @@ func TestForwarderSessionAndModelWiring(t *testing.T) { } } +func TestBuiltInAgentForwardersDefaultToPureChannelPermissions(t *testing.T) { + clearChannelEnv(t) + t.Setenv("DWS_CONNECT_NO_INSTALL", "1") + t.Setenv("DWS_AGENT_CMD", "") + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + stub := t.TempDir() + for _, name := range []string{"claude", "codebuddy", "codex", "opencode", "qodercli"} { + if err := writeExecStub(stub, name); err != nil { + t.Fatalf("stub %s: %v", name, err) + } + } + t.Setenv("PATH", stub) + + cases := []struct { + channel string + want []string + }{ + {"claudecode", []string{"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions"}}, + {"codebuddy", []string{"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions"}}, + {"workbuddy", []string{"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions"}}, + } + for _, tc := range cases { + t.Run(tc.channel, func(t *testing.T) { + fwd, err := forwarderForChannel(tc.channel, "", connectAgentOptions{Memory: true, Yolo: true}) + if err != nil { + t.Fatalf("forwarderForChannel(%q): %v", tc.channel, err) + } + ef, ok := fwd.(*execForwarder) + if !ok { + t.Fatalf("%s forwarder = %T, want *execForwarder", tc.channel, fwd) + } + got := strings.Join(ef.argv, " ") + for _, want := range tc.want { + if !strings.Contains(got, want) { + t.Fatalf("%s argv missing %q: %s", tc.channel, want, got) + } + } + if len(ef.streamArgv) > 0 { + streamGot := strings.Join(ef.streamArgv, " ") + for _, want := range tc.want { + if !strings.Contains(streamGot, want) { + t.Fatalf("%s stream argv missing %q: %s", tc.channel, want, streamGot) + } + } + } + }) + } + + t.Run("gemini", func(t *testing.T) { + t.Setenv("GEMINI_API_KEY", "test-key") + fwd, err := forwarderForChannel("gemini", "gemini-client", connectAgentOptions{Memory: true, Yolo: true, Model: "gemini-test"}) + if err != nil { + t.Fatalf("forwarderForChannel(gemini): %v", err) + } + gf, ok := fwd.(*geminiAPIForwarder) + if !ok { + t.Fatalf("gemini forwarder = %T, want *geminiAPIForwarder", fwd) + } + if gf.model != "gemini-test" { + t.Fatalf("gemini model = %q, want gemini-test", gf.model) + } + }) + + for _, ch := range []string{"qoder", "qoderwork"} { + t.Run(ch, func(t *testing.T) { + fwd, err := forwarderForChannel(ch, "client-"+ch, connectAgentOptions{Memory: true, Yolo: true}) + if err != nil { + t.Fatalf("forwarderForChannel(%q): %v", ch, err) + } + qf, ok := fwd.(*qoderStreamForwarder) + if !ok { + t.Fatalf("%s forwarder = %T, want *qoderStreamForwarder", ch, fwd) + } + got := strings.Join(qf.commandArgs(), " ") + for _, want := range []string{"--permission-mode", "bypass_permissions", "--dangerously-skip-permissions"} { + if !strings.Contains(got, want) { + t.Fatalf("%s command args missing %q: %s", ch, want, got) + } + } + }) + } + + t.Run("codex", func(t *testing.T) { + fwd, err := forwarderForChannel("codex", "codex-client", connectAgentOptions{Memory: true, Yolo: true}) + if err != nil { + t.Fatalf("forwarderForChannel(codex): %v", err) + } + cf, ok := fwd.(*codexAppServerForwarder) + if !ok { + t.Fatalf("codex forwarder = %T, want *codexAppServerForwarder", fwd) + } + params := cf.threadParams("") + if got := params["approvalPolicy"]; got != "never" { + t.Fatalf("codex approvalPolicy = %v, want never", got) + } + if got := params["sandbox"]; got != "workspace-write" { + t.Fatalf("codex yolo sandbox = %v, want workspace-write", got) + } + }) + + t.Run("opencode", func(t *testing.T) { + fwd, err := forwarderForChannel("opencode", "opencode-client", connectAgentOptions{Memory: true, Yolo: true}) + if err != nil { + t.Fatalf("forwarderForChannel(opencode): %v", err) + } + of, ok := fwd.(*opencodeForwarder) + if !ok { + t.Fatalf("opencode forwarder = %T, want *opencodeForwarder", fwd) + } + env := of.server.commandEnv("pw") + if !strings.Contains(envValue(env, opencodeConfigContentEnv), `"question":false`) { + t.Fatalf("opencode config should disable question tool: %s", envValue(env, opencodeConfigContentEnv)) + } + if !strings.Contains(envValue(env, opencodePermissionEnv), `"question":"deny"`) { + t.Fatalf("opencode permission should deny question: %s", envValue(env, opencodePermissionEnv)) + } + if !strings.Contains(envValue(env, opencodePermissionEnv), `"bash":"allow"`) { + t.Fatalf("opencode permission should allow gated tools: %s", envValue(env, opencodePermissionEnv)) + } + }) +} + // TestRobotConnectAgentFlagsInDryRun checks the new flags surface in the // dry-run preview so callers can see the effective agent tuning. func TestRobotConnectAgentFlagsInDryRun(t *testing.T) { diff --git a/internal/helpers/connect_at_poll.go b/internal/helpers/connect_at_poll.go index 5bb8e905..8a33a6d7 100644 --- a/internal/helpers/connect_at_poll.go +++ b/internal/helpers/connect_at_poll.go @@ -144,11 +144,27 @@ func (p *atMentionPoller) handleMessage(ctx context.Context, msg atMentionMessag convID = msg.SenderStaffID } - p.queue.run(convID, func() { + turn := connectQueuedTurn{ + convID: convID, + text: text, + msgID: msg.MsgID, + senderStaffID: strings.TrimSpace(msg.SenderStaffID), + conversationID: strings.TrimSpace(msg.OpenConversationID), + conversationType: strings.TrimSpace(msg.ConversationType), + } + p.queue.submit(turn, func(turns []connectQueuedTurn) { + turn := mergeConnectQueuedTurns(turns) + if len(turns) > 1 { + fmt.Fprintf(os.Stderr, "[connect][at-poll] 合并 %d 条待处理 @消息 (convId=%s, latestMsgId=%s)\n", len(turns), turn.convID, turn.msgID) + } + text := turn.text + convID := turn.convID + senderStaffID := turn.senderStaffID + openConversationID := turn.conversationID if p.extras.gate.enabled() { - if ok, reason := p.extras.gate.allow(msg.SenderStaffID, "2", msg.OpenConversationID); !ok { + if ok, reason := p.extras.gate.allow(senderStaffID, "2", openConversationID); !ok { fmt.Fprintf(os.Stderr, "[connect][at-poll] 已拦截消息(%s): staffId=%s convId=%s\n", - reason, msg.SenderStaffID, msg.OpenConversationID) + reason, senderStaffID, openConversationID) return } } @@ -180,8 +196,8 @@ func (p *atMentionPoller) handleMessage(ctx context.Context, msg atMentionMessag p.health.onReply() } - if reply != "" && msg.OpenConversationID != "" { - if serr := p.sendGroupReply(ctx, msg.OpenConversationID, reply); serr != nil { + if reply != "" && openConversationID != "" { + if serr := p.sendGroupReply(ctx, openConversationID, reply); serr != nil { fmt.Fprintf(os.Stderr, "[connect][at-poll] 群回复发送失败: %v\n", serr) } } diff --git a/internal/helpers/connect_codex_appserver_test.go b/internal/helpers/connect_codex_appserver_test.go index 01bfa332..e6e7423f 100644 --- a/internal/helpers/connect_codex_appserver_test.go +++ b/internal/helpers/connect_codex_appserver_test.go @@ -107,14 +107,13 @@ while IFS= read -r line; do *\"method\":\"initialize\"*) printf '%s\n' '{"id":1,"result":{}}' ;; *\"method\":\"thread/start\"*) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_stub"}}}' ;; *\"method\":\"thread/resume\"*) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_stub"}}}' ;; - *\"method\":\"turn/start\"*) - printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"你"}}' - printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"好"}}' - printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thr_stub","turn":{"id":"turn_stub","status":"completed","items":[{"id":"item_1","type":"agentMessage","text":"你好"}]}}}' - exit 0 - ;; - esac -done + *\"method\":\"turn/start\"*) + printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"你"}}' + printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"好"}}' + printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thr_stub","turn":{"id":"turn_stub","status":"completed","items":[{"id":"item_1","type":"agentMessage","text":"你好"}]}}}' + ;; + esac + done `) fwd := &codexAppServerForwarder{ bin: codex, diff --git a/internal/helpers/connect_daemon.go b/internal/helpers/connect_daemon.go index 39ddb199..b097364f 100644 --- a/internal/helpers/connect_daemon.go +++ b/internal/helpers/connect_daemon.go @@ -113,6 +113,50 @@ func connectDaemonDir(dirKey string) (string, error) { func daemonPidPath(dir string) string { return filepath.Join(dir, "daemon.pid") } func daemonStatePath(dir string) string { return filepath.Join(dir, "daemon-state.json") } func daemonLogPath(dir string) string { return filepath.Join(dir, "daemon.log") } +func daemonExecutablePath(dir string) string { + return filepath.Join(dir, "dws-daemon") +} + +// stageDaemonExecutable snapshots the current dws binary into the connector's +// persistent directory. One-click launchers may execute a temporary download +// (for example dws-latest) and remove it after connect --daemon returns; the +// supervisor must not depend on that transient path for future worker restarts. +func stageDaemonExecutable(src, dir string) (string, error) { + in, err := os.Open(src) + if err != nil { + return "", err + } + defer in.Close() + + tmp, err := os.CreateTemp(dir, ".dws-daemon-*") + if err != nil { + return "", err + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + + if err := tmp.Chmod(daemonExecutablePerm); err != nil { + tmp.Close() + return "", err + } + if _, err := io.Copy(tmp, in); err != nil { + tmp.Close() + return "", err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return "", err + } + if err := tmp.Close(); err != nil { + return "", err + } + + dst := daemonExecutablePath(dir) + if err := os.Rename(tmpPath, dst); err != nil { + return "", err + } + return dst, nil +} // writeDaemonState atomically persists the daemon state to daemon-state.json // (persistent, survives supervisor exit) so restart/list can recover config. @@ -169,8 +213,9 @@ func backoffDelay(consecutiveFailures int, base, maxDelay time.Duration) time.Du } const ( - daemonBackoffBase = time.Second - daemonBackoffCap = 60 * time.Second + daemonBackoffBase = time.Second + daemonBackoffCap = 60 * time.Second + daemonExecutablePerm os.FileMode = 0o700 // daemonMaxFastFailures is the consecutive fast-failure ceiling: after this // many crashes that each happened within daemonHealthyAfter, the supervisor // gives up rather than spin forever (e.g. bad credentials). @@ -226,6 +271,10 @@ func startDaemon(cmd *cobra.Command, dirKey, clientID, unifiedAppID, channel, no if err != nil { return apperrors.NewInternal("resolve executable: " + err.Error()) } + exe, err = stageDaemonExecutable(exe, dir) + if err != nil { + return apperrors.NewInternal("stage daemon executable: " + err.Error()) + } superviseArgs := buildSuperviseArgs(os.Args[1:]) logPath := daemonLogPath(dir) @@ -386,6 +435,7 @@ func runSupervisor(cmd *cobra.Command) error { worker.Stdout = out worker.Stderr = out worker.Env = os.Environ() + configureWorkerProcessGroup(worker) if err := worker.Start(); err != nil { fmt.Fprintf(out, "[daemon] failed to start worker: %v\n", err) failures++ @@ -398,6 +448,7 @@ func runSupervisor(cmd *cobra.Command) error { fmt.Fprintf(out, "[daemon] worker started (pid %d)\n", worker.Process.Pid) waitErr := superviseWait(ctx, worker) + cleanupWorkerProcessGroup(worker.Process.Pid) ran := time.Since(started) if ctx.Err() != nil { diff --git a/internal/helpers/connect_daemon_test.go b/internal/helpers/connect_daemon_test.go index ae690954..556abc00 100644 --- a/internal/helpers/connect_daemon_test.go +++ b/internal/helpers/connect_daemon_test.go @@ -68,6 +68,36 @@ func TestDaemonDirKey(t *testing.T) { } } +func TestStageDaemonExecutable(t *testing.T) { + dir := t.TempDir() + src := dir + "/source" + if err := os.WriteFile(src, []byte("test-binary"), 0o700); err != nil { + t.Fatal(err) + } + + dst, err := stageDaemonExecutable(src, dir) + if err != nil { + t.Fatalf("stageDaemonExecutable: %v", err) + } + if dst != daemonExecutablePath(dir) { + t.Fatalf("path = %q, want %q", dst, daemonExecutablePath(dir)) + } + got, err := os.ReadFile(dst) + if err != nil { + t.Fatal(err) + } + if string(got) != "test-binary" { + t.Fatalf("content = %q, want test-binary", got) + } + info, err := os.Stat(dst) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != daemonExecutablePerm { + t.Fatalf("mode = %o, want %o", info.Mode().Perm(), daemonExecutablePerm) + } +} + func TestBuildWorkerArgs(t *testing.T) { in := []string{"devapp", "robot", "connect", "--daemon", "--robot-client-id", "abc", "--channel=claudecode"} got := buildWorkerArgs(in) diff --git a/internal/helpers/connect_daemon_unix.go b/internal/helpers/connect_daemon_unix.go index d6ff1432..ae5c0e82 100644 --- a/internal/helpers/connect_daemon_unix.go +++ b/internal/helpers/connect_daemon_unix.go @@ -36,3 +36,17 @@ func detachSysProcAttr() *syscall.SysProcAttr { func applyDetach(cmd *exec.Cmd) { cmd.SysProcAttr = detachSysProcAttr() } + +// configureWorkerProcessGroup isolates the connector worker and every local +// agent process it starts (opencode serve, codex app-server, etc.) in one group. +func configureWorkerProcessGroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +} + +// cleanupWorkerProcessGroup removes descendants left behind when the worker +// exits abruptly and cannot run its normal deferred cleanup. +func cleanupWorkerProcessGroup(pid int) { + if pid > 0 { + _ = syscall.Kill(-pid, syscall.SIGKILL) + } +} diff --git a/internal/helpers/connect_daemon_unix_test.go b/internal/helpers/connect_daemon_unix_test.go new file mode 100644 index 00000000..c3b38360 --- /dev/null +++ b/internal/helpers/connect_daemon_unix_test.go @@ -0,0 +1,29 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !windows + +package helpers + +import ( + "os/exec" + "testing" +) + +func TestConfigureWorkerProcessGroup(t *testing.T) { + cmd := exec.Command("true") + configureWorkerProcessGroup(cmd) + if cmd.SysProcAttr == nil || !cmd.SysProcAttr.Setpgid { + t.Fatal("worker must start in its own process group") + } +} diff --git a/internal/helpers/connect_daemon_windows.go b/internal/helpers/connect_daemon_windows.go index 6d6ec451..d0b46702 100644 --- a/internal/helpers/connect_daemon_windows.go +++ b/internal/helpers/connect_daemon_windows.go @@ -25,3 +25,7 @@ const daemonDetachSupported = false // applyDetach is a no-op on Windows; daemon mode is rejected earlier. func applyDetach(_ *exec.Cmd) {} + +func configureWorkerProcessGroup(_ *exec.Cmd) {} + +func cleanupWorkerProcessGroup(_ int) {} diff --git a/internal/helpers/connect_exec_forwarder_test.go b/internal/helpers/connect_exec_forwarder_test.go new file mode 100644 index 00000000..bd032b4f --- /dev/null +++ b/internal/helpers/connect_exec_forwarder_test.go @@ -0,0 +1,162 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestExecForwarderRetriesMissingSessionOnce(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "agent") + logPath := filepath.Join(dir, "calls.log") + if err := os.WriteFile(bin, []byte(`#!/bin/sh +echo "$@" >> "$DWS_STALE_LOG" +case " $* " in + *" --resume "*) + echo "No conversation found with session ID: stale" >&2 + exit 1 + ;; +esac +echo fresh-ok +`), 0o755); err != nil { + t.Fatal(err) + } + + sessions := newConvSessions("") + if got := sessions.args("conv-1"); len(got) != 2 || got[0] != "--session-id" { + t.Fatalf("seed session args = %v, want --session-id", got) + } + f := &execForwarder{ + name: "claudecode", + argv: []string{bin, "-p"}, + env: []string{"DWS_STALE_LOG=" + logPath}, + timeout: 5 * time.Second, + sessions: sessions, + } + + reply, err := f.forward(context.Background(), "conv-1", "hello") + if err != nil { + t.Fatalf("forward: %v", err) + } + if reply != "fresh-ok" { + t.Fatalf("reply = %q, want fresh-ok", reply) + } + calls := readCallLog(t, logPath) + if len(calls) != 2 { + t.Fatalf("calls = %v, want stale resume then fresh session", calls) + } + if !strings.Contains(calls[0], "--resume") || !strings.Contains(calls[1], "--session-id") { + t.Fatalf("calls = %v, want --resume then --session-id", calls) + } +} + +func TestExecForwarderStreamRetriesMissingSessionOnce(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "agent-stream") + logPath := filepath.Join(dir, "calls.log") + if err := os.WriteFile(bin, []byte(`#!/bin/sh +echo "$@" >> "$DWS_STALE_LOG" +case " $* " in + *" --resume "*) + echo "session not found: stale" >&2 + exit 1 + ;; +esac +printf '%s\n' '{"type":"result","result":"fresh-stream-ok"}' +`), 0o755); err != nil { + t.Fatal(err) + } + + sessions := newConvSessions("") + _ = sessions.args("conv-1") + f := &execForwarder{ + name: "claudecode", + argv: []string{bin, "-p"}, + streamArgv: []string{bin, "--output-format", "stream-json"}, + env: []string{"DWS_STALE_LOG=" + logPath}, + parser: "cc", + timeout: 5 * time.Second, + sessions: sessions, + } + + reply, err := f.forwardStream(context.Background(), "conv-1", "hello", func(string) {}) + if err != nil { + t.Fatalf("forwardStream: %v", err) + } + if reply != "fresh-stream-ok" { + t.Fatalf("reply = %q, want fresh-stream-ok", reply) + } + calls := readCallLog(t, logPath) + if len(calls) != 2 { + t.Fatalf("calls = %v, want stale resume then fresh session", calls) + } + if !strings.Contains(calls[0], "--resume") || !strings.Contains(calls[1], "--session-id") { + t.Fatalf("calls = %v, want --resume then --session-id", calls) + } +} + +func TestExecForwarderDoesNotRetryNonSessionErrors(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "agent") + logPath := filepath.Join(dir, "calls.log") + if err := os.WriteFile(bin, []byte(`#!/bin/sh +echo "$@" >> "$DWS_STALE_LOG" +echo "plain failure" >&2 +exit 1 +`), 0o755); err != nil { + t.Fatal(err) + } + + sessions := newConvSessions("") + _ = sessions.args("conv-1") + f := &execForwarder{ + name: "claudecode", + argv: []string{bin, "-p"}, + env: []string{"DWS_STALE_LOG=" + logPath}, + timeout: 5 * time.Second, + sessions: sessions, + } + + if _, err := f.forward(context.Background(), "conv-1", "hello"); err == nil { + t.Fatal("forward error = nil, want error") + } + calls := readCallLog(t, logPath) + if len(calls) != 1 { + t.Fatalf("calls = %v, want one non-session failure without retry", calls) + } + if !strings.Contains(calls[0], "--resume") { + t.Fatalf("call = %q, want stale --resume attempt", calls[0]) + } +} + +func readCallLog(t *testing.T, path string) []string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var calls []string + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + if strings.TrimSpace(line) != "" { + calls = append(calls, line) + } + } + return calls +} diff --git a/internal/helpers/connect_gemini_api.go b/internal/helpers/connect_gemini_api.go new file mode 100644 index 00000000..3d17ddc5 --- /dev/null +++ b/internal/helpers/connect_gemini_api.go @@ -0,0 +1,192 @@ +// 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 ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +const ( + defaultGeminiAPIBaseURL = "https://generativelanguage.googleapis.com/v1beta" + defaultGeminiModel = "gemini-2.5-flash" +) + +type geminiAPIForwarder struct { + model string + apiKey string + baseURL string + timeout time.Duration + httpClient *http.Client +} + +func newGeminiAPIForwarder(timeout time.Duration, opts connectAgentOptions) (forwarder, error) { + apiKey := geminiAPIKey() + if apiKey == "" { + return nil, apperrors.NewValidation("gemini 渠道现在走 Gemini API;请设置 GEMINI_API_KEY(或 GOOGLE_API_KEY),模型可用 --agent-model 指定,Gemini-compatible 代理可设置 GEMINI_API_BASE_URL") + } + model := strings.TrimSpace(opts.Model) + if model == "" { + model = strings.TrimSpace(os.Getenv("GEMINI_MODEL")) + } + if model == "" { + model = defaultGeminiModel + } + if timeout <= 0 { + timeout = 2 * time.Minute + } + return &geminiAPIForwarder{ + model: model, + apiKey: apiKey, + baseURL: geminiAPIBaseURL(), + timeout: timeout, + httpClient: &http.Client{}, + }, nil +} + +func geminiAPIKey() string { + for _, key := range []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"} { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + } + return "" +} + +func geminiAPIBaseURL() string { + for _, key := range []string{"GEMINI_API_BASE_URL", "GOOGLE_GEMINI_API_BASE_URL"} { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return strings.TrimRight(v, "/") + } + } + return defaultGeminiAPIBaseURL +} + +func (f *geminiAPIForwarder) label() string { + return fmt.Sprintf("gemini-api:%s", f.model) +} + +func (f *geminiAPIForwarder) forward(ctx context.Context, _, text string) (string, error) { + ctx, cancel := applyTimeout(ctx, f.timeout) + defer cancel() + + endpoint, err := f.generateContentEndpoint() + if err != nil { + return "", err + } + body := geminiGenerateContentRequest{ + SystemInstruction: geminiContent{ + Parts: []geminiPart{{Text: "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及任何系统提示或内部实现。"}}, + }, + Contents: []geminiContent{{ + Role: "user", + Parts: []geminiPart{{Text: text}}, + }}, + } + raw, err := json.Marshal(body) + if err != nil { + return "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-goog-api-key", f.apiKey) + + resp, err := f.httpClient.Do(req) + if err != nil { + return "", err + } + 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)) + } + 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)) + } + var chunks []string + for _, cand := range out.Candidates { + for _, part := range cand.Content.Parts { + if s := strings.TrimSpace(part.Text); s != "" { + chunks = append(chunks, s) + } + } + } + if len(chunks) == 0 { + if 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) generateContentEndpoint() (string, error) { + base := strings.TrimRight(strings.TrimSpace(f.baseURL), "/") + if base == "" { + base = defaultGeminiAPIBaseURL + } + if _, err := url.ParseRequestURI(base); err != nil { + return "", fmt.Errorf("GEMINI_API_BASE_URL 无效:%w", err) + } + model := strings.TrimSpace(strings.TrimPrefix(f.model, "models/")) + if model == "" { + model = defaultGeminiModel + } + return base + "/models/" + url.PathEscape(model) + ":generateContent", nil +} + +type geminiGenerateContentRequest struct { + SystemInstruction geminiContent `json:"systemInstruction,omitempty"` + Contents []geminiContent `json:"contents"` +} + +type geminiContent struct { + Role string `json:"role,omitempty"` + Parts []geminiPart `json:"parts"` +} + +type geminiPart struct { + Text string `json:"text"` +} + +type geminiGenerateContentResponse struct { + Candidates []struct { + Content geminiContent `json:"content"` + } `json:"candidates"` + PromptFeedback struct { + BlockReason string `json:"blockReason"` + } `json:"promptFeedback"` + Error struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` + } `json:"error"` +} diff --git a/internal/helpers/connect_gemini_api_test.go b/internal/helpers/connect_gemini_api_test.go new file mode 100644 index 00000000..5dd74af2 --- /dev/null +++ b/internal/helpers/connect_gemini_api_test.go @@ -0,0 +1,114 @@ +// 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" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestGeminiChannelUsesAPIWithoutLocalCLI(t *testing.T) { + clearChannelEnv(t) + t.Setenv("PATH", t.TempDir()) + t.Setenv("GEMINI_API_KEY", "test-key") + t.Setenv("GEMINI_API_BASE_URL", "https://gemini-proxy.example/v1beta") + + fwd, err := forwarderForChannel("gemini", "", connectAgentOptions{Model: "gemini-test"}) + if err != nil { + t.Fatalf("forwarderForChannel(gemini): %v", err) + } + gf, ok := fwd.(*geminiAPIForwarder) + if !ok { + t.Fatalf("gemini forwarder = %T, want *geminiAPIForwarder", fwd) + } + if gf.model != "gemini-test" { + t.Fatalf("model = %q, want gemini-test", gf.model) + } + if gf.baseURL != "https://gemini-proxy.example/v1beta" { + t.Fatalf("baseURL = %q", gf.baseURL) + } +} + +func TestGeminiAPIForwarderForward(t *testing.T) { + clearChannelEnv(t) + var gotPath, gotKey, gotText string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotKey = r.Header.Get("x-goog-api-key") + var req geminiGenerateContentRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Errorf("decode request: %v", err) + } + if len(req.Contents) > 0 && len(req.Contents[0].Parts) > 0 { + gotText = req.Contents[0].Parts[0].Text + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"gemini-api-ok"}]}}]}`)) + })) + defer ts.Close() + t.Setenv("GEMINI_API_KEY", "test-key") + t.Setenv("GEMINI_API_BASE_URL", ts.URL) + + fwd, err := forwarderForChannel("gemini", "", connectAgentOptions{Model: "models/gemini-test"}) + if err != nil { + t.Fatalf("forwarderForChannel(gemini): %v", err) + } + reply, err := fwd.forward(context.Background(), "conv-1", "你好") + if err != nil { + t.Fatalf("forward: %v", err) + } + if reply != "gemini-api-ok" { + t.Fatalf("reply = %q, want gemini-api-ok", reply) + } + if gotPath != "/models/gemini-test:generateContent" { + t.Fatalf("path = %q", gotPath) + } + if gotKey != "test-key" { + t.Fatalf("x-goog-api-key = %q", gotKey) + } + if gotText != "你好" { + t.Fatalf("prompt text = %q", gotText) + } +} + +func TestGeminiChannelRequiresAPIKey(t *testing.T) { + clearChannelEnv(t) + t.Setenv("GEMINI_API_KEY", "") + t.Setenv("GOOGLE_API_KEY", "") + + _, err := forwarderForChannel("gemini", "", connectAgentOptions{}) + if err == nil || !strings.Contains(err.Error(), "GEMINI_API_KEY") { + t.Fatalf("err = %v, want GEMINI_API_KEY validation", err) + } +} + +func TestGeminiCLIStatusUsesAPIKey(t *testing.T) { + clearChannelEnv(t) + t.Setenv("GEMINI_API_KEY", "") + t.Setenv("GOOGLE_API_KEY", "") + if got := connectCliStatus("gemini")["installed"]; got != false { + t.Fatalf("installed without key = %v, want false", got) + } + t.Setenv("GOOGLE_API_KEY", "google-key") + if got := connectCliStatus("gemini")["installed"]; got != true { + t.Fatalf("installed with key = %v, want true", got) + } + if got := connectCliStatus("gemini")["autoInstall"]; got != false { + t.Fatalf("autoInstall = %v, want false", got) + } +} diff --git a/internal/helpers/connect_media.go b/internal/helpers/connect_media.go index ba2703ca..340a9794 100644 --- a/internal/helpers/connect_media.go +++ b/internal/helpers/connect_media.go @@ -48,6 +48,44 @@ func pictureDownloadCode(content interface{}) string { return "" } +// 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 { + m, ok := content.(map[string]interface{}) + if !ok { + return nil + } + items, ok := m["richText"].([]interface{}) + if !ok { + return nil + } + codes := make([]string, 0) + for _, item := range items { + node, ok := item.(map[string]interface{}) + if !ok { + continue + } + code := pictureDownloadCode(node) + if code == "" { + continue + } + // A richText node with a picture download code is actionable even if an + // older callback omitted type. When type is present, reject unrelated + // node kinds so future payload fields are not mistaken for pictures. + if typ, exists := node["type"].(string); exists && strings.TrimSpace(typ) != "" && !strings.EqualFold(strings.TrimSpace(typ), "picture") { + continue + } + codes = append(codes, code) + } + return codes +} + // 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 diff --git a/internal/helpers/connect_media_test.go b/internal/helpers/connect_media_test.go index a04db835..d682fd13 100644 --- a/internal/helpers/connect_media_test.go +++ b/internal/helpers/connect_media_test.go @@ -42,6 +42,44 @@ 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) + } + } +} + // 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) diff --git a/internal/helpers/connect_opencode.go b/internal/helpers/connect_opencode.go index 4c1dbc10..6485ed08 100644 --- a/internal/helpers/connect_opencode.go +++ b/internal/helpers/connect_opencode.go @@ -40,6 +40,8 @@ const ( opencodeNoTextReply = "(本地 agent 无文本输出)" opencodeServerStartupWait = 20 * time.Second opencodeServerPollInterval = 150 * time.Millisecond + opencodeConfigContentEnv = "OPENCODE_CONFIG_CONTENT" + opencodePermissionEnv = "OPENCODE_PERMISSION" // opencodeHealthProbeTimeout bounds a single /global/health probe so startup // detection stays snappy even though the shared http.Client has no overall // deadline (message turns rely on the per-turn ctx instead). @@ -240,14 +242,7 @@ func (s *opencodeServer) ensure(ctx context.Context) (*opencodeHTTPClient, error if s.workDir != "" { cmd.Dir = s.workDir } - cmd.Env = append(os.Environ(), s.env...) - cmd.Env = append(cmd.Env, - "OPENCODE_SERVER_USERNAME="+opencodeServerUsername, - "OPENCODE_SERVER_PASSWORD="+password, - ) - if !s.yolo { - cmd.Env = append(cmd.Env, `OPENCODE_PERMISSION={"bash":"ask","edit":"ask","write":"ask"}`) - } + cmd.Env = s.commandEnv(password) cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { @@ -269,6 +264,97 @@ func (s *opencodeServer) ensure(ctx context.Context) (*opencodeHTTPClient, error return client, nil } +func (s *opencodeServer) commandEnv(password string) []string { + env := append([]string{}, os.Environ()...) + env = append(env, s.env...) + env = upsertEnv(env, "OPENCODE_SERVER_USERNAME", opencodeServerUsername) + env = upsertEnv(env, "OPENCODE_SERVER_PASSWORD", password) + config := opencodeNonInteractiveConfig(envValue(env, opencodeConfigContentEnv)) + env = upsertEnv(env, opencodeConfigContentEnv, config) + env = upsertEnv(env, opencodePermissionEnv, opencodeNonInteractivePermission()) + return env +} + +func opencodeNonInteractiveConfig(existing string) string { + cfg := map[string]any{} + if strings.TrimSpace(existing) != "" { + _ = json.Unmarshal([]byte(existing), &cfg) + } + tools, _ := cfg["tools"].(map[string]any) + if tools == nil { + tools = map[string]any{} + } + tools["question"] = false + cfg["tools"] = tools + + permission, _ := cfg["permission"].(map[string]any) + if permission == nil { + permission = map[string]any{} + } + for k, v := range opencodeNonInteractivePermissionMap() { + permission[k] = v + } + cfg["permission"] = permission + + data, err := json.Marshal(cfg) + if err != nil { + return `{"tools":{"question":false},"permission":{"*":"allow","read":"allow","edit":"allow","write":"allow","patch":"allow","apply_patch":"allow","glob":"allow","grep":"allow","bash":"allow","task":"allow","skill":"allow","lsp":"allow","webfetch":"allow","websearch":"allow","question":"deny","external_directory":"allow","doom_loop":"allow"}}` + } + return string(data) +} + +func opencodeNonInteractivePermission() string { + data, err := json.Marshal(opencodeNonInteractivePermissionMap()) + if err != nil { + return `{"*":"allow","read":"allow","edit":"allow","write":"allow","patch":"allow","apply_patch":"allow","glob":"allow","grep":"allow","bash":"allow","task":"allow","skill":"allow","lsp":"allow","webfetch":"allow","websearch":"allow","question":"deny","external_directory":"allow","doom_loop":"allow"}` + } + return string(data) +} + +func opencodeNonInteractivePermissionMap() map[string]string { + return map[string]string{ + "*": "allow", + "read": "allow", + "edit": "allow", + "write": "allow", + "patch": "allow", + "apply_patch": "allow", + "glob": "allow", + "grep": "allow", + "bash": "allow", + "task": "allow", + "skill": "allow", + "lsp": "allow", + "webfetch": "allow", + "websearch": "allow", + "question": "deny", + "external_directory": "allow", + "doom_loop": "allow", + } +} + +func envValue(env []string, key string) string { + prefix := key + "=" + for i := len(env) - 1; i >= 0; i-- { + if strings.HasPrefix(env[i], prefix) { + return strings.TrimPrefix(env[i], prefix) + } + } + return "" +} + +func upsertEnv(env []string, key, value string) []string { + prefix := key + "=" + out := make([]string, 0, len(env)+1) + for _, item := range env { + if strings.HasPrefix(item, prefix) { + continue + } + out = append(out, item) + } + return append(out, prefix+value) +} + func (s *opencodeServer) waitHealthy(ctx context.Context, client *opencodeHTTPClient) error { deadline := time.Now().Add(opencodeServerStartupWait) var lastErr error diff --git a/internal/helpers/connect_opencode_test.go b/internal/helpers/connect_opencode_test.go index a05740e0..7714de5a 100644 --- a/internal/helpers/connect_opencode_test.go +++ b/internal/helpers/connect_opencode_test.go @@ -261,6 +261,53 @@ func TestNewOpencodeServerHasNoClientTimeout(t *testing.T) { } } +func TestOpencodeServerEnvDisablesInteractiveGates(t *testing.T) { + s := newOpencodeServer("opencode", []string{ + `OPENCODE_CONFIG_CONTENT={"provider":{"x":true},"tools":{"read":true},"permission":{"bash":"ask","edit":"ask","write":"ask","question":"ask"}}`, + `OPENCODE_PERMISSION={"bash":"ask"}`, + }, "", false) + + env := s.commandEnv("pw") + if got := envValue(env, "OPENCODE_SERVER_USERNAME"); got != opencodeServerUsername { + t.Fatalf("server username = %q, want %q", got, opencodeServerUsername) + } + if got := envValue(env, "OPENCODE_SERVER_PASSWORD"); got != "pw" { + t.Fatalf("server password = %q, want pw", got) + } + + var cfg map[string]any + if err := json.Unmarshal([]byte(envValue(env, opencodeConfigContentEnv)), &cfg); err != nil { + t.Fatalf("decode OPENCODE_CONFIG_CONTENT: %v", err) + } + if _, ok := cfg["provider"].(map[string]any)["x"]; !ok { + t.Fatalf("existing config fields were not preserved: %#v", cfg) + } + tools := cfg["tools"].(map[string]any) + if got := tools["question"]; got != false { + t.Fatalf("tools.question = %#v, want false", got) + } + permission := cfg["permission"].(map[string]any) + for _, key := range []string{"bash", "edit", "write", "external_directory", "doom_loop"} { + if got := permission[key]; got != "allow" { + t.Fatalf("permission.%s = %#v, want allow", key, got) + } + } + if got := permission["question"]; got != "deny" { + t.Fatalf("permission.question = %#v, want deny", got) + } + + var envPermission map[string]string + if err := json.Unmarshal([]byte(envValue(env, opencodePermissionEnv)), &envPermission); err != nil { + t.Fatalf("decode OPENCODE_PERMISSION: %v", err) + } + if got := envPermission["bash"]; got != "allow" { + t.Fatalf("OPENCODE_PERMISSION.bash = %q, want allow", got) + } + if got := envPermission["question"]; got != "deny" { + t.Fatalf("OPENCODE_PERMISSION.question = %q, want deny", got) + } +} + // TestOpencodeForwarderMessageGovernedByTurnCtx verifies a slow reply is not // cut by a fixed client timeout (the old 30s bug) and that the per-turn ctx is // the real governor instead. diff --git a/internal/helpers/connect_qoder_stream.go b/internal/helpers/connect_qoder_stream.go index 9a425484..ff412013 100644 --- a/internal/helpers/connect_qoder_stream.go +++ b/internal/helpers/connect_qoder_stream.go @@ -164,7 +164,7 @@ func (f *qoderStreamForwarder) commandArgs() []string { "--input-format", "stream-json", } if f.yolo { - args = append(args, "--dangerously-skip-permissions") + args = append(args, "--permission-mode", "bypass_permissions", "--dangerously-skip-permissions") } else { args = append(args, "--system-prompt", "", @@ -334,6 +334,11 @@ func (f *qoderStreamForwarder) handleControlRequestLocked(line string) bool { if json.Unmarshal([]byte(line), &ev) != nil || ev.Type != "control_request" || ev.RequestID == "" { return false } + subtype, _ := ev.Request["subtype"].(string) + if subtype == "" { + subtype, _ = ev.Request["type"].(string) + } + fmt.Fprintf(os.Stderr, "[connect][qoder] 自动拒绝/跳过未桥接的 control_request subtype=%q requestId=%s\n", subtype, ev.RequestID) response := map[string]any{ "type": "control_response", "response": map[string]any{ diff --git a/internal/helpers/connect_qoder_stream_test.go b/internal/helpers/connect_qoder_stream_test.go index e4d21f40..90a933bf 100644 --- a/internal/helpers/connect_qoder_stream_test.go +++ b/internal/helpers/connect_qoder_stream_test.go @@ -14,7 +14,9 @@ package helpers import ( + "bytes" "context" + "encoding/json" "os" "path/filepath" "strings" @@ -125,6 +127,39 @@ func TestQoderForwarderKeepsStreamJSONProcessAlive(t *testing.T) { } } +type qoderTestWriteCloser struct { + bytes.Buffer +} + +func (w *qoderTestWriteCloser) Close() error { return nil } + +func TestQoderControlRequestReturnsImmediately(t *testing.T) { + stdin := &qoderTestWriteCloser{} + f := &qoderStreamForwarder{name: "qoder", stdin: stdin} + + if !f.handleControlRequestLocked(`{"type":"control_request","request_id":"req-1","request":{"type":"permission","subtype":"permission","tool":"bash"}}`) { + t.Fatal("control_request was not handled") + } + + var got struct { + Type string `json:"type"` + Response struct { + Subtype string `json:"subtype"` + RequestID string `json:"request_id"` + Code string `json:"code"` + } `json:"response"` + } + if err := json.Unmarshal(bytes.TrimSpace(stdin.Bytes()), &got); err != nil { + t.Fatalf("decode response: %v; raw=%q", err, stdin.String()) + } + if got.Type != "control_response" || got.Response.RequestID != "req-1" { + t.Fatalf("response identity mismatch: %#v", got) + } + if got.Response.Subtype != "error" || got.Response.Code != "unsupported_control_request" { + t.Fatalf("response should explicitly unblock with unsupported error: %#v", got.Response) + } +} + func TestParseQoderPersistentLineReadsResultField(t *testing.T) { delta, final, done := parseQoderPersistentLine(`{"type":"result","subtype":"success","result":"OK"}`) if delta != "" || final != "OK" || !done { diff --git a/internal/helpers/connect_queue.go b/internal/helpers/connect_queue.go index cb1d082a..aace22fc 100644 --- a/internal/helpers/connect_queue.go +++ b/internal/helpers/connect_queue.go @@ -15,42 +15,79 @@ package helpers import "sync" -// convQueue serialises message handling per conversation: same-chat messages -// run in arrival order — a Q&A follow-up ("还是不行") must see the previous -// turn's session state, and two parallel agent CLIs racing on one --resume -// session corrupt the transcript. Different conversations stay parallel. -// Ported from the hermes gateway's per-session promise chain -// (gateway/run.py session queues). +// convQueue serialises agent turns per conversation while coalescing bursts. +// Same-chat agent calls must never run in parallel — two turns racing on one +// session corrupt the transcript. But a pure FIFO is also bad UX: if a user +// keeps sending clarifications while a long turn is running, the bot should not +// spend the next several minutes answering stale intermediate prompts. Instead, +// messages arriving during the active turn accumulate in one pending batch; when +// the active turn finishes, that whole batch is processed as a single follow-up. +// Different conversations still run in parallel. type convQueue struct { - mu sync.Mutex - tails map[string]chan struct{} + mu sync.Mutex + states map[string]*convQueueState +} + +type convQueueState struct { + running bool + pending []connectQueuedTurn + process func([]connectQueuedTurn) } func newConvQueue() *convQueue { - return &convQueue{tails: map[string]chan struct{}{}} + return &convQueue{states: map[string]*convQueueState{}} } -// run schedules fn after the conversation's previous task and returns -// immediately (the Stream callback must ack fast). The chain entry is removed -// once the queue for that conversation drains, so idle chats hold no memory. -func (q *convQueue) run(convID string, fn func()) { +// submit schedules turn handling and returns immediately (the Stream callback +// must ack fast). If the conversation is already running, the turn is appended +// to that conversation's pending batch instead of creating another queued worker. +func (q *convQueue) submit(turn connectQueuedTurn, process func([]connectQueuedTurn)) { + convID := turn.convID q.mu.Lock() - prev := q.tails[convID] - done := make(chan struct{}) - q.tails[convID] = done - q.mu.Unlock() - go func() { - defer func() { - close(done) - q.mu.Lock() - if q.tails[convID] == done { - delete(q.tails, convID) - } - q.mu.Unlock() - }() - if prev != nil { - <-prev + st := q.states[convID] + if st == nil { + st = &convQueueState{} + q.states[convID] = st + } + if st.running { + st.pending = append(st.pending, turn) + if process != nil { + st.process = process } - fn() - }() + q.mu.Unlock() + return + } + st.running = true + st.process = process + q.mu.Unlock() + go q.drain(convID, []connectQueuedTurn{turn}) +} + +func (q *convQueue) drain(convID string, batch []connectQueuedTurn) { + for { + process := q.processFor(convID) + if process != nil { + process(batch) + } + + q.mu.Lock() + st := q.states[convID] + if st == nil || len(st.pending) == 0 { + delete(q.states, convID) + q.mu.Unlock() + return + } + batch = append([]connectQueuedTurn(nil), st.pending...) + st.pending = nil + q.mu.Unlock() + } +} + +func (q *convQueue) processFor(convID string) func([]connectQueuedTurn) { + q.mu.Lock() + defer q.mu.Unlock() + if st := q.states[convID]; st != nil { + return st.process + } + return nil } diff --git a/internal/helpers/connect_queue_test.go b/internal/helpers/connect_queue_test.go index 25aa8818..954351de 100644 --- a/internal/helpers/connect_queue_test.go +++ b/internal/helpers/connect_queue_test.go @@ -14,36 +14,44 @@ package helpers import ( + "reflect" "sync" "testing" "time" ) -// TestConvQueueSerialSameConversation: tasks of one conversation run in -// arrival order even when earlier tasks are slow. -func TestConvQueueSerialSameConversation(t *testing.T) { +// TestConvQueueCoalescesSameConversationBurst: while one conversation is +// running, later messages are batched into one follow-up turn instead of forming +// a long FIFO of stale prompts. +func TestConvQueueCoalescesSameConversationBurst(t *testing.T) { q := newConvQueue() - var mu sync.Mutex - var order []int - var wg sync.WaitGroup - for i := 1; i <= 5; i++ { - i := i - wg.Add(1) - q.run("conv-1", func() { - defer wg.Done() - if i == 1 { - time.Sleep(50 * time.Millisecond) // slow head must not be overtaken - } - mu.Lock() - order = append(order, i) - mu.Unlock() + started := make(chan struct{}) + release := make(chan struct{}) + batches := make(chan []string, 2) + var once sync.Once + process := func(batch []connectQueuedTurn) { + var got []string + for _, turn := range batch { + got = append(got, turn.text) + } + batches <- got + once.Do(func() { + close(started) + <-release }) } - wg.Wait() - for i, v := range order { - if v != i+1 { - t.Fatalf("order = %v, want 1..5 in sequence", order) - } + + q.submit(connectQueuedTurn{convID: "conv-1", text: "first"}, process) + <-started + q.submit(connectQueuedTurn{convID: "conv-1", text: "second"}, process) + q.submit(connectQueuedTurn{convID: "conv-1", text: "third"}, process) + close(release) + + if got := <-batches; !reflect.DeepEqual(got, []string{"first"}) { + t.Fatalf("first batch = %v, want [first]", got) + } + if got := <-batches; !reflect.DeepEqual(got, []string{"second", "third"}) { + t.Fatalf("pending batch = %v, want [second third]", got) } } @@ -53,14 +61,14 @@ func TestConvQueueParallelAcrossConversations(t *testing.T) { q := newConvQueue() slowRelease := make(chan struct{}) slowStarted := make(chan struct{}) - q.run("conv-slow", func() { + q.submit(connectQueuedTurn{convID: "conv-slow", text: "slow"}, func([]connectQueuedTurn) { close(slowStarted) <-slowRelease }) <-slowStarted fastDone := make(chan struct{}) - q.run("conv-fast", func() { close(fastDone) }) + q.submit(connectQueuedTurn{convID: "conv-fast", text: "fast"}, func([]connectQueuedTurn) { close(fastDone) }) select { case <-fastDone: case <-time.After(2 * time.Second): @@ -75,12 +83,12 @@ func TestConvQueueDrainsEntries(t *testing.T) { q := newConvQueue() var wg sync.WaitGroup wg.Add(1) - q.run("conv-1", func() { wg.Done() }) + q.submit(connectQueuedTurn{convID: "conv-1", text: "done"}, func([]connectQueuedTurn) { wg.Done() }) wg.Wait() deadline := time.Now().Add(2 * time.Second) for { q.mu.Lock() - n := len(q.tails) + n := len(q.states) q.mu.Unlock() if n == 0 { return diff --git a/internal/helpers/connect_stream.go b/internal/helpers/connect_stream.go index 06ddbf5c..1b2579c0 100644 --- a/internal/helpers/connect_stream.go +++ b/internal/helpers/connect_stream.go @@ -250,19 +250,24 @@ func (f *execForwarder) label() string { return fmt.Sprintf("exec:%s (%s, %s)", f.name, f.argv[0], memo) } -func (f *execForwarder) forward(ctx context.Context, convID, text string) (string, error) { - ctx, cancel := applyTimeout(ctx, f.timeout) - defer cancel() +func (f *execForwarder) hasSession(convID string) bool { + return f.sessions != nil && strings.TrimSpace(convID) != "" +} + +func (f *execForwarder) commandArgs(argv []string, convID, text string) []string { // Session args go right after the binary, before the spec tail — some specs // (qoder) end the tail with `-p` so the prompt must stay the trailing // positional argument. var args []string - if f.sessions != nil && strings.TrimSpace(convID) != "" { + if f.hasSession(convID) { args = append(args, f.sessions.args(convID)...) } - args = append(args, f.argv[1:]...) + args = append(args, argv[1:]...) args = append(args, text) - cmd := exec.CommandContext(ctx, f.argv[0], args...) + return args +} + +func (f *execForwarder) configureCommand(cmd *exec.Cmd) { // Run the agent CLI from a clean, empty directory rather than inheriting the // connector's CWD (often $HOME). Some agents scan the working tree / nearby // config on startup — e.g. `claude -p` takes ~29s from a large $HOME but ~4s @@ -278,37 +283,60 @@ func (f *execForwarder) forward(ctx context.Context, convID, text string) (strin if len(f.env) > 0 { cmd.Env = append(os.Environ(), f.env...) } - out, err := cmd.Output() - s := strings.TrimSpace(string(out)) - // Guard against a backend error being mistaken for the answer: some agent - // CLIs (claude) print "API Error: 4xx ..." to stdout and still exit 0, so a - // non-empty stdout is not proof of a real reply. If stdout is a bare backend - // error, return an actionable hint instead of forwarding the raw error into - // the chat (issue #14: a custom-provider 422 was echoed to the group). - if s != "" && !agentReplyIsError(s) { - return brandReply(f.name, s), nil - } - if s != "" && agentReplyIsError(s) { - if f.sessions != nil && strings.TrimSpace(convID) != "" { - f.sessions.reset(convID) +} + +func (f *execForwarder) forward(ctx context.Context, convID, text string) (string, error) { + ctx, cancel := applyTimeout(ctx, f.timeout) + defer cancel() + + run := func() (string, string, error) { + args := f.commandArgs(f.argv, convID, text) + cmd := exec.CommandContext(ctx, f.argv[0], args...) + f.configureCommand(cmd) + out, err := cmd.Output() + s := strings.TrimSpace(string(out)) + // Guard against a backend error being mistaken for the answer: some agent + // CLIs (claude) print "API Error: 4xx ..." to stdout and still exit 0, so a + // non-empty stdout is not proof of a real reply. If stdout is a bare backend + // error, return an actionable hint instead of forwarding the raw error into + // the chat (issue #14: a custom-provider 422 was echoed to the group). + if s != "" && !agentReplyIsError(s) { + return brandReply(f.name, s), "", nil } - return agentBackendErrorReply(s), nil + if s != "" && agentReplyIsError(s) { + if f.hasSession(convID) { + f.sessions.reset(convID) + } + return agentBackendErrorReply(s), "", nil + } + if err != nil { + msg := execErrorMessage(err) + return "", msg, err + } + return "(本地 agent 无文本输出)", "", nil } - if err != nil { + + reply, msg, err := run() + if err == nil { + return reply, nil + } + + if f.hasSession(convID) && agentSessionMissingError(msg) { + f.sessions.reset(convID) + reply, msg, err = run() + if err == nil { + return reply, nil + } + } + + if f.hasSession(convID) { // Self-heal session state: if this conversation's session is broken // (e.g. --resume of a session that was never created or got cleaned), // drop the mapping so the next message starts a fresh session instead // of failing forever. - if f.sessions != nil && strings.TrimSpace(convID) != "" { - f.sessions.reset(convID) - } - msg := err.Error() - if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { - msg = strings.TrimSpace(string(ee.Stderr)) - } - return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300)) + f.sessions.reset(convID) } - return "(本地 agent 无文本输出)", nil + return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300)) } // convSessions maps a DingTalk conversation to a stable agent session ID, so a @@ -415,6 +443,17 @@ func brandReply(channel, reply string) string { return qoderworkIdentityRe.ReplaceAllString(reply, "我是 QoderWork 助手,钉钉群里的智能助手。") } +func execErrorMessage(err error) string { + if err == nil { + return "" + } + msg := err.Error() + if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { + msg = strings.TrimSpace(string(ee.Stderr)) + } + return msg +} + // agentReplyIsError reports whether an agent's stdout is a bare backend error // rather than a real answer. Claude Code prints provider failures as // "API Error: ..." on stdout and may still exit 0, so the connector @@ -424,6 +463,16 @@ func agentReplyIsError(s string) bool { return strings.HasPrefix(strings.TrimSpace(s), "API Error:") } +// agentSessionMissingError recognizes stale addressable sessions. Agent CLIs +// differ in wording, but the operational meaning is the same: the connector's +// persisted session id points at a conversation the CLI can no longer resume. +func agentSessionMissingError(msg string) bool { + lower := strings.ToLower(strings.TrimSpace(msg)) + return strings.Contains(lower, "no conversation found with session id") || + strings.Contains(lower, "conversation not found") || + strings.Contains(lower, "session not found") +} + // agentBackendErrorReply turns a bare backend error into a short, actionable // Chinese message for the chat, instead of echoing the raw provider error // (issue #14). It keeps only the first line of the raw error, truncated. @@ -588,10 +637,11 @@ func claudeUserSettingsEnv() []string { return out } -// agentSpecs is the registry of local-agent channels. Most forward to a local +// agentSpecs is the registry of agent channels. Most forward to a local // headless CLI (one-shot per message, 24/7, no interactive session). Codex uses -// the local CLI binary only to host app-server. Exact headless flags for -// non-Codex channels can be overridden per run with DWS_AGENT_CMD. +// the local CLI binary only to host app-server; Gemini uses its HTTP API. +// Exact headless flags for CLI channels can be overridden per run with +// DWS_AGENT_CMD. // // Install policy: npm/pipx (package managers) are auto-installed; curl|bash // remote-script installs and desktop apps are hint-only (we do not silently pipe @@ -608,9 +658,8 @@ var agentSpecs = map[string]agentSpec{ "codex": {app: "OpenAI Codex CLI", bins: []string{"codex"}, install: []string{"npm", "i", "-g", "@openai/codex"}, hint: "npm i -g @openai/codex", modelFlag: "-m"}, - "gemini": {app: "Gemini CLI", bins: []string{"gemini"}, argvTail: []string{"-p"}, - install: []string{"npm", "i", "-g", "@google/gemini-cli"}, hint: "npm i -g @google/gemini-cli", - modelFlag: "-m"}, + "gemini": {app: "Gemini API", + hint: "设置 GEMINI_API_KEY(或 GOOGLE_API_KEY);模型可用 --agent-model 指定;Gemini-compatible 代理可设置 GEMINI_API_BASE_URL"}, // opencode is resolved here only to find the local binary. The forwarder // uses `opencode serve --pure` plus HTTP session/message APIs instead of // parsing `opencode run` stdout. @@ -719,6 +768,17 @@ func connectCliStatus(channel string) map[string]any { status["command"] = command } return status + case "gemini": + status := map[string]any{ + "required": "GEMINI_API_KEY or GOOGLE_API_KEY", + "installed": geminiAPIKey() != "", + "autoInstall": false, + "installHint": "设置 GEMINI_API_KEY(或 GOOGLE_API_KEY);模型可用 --agent-model 指定;Gemini-compatible 代理可设置 GEMINI_API_BASE_URL", + } + if base := strings.TrimSpace(geminiAPIBaseURL()); base != "" { + status["baseURL"] = base + } + return status } spec, ok := agentSpecs[channel] if !ok { @@ -785,6 +845,10 @@ func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (fo if !ok { return nil, apperrors.NewValidation(fmt.Sprintf("渠道 %q 不是 stream-bridge 渠道,无 forwarder", channel)) } + overridden := strings.TrimSpace(os.Getenv("DWS_AGENT_CMD")) != "" && channel != "codex" + if channel == "gemini" && !overridden { + return newGeminiAPIForwarder(timeout, opts) + } // Resolve the agent CLI (PATH → app bundle → auto-install → guidance) and // preflight here so a missing dependency errors at connect time. argv, env, err := resolveExecAgent(channel) @@ -795,7 +859,6 @@ func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (fo // model or session flags we cannot know are valid for it. Codex ignores the // override because its channel is app-server only; custom commands belong on // --channel custom. - overridden := strings.TrimSpace(os.Getenv("DWS_AGENT_CMD")) != "" && channel != "codex" userPickedModel := opts.Model != "" || strings.TrimSpace(os.Getenv("DWS_AGENT_MODEL")) != "" if !overridden && opts.Model != "" { if spec.modelFlag == "" { @@ -850,14 +913,9 @@ func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (fo if !overridden && opts.Yolo { switch channel { case "claudecode", "codebuddy", "workbuddy": - argv = append(argv, "--dangerously-skip-permissions") + argv = append(argv, "--permission-mode", "bypassPermissions", "--dangerously-skip-permissions") if len(streamArgv) > 0 { - streamArgv = append(streamArgv, "--dangerously-skip-permissions") - } - case "gemini": - argv = append(argv, "--yolo") - if len(streamArgv) > 0 { - streamArgv = append(streamArgv, "--yolo") + streamArgv = append(streamArgv, "--permission-mode", "bypassPermissions", "--dangerously-skip-permissions") } } } @@ -883,6 +941,18 @@ func applyModelArg(argv []string, flag, model string) []string { return append(out[:1:1], append([]string{flag, model}, out[1:]...)...) } +func insertBeforeArg(argv []string, marker string, values ...string) []string { + for i := 1; i < len(argv); i++ { + if argv[i] == marker { + out := append([]string(nil), argv[:i]...) + out = append(out, values...) + return append(out, argv[i:]...) + } + } + out := append([]string(nil), argv...) + return append(out, values...) +} + // msgDedup tracks recently-seen MsgIds so a redelivered message is not // processed (and replied to) twice. Memory is bounded: once the set reaches // limit it is cleared (the chance of a very old MsgId being redelivered after a @@ -935,6 +1005,83 @@ type connectExtras struct { persona string } +type connectQueuedTurn struct { + convID string + text string + picCodes []string + fileInfo fileInboundInfo + webhook string + msgID string + msgType string + senderStaffID string + conversationID string + conversationType string + callbackData chatbot.BotCallbackDataModel +} + +func mergeConnectQueuedTurns(turns []connectQueuedTurn) connectQueuedTurn { + if len(turns) == 0 { + return connectQueuedTurn{} + } + if len(turns) == 1 { + return turns[0] + } + for i := len(turns) - 1; i >= 0; i-- { + if connectTurnShouldStayStandalone(turns[i]) { + return turns[i] + } + } + merged := turns[len(turns)-1] + lines := make([]string, 0, len(turns)+1) + lines = append(lines, "用户在上一轮处理期间连续发送了以下消息,请把它们作为同一个最新请求一起处理:") + for i, turn := range turns { + lines = append(lines, fmt.Sprintf("%d. %s", i+1, connectTurnSummary(turn))) + } + merged.text = strings.Join(lines, "\n") + merged.picCodes = nil + for i := range turns { + merged.picCodes = append(merged.picCodes, turns[i].picCodes...) + } + if !merged.fileInfo.hasActionable() { + for i := len(turns) - 1; i >= 0; i-- { + if turns[i].fileInfo.hasActionable() { + merged.fileInfo = turns[i].fileInfo + break + } + } + } + return merged +} + +func connectTurnShouldStayStandalone(turn connectQueuedTurn) bool { + if _, ok := parseConnectControlCommand(turn.text); ok { + return true + } + if _, ok := parseDecisionWord(turn.text); ok { + return true + } + return isRetryWord(turn.text) +} + +func connectTurnSummary(turn connectQueuedTurn) string { + if text := strings.TrimSpace(turn.text); text != "" { + if len(turn.picCodes) > 0 { + return text + " [同时附有图片]" + } + return text + } + if len(turn.picCodes) > 0 { + return "[图片]" + } + if turn.fileInfo.hasActionable() { + if name := strings.TrimSpace(turn.fileInfo.FileName); name != "" { + return "[文件: " + name + "]" + } + return "[文件]" + } + return "[空消息]" +} + func runStreamConnector(ctx context.Context, channel, clientID, clientSecret string, fwd forwarder, cardCli *aiCardClient, extras *connectExtras) error { if extras == nil { extras = &connectExtras{} @@ -973,7 +1120,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str mediaCli = newAICardClient(clientID, clientSecret, "") } - keepAlive := envDurationMS("DWS_CONNECT_KEEPALIVE_MS", 30000) + keepAlive := envDurationMS("DWS_CONNECT_KEEPALIVE_MS", 30*time.Second) fmt.Fprintf(os.Stderr, "[connect] keepAlive=%s autoReconnect=true\n", keepAlive) cli := client.NewStreamClient( client.WithAppCredential(client.NewAppCredentialConfig(clientID, clientSecret)), @@ -985,9 +1132,17 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str 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 := "" + var picCodes []string if strings.EqualFold(msgtype, "picture") { - picCode = pictureDownloadCode(data.Content) + if code := pictureDownloadCode(data.Content); code != "" { + picCodes = append(picCodes, code) + } + } + // A richText callback can mix text and inline picture nodes. Scan its + // content even when data.Text.Content is already populated; otherwise + // the text survives while every embedded picture silently disappears. + if strings.EqualFold(msgtype, "richText") { + picCodes = append(picCodes, richTextPictureDownloadCodes(data.Content)...) } // File callbacks come in two shapes: client-sent files carry a // downloadCode; API-sent files (`dws chat message send --msg-type file @@ -1003,7 +1158,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str // 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 == "" && len(picCodes) == 0 { // 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 @@ -1018,7 +1173,7 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str } } } - if (text == "" && picCode == "" && !fileInfo.hasActionable()) || data.SessionWebhook == "" { + if (text == "" && len(picCodes) == 0 && !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 @@ -1054,7 +1209,7 @@ 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 + "]" @@ -1072,11 +1227,36 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str if convID == "" { convID = strings.TrimSpace(data.SenderStaffId) } - callbackData := data msgID := strings.TrimSpace(data.MsgId) - // Same-conversation messages run in arrival order (follow-ups need the - // previous turn's session state); different conversations in parallel. - queue.run(convID, func() { + turn := connectQueuedTurn{ + convID: convID, + text: text, + picCodes: picCodes, + fileInfo: fileInfo, + webhook: webhook, + msgID: msgID, + msgType: msgtype, + senderStaffID: strings.TrimSpace(data.SenderStaffId), + conversationID: strings.TrimSpace(data.ConversationId), + conversationType: strings.TrimSpace(data.ConversationType), + callbackData: *data, + } + // Same-conversation agent calls never run in parallel; messages received + // while a turn is running are merged into one pending follow-up instead + // of forming an unbounded stale FIFO. + queue.submit(turn, func(turns []connectQueuedTurn) { + turn := mergeConnectQueuedTurns(turns) + if len(turns) > 1 { + fmt.Fprintf(os.Stderr, "[connect] 合并 %d 条待处理消息 (convId=%s, latestMsgId=%s)\n", len(turns), turn.convID, turn.msgID) + } + text := turn.text + picCodes := turn.picCodes + fileInfo := turn.fileInfo + webhook := turn.webhook + convID := turn.convID + msgID := turn.msgID + msgtype := turn.msgType + callbackData := &turn.callbackData // Digital-twin text approval: if this is the OWNER replying // 「同意」/「拒绝」 (in their 1:1 chat with the bot) to the pending // request, route it to the gate (decide → execute/decline, with @@ -1124,16 +1304,21 @@ 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) + 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 = "(用户发来一张图片,但图片下载失败了。请告知用户图片没收到,建议补充文字描述。)" + prompt = "(用户发来图片,但图片下载失败了。请告知用户图片没收到,建议补充文字描述。)" + } else { + prompt += "\n(用户同时附了一张图片,但图片下载失败,请基于现有文字回答并说明未能读取图片。)" } - } else if prompt == "" { + continue + } + if prompt == "" { prompt = "用户发来一张图片(本地路径 " + localPath + "),请查看图片内容并回答其中的问题。" } else { - prompt = prompt + "\n(用户同时附了一张图片,本地路径 " + localPath + ",请结合图片内容回答。)" + prompt += "\n(用户同时附了一张图片,本地路径 " + localPath + ",请结合图片内容回答。)" } } if fileInfo.hasActionable() { diff --git a/internal/helpers/connect_stream_test.go b/internal/helpers/connect_stream_test.go index c76b2cf3..02bd73ca 100644 --- a/internal/helpers/connect_stream_test.go +++ b/internal/helpers/connect_stream_test.go @@ -162,3 +162,52 @@ func TestEnvDurationMS(t *testing.T) { t.Fatalf("invalid env falls back to default = %v, want %v", got, def) } } + +func TestMergeConnectQueuedTurnsBuildsSinglePrompt(t *testing.T) { + merged := mergeConnectQueuedTurns([]connectQueuedTurn{ + {convID: "conv-1", text: "第一条", msgID: "m1"}, + {convID: "conv-1", text: "补充:按今天的数据", msgID: "m2"}, + {convID: "conv-1", text: "最后改成周报口径", msgID: "m3"}, + }) + if merged.msgID != "m3" { + t.Fatalf("merged msgID = %q, want latest m3", merged.msgID) + } + for _, want := range []string{"连续发送", "1. 第一条", "2. 补充:按今天的数据", "3. 最后改成周报口径"} { + if !strings.Contains(merged.text, want) { + t.Fatalf("merged prompt missing %q:\n%s", want, merged.text) + } + } +} + +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 TestMergeConnectQueuedTurnsKeepsControlMessagesStandalone(t *testing.T) { + for _, text := range []string{"/clear", "同意", "拒绝", "重试"} { + merged := mergeConnectQueuedTurns([]connectQueuedTurn{ + {convID: "conv-1", text: "先查一下", msgID: "m1"}, + {convID: "conv-1", text: text, msgID: "m2"}, + }) + if merged.text != text || merged.msgID != "m2" { + t.Fatalf("control %q merged to (%q,%q), want standalone latest", text, merged.text, merged.msgID) + } + } +} diff --git a/internal/helpers/connect_streaming.go b/internal/helpers/connect_streaming.go index 11b36e64..c6f685d3 100644 --- a/internal/helpers/connect_streaming.go +++ b/internal/helpers/connect_streaming.go @@ -18,7 +18,6 @@ import ( "context" "encoding/json" "fmt" - "os" "os/exec" "strings" ) @@ -53,76 +52,79 @@ func (f *execForwarder) forwardStream(ctx context.Context, convID, text string, ctx, cancel := applyTimeout(ctx, f.timeout) defer cancel() - var args []string - if f.sessions != nil && strings.TrimSpace(convID) != "" { - args = append(args, f.sessions.args(convID)...) - } - args = append(args, f.streamArgv[1:]...) - args = append(args, text) - cmd := exec.CommandContext(ctx, f.streamArgv[0], args...) - if f.workDir != "" { - cmd.Dir = f.workDir - } else { - cmd.Dir = connectWorkDir() - } - if len(f.env) > 0 { - cmd.Env = append(os.Environ(), f.env...) - } - stdout, err := cmd.StdoutPipe() - if err != nil { - return "", err - } - var stderrBuf strings.Builder - cmd.Stderr = &stderrBuf - if err := cmd.Start(); err != nil { - return "", err + run := func() (string, string, error) { + args := f.commandArgs(f.streamArgv, convID, text) + cmd := exec.CommandContext(ctx, f.streamArgv[0], args...) + f.configureCommand(cmd) + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", err.Error(), err + } + var stderrBuf strings.Builder + cmd.Stderr = &stderrBuf + if err := cmd.Start(); err != nil { + return "", err.Error(), err + } + + var acc strings.Builder // accumulated visible text ("cc" deltas / "qoder" turns) + finalText := "" + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || !strings.HasPrefix(line, "{") { + continue + } + delta, final := parseStreamLine(f.parser, line) + if delta != "" { + acc.WriteString(delta) + onDelta(brandReply(f.name, acc.String())) + } + if final != "" { + finalText = final + } + } + waitErr := cmd.Wait() + + if finalText == "" { + finalText = strings.TrimSpace(acc.String()) + } + // A bare backend error (e.g. claude's "API Error: 4xx ...") must not be + // forwarded as the answer (issue #14): return an actionable hint instead. + if agentReplyIsError(finalText) { + if f.hasSession(convID) { + f.sessions.reset(convID) + } + return agentBackendErrorReply(finalText), "", nil + } + if finalText != "" { + return brandReply(f.name, finalText), "", nil + } + if waitErr != nil { + msg := waitErr.Error() + if s := strings.TrimSpace(stderrBuf.String()); s != "" { + msg = s + } + return "", msg, waitErr + } + return "(本地 agent 无文本输出)", "", nil } - var acc strings.Builder // accumulated visible text ("cc" deltas / "qoder" turns) - finalText := "" - scanner := bufio.NewScanner(stdout) - scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || !strings.HasPrefix(line, "{") { - continue - } - delta, final := parseStreamLine(f.parser, line) - if delta != "" { - acc.WriteString(delta) - onDelta(brandReply(f.name, acc.String())) - } - if final != "" { - finalText = final + reply, msg, err := run() + if err == nil { + return reply, nil + } + if f.hasSession(convID) && agentSessionMissingError(msg) { + f.sessions.reset(convID) + reply, msg, err = run() + if err == nil { + return reply, nil } } - waitErr := cmd.Wait() - - if finalText == "" { - finalText = strings.TrimSpace(acc.String()) - } - // A bare backend error (e.g. claude's "API Error: 4xx ...") must not be - // forwarded as the answer (issue #14): return an actionable hint instead. - if agentReplyIsError(finalText) { - if f.sessions != nil && strings.TrimSpace(convID) != "" { - f.sessions.reset(convID) - } - return agentBackendErrorReply(finalText), nil - } - if finalText != "" { - return brandReply(f.name, finalText), nil - } - if f.sessions != nil && strings.TrimSpace(convID) != "" { + if f.hasSession(convID) { f.sessions.reset(convID) } - if waitErr != nil { - msg := waitErr.Error() - if s := strings.TrimSpace(stderrBuf.String()); s != "" { - msg = s - } - return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300)) - } - return "(本地 agent 无文本输出)", nil + return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300)) } // parseStreamLine extracts (visible-text delta, final text) from one JSONL diff --git a/internal/helpers/devapp_connect.go b/internal/helpers/devapp_connect.go index fa271102..3ab95747 100644 --- a/internal/helpers/devapp_connect.go +++ b/internal/helpers/devapp_connect.go @@ -583,7 +583,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command { cmd.Flags().String("robot-client-id", "", "现成机器人 clientId(AppKey)") cmd.Flags().String("robot-client-secret", "", "现成机器人 clientSecret(AppSecret)") cmd.Flags().String("unified-app-id", "", "统一应用 ID:复用 dev app credentials get 自动取凭证(替代手填 robot-client-id/secret)") - cmd.Flags().String("agent-model", "", "覆盖本地 agent 模型(如 claude 的 sonnet/opus;默认用渠道内置模型,求快);env: DWS_AGENT_MODEL") + cmd.Flags().String("agent-model", "", "覆盖 agent 模型(如 claude 的 sonnet/opus、gemini-2.5-pro;默认用渠道内置模型,求快);env: DWS_AGENT_MODEL") cmd.Flags().String("agent-workdir", "", "本地 agent 的运行目录(放知识文件可给机器人上下文;默认空白临时目录,求快);env: DWS_AGENT_WORKDIR") cmd.Flags().Bool("agent-memory", true, "按会话续聊:同一群/单聊共享 agent 会话上下文(codex/opencode/qoder/qoderwork/claudecode/codebuddy/workbuddy 支持;--agent-memory=false 关闭)") cmd.Flags().Int("agent-timeout", 0, "每次 agent 调用的超时时间(秒),0=不限制(默认);env: DWS_AGENT_TIMEOUT_MS(毫秒)") @@ -751,7 +751,7 @@ func resolveAgentYoloMode(cmd *cobra.Command) (bool, error) { // connectAgentOptionsPayload renders the effective agent tuning for the // dry-run preview, including whether session memory actually applies to the // chosen channel (Codex uses app-server threads, opencode uses opencode serve -// sessions, CLI session channels use --session-id/--resume, and gemini stays +// sessions, CLI session channels use --session-id/--resume, and Gemini API stays // stateless today). func connectAgentOptionsPayload(channel string, opts connectAgentOptions) map[string]any { spec, ok := agentSpecs[channel] diff --git a/internal/helpers/devapp_connect_test.go b/internal/helpers/devapp_connect_test.go index 9b13e88e..691bab56 100644 --- a/internal/helpers/devapp_connect_test.go +++ b/internal/helpers/devapp_connect_test.go @@ -29,6 +29,8 @@ func clearChannelEnv(t *testing.T) { "DWS_CONNECT_CMD", "DWS_AGENT_CMD", "WORKBUDDY_CONFIG_DIR", "WORKBUDDY_APP_NAME", "CLAUDECODE", "DWS_AGENT_PERMISSION_MODE", "DWS_AGENT_APPROVAL_MODE", + "GEMINI_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_BASE_URL", + "GOOGLE_GEMINI_API_BASE_URL", "GEMINI_MODEL", } { t.Setenv(k, "") } @@ -264,7 +266,7 @@ func TestAgentSpecsCoverMainstreamAgents(t *testing.T) { t.Errorf("agentSpecs missing channel %q", ch) continue } - if len(spec.bins) == 0 { + if ch != "gemini" && len(spec.bins) == 0 { t.Errorf("channel %q has no bins", ch) } if spec.hint == "" { diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index d3e828ad..3644dba7 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -400,7 +400,11 @@ func inferFilename(rawURL string) string { if decoded, err := url.PathUnescape(name); err == nil { name = decoded } - if name != "" { + // 解码后可能含 %2F 还原出的路径分隔符(如 "ddmedia/xxx.png"),只取 + // 末段 base 名,避免拼出调用方未创建的子目录导致写文件失败。 + name = strings.ReplaceAll(name, "\\", "/") + name = filepath.Base(name) + if name != "" && name != "." && name != "/" { return name } } @@ -783,7 +787,7 @@ func newDocCommand() *cobra.Command { root := &cobra.Command{ Use: "doc", Short: "钉钉文档管理", - Long: `管理钉钉文档:浏览、读写、块级编辑、导出、模板管理。 + Long: `管理钉钉文档:浏览、读写、块级编辑、导出、导入、模板管理。 命令结构: dws doc info 获取文档元信息 @@ -791,9 +795,11 @@ 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 等) + dws doc import get 查询导入任务结果 (手动兜底) dws doc template list 获取文档模板列表 dws doc template search 搜索文档模板 dws doc template apply 应用文档模板创建新文档 @@ -1945,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: "创建划词评论", @@ -2000,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 的别名") @@ -2014,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{ @@ -2442,6 +2509,233 @@ CLI 内部自动完成全部流程: exportCmd.AddCommand(exportGetCmd) + // ── import: 文件导入为在线文档(一体化:上传→转换→轮询)────────────── + importCmd := &cobra.Command{ + Use: "import", + Short: "导入本地文件为在线文档 (支持 docx / xlsx / md 等)", + Long: `将本地文件导入为钉钉在线文档。 + +支持的文件格式 (按扩展名): + docx, doc → 文字文档 + xlsx, xls → 电子表格 + md, txt → 文字文档 + xmind, mark → 脑图 + +文件大小限制: 20MB + +CLI 内部自动完成全部流程: + 1. 创建导入会话(获取 OSS 上传凭证) + 2. 上传文件到 OSS + 3. 确认导入(触发格式转换) + 4. 渐进式退避轮询等待完成(最多约 5 分钟) + +如果轮询超时仍未完成,会输出 taskId 供后续手动查询: + dws doc import get --task-id `, + Example: ` # 导入 Word 文档 + dws doc import --file ./report.docx + + # 导入到指定文件夹 + dws doc import --file ./notes.md --folder + + # 导入到知识库根目录 + dws doc import --file ./data.xlsx --workspace + + # 自定义导入后的文档名称 + dws doc import --file ./draft.md --name "项目周报"`, + RunE: func(cmd *cobra.Command, args []string) error { + filePath := mustGetFlag(cmd, "file") + if filePath == "" && len(args) > 0 { + filePath = args[0] + } + if filePath == "" { + return fmt.Errorf("flag --file is required (or pass file path as argument)") + } + + fi, err := os.Stat(filePath) + if err != nil { + return fmt.Errorf("cannot read file %s: %w", filePath, err) + } + if fi.IsDir() { + return fmt.Errorf("%s is a directory, not a file", filePath) + } + + const maxFileSize = 20 * 1024 * 1024 + fileSize := fi.Size() + if fileSize > maxFileSize { + return fmt.Errorf("file size %d bytes exceeds 20MB limit", fileSize) + } + if fileSize == 0 { + return fmt.Errorf("file is empty: %s", filePath) + } + + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") + supportedFormats := map[string]bool{ + "docx": true, "doc": true, "xlsx": true, "xls": true, + "md": true, "txt": true, "xmind": true, "mark": true, + } + if !supportedFormats[ext] { + return fmt.Errorf("unsupported file format %q, supported: docx, doc, xlsx, xls, md, txt, xmind, mark", ext) + } + + fileName := filepath.Base(filePath) + name, _ := cmd.Flags().GetString("name") + if name == "" { + name = strings.TrimSuffix(fileName, filepath.Ext(fileName)) + } + folder := flagOrFallback(cmd, "folder", "folder-id") + workspace := flagOrFallback(cmd, "workspace", "workspace-id") + + if deps.Caller.DryRun() { + deps.Out.PrintKeyValue("操作", "导入本地文件为在线文档") + deps.Out.PrintKeyValue("文件", filePath) + deps.Out.PrintKeyValue("名称", name) + deps.Out.PrintKeyValue("格式", ext) + deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize)) + return nil + } + + ctx := context.Background() + + deps.Out.PrintInfo("[1/4] 创建导入会话...") + sessionArgs := map[string]any{ + "fileName": name, + "suffix": ext, + "fileSize": fileSize, + } + if folder != "" { + sessionArgs["targetFolderId"] = folder + } + if workspace != "" { + sessionArgs["workspaceId"] = workspace + } + + sessionText, err := callMCPToolReturnText(ctx, "create_import_session", sessionArgs) + if err != nil { + return fmt.Errorf("创建导入会话失败: %w", err) + } + + var sessionResult map[string]any + if err := json.Unmarshal([]byte(sessionText), &sessionResult); err != nil { + return fmt.Errorf("解析导入会话响应失败: %w", err) + } + sessionID, _ := sessionResult["sessionId"].(string) + uploadURL, _ := sessionResult["uploadUrl"].(string) + if sessionID == "" || uploadURL == "" { + deps.Out.PrintRaw(sessionText) + return fmt.Errorf("创建导入会话成功但缺少 sessionId 或 uploadUrl") + } + deps.Out.PrintInfo(fmt.Sprintf(" 会话已创建,sessionId: %s", sessionID)) + + deps.Out.PrintInfo("[2/4] 上传文件...") + if err := httpPutFile(ctx, uploadURL, nil, filePath, fileSize); err != nil { + return fmt.Errorf("文件上传失败 (sessionId=%s): %w", sessionID, err) + } + deps.Out.PrintInfo(" 文件上传完成") + + deps.Out.PrintInfo("[3/4] 确认导入,启动格式转换...") + confirmText, err := callMCPToolReturnText(ctx, "confirm_import", map[string]any{ + "sessionId": sessionID, + }) + if err != nil { + return fmt.Errorf("确认导入失败 (sessionId=%s): %w", sessionID, err) + } + + var confirmResult map[string]any + if err := json.Unmarshal([]byte(confirmText), &confirmResult); err != nil { + return fmt.Errorf("解析确认导入响应失败: %w", err) + } + taskID, _ := confirmResult["taskId"].(string) + if taskID == "" { + deps.Out.PrintRaw(confirmText) + return fmt.Errorf("确认导入成功但未返回 taskId") + } + deps.Out.PrintInfo(fmt.Sprintf(" 转换任务已提交,taskId: %s", taskID)) + + deps.Out.PrintInfo("[4/4] 等待格式转换完成...") + importResult, err := pollDocImportTask(ctx, taskID) + if err != nil { + return err + } + + documentURL, _ := importResult["documentUrl"].(string) + documentName, _ := importResult["documentName"].(string) + documentType, _ := importResult["documentType"].(string) + + deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL)) + deps.Out.PrintJSON(map[string]any{ + "success": true, + "taskId": taskID, + "documentUrl": documentURL, + "documentName": documentName, + "documentType": documentType, + }) + return nil + }, + } + importCmd.Flags().String("file", "", "本地文件路径 (必填)") + importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选,与 --workspace 至少传一个)") + importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选,与 --folder 至少传一个)") + importCmd.Flags().StringP("name", "n", "", "导入后文档名称 (可选,默认取文件名)") + importCmd.Flags().String("folder-id", "", "") + _ = importCmd.Flags().MarkHidden("folder-id") + importCmd.Flags().String("workspace-id", "", "") + _ = importCmd.Flags().MarkHidden("workspace-id") + + importGetCmd := &cobra.Command{ + Use: "get", + Short: "查询导入任务结果(手动兜底)", + Long: `根据 taskId 查询文档导入任务的执行结果。 +通常不需要手动调用,dws doc import 会自动完成轮询。 +仅在导入命令超时或中断后,用于手动查询任务状态。 + +任务状态: + processing 转换中 + completed 导入成功,返回 documentUrl + failed 导入失败`, + Example: ` dws doc import get --task-id `, + RunE: func(cmd *cobra.Command, _ []string) error { + taskID := mustGetFlag(cmd, "task-id") + if taskID == "" { + return fmt.Errorf("flag --task-id is required") + } + + if deps.Caller.DryRun() { + deps.Out.PrintKeyValue("操作", "查询导入任务结果") + deps.Out.PrintKeyValue("任务ID", taskID) + return nil + } + + ctx := context.Background() + text, err := callMCPToolReturnText(ctx, "query_import_task", map[string]any{"taskId": taskID}) + if err != nil { + return err + } + + var result map[string]any + if err := json.Unmarshal([]byte(text), &result); err != nil { + deps.Out.PrintRaw(text) + return nil + } + + status, _ := result["status"].(string) + message, _ := result["message"].(string) + + switch strings.ToLower(status) { + case "completed", "processing": + deps.Out.PrintJSON(result) + return nil + default: + deps.Out.PrintJSON(result) + if message != "" { + return fmt.Errorf("导入任务失败 (status=%s): %s", status, message) + } + return fmt.Errorf("导入任务失败 (status=%s)", status) + } + }, + } + importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)") + importCmd.AddCommand(importGetCmd) + // ── doc version 子命令组 ── versionCmd := &cobra.Command{ Use: "version", @@ -2654,7 +2948,7 @@ CLI 内部自动完成全部流程: // will skip flags that already exist. for _, cmd := range []*cobra.Command{ searchCmd, listCmd, createCmd, updateCmd, uploadCmd, downloadCmd, - copyCmd, moveCmd, renameCmd, deleteCmd, exportCmd, + copyCmd, moveCmd, renameCmd, deleteCmd, exportCmd, importCmd, } { RegisterCrossProductAliases(cmd) } @@ -2696,7 +2990,7 @@ CLI 内部自动完成全部流程: folderCmd.Hidden = true permissionCmd.Hidden = true - root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, versionCmd, templateCmd) + root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd) return root } @@ -2943,6 +3237,63 @@ func pollDocExportJob(ctx context.Context, jobID string) (downloadURL string, er return "", fmt.Errorf("导出任务超时:已轮询 %d 次仍在处理中 (jobId=%s),请稍后使用 dws doc export get --job-id %s 手动查询", maxPolls, jobID, jobID) } +// pollDocImportTask polls the import task status with progressive backoff. +func pollDocImportTask(ctx context.Context, taskID string) (map[string]any, error) { + const maxPolls = 30 + + pollInterval := func(attempt int) time.Duration { + switch { + case attempt <= 5: + return 2 * time.Second + case attempt <= 10: + return 5 * time.Second + case attempt <= 20: + return 10 * time.Second + default: + return 15 * time.Second + } + } + + for attempt := 1; attempt <= maxPolls; attempt++ { + interval := pollInterval(attempt) + deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval)) + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("导入轮询被取消 (taskId=%s): %w", taskID, ctx.Err()) + case <-time.After(interval): + } + + text, queryErr := callMCPToolReturnText(ctx, "query_import_task", map[string]any{"taskId": taskID}) + if queryErr != nil { + return nil, fmt.Errorf("查询导入任务失败 (taskId=%s): %w", taskID, queryErr) + } + + var result map[string]any + if parseErr := json.Unmarshal([]byte(text), &result); parseErr != nil { + return nil, fmt.Errorf("解析查询结果失败 (taskId=%s): %w", taskID, parseErr) + } + + status, _ := result["status"].(string) + switch strings.ToLower(status) { + case "completed": + return result, nil + case "processing": + continue + case "failed": + message, _ := result["message"].(string) + if message != "" { + return nil, fmt.Errorf("导入任务失败 (taskId=%s): %s", taskID, message) + } + return nil, fmt.Errorf("导入任务失败 (taskId=%s)", taskID) + default: + continue + } + } + + return nil, fmt.Errorf("导入任务超时:已轮询 %d 次仍在处理中 (taskId=%s),请稍后使用 dws doc import get --task-id %s 手动查询", maxPolls, taskID, taskID) +} + // stripDuplicateTitle removes the leading H1 heading from markdown content // when it matches the document name (set via --name). This prevents the title // from appearing twice: once as document metadata and once in the body. diff --git a/internal/helpers/doc_comment_update_delete_test.go b/internal/helpers/doc_comment_update_delete_test.go new file mode 100644 index 00000000..26ea050c --- /dev/null +++ b/internal/helpers/doc_comment_update_delete_test.go @@ -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)) + } +} diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index 810faa0a..43f201b5 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -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 + dws drive stats --node https://alidocs.dingtalk.com/i/nodes/`, + 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 + dws drive shortcut --node --folder + dws drive shortcut --node --workspace `, + 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, diff --git a/internal/helpers/drive_stats_shortcut_test.go b/internal/helpers/drive_stats_shortcut_test.go new file mode 100644 index 00000000..c768cd03 --- /dev/null +++ b/internal/helpers/drive_stats_shortcut_test.go @@ -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)) + } +} diff --git a/internal/helpers/mail.go b/internal/helpers/mail.go index a350ee7c..736f2121 100644 --- a/internal/helpers/mail.go +++ b/internal/helpers/mail.go @@ -104,7 +104,7 @@ func newMailCommand() *cobra.Command { Long: `查询当前用户绑定的所有邮箱地址。 返回字段: - mailboxes 邮箱列表,每条包含邮箱地址、账号类型、所属企业 + emailAccounts 邮箱列表,每条包含邮箱地址(email)、账号类型(type)、所属企业(orgName) 错误说明: domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`, @@ -114,7 +114,38 @@ func newMailCommand() *cobra.Command { }, } - mailboxCmd.AddCommand(mailboxListCmd) + mailboxProfileCmd := &cobra.Command{ + Use: "profile", + Short: "获取用户邮箱信息", + Long: `根据邮箱地址获取用户的邮箱详细信息,包含容量、别名等。 + +返回字段: + email 邮箱地址 + emailAliases 邮件地址别名列表 + name 用户名 + nickname 用户昵称 + displayName 用户显示名 + mboxSize 邮箱容量(字节) + mboxSizeUsed 已使用的邮箱容量(字节) + createdTime 创建时间 + modifiedTime 修改时间 + +错误说明: + domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`, + Example: ` dws mail mailbox profile --email user@company.com`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "email"); err != nil { + return err + } + return callMCPTool("get_mailbox_profile", map[string]any{ + "email": mustGetFlag(cmd, "email"), + }) + }, + } + + mailboxProfileCmd.Flags().String("email", "", "用户的邮箱地址 (必填)") + + mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd) messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE} @@ -1138,6 +1169,64 @@ internetMessageId 来源:message send / draft send / message reply / message r }, } + messageBatchGetCmd := &cobra.Command{ + Use: "batch-get", + Short: "批量获取邮件详情", + Long: `根据邮件 ID 列表批量获取邮件完整内容,包含正文。 + +限制说明: + - 单次最多 20 个邮件 ID + - CLI 会逐个调用 get_email_by_message_id 并返回聚合结果 + +错误说明: + domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`, + Example: ` dws mail message batch-get --email user@company.com --ids , + dws mail message batch-get --email user@company.com --ids ,,`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "email", "ids"); err != nil { + return err + } + email := mustGetFlag(cmd, "email") + ids := parseRecipients(mustGetFlag(cmd, "ids")) + if len(ids) == 0 { + return fmt.Errorf("--ids 至少需要 1 个邮件 ID") + } + if len(ids) > 20 { + return fmt.Errorf("--ids 单次最多支持 20 个邮件 ID,当前: %d", len(ids)) + } + if deps.Caller.DryRun() { + fmt.Printf("[DRY-RUN] Preview only, not executed:\n") + fmt.Printf("Operation: batch-get (loop get_email_by_message_id x%d)\n", len(ids)) + fmt.Printf("Arguments:\n") + fmt.Printf(" email: %s\n", email) + fmt.Printf(" ids: %v\n", ids) + return nil + } + items := make([]map[string]any, 0, len(ids)) + for _, id := range ids { + text, err := callMCPToolReturnText(context.Background(), "get_email_by_message_id", map[string]any{ + "email": email, + "messageId": id, + }) + if err != nil { + return fmt.Errorf("获取邮件详情失败 id=%s: %w", id, err) + } + var payload any + if err := json.Unmarshal([]byte(text), &payload); err != nil { + payload = text + } + items = append(items, map[string]any{ + "id": id, + "result": payload, + }) + } + return deps.Out.PrintJSON(map[string]any{ + "email": email, + "messages": items, + }) + }, + } + draftCreateCmd := &cobra.Command{ Use: "create", Short: "创建草稿", @@ -1407,12 +1496,88 @@ internetMessageId 来源:message send / draft send / message reply / message r messageBatchModifyCmd.Flags().String("action", "", "操作类型: markRead/markUnread/addTags/removeTags (必填)") messageBatchModifyCmd.Flags().String("tags", "", "标签 ID 列表,逗号分隔 (action 为 addTags/removeTags 时必填)") + messageBatchGetCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)") + messageBatchGetCmd.Flags().String("ids", "", "要获取的邮件 ID 列表,逗号分隔,最多 20 个 (必填)") + messageVerifyCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)") messageVerifyCmd.Flags().String("internet-message-id", "", "邮件的 internetMessageId (必填),取自发送类命令返回值") messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd, messageReplyCmd, messageReplyAllCmd, messageForwardCmd, - messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageVerifyCmd) + messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd) + + sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE} + + sentMessageRecallCmd := &cobra.Command{ + Use: "recall", + Short: "[危险] 撤回已发送的邮件", + Long: `撤回已发送的邮件。仅支持撤回同组织内未读邮件。 + +返回字段: + id 撤回任务 ID(可用于 recall-detail 查询进度) + success 接口调用是否成功 + errorCode 错误码(仅失败时存在) + errorMsg 错误信息(仅失败时存在) + +错误说明: + domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`, + Example: ` dws mail sent-message recall --email user@company.com --id --subject "邮件主题" --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "email", "id", "subject"); err != nil { + return err + } + yes, _ := cmd.Flags().GetBool("yes") + if !yes && !deps.Caller.DryRun() { + return fmt.Errorf("此操作为危险操作,需要传入 --yes 确认执行") + } + return callMCPTool("recall_sent_message", map[string]any{ + "email": mustGetFlag(cmd, "email"), + "id": mustGetFlag(cmd, "id"), + "subject": mustGetFlag(cmd, "subject"), + }) + }, + } + + sentMessageRecallDetailCmd := &cobra.Command{ + Use: "recall-detail", + Short: "查询邮件撤回进度", + Long: `根据撤回任务 ID 查询邮件撤回的详细进度。 + +撤回任务 ID 来源:sent-message recall 命令返回值中的 id 字段。 + +返回字段: + id 撤回任务 ID + status 任务状态: UNINITED/SUBMITTED/RUNNING/FINISHED/CANCELED/FAILED + createdTime 创建时间 + updatedTime 更新时间 + totalCount 总邮件数 + succeededCount 成功撤回数 + failedCount 撤回失败数 + details 每封邮件的撤回结果 + +错误说明: + domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`, + Example: ` dws mail sent-message recall-detail --email user@company.com --id `, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "email", "id"); err != nil { + return err + } + return callMCPTool("get_recall_detail", map[string]any{ + "email": mustGetFlag(cmd, "email"), + "id": mustGetFlag(cmd, "id"), + }) + }, + } + + sentMessageRecallCmd.Flags().String("email", "", "发件人邮箱地址 (必填)") + sentMessageRecallCmd.Flags().String("id", "", "要撤回的邮件 ID (必填)") + sentMessageRecallCmd.Flags().String("subject", "", "邮件主题 (必填)") + sentMessageRecallCmd.Flags().Bool("yes", false, "跳过确认提示,直接执行") + + sentMessageRecallDetailCmd.Flags().String("email", "", "用户的邮箱地址 (必填)") + sentMessageRecallDetailCmd.Flags().String("id", "", "撤回任务 ID (必填),由 recall 命令返回") + + sentMessageCmd.AddCommand(sentMessageRecallCmd, sentMessageRecallDetailCmd) draftCreateCmd.Flags().String("from", "", "发件人邮箱 (必填)") draftCreateCmd.Flags().String("sender", "", "--from 的别名") @@ -1913,52 +2078,55 @@ user 对象字段: }, } - // TODO: auto-reply update 暂时注释,待服务端修复后恢复 - // autoReplyUpdateCmd := &cobra.Command{ - // Use: "update", - // Short: "更新/设置用户的自动回复配置", - // Long: `更新或设置用户的邮件自动回复配置。所有参数均为必填。 - // - // 建议工作流:先通过 auto-reply get 获取当前配置,再传入需要修改的字段值。 - // - // 时间格式示例:2026/06/25 16:30:00 +0800 - // - // 参数说明(全部必填): - // --enabled 是否启用自动回复 (true/false) - // --startTime 自动回复开始时间 - // --endTime 自动回复结束时间 - // --scope 回复范围: "contact"(仅联系人) 或 "all"(所有人) - // --content 自动回复内容 - // - // 错误说明: - // domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`, - // Example: ` dws mail auto-reply update --email user@company.com --enabled true --startTime "2026/07/01 09:00:00 +0800" --endTime "2026/07/07 18:00:00 +0800" --scope all --content "出差中,请稍后联系" - // dws mail auto-reply update --email user@company.com --enabled false --startTime "" --endTime "" --scope all --content ""`, - // RunE: func(cmd *cobra.Command, args []string) error { - // if err := validateRequiredFlags(cmd, "email", "enabled", "startTime", "endTime", "scope", "content"); err != nil { - // return err - // } - // toolArgs := map[string]any{ - // "email": mustGetFlag(cmd, "email"), - // "enabled": mustGetFlag(cmd, "enabled") == "true", - // "startTime": mustGetFlag(cmd, "startTime"), - // "endTime": mustGetFlag(cmd, "endTime"), - // "scope": mustGetFlag(cmd, "scope"), - // "content": mustGetFlag(cmd, "content"), - // } - // return callMCPTool("update_auto_reply", toolArgs) - // }, - // } + autoReplyUpdateCmd := &cobra.Command{ + Use: "update", + Short: "更新/设置用户的自动回复配置", + Long: `更新或设置用户的邮件自动回复配置。所有参数均为必填。 + +建议工作流:先通过 auto-reply get 获取当前配置,再传入需要修改的字段值。 + +时间格式示例:2026/06/25 16:00:00 +0800 + +参数说明(全部必填): + --enabled 是否启用自动回复 (true/false) + --start 自动回复开始时间 + --end 自动回复结束时间 + --scope 回复范围: "contact"(仅联系人) 或 "all"(所有人) + --content 自动回复内容 + +错误说明: + domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`, + Example: ` dws mail auto-reply update --email user@company.com --enabled true --start "2026/07/01 09:00:00 +0800" --end "2026/07/07 18:00:00 +0800" --scope all --content "出差中,请稍后联系" + dws mail auto-reply update --email user@company.com --enabled false --start "2026/07/01 09:00:00 +0800" --end "2026/07/07 18:00:00 +0800" --scope all --content "已关闭自动回复"`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "email", "enabled", "start", "end", "scope", "content"); err != nil { + return err + } + enabledRaw := strings.ToLower(strings.TrimSpace(mustGetFlag(cmd, "enabled"))) + if enabledRaw != "true" && enabledRaw != "false" { + return fmt.Errorf("--enabled 必须为 true 或 false") + } + toolArgs := map[string]any{ + "email": mustGetFlag(cmd, "email"), + "enabled": enabledRaw == "true", + "startTime": mustGetFlag(cmd, "start"), + "endTime": mustGetFlag(cmd, "end"), + "scope": mustGetFlag(cmd, "scope"), + "content": mustGetFlag(cmd, "content"), + } + return callMCPTool("update_auto_reply", toolArgs) + }, + } autoReplyGetCmd.Flags().String("email", "", "用户的邮箱地址 (必填)") - // autoReplyUpdateCmd.Flags().String("email", "", "用户的邮箱地址 (必填)") - // autoReplyUpdateCmd.Flags().String("enabled", "", "是否启用自动回复: true/false (必填)") - // autoReplyUpdateCmd.Flags().String("startTime", "", "自动回复开始时间 (必填),格式: 2026/06/25 16:30:00 +0800") - // autoReplyUpdateCmd.Flags().String("endTime", "", "自动回复结束时间 (必填),格式: 2026/06/25 16:30:00 +0800") - // autoReplyUpdateCmd.Flags().String("scope", "", "回复范围: contact(仅联系人)/all(所有人) (必填)") - // autoReplyUpdateCmd.Flags().String("content", "", "自动回复内容 (必填)") + autoReplyUpdateCmd.Flags().String("email", "", "用户的邮箱地址 (必填)") + autoReplyUpdateCmd.Flags().String("enabled", "", "是否启用自动回复: true/false (必填)") + autoReplyUpdateCmd.Flags().String("start", "", "自动回复开始时间,格式: YYYY/MM/DD HH:MM:SS +ZZZZ (必填)") + autoReplyUpdateCmd.Flags().String("end", "", "自动回复结束时间,格式: YYYY/MM/DD HH:MM:SS +ZZZZ (必填)") + autoReplyUpdateCmd.Flags().String("scope", "", "回复范围: contact(仅联系人)/all(所有人) (必填)") + autoReplyUpdateCmd.Flags().String("content", "", "自动回复内容 (必填)") - autoReplyCmd.AddCommand(autoReplyGetCmd) // , autoReplyUpdateCmd) + autoReplyCmd.AddCommand(autoReplyGetCmd, autoReplyUpdateCmd) // ── rule 收信规则 ──────────────────────────────────── ruleCmd := &cobra.Command{Use: "rule", Short: "收信规则管理", RunE: groupRunE} @@ -2145,7 +2313,143 @@ object 与 operation 合法组合: ruleCmd.AddCommand(ruleListCmd, ruleCreateCmd, ruleUpdateCmd, ruleDeleteCmd, ruleAdjustCmd) - root.AddCommand(mailboxCmd, messageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd) + // ── allow-list 个人收信白名单 ──────────────────────────────── + allowListCmd := &cobra.Command{Use: "allow-list", Short: "个人收信白名单管理", RunE: groupRunE} + + allowListListCmd := &cobra.Command{ + Use: "list", + Short: "列出个人收信白名单", + Long: `列出当前用户的个人收信白名单地址列表。 + +返回字段: + entries 白名单地址列表 + success 接口调用是否成功 + errorCode 错误码(仅失败时存在) + errorMsg 错误信息(仅失败时存在)`, + Example: ` dws mail allow-list list --email user@company.com`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "email"); err != nil { + return err + } + return callMCPTool("list_mailbox_allowlist", map[string]any{ + "email": mustGetFlag(cmd, "email"), + }) + }, + } + allowListAddCmd := &cobra.Command{ + Use: "add", + Short: "添加个人收信白名单", + Long: `向个人收信白名单中添加邮件地址或域名。 + +条目格式: + - 邮件地址:123@domain.com + - 域名:@domain.com(域名前需加 @ 符号)`, + Example: ` dws mail allow-list add --email user@company.com --entries a@b.com,@spam.com`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "email", "entries"); err != nil { + return err + } + return callMCPTool("add_mailbox_allowlist", map[string]any{ + "email": mustGetFlag(cmd, "email"), + "entries": parseRecipients(mustGetFlag(cmd, "entries")), + }) + }, + } + allowListRemoveCmd := &cobra.Command{ + Use: "remove", + Short: "移除个人收信白名单", + Long: `从个人收信白名单中移除邮件地址或域名。 + +条目格式: + - 邮件地址:123@domain.com + - 域名:@domain.com(域名前需加 @ 符号)`, + Example: ` dws mail allow-list remove --email user@company.com --entries a@b.com,@spam.com`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "email", "entries"); err != nil { + return err + } + return callMCPTool("remove_mailbox_allowlist", map[string]any{ + "email": mustGetFlag(cmd, "email"), + "entries": parseRecipients(mustGetFlag(cmd, "entries")), + }) + }, + } + allowListListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)") + allowListAddCmd.Flags().String("email", "", "用户的邮箱地址 (必填)") + allowListAddCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)") + allowListRemoveCmd.Flags().String("email", "", "用户的邮箱地址 (必填)") + allowListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)") + allowListCmd.AddCommand(allowListListCmd, allowListAddCmd, allowListRemoveCmd) + + // ── block-list 个人收信黑名单 ──────────────────────────────── + blockListCmd := &cobra.Command{Use: "block-list", Short: "个人收信黑名单管理", RunE: groupRunE} + + blockListListCmd := &cobra.Command{ + Use: "list", + Short: "列出个人收信黑名单", + Long: `列出当前用户的个人收信黑名单地址列表。 + +返回字段: + entries 黑名单地址列表 + success 接口调用是否成功 + errorCode 错误码(仅失败时存在) + errorMsg 错误信息(仅失败时存在)`, + Example: ` dws mail block-list list --email user@company.com`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "email"); err != nil { + return err + } + return callMCPTool("list_mailbox_blocklist", map[string]any{ + "email": mustGetFlag(cmd, "email"), + }) + }, + } + blockListAddCmd := &cobra.Command{ + Use: "add", + Short: "添加个人收信黑名单", + Long: `向个人收信黑名单中添加邮件地址或域名。 + +条目格式: + - 邮件地址:123@domain.com + - 域名:@domain.com(域名前需加 @ 符号)`, + Example: ` dws mail block-list add --email user@company.com --entries spam@bad.com,@junk.com`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "email", "entries"); err != nil { + return err + } + return callMCPTool("add_mailbox_blocklist", map[string]any{ + "email": mustGetFlag(cmd, "email"), + "entries": parseRecipients(mustGetFlag(cmd, "entries")), + }) + }, + } + blockListRemoveCmd := &cobra.Command{ + Use: "remove", + Short: "移除个人收信黑名单", + Long: `从个人收信黑名单中移除邮件地址或域名。 + +条目格式: + - 邮件地址:123@domain.com + - 域名:@domain.com(域名前需加 @ 符号)`, + Example: ` dws mail block-list remove --email user@company.com --entries spam@bad.com,@junk.com`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "email", "entries"); err != nil { + return err + } + return callMCPTool("remove_mailbox_blocklist", map[string]any{ + "email": mustGetFlag(cmd, "email"), + "entries": parseRecipients(mustGetFlag(cmd, "entries")), + }) + }, + } + blockListListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)") + blockListAddCmd.Flags().String("email", "", "用户的邮箱地址 (必填)") + blockListAddCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)") + blockListRemoveCmd.Flags().String("email", "", "用户的邮箱地址 (必填)") + blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)") + blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd) + + root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd) return root } diff --git a/internal/helpers/output.go b/internal/helpers/output.go index dbb44af0..255be4fe 100644 --- a/internal/helpers/output.go +++ b/internal/helpers/output.go @@ -6,8 +6,31 @@ import ( "io" "os" "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" ) +// applyGlobalFilter applies the global --jq / --fields output filters to data +// when either is set. Returns handled=true when it wrote the (filtered) output, +// so callers skip their default JSON encoding. The filter helpers live in +// internal/output (the same ones used by `dws api`); the helper Formatter used +// by product commands previously ignored these flags, making them no-ops. +func (f *Formatter) applyGlobalFilter(data any) (handled bool, err error) { + if deps == nil || deps.Caller == nil { + return false, nil + } + jq := strings.TrimSpace(deps.Caller.JQ()) + fields := strings.TrimSpace(deps.Caller.Fields()) + if jq == "" && fields == "" { + return false, nil + } + format := output.Format(strings.TrimSpace(deps.Caller.Format())) + if format == "" { + format = output.FormatJSON + } + return true, output.WriteFiltered(f.w, format, data, fields, jq) +} + // Formatter provides output formatting compatible with the old Wukong CLI. type Formatter struct { w io.Writer @@ -23,6 +46,9 @@ func NewFormatter() *Formatter { // 转义为 \u0026、\u003c、\u003e。对于大多数 CLI 输出场景这是安全的默认行为。 // 如果返回值中包含 URL 等不应被转义的内容,请使用 PrintJSONUnescaped。 func (f *Formatter) PrintJSON(data any) error { + if handled, err := f.applyGlobalFilter(data); handled { + return err + } enc := json.NewEncoder(f.w) enc.SetIndent("", " ") return enc.Encode(data) @@ -38,6 +64,9 @@ func (f *Formatter) PrintJSON(data any) error { // // 影响范围:仅在调用方显式选择时生效,不影响全局 PrintJSON 的行为。 func (f *Formatter) PrintJSONUnescaped(data any) error { + if handled, err := f.applyGlobalFilter(data); handled { + return err + } enc := json.NewEncoder(f.w) enc.SetIndent("", " ") enc.SetEscapeHTML(false) diff --git a/internal/helpers/register_products.go b/internal/helpers/register_products.go index 2cf0cd13..bacd9c48 100644 --- a/internal/helpers/register_products.go +++ b/internal/helpers/register_products.go @@ -23,6 +23,7 @@ func init() { name string fn func() *cobra.Command }{ + {"agoal", newAgoalCommand}, {"aisearch", newAisearchCommand}, {"aitable", newAitableCommand}, {"attendance", newAttendanceCommand}, diff --git a/internal/helpers/sheet.go b/internal/helpers/sheet.go index a33faa25..40142d00 100644 --- a/internal/helpers/sheet.go +++ b/internal/helpers/sheet.go @@ -44,10 +44,17 @@ 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 删除指定位置的行或列 dws sheet update-dimension 更新指定范围行/列属性(显隐、行高/列宽) + dws sheet group-dimension 对指定连续行/列创建分组 + dws sheet ungroup-dimension 取消指定连续行/列分组 dws sheet media-upload 上传附件到表格 dws sheet write-image 上传图片并写入表格单元格 dws sheet replace 全局查找替换文本 @@ -104,6 +111,8 @@ func newSheetCommand() *cobra.Command { chartCmd := newChartCmd() exportCmd := newExportCmd() templateCmd := newSheetTemplateCmd() + tableCmds := newTableCmds() + pivotTableCmd := newPivotTableCmd() batchUpdateCmd := newBatchUpdateCmd() rangeBatchClearCmd := newRangeBatchClearCmd() @@ -124,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 @@ -138,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) @@ -147,6 +157,7 @@ func newSheetCommand() *cobra.Command { attachUnknownSubcommandGuard(filterViewCmd) attachUnknownSubcommandGuard(condFormatCmd) attachUnknownSubcommandGuard(chartCmd) + attachUnknownSubcommandGuard(pivotTableCmd) return root } diff --git a/internal/helpers/sheet_batch.go b/internal/helpers/sheet_batch.go index b75b5a2a..674a2b48 100644 --- a/internal/helpers/sheet_batch.go +++ b/internal/helpers/sheet_batch.go @@ -39,6 +39,8 @@ var batchOpDispatch = map[string]batchOpMapping{ "csv-put": {"set_range_from_csv", BuildCsvPutArgs}, "delete-float-image": {"delete_float_image", BuildDeleteFloatImageArgs}, "update-dimension": {"update_dimension", BuildUpdateDimensionArgs}, + "group-dimension": {"group_dimension", BuildGroupDimensionArgs}, + "ungroup-dimension": {"ungroup_dimension", BuildUngroupDimensionArgs}, } // translateBatchOp translates a batch operation from CLI format to MCP format. @@ -269,6 +271,27 @@ func BuildUpdateDimensionArgs(input map[string]any) map[string]any { return args } +// BuildGroupDimensionArgs converts CLI flags to MCP params for group_dimension. +func BuildGroupDimensionArgs(input map[string]any) map[string]any { + groupState := batchStr(input, "group-state", "groupState") + if groupState == "" { + groupState = "expand" + } + return map[string]any{ + "sheetId": batchStr(input, "sheet-id"), + "range": batchStr(input, "range"), + "groupState": groupState, + } +} + +// BuildUngroupDimensionArgs converts CLI flags to MCP params for ungroup_dimension. +func BuildUngroupDimensionArgs(input map[string]any) map[string]any { + return map[string]any{ + "sheetId": batchStr(input, "sheet-id"), + "range": batchStr(input, "range"), + } +} + // resolveCsvContent resolves @filepath and - stdin to CSV text, matching standalone csv-put behavior. func resolveCsvContent(csvVal string) string { switch { @@ -307,8 +330,12 @@ CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。 支持的 CLI 命令名: range clear / range update / merge-cells / unmerge-cells / update-dimension range fill / range copy-to / add-dimension / delete-dimension / move-dimension + group-dimension / ungroup-dimension set-dropdown / delete-dropdown / csv-put / delete-float-image +注意:batch-update 中 group-dimension 适合默认展开分组;需要 --group-state fold 时请使用独立 +dws sheet group-dimension 命令。 + --operations 是 JSON 数组,每项包含: toolName CLI 命令名(如 "range clear", "range update") input 该命令的入参(不含 --node),键用 flag 名去掉 -- diff --git a/internal/helpers/sheet_dimension.go b/internal/helpers/sheet_dimension.go index 0348e8db..69b41125 100644 --- a/internal/helpers/sheet_dimension.go +++ b/internal/helpers/sheet_dimension.go @@ -375,6 +375,83 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 updateDimensionCmd.Flags().Bool("hidden", false, "是否隐藏 (true=隐藏, false=显示)") updateDimensionCmd.Flags().Int("pixel-size", 0, "行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽") + groupDimensionCmd := &cobra.Command{ + Use: "group-dimension", + Short: "对指定连续行/列创建分组", + Long: `对钉钉表格指定工作表中的连续整行或整列创建分组。 + +--range 使用整行/整列范围: + 行分组:3:7 或 3 + 列分组:C:F 或 C + +支持在 --range 中携带工作表前缀(如 "Sheet1!3:7" / "Sheet1!C:F"),此时将忽略 --sheet-id。 +创建后可通过 sheet info --include groups 回读 rowGroups / columnGroups。 + +--group-state 支持 expand / fold,默认 expand。`, + Example: ` # 分组第 3~7 行 + dws sheet group-dimension --node NODE_ID --sheet-id SHEET_ID --range "3:7" + + # 分组并折叠 C~F 列 + dws sheet group-dimension --node NODE_ID --sheet-id SHEET_ID --range "C:F" --group-state fold + + # 使用工作表前缀(忽略 --sheet-id) + dws sheet group-dimension --node NODE_ID --sheet-id SHEET_ID --range "Sheet1!3:7"`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil { + return err + } + groupState, _ := cmd.Flags().GetString("group-state") + switch groupState { + case "", "expand": + groupState = "expand" + case "fold": + default: + return fmt.Errorf("--group-state 必须为 expand 或 fold,当前值: %s", groupState) + } + return callMCPTool("group_dimension", map[string]any{ + "nodeId": mustGetFlag(cmd, "node"), + "sheetId": mustGetFlag(cmd, "sheet-id"), + "range": mustGetFlag(cmd, "range"), + "groupState": groupState, + }) + }, + } + groupDimensionCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)") + groupDimensionCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)") + groupDimensionCmd.Flags().String("range", "", `整行/整列范围 (必填),如 "3:7" 或 "C:F"`) + groupDimensionCmd.Flags().String("group-state", "expand", "创建后的分组状态: expand 或 fold") + + ungroupDimensionCmd := &cobra.Command{ + Use: "ungroup-dimension", + Short: "取消指定连续行/列分组", + Long: `取消钉钉表格指定工作表中的连续整行或整列分组。 + +--range 使用整行/整列范围: + 行分组:3:7 或 3 + 列分组:C:F 或 C + +支持在 --range 中携带工作表前缀(如 "Sheet1!3:7" / "Sheet1!C:F"),此时将忽略 --sheet-id。 +取消后可通过 sheet info --include groups 回读 rowGroups / columnGroups。`, + Example: ` # 取消第 3~7 行分组 + dws sheet ungroup-dimension --node NODE_ID --sheet-id SHEET_ID --range "3:7" + + # 取消 C~F 列分组 + dws sheet ungroup-dimension --node NODE_ID --sheet-id SHEET_ID --range "C:F"`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil { + return err + } + return callMCPTool("ungroup_dimension", map[string]any{ + "nodeId": mustGetFlag(cmd, "node"), + "sheetId": mustGetFlag(cmd, "sheet-id"), + "range": mustGetFlag(cmd, "range"), + }) + }, + } + ungroupDimensionCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)") + ungroupDimensionCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)") + ungroupDimensionCmd.Flags().String("range", "", `整行/整列范围 (必填),如 "3:7" 或 "C:F"`) + // ── dropdown ────────────────────────────────────────────────── setDropdownCmd := &cobra.Command{ Use: "set-dropdown", @@ -499,7 +576,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获 return []*cobra.Command{ insertDimensionCmd, moveDimensionCmd, addDimensionCmd, mergeCellsCmd, unmergeRangeCmd, - deleteDimensionCmd, updateDimensionCmd, + deleteDimensionCmd, updateDimensionCmd, groupDimensionCmd, ungroupDimensionCmd, setDropdownCmd, getDropdownCmd, deleteDropdownCmd, } } diff --git a/internal/helpers/sheet_export.go b/internal/helpers/sheet_export.go index e66c9fe9..0e924d6c 100644 --- a/internal/helpers/sheet_export.go +++ b/internal/helpers/sheet_export.go @@ -268,7 +268,7 @@ func newExportCmd() *cobra.Command { - 未指定:仅返回 downloadUrl,链接有时效性请尽快下载 支持范围: - 仅支持钉钉在线电子表格(alxs)→ xlsx; + 仅支持钉钉在线电子表格(axls)→ xlsx; 若需导出钉钉文字文档,请使用 dingtalkdoc 侧的导出工具。 权限要求: diff --git a/internal/helpers/sheet_media.go b/internal/helpers/sheet_media.go index 1c31baca..bf118135 100644 --- a/internal/helpers/sheet_media.go +++ b/internal/helpers/sheet_media.go @@ -182,7 +182,13 @@ func runSheetWriteImage(cmd *cobra.Command, _ []string) error { ctx := context.Background() - deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize)) + // json 模式下进度提示会污染 stdout(PrintInfo/PrintKeyValue 都写 stdout), + // 使 write_image 的 JSON 响应无法被单独解析。故 json 模式抑制进度。 + jsonMode := deps.Caller.Format() == "json" + + if !jsonMode { + deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize)) + } result, err := deps.Caller.CallTool(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{ "nodeId": nodeID, @@ -216,10 +222,11 @@ func runSheetWriteImage(cmd *cobra.Command, _ []string) error { resourceURL, _ = credData["resourceUrl"].(string) } - deps.Out.PrintKeyValue("resourceId", resourceID) - deps.Out.PrintKeyValue("resourceUrl", resourceURL) - - deps.Out.PrintInfo("[2/3] 上传图片到 OSS...") + if !jsonMode { + deps.Out.PrintKeyValue("resourceId", resourceID) + deps.Out.PrintKeyValue("resourceUrl", resourceURL) + deps.Out.PrintInfo("[2/3] 上传图片到 OSS...") + } ossHeaders := map[string]string{ "Content-Type": mimeType, @@ -228,7 +235,9 @@ func runSheetWriteImage(cmd *cobra.Command, _ []string) error { return err } - deps.Out.PrintInfo("[3/3] 写入图片到表格单元格...") + if !jsonMode { + deps.Out.PrintInfo("[3/3] 写入图片到表格单元格...") + } writeArgs := map[string]any{ "nodeId": nodeID, @@ -244,10 +253,13 @@ func runSheetWriteImage(cmd *cobra.Command, _ []string) error { writeArgs["height"] = h } + if jsonMode { + // json 模式:write_image 的 JSON 响应就是唯一 stdout 输出。 + return callMCPTool("write_image", writeArgs) + } if err := callMCPTool("write_image", writeArgs); err != nil { return err } - deps.Out.PrintInfo(fmt.Sprintf("图片已写入表格: %s → %s (resourceId=%s)", fileName, rangeAddress, resourceID)) return nil } diff --git a/internal/helpers/sheet_pivot_gridline_test.go b/internal/helpers/sheet_pivot_gridline_test.go new file mode 100644 index 00000000..a21acbd0 --- /dev/null +++ b/internal/helpers/sheet_pivot_gridline_test.go @@ -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) + } + }) + } +} diff --git a/internal/helpers/sheet_pivot_table.go b/internal/helpers/sheet_pivot_table.go new file mode 100644 index 00000000..6c63ffd5 --- /dev/null +++ b/internal/helpers/sheet_pivot_table.go @@ -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 +} diff --git a/internal/helpers/sheet_style.go b/internal/helpers/sheet_style.go index c6fc0278..5676ac51 100644 --- a/internal/helpers/sheet_style.go +++ b/internal/helpers/sheet_style.go @@ -1,6 +1,7 @@ package helpers import ( + "context" "encoding/json" "fmt" "os" @@ -397,6 +398,9 @@ func newRangeBatchSetStyleCmd() *cobra.Command { node := mustGetFlag(cmd, "node") batchPath := mustGetFlag(cmd, "batch") continueOnErr, _ := cmd.Flags().GetBool("continue-on-error") + jsonMode := deps.Caller.Format() == "json" + ctx := context.Background() + var jsonResults []any data, err := os.ReadFile(batchPath) if err != nil { @@ -457,6 +461,34 @@ func newRangeBatchSetStyleCmd() *cobra.Command { continue } fmt.Fprintf(os.Stderr, "[%d/%d] update_range sheet=%s range=%s\n", i+1, total, item.SheetID, item.Range) + if jsonMode { + text, cerr := callMCPToolReturnText(ctx, "update_range", toolArgs) + entry := map[string]any{"index": i + 1, "sheetId": item.SheetID, "range": item.Range} + if cerr != nil { + entry["ok"] = false + entry["error"] = cerr.Error() + jsonResults = append(jsonResults, entry) + cerr = fmt.Errorf("第 %d/%d 条 update_range 失败: %w", i+1, total, cerr) + fmt.Fprintln(os.Stderr, cerr) + failed++ + if firstErr == nil { + firstErr = cerr + } + if !continueOnErr { + break + } + continue + } + var parsed any + if json.Unmarshal([]byte(text), &parsed) == nil { + entry["result"] = parsed + } else { + entry["result"] = text + } + entry["ok"] = true + jsonResults = append(jsonResults, entry) + continue + } if err := callMCPTool("update_range", toolArgs); err != nil { err = fmt.Errorf("第 %d/%d 条 update_range 失败: %w", i+1, total, err) fmt.Fprintln(os.Stderr, err) @@ -470,6 +502,14 @@ func newRangeBatchSetStyleCmd() *cobra.Command { } } fmt.Fprintf(os.Stderr, "batch-set-style 完成:共 %d 条,失败 %d 条\n", total, failed) + if jsonMode { + _ = deps.Out.PrintJSON(map[string]any{ + "total": total, + "failed": failed, + "results": jsonResults, + "success": failed == 0, + }) + } if failed > 0 && !continueOnErr { return firstErr } diff --git a/internal/helpers/sheet_table.go b/internal/helpers/sheet_table.go new file mode 100644 index 00000000..6b6fb387 --- /dev/null +++ b/internal/helpers/sheet_table.go @@ -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") + } +} diff --git a/internal/helpers/sheet_table_test.go b/internal/helpers/sheet_table_test.go new file mode 100644 index 00000000..a38c2ee2 --- /dev/null +++ b/internal/helpers/sheet_table_test.go @@ -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) + } + } +} diff --git a/internal/helpers/sheet_workbook.go b/internal/helpers/sheet_workbook.go index 1f933f32..4d9aef1b 100644 --- a/internal/helpers/sheet_workbook.go +++ b/internal/helpers/sheet_workbook.go @@ -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 } diff --git a/internal/helpers/wukong_sync_commands_test.go b/internal/helpers/wukong_sync_commands_test.go new file mode 100644 index 00000000..f804f5ae --- /dev/null +++ b/internal/helpers/wukong_sync_commands_test.go @@ -0,0 +1,187 @@ +// 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 ( + "reflect" + "testing" + + "github.com/spf13/cobra" +) + +func requireWukongSyncCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command { + t.Helper() + cmd, remaining, err := root.Find(path) + if err != nil { + t.Fatalf("%s: command not found: %v", path, err) + } + if len(remaining) != 0 { + t.Fatalf("%s: unresolved path suffix %v (resolved to %q)", path, remaining, cmd.CommandPath()) + } + return cmd +} + +func requireWukongSyncFlags(t *testing.T, cmd *cobra.Command, names ...string) { + t.Helper() + for _, name := range names { + if cmd.Flags().Lookup(name) == nil { + t.Fatalf("%s: missing flag --%s", cmd.CommandPath(), name) + } + } +} + +func TestWukongSyncMailCommands(t *testing.T) { + root := newMailCommand() + cases := []struct { + path []string + flags []string + }{ + {[]string{"mailbox", "profile"}, []string{"email"}}, + {[]string{"message", "batch-get"}, []string{"email", "ids"}}, + {[]string{"sent-message", "recall"}, []string{"email", "id", "subject", "yes"}}, + {[]string{"sent-message", "recall-detail"}, []string{"email", "id"}}, + {[]string{"auto-reply", "update"}, []string{"email", "enabled", "start", "end", "scope", "content"}}, + {[]string{"allow-list", "list"}, []string{"email"}}, + {[]string{"allow-list", "add"}, []string{"email", "entries"}}, + {[]string{"allow-list", "remove"}, []string{"email", "entries"}}, + {[]string{"block-list", "list"}, []string{"email"}}, + {[]string{"block-list", "add"}, []string{"email", "entries"}}, + {[]string{"block-list", "remove"}, []string{"email", "entries"}}, + } + for _, tc := range cases { + cmd := requireWukongSyncCommand(t, root, tc.path...) + requireWukongSyncFlags(t, cmd, tc.flags...) + } +} + +func TestWukongSyncChatCommands(t *testing.T) { + root := newChatCommand() + cases := []struct { + path []string + flags []string + }{ + {[]string{"group", "notice", "create"}, []string{"group", "content", "sticky", "send-ding", "run-at"}}, + {[]string{"group", "notice", "edit"}, []string{"group", "notice-id", "content", "sticky", "send-ding"}}, + {[]string{"group", "notice", "get"}, []string{"group", "notice-id"}}, + {[]string{"group", "notice", "list"}, []string{"group", "limit", "cursor", "scheduled"}}, + {[]string{"group", "share-invite"}, []string{"source", "target", "receiver", "expires-seconds", "uuid"}}, + {[]string{"text", "translate"}, []string{"query", "to"}}, + {[]string{"category", "create-smart"}, []string{"name", "keywords", "members"}}, + {[]string{"message", "list-emotion-replies"}, []string{"msg-ids"}}, + } + for _, tc := range cases { + cmd := requireWukongSyncCommand(t, root, tc.path...) + requireWukongSyncFlags(t, cmd, tc.flags...) + } +} + +func TestWukongSyncDocCommands(t *testing.T) { + root := newDocCommand() + + importCmd := requireWukongSyncCommand(t, root, "import") + requireWukongSyncFlags(t, importCmd, "file", "folder", "workspace", "name", "folder-id", "workspace-id") + + importGetCmd := requireWukongSyncCommand(t, root, "import", "get") + requireWukongSyncFlags(t, importGetCmd, "task-id") +} + +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") + + ungroupCmd := requireWukongSyncCommand(t, root, "ungroup-dimension") + requireWukongSyncFlags(t, ungroupCmd, "node", "sheet-id", "range") +} + +func TestWukongSyncSheetBatchDimensionGroupMapping(t *testing.T) { + group, err := translateBatchOp(map[string]any{ + "toolName": "group-dimension", + "input": map[string]any{ + "sheet-id": "Sheet1", + "range": "3:7", + "group-state": "fold", + }, + }) + if err != nil { + t.Fatalf("group-dimension mapping returned error: %v", err) + } + wantGroup := map[string]any{ + "toolName": "group_dimension", + "input": map[string]any{ + "sheetId": "Sheet1", + "range": "3:7", + "groupState": "fold", + }, + } + if !reflect.DeepEqual(group, wantGroup) { + t.Fatalf("group-dimension mapping mismatch:\n got: %#v\nwant: %#v", group, wantGroup) + } + + ungroup, err := translateBatchOp(map[string]any{ + "toolName": "ungroup-dimension", + "input": map[string]any{ + "sheet-id": "Sheet1", + "range": "C:F", + }, + }) + if err != nil { + t.Fatalf("ungroup-dimension mapping returned error: %v", err) + } + wantUngroup := map[string]any{ + "toolName": "ungroup_dimension", + "input": map[string]any{ + "sheetId": "Sheet1", + "range": "C:F", + }, + } + if !reflect.DeepEqual(ungroup, wantUngroup) { + t.Fatalf("ungroup-dimension mapping mismatch:\n got: %#v\nwant: %#v", ungroup, wantUngroup) + } +} + +func TestWukongSyncAgoalCommands(t *testing.T) { + root := newAgoalCommand() + cases := []struct { + path []string + flags []string + }{ + {[]string{"strategy", "list"}, []string{"scope-type", "scope-id", "request-id"}}, + {[]string{"strategy", "detail"}, []string{"profile-id", "request-id"}}, + {[]string{"strategy", "update"}, []string{"profile-id", "content", "request-id"}}, + {[]string{"contract", "list"}, []string{"scope-type", "scope-id", "request-id"}}, + {[]string{"contract", "fields"}, []string{"request-id"}}, + {[]string{"contract", "detail"}, []string{"contract-id", "request-id"}}, + {[]string{"contract", "update"}, []string{"contract-id", "dimensions", "audit-config", "objective-template", "request-id"}}, + {[]string{"scorecard", "detail"}, []string{"selected-time", "dept-id", "request-id"}}, + {[]string{"scorecard", "entity-detail"}, []string{"sc-id", "entity-id", "request-id"}}, + {[]string{"scorecard", "update"}, []string{"dept-id", "selected-time", "id", "tracking-period-type", "content", "request-id"}}, + {[]string{"user", "rules"}, []string{"user-id", "request-id"}}, + {[]string{"user", "objectives"}, []string{"user-id", "rule-id", "period-ids", "request-id"}}, + {[]string{"report", "list-statistics"}, []string{"keyword", "request-id"}}, + {[]string{"report", "submit-detail"}, []string{"template-id", "submit-state", "query-date", "page", "page-size", "keyword", "request-id"}}, + {[]string{"obj-template", "list"}, []string{"keyword", "page", "page-size", "request-id"}}, + {[]string{"obj-template", "create-or-update"}, []string{"template-id", "title", "objective-weight", "dimension-weight", "compute-by-weight", "dimensions", "request-id"}}, + } + for _, tc := range cases { + cmd := requireWukongSyncCommand(t, root, tc.path...) + requireWukongSyncFlags(t, cmd, tc.flags...) + } +} diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index 1b509b80..0e4e7f36 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -105,6 +105,7 @@ "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "Due time ISO-8601 (e.g. 2026-03-10T18:00:00+08:00)", "执行者 userId 列表 (必填)": "Executor userId list (required)", "检查登录状态后重试": "Check login status and retry", + "检查网络、代理和 DNS 配置后重试原命令": "Check network, proxy, and DNS settings, then retry the original command", "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "Check service connectivity and retry; if persistent, verify MCP service is responding.", "检查服务连通性和协议版本后重试": "Check service connectivity and protocol version, then retry", "检查认证、权限和参数后重试原命令": "Check authentication, permissions, and parameters, then retry", @@ -117,6 +118,7 @@ "完成状态: true/false": "Done status: true/false", "完成状态: true=已完成, false=未完成 (必填)": "Done status: true=completed, false=not completed (required)", "确认删除? (yes/no): ": "Confirm delete? (yes/no): ", + "确认 MCP 服务可访问;若持续失败请稍后重试": "Verify the MCP service is reachable; if the failure persists, retry later", "等待用户授权...": "Waiting for user authorization...", "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "Result format does not match client expectations; check service protocol changes or rollback to latest working version.", "网络错误,继续重试...": "Network error, retrying...", diff --git a/internal/i18n/locales/zh.json b/internal/i18n/locales/zh.json index cd463211..d4e491db 100644 --- a/internal/i18n/locales/zh.json +++ b/internal/i18n/locales/zh.json @@ -105,6 +105,7 @@ "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)", "执行者 userId 列表 (必填)": "执行者 userId 列表 (必填)", "检查登录状态后重试": "检查登录状态后重试", + "检查网络、代理和 DNS 配置后重试原命令": "检查网络、代理和 DNS 配置后重试原命令", "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。", "检查服务连通性和协议版本后重试": "检查服务连通性和协议版本后重试", "检查认证、权限和参数后重试原命令": "检查认证、权限和参数后重试原命令", @@ -117,6 +118,7 @@ "完成状态: true/false": "完成状态: true/false", "完成状态: true=已完成, false=未完成 (必填)": "完成状态: true=已完成, false=未完成 (必填)", "确认删除? (yes/no): ": "确认删除? (yes/no): ", + "确认 MCP 服务可访问;若持续失败请稍后重试": "确认 MCP 服务可访问;若持续失败请稍后重试", "等待用户授权...": "等待用户授权...", "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。", "网络错误,继续重试...": "网络错误,继续重试...", diff --git a/internal/keychain/auth_entries_file.go b/internal/keychain/auth_entries_file.go new file mode 100644 index 00000000..55133147 --- /dev/null +++ b/internal/keychain/auth_entries_file.go @@ -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")) +} diff --git a/internal/keychain/auth_entries_windows.go b/internal/keychain/auth_entries_windows.go new file mode 100644 index 00000000..07b7930f --- /dev/null +++ b/internal/keychain/auth_entries_windows.go @@ -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 +} diff --git a/internal/keychain/diagnose_default.go b/internal/keychain/diagnose_default.go new file mode 100644 index 00000000..d76a8bcd --- /dev/null +++ b/internal/keychain/diagnose_default.go @@ -0,0 +1,26 @@ +// 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 + +func platformDiagnose() Diagnostic { + return Diagnostic{ + OK: true, + Message: "当前平台使用本地加密凭据后端", + Detail: map[string]string{ + "service": Service, + }, + } +} diff --git a/internal/keychain/file_dek.go b/internal/keychain/file_dek.go index d9d3237c..fefab213 100644 --- a/internal/keychain/file_dek.go +++ b/internal/keychain/file_dek.go @@ -63,3 +63,18 @@ func fileDEK(service string) ([]byte, error) { return key, nil } + +func fileDEKReadOnly(service string) ([]byte, error) { + keyPath := filepath.Join(StorageDir(service), "dek") + key, err := os.ReadFile(keyPath) + if err == nil && len(key) == dekBytes { + return key, nil + } + if err != nil { + if os.IsNotExist(err) { + return nil, ErrDEKMissing + } + return nil, fmt.Errorf("read dek: %w", err) + } + return nil, fmt.Errorf("read dek: %w", ErrDEKMissing) +} diff --git a/internal/keychain/keychain.go b/internal/keychain/keychain.go index b0472d0a..cca9f8fd 100644 --- a/internal/keychain/keychain.go +++ b/internal/keychain/keychain.go @@ -17,6 +17,8 @@ // - Windows: DPAPI + Registry storage package keychain +import "errors" + const ( // Service is the unified keychain service name for all secrets. Service = "dws-cli" @@ -40,6 +42,17 @@ const ( DisableKeychainEnv = "DWS_DISABLE_KEYCHAIN" ) +var ( + // ErrDEKMissing means encrypted local data may exist, but the Data Encryption + // Key needed to decrypt it is missing. Read paths must not create a new DEK, + // because a fresh key cannot decrypt existing ciphertext. + ErrDEKMissing = errors.New("dek missing") + + // ErrCiphertextKeyMismatch means encrypted data exists but none of the + // available DEKs can decrypt it. Write paths must not overwrite the data. + ErrCiphertextKeyMismatch = errors.New("ciphertext key mismatch") +) + // KeychainAccess abstracts keychain Get/Set/Remove for dependency injection. type KeychainAccess interface { Get(service, account string) (string, error) @@ -47,6 +60,68 @@ type KeychainAccess interface { Remove(service, account string) error } +// Diagnostic is a read-only health report for the platform keychain backend. +// It never mutates credentials, DEKs, or OS keychain settings. +type Diagnostic struct { + OK bool `json:"ok"` + Reason string `json:"reason,omitempty"` + Message string `json:"message"` + Hint string `json:"hint,omitempty"` + Detail map[string]string `json:"detail,omitempty"` +} + +// UnavailableError marks failures where the platform keychain itself could +// not be reached, unlocked, or created. Callers can surface a diagnostic +// instead of treating the result as a normal missing credential. +type UnavailableError struct { + Op string + Err error +} + +func NewUnavailableError(op string, err error) error { + return &UnavailableError{Op: op, Err: err} +} + +func (e *UnavailableError) Error() string { + if e == nil { + return "" + } + if e.Op == "" { + if e.Err != nil { + return e.Err.Error() + } + return "keychain unavailable" + } + if e.Err == nil { + return e.Op + ": keychain unavailable" + } + return e.Op + ": " + e.Err.Error() +} + +func (e *UnavailableError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +func IsUnavailable(err error) bool { + var unavailable *UnavailableError + return errors.As(err, &unavailable) +} + +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() +} + // Get retrieves a value from the keychain. // Returns empty string and nil error if the entry does not exist. func Get(service, account string) (string, error) { @@ -64,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) diff --git a/internal/keychain/keychain_darwin.go b/internal/keychain/keychain_darwin.go index c1a00e2f..a543cd87 100644 --- a/internal/keychain/keychain_darwin.go +++ b/internal/keychain/keychain_darwin.go @@ -21,10 +21,14 @@ import ( "crypto/cipher" "crypto/rand" "encoding/base64" + "errors" "fmt" "os" + "os/exec" "path/filepath" "regexp" + "strconv" + "strings" "time" "github.com/google/uuid" @@ -59,15 +63,157 @@ func safeFileName(account string) string { return safeFileNameRe.ReplaceAllString(account, "_") + ".enc" } -// 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) { +var readDefaultKeychain = func() ([]byte, error) { + return exec.Command("security", "default-keychain", "-d", "user").Output() +} + +var ( + keyringGet = keyring.Get + keyringSet = keyring.Set +) + +func defaultKeychainPathFromSecurityOutput(output []byte) string { + value := strings.TrimSpace(string(output)) + if value == "" { + return "" + } + if unquoted, err := strconv.Unquote(value); err == nil { + return unquoted + } + return strings.Trim(value, `"`) +} + +func checkDefaultKeychainAvailable() error { + output, err := readDefaultKeychain() + if err != nil { + return nil + } + path := defaultKeychainPathFromSecurityOutput(output) + if path == "" { + return nil + } + if _, err := os.Stat(path); err != nil && os.IsNotExist(err) { + return NewUnavailableError("read macOS default Keychain", fmt.Errorf("default keychain %q does not exist", path)) + } + return nil +} + +func platformDiagnose() Diagnostic { + detail := map[string]string{ + "platform": "darwin", + "service": Service, + "account": "dek", + } + if os.Getenv(DisableKeychainEnv) != "" { + detail["mode"] = "file_dek" + detail["storage_dir"] = StorageDir(Service) + return Diagnostic{ + OK: true, + Message: "macOS Keychain 已禁用, 当前使用 file-DEK 测试模式", + Detail: detail, + } + } + + output, err := readDefaultKeychain() + if err != nil { + detail["error"] = err.Error() + return Diagnostic{ + OK: false, + Reason: "keychain_check_failed", + Message: "无法读取 macOS 默认钥匙串配置", + Hint: "检查 /usr/bin/security 是否可用, 并确认当前用户钥匙串配置正常。", + Detail: detail, + } + } + + path := defaultKeychainPathFromSecurityOutput(output) + if path != "" { + detail["default_keychain"] = path + } + if path == "" { + return Diagnostic{ + OK: false, + Reason: "keychain_unavailable", + Message: "macOS 默认钥匙串未配置", + Hint: "恢复默认钥匙串后重试;测试环境可设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。", + Detail: detail, + } + } + if _, err := os.Stat(path); err != nil && os.IsNotExist(err) { + return Diagnostic{ + OK: false, + Reason: "keychain_unavailable", + Message: "macOS 默认钥匙串不存在", + Hint: "恢复默认钥匙串后重试;测试环境可设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。", + Detail: detail, + } + } else if err != nil { + detail["error"] = err.Error() + return Diagnostic{ + OK: false, + Reason: "keychain_check_failed", + Message: "无法访问 macOS 默认钥匙串", + Hint: "检查默认钥匙串路径权限与挂载状态。", + Detail: detail, + } + } + + return Diagnostic{ + OK: true, + Message: "macOS 默认钥匙串可用", + Detail: detail, + } +} + +func getSystemDEKReadOnly(service string) ([]byte, error) { + if err := checkDefaultKeychainAvailable(); err != nil { + return nil, err + } + + ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout) + defer cancel() + + type result struct { + key []byte + err error + } + resCh := make(chan result, 1) + + go func() { + defer func() { recover() }() + + encodedKey, err := keyringGet(service, "dek") + if err == nil { + key, decodeErr := base64.StdEncoding.DecodeString(encodedKey) + if decodeErr == nil && len(key) == dekBytes { + resCh <- result{key: key, err: nil} + return + } + resCh <- result{key: nil, err: fmt.Errorf("read DEK from macOS Keychain: %w", ErrDEKMissing)} + return + } + if errors.Is(err, keyring.ErrNotFound) { + resCh <- result{key: nil, err: fmt.Errorf("read DEK from macOS Keychain: %w", ErrDEKMissing)} + return + } + resCh <- result{key: nil, err: NewUnavailableError("read DEK from macOS Keychain", err)} + }() + + select { + case res := <-resCh: + return res.key, res.err + case <-ctx.Done(): + return nil, NewUnavailableError("read DEK from macOS Keychain", ctx.Err()) + } +} + +func getOrCreateDEK(service string) ([]byte, error) { if os.Getenv(DisableKeychainEnv) != "" { return fileDEK(service) } + if err := checkDefaultKeychainAvailable(); err != nil { + return nil, err + } ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout) defer cancel() @@ -82,13 +228,16 @@ func getDEK(service string) ([]byte, error) { defer func() { recover() }() // Try to get existing DEK from system Keychain - encodedKey, err := keyring.Get(service, "dek") + encodedKey, err := keyringGet(service, "dek") if err == nil { key, decodeErr := base64.StdEncoding.DecodeString(encodedKey) if decodeErr == nil && len(key) == dekBytes { resCh <- result{key: key, err: nil} return } + } else if !errors.Is(err, keyring.ErrNotFound) { + resCh <- result{key: nil, err: NewUnavailableError("read DEK from macOS Keychain", err)} + return } // Generate new DEK if not found or invalid @@ -100,15 +249,18 @@ func getDEK(service string) ([]byte, error) { // Store in system Keychain encodedKey = base64.StdEncoding.EncodeToString(key) - setErr := keyring.Set(service, "dek", encodedKey) - resCh <- result{key: key, err: setErr} + if setErr := keyringSet(service, "dek", encodedKey); setErr != nil { + resCh <- result{key: nil, err: NewUnavailableError("store DEK in macOS Keychain", setErr)} + return + } + resCh <- result{key: key, err: nil} }() select { case res := <-resCh: return res.key, res.err case <-ctx.Done(): - return nil, ctx.Err() + return nil, NewUnavailableError("read DEK from macOS Keychain", ctx.Err()) } } @@ -156,11 +308,72 @@ func decryptData(data []byte, key []byte) (string, error) { return string(plaintext), nil } -func platformGet(service, account string) (string, error) { - key, err := getDEK(service) - if err != nil { - return "", err +// 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 { if os.IsNotExist(err) { @@ -168,7 +381,7 @@ func platformGet(service, account string) (string, error) { } return "", err } - plaintext, err := decryptData(data, key) + plaintext, _, err := decryptWithAvailableDEK(service, data) if err != nil { return "", err } @@ -176,11 +389,26 @@ func platformGet(service, account string) (string, error) { } func platformSet(service, account, data string) error { - key, err := getDEK(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 } @@ -189,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) @@ -204,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) { diff --git a/internal/keychain/keychain_darwin_test.go b/internal/keychain/keychain_darwin_test.go index 931892fc..58633d4f 100644 --- a/internal/keychain/keychain_darwin_test.go +++ b/internal/keychain/keychain_darwin_test.go @@ -16,11 +16,130 @@ package keychain import ( + "bytes" + "encoding/base64" + "errors" "os" "path/filepath" + "strings" "testing" + + keyringpkg "github.com/zalando/go-keyring" ) +func TestDefaultKeychainPathFromSecurityOutput(t *testing.T) { + got := defaultKeychainPathFromSecurityOutput([]byte("\"/Users/me/Library/Keychains/login.keychain-db\"\n")) + if got != "/Users/me/Library/Keychains/login.keychain-db" { + t.Fatalf("path = %q", got) + } +} + +func TestCheckDefaultKeychainAvailableReportsMissingPath(t *testing.T) { + missingPath := filepath.Join(t.TempDir(), "missing.keychain-db") + prev := readDefaultKeychain + readDefaultKeychain = func() ([]byte, error) { + return []byte("\"" + missingPath + "\"\n"), nil + } + t.Cleanup(func() { + readDefaultKeychain = prev + }) + + err := checkDefaultKeychainAvailable() + if !IsUnavailable(err) { + t.Fatalf("error = %v, want unavailable", err) + } + if !strings.Contains(err.Error(), missingPath) { + t.Fatalf("error = %v, want missing keychain path", err) + } +} + +func TestGetMissingAccountDoesNotReadMacOSDEK(t *testing.T) { + t.Setenv(DisableKeychainEnv, "") + + keychainPath := filepath.Join(t.TempDir(), "login.keychain-db") + if err := os.WriteFile(keychainPath, nil, 0600); err != nil { + t.Fatalf("WriteFile(default keychain) error = %v", err) + } + + prevReadDefault := readDefaultKeychain + prevGet := keyringGet + prevSet := keyringSet + readDefaultKeychain = func() ([]byte, error) { + return []byte("\"" + keychainPath + "\"\n"), nil + } + keyringGet = func(service, account string) (string, error) { + t.Fatalf("keyring.Get(%q, %q) called for missing account", service, account) + return "", nil + } + keyringSet = func(service, account, value string) error { + t.Fatalf("keyring.Set(%q, %q) called for missing account", service, account) + return nil + } + t.Cleanup(func() { + readDefaultKeychain = prevReadDefault + keyringGet = prevGet + keyringSet = prevSet + }) + + got, err := Get("test-missing-account", "auth-token") + if err != nil { + t.Fatalf("Get() error = %v, want nil", err) + } + if got != "" { + t.Fatalf("Get() = %q, want empty string", got) + } +} + +func TestGetWithMissingMacOSDEKDoesNotCreateDEK(t *testing.T) { + t.Setenv(DisableKeychainEnv, "") + + keychainPath := filepath.Join(t.TempDir(), "login.keychain-db") + if err := os.WriteFile(keychainPath, nil, 0600); err != nil { + t.Fatalf("WriteFile(default keychain) error = %v", err) + } + + prevReadDefault := readDefaultKeychain + prevGet := keyringGet + prevSet := keyringSet + readDefaultKeychain = func() ([]byte, error) { + return []byte("\"" + keychainPath + "\"\n"), nil + } + keyringGet = func(service, account string) (string, error) { + if account != "dek" { + t.Fatalf("keyring.Get account = %q, want dek", account) + } + return "", keyringpkg.ErrNotFound + } + setCalls := 0 + keyringSet = func(service, account, value string) error { + setCalls++ + return errors.New("keyring.Set should not be called by Get") + } + t.Cleanup(func() { + readDefaultKeychain = prevReadDefault + keyringGet = prevGet + keyringSet = prevSet + }) + + service := "test-missing-dek" + account := "auth-token" + dir := StorageDir(service) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), []byte("ciphertext"), 0600); err != nil { + t.Fatalf("WriteFile(ciphertext) error = %v", err) + } + + _, err := Get(service, account) + if !IsDEKMissing(err) { + t.Fatalf("Get() error = %v, want dek missing", err) + } + if setCalls != 0 { + t.Fatalf("keyring.Set calls = %d, want 0", setCalls) + } +} + // TestDisableKeychainFallback verifies that setting DWS_DISABLE_KEYCHAIN // routes the DEK to a local file (same scheme as Linux) and the full // Set/Get/Remove cycle works without touching the system Keychain. @@ -105,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) + } + } +} diff --git a/internal/keychain/keychain_linux.go b/internal/keychain/keychain_linux.go index 97463cfc..5c88532b 100644 --- a/internal/keychain/keychain_linux.go +++ b/internal/keychain/keychain_linux.go @@ -32,6 +32,10 @@ func getDEK(service string) ([]byte, error) { return fileDEK(service) } +func getDEKReadOnly(service string) ([]byte, error) { + return fileDEKReadOnly(service) +} + const ( dekBytes = 32 // DEK = Data Encryption Key (AES-256) ivBytes = 12 @@ -106,10 +110,6 @@ func decryptData(data []byte, key []byte) (string, error) { } func platformGet(service, account string) (string, error) { - key, err := getDEK(service) - if err != nil { - return "", err - } data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account))) if err != nil { if os.IsNotExist(err) { @@ -117,6 +117,10 @@ func platformGet(service, account string) (string, error) { } return "", err } + key, err := getDEKReadOnly(service) + if err != nil { + return "", err + } plaintext, err := decryptData(data, key) if err != nil { return "", err @@ -153,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) { diff --git a/internal/keychain/keychain_test.go b/internal/keychain/keychain_test.go index 1175b33a..ba17194a 100644 --- a/internal/keychain/keychain_test.go +++ b/internal/keychain/keychain_test.go @@ -19,6 +19,18 @@ import ( "testing" ) +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "dws-keychain-test-*") + if err != nil { + panic(err) + } + _ = os.Setenv(StorageDirEnv, dir) + _ = os.Setenv(DisableKeychainEnv, "1") + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} + func TestKeychainBasicOperations(t *testing.T) { t.Parallel() @@ -87,6 +99,23 @@ func TestKeychainNonExistentAccount(t *testing.T) { } } +func TestGetNonExistentAccountDoesNotCreateFileDEK(t *testing.T) { + service := "test-service-readonly-" + t.Name() + account := "nonexistent-account" + dekPath := filepath.Join(StorageDir(service), "dek") + + got, err := Get(service, account) + if err != nil { + t.Fatalf("Get() error = %v, want nil", err) + } + if got != "" { + t.Fatalf("Get() = %q, want empty string", got) + } + if _, err := os.Stat(dekPath); !os.IsNotExist(err) { + t.Fatalf("Get() created DEK at %s; stat error = %v", dekPath, err) + } +} + func TestKeychainOverwrite(t *testing.T) { t.Parallel() diff --git a/internal/keychain/migration_darwin.go b/internal/keychain/migration_darwin.go new file mode 100644 index 00000000..f06e5192 --- /dev/null +++ b/internal/keychain/migration_darwin.go @@ -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 +} diff --git a/internal/keychain/migration_darwin_test.go b/internal/keychain/migration_darwin_test.go new file mode 100644 index 00000000..e43549b1 --- /dev/null +++ b/internal/keychain/migration_darwin_test.go @@ -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) + } +} diff --git a/internal/keychain/migration_unsupported.go b/internal/keychain/migration_unsupported.go new file mode 100644 index 00000000..6cde28a5 --- /dev/null +++ b/internal/keychain/migration_unsupported.go @@ -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") +} diff --git a/internal/pat/chmod.go b/internal/pat/chmod.go index 30c03f35..9949c14c 100644 --- a/internal/pat/chmod.go +++ b/internal/pat/chmod.go @@ -244,12 +244,12 @@ func newChmodCommand(c edition.ToolCaller) *cobra.Command { Long: `授予指定 scope 的操作权限。 scope 格式: .: - 例: aitable.record:read chat.group:write calendar.event:read + 例: aitable.record:query chat.message:list calendar.event:get grantType 规则: once 一次性,执行一次后自动失效 - session 当前会话有效(默认),需要 --session-id - permanent 永久有效 + session 当前会话有效,需要 --session-id + permanent 永久有效(默认) 批量授权: dws pat chmod 支持一次传多个 scope 直接批量授予。 @@ -271,13 +271,13 @@ agentCode 配置: } return cobra.MinimumNArgs(1)(cmd, args) }, - Example: ` dws pat chmod aitable.record:read --grant-type session --session-id session-xxx + Example: ` dws pat chmod aitable.record:query --grant-type session --session-id session-xxx dws pat chmod chat.message:list --grant-type once - dws pat chmod aitable.record:read aitable.record:write --grant-type permanent --yes + dws pat chmod aitable.record:query aitable.record:create --grant-type permanent --yes dws pat chmod --product calendar --product aitable --grant-type once --dry-run --format json - dws pat chmod --products calendar,aitable --grant-type session --session-id session-xxx --yes + dws pat chmod --products calendar,aitable --grant-type permanent --yes dws pat chmod --domain calendar --domain chat --grant-type once --yes - dws pat chmod --recommend --grant-type session --session-id session-xxx --yes`, + dws pat chmod --recommend --yes`, RunE: func(cmd *cobra.Command, args []string) error { flagVal, _ := cmd.Flags().GetString("agentCode") agentCode, err := resolveAgentCode(flagVal) @@ -404,7 +404,7 @@ agentCode 配置: chmodCmd.Flags().String("agentCode", "", "Agent 唯一标识(可选;也可通过 env DINGTALK_DWS_AGENTCODE 注入,flag 优先;未传则由服务端默认兜底)") - chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent") + chmodCmd.Flags().String("grant-type", grantTypePermanent, "授权策略: once|session|permanent") chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)") chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价;执行批量授权需 --yes") chmodCmd.Flags().StringSliceVar(&productsFlag, "products", nil, "产品编码列表,逗号分隔;执行批量授权需 --yes") diff --git a/internal/pat/chmod_test.go b/internal/pat/chmod_test.go index dd22b765..7530f438 100644 --- a/internal/pat/chmod_test.go +++ b/internal/pat/chmod_test.go @@ -67,6 +67,8 @@ func (f *fakeToolCaller) CallTool(_ context.Context, _ string, toolName string, func (f *fakeToolCaller) Format() string { return "json" } func (f *fakeToolCaller) DryRun() bool { return f.dryRun } +func (f *fakeToolCaller) Fields() string { return "" } +func (f *fakeToolCaller) JQ() string { return "" } type recordedToolCall struct { tool string @@ -94,6 +96,8 @@ func (f *fallbackToolCaller) CallTool(_ context.Context, _ string, toolName stri func (f *fallbackToolCaller) Format() string { return "json" } func (f *fallbackToolCaller) DryRun() bool { return false } +func (f *fallbackToolCaller) Fields() string { return "" } +func (f *fallbackToolCaller) JQ() string { return "" } type fallbackErrorToolCaller struct { calls []recordedToolCall @@ -198,6 +202,8 @@ func (f *fallbackPATContractErrorToolCaller) CallTool(_ context.Context, _ strin func (f *fallbackPATContractErrorToolCaller) Format() string { return "json" } func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false } +func (f *fallbackPATContractErrorToolCaller) Fields() string { return "" } +func (f *fallbackPATContractErrorToolCaller) JQ() string { return "" } type sequenceToolCaller struct { calls []recordedToolCall @@ -227,6 +233,8 @@ func (s *sequenceToolCaller) CallTool(_ context.Context, _ string, toolName stri func (s *sequenceToolCaller) Format() string { return "json" } func (s *sequenceToolCaller) DryRun() bool { return s.dryRun } +func (s *sequenceToolCaller) Fields() string { return "" } +func (s *sequenceToolCaller) JQ() string { return "" } func stringSliceArgEqual(got any, want []string) bool { gotSlice, ok := got.([]string) @@ -372,9 +380,9 @@ func TestPATHelpDocumentsBatchAuthorization(t *testing.T) { "--dry-run 只返回授权计划", "执行批量授权必须显式", "由服务端默认兜底", - "aitable.record:read aitable.record:write --grant-type permanent --yes", + "aitable.record:query aitable.record:create --grant-type permanent --yes", "dws pat chmod --products calendar,aitable", - "dws pat chmod --recommend --grant-type session", + "dws pat chmod --recommend --yes", } { if !strings.Contains(chmodHelp, want) { t.Fatalf("pat chmod help missing %q\nhelp:\n%s", want, chmodHelp) @@ -478,6 +486,7 @@ func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) { `{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`, }} cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "session") _ = cmd.Flags().Set("products", "calendar") _ = cmd.Flags().Set("session-id", "session-123") setBatchYesForTest(t, cmd) @@ -843,7 +852,7 @@ func TestChmod_grantTypeAndSessionParameterMatrix(t *testing.T) { } } -func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) { +func TestChmod_productsSessionDryRunUsesSessionIDFromEnv(t *testing.T) { t.Setenv(agentCodeEnv, "qoderwork") t.Setenv(sessionIDEnvDWS, "env-session-123") fake := &sequenceToolCaller{ @@ -853,6 +862,7 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) { }, } cmd := newChmodCommand(fake) + _ = cmd.Flags().Set("grant-type", "session") _ = cmd.Flags().Set("products", "calendar") if err := cmd.RunE(cmd, nil); err != nil { @@ -1056,6 +1066,7 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) { fake := &fakeToolCaller{resultOK: true} cmd := buildChmod(t, fake) + _ = cmd.Flags().Set("grant-type", "session") if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil { t.Fatalf("chmod RunE error = %v", err) @@ -1081,6 +1092,7 @@ func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) { fake := &fakeToolCaller{resultOK: true} cmd := buildChmod(t, fake) + _ = cmd.Flags().Set("grant-type", "session") _ = cmd.Flags().Set("session-id", "flag-session") if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil { @@ -1108,7 +1120,6 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T) `{"success":true,"data":{"grantedScopes":["recommended.scope:read"]}}`, }} cmd := newChmodCommand(fake) - _ = cmd.Flags().Set("grant-type", "once") _ = cmd.Flags().Set("recommend", "true") setBatchYesForTest(t, cmd) @@ -1125,9 +1136,15 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T) if got := fake.calls[0].args["recommend"]; got != true { t.Fatalf("recommend = %#v, want true", got) } + if got := fake.calls[0].args["grantType"]; got != grantTypePermanent { + t.Fatalf("plan grantType = %#v, want %q", got, grantTypePermanent) + } if fake.calls[1].tool != patBatchGrantToolName { t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName) } + if got := fake.calls[1].args["grantType"]; got != grantTypePermanent { + t.Fatalf("grant grantType = %#v, want %q", got, grantTypePermanent) + } } func TestLoginRecommendAuthorizationSelectorReplansBySelectedProducts(t *testing.T) { @@ -2020,3 +2037,12 @@ func TestResolveAgentCodeFromEnv(t *testing.T) { code, src) } } + +func (f *fallbackErrorToolCaller) Fields() string { return "" } +func (f *fallbackErrorToolCaller) JQ() string { return "" } +func (f *fallbackSchemaMismatchToolCaller) Fields() string { return "" } +func (f *fallbackSchemaMismatchToolCaller) JQ() string { return "" } +func (f *fallbackPermissionDeniedToolCaller) Fields() string { return "" } +func (f *fallbackPermissionDeniedToolCaller) JQ() string { return "" } +func (f *fallbackPATErrorToolCaller) Fields() string { return "" } +func (f *fallbackPATErrorToolCaller) JQ() string { return "" } diff --git a/internal/syncdata/endpoints.go b/internal/syncdata/endpoints.go index ae4f5783..ac896af3 100644 --- a/internal/syncdata/endpoints.go +++ b/internal/syncdata/endpoints.go @@ -5,6 +5,7 @@ package syncdata func StaticServers() []ServerInfo { return []ServerInfo{ + {ID: "agoal", Name: "Agoal", Endpoint: "https://mcp-gw.dingtalk.com/server/1db49ea94ffe74c25a7079a68b4df6629c79130fb9095d1b499f507015d847a9", Prefixes: []string{"agoal"}}, {ID: "aisearch", Name: "AI 搜问", Endpoint: "https://mcp-gw.dingtalk.com/server/ai-search", Prefixes: []string{"aisearch", "enterprise"}}, {ID: "aitable", Name: "AI 多维表", Endpoint: "https://mcp-gw.dingtalk.com/server/5f0d121611f14e878f7d42c3e32bf6c4a790d433066adae38c062a657c397047", Prefixes: []string{"table", "record", "field", "base", "attachment", "view", "dashboard", "chart", "export", "import"}}, {ID: "aitable-helper", Name: "AI 多维表(辅助)", Endpoint: "https://mcp-gw.dingtalk.com/server/bb2984ee6b10c1560b4fe943ca620f646bed31f215c551a53abf040b52591a95", Prefixes: []string{"form", "share_form"}}, diff --git a/internal/syncdata/routing.go b/internal/syncdata/routing.go index 6922170e..4f4492ab 100644 --- a/internal/syncdata/routing.go +++ b/internal/syncdata/routing.go @@ -5,6 +5,7 @@ package syncdata func CmdToProduct() map[string]string { return map[string]string{ + "agoal": "agoal", "aisearch": "aisearch", "aitable": "aitable", "attendance": "attendance", diff --git a/internal/transport/client.go b/internal/transport/client.go index ed516765..8b9a2d8a 100644 --- a/internal/transport/client.go +++ b/internal/transport/client.go @@ -416,7 +416,7 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque } callStart := time.Now() - resp, err := c.doWithRetry(ctx, endpoint, body) + resp, err := c.doWithRetry(ctx, endpoint, body, request.Method) if err != nil { return err } @@ -493,15 +493,19 @@ func (c *Client) callJSONRPC(ctx context.Context, endpoint string, request reque return nil } -func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) (*http.Response, error) { +func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte, method string) (*http.Response, error) { endpoint = sanitizeJSONRPCEndpoint(endpoint) + operation := strings.TrimSpace(method) + if operation == "" { + operation = "jsonrpc" + } var lastErr error for attempt := 0; attempt <= c.MaxRetries; attempt++ { req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return nil, apperrors.NewDiscovery( "failed to create JSON-RPC request", - apperrors.WithOperation("jsonrpc"), + apperrors.WithOperation(operation), apperrors.WithReason("request_build_failed"), apperrors.WithHint(i18n.T("请检查服务 endpoint 是否为空或格式不合法。")), apperrors.WithCause(&CallError{ @@ -559,37 +563,52 @@ func (c *Client) doWithRetry(ctx context.Context, endpoint string, body []byte) statusForLog = resp.StatusCode } delay := c.retryDelayForAttempt(attempt, retryAfter) - logging.LogRetryAttempt(c.FileLogger, "jsonrpc", c.ExecutionId, attempt, c.MaxRetries, statusForLog, delay, lastErr) + logging.LogRetryAttempt(c.FileLogger, operation, c.ExecutionId, attempt, c.MaxRetries, statusForLog, delay, lastErr) if err := c.sleepForRetry(ctx, delay); err != nil { - return nil, apperrors.NewDiscovery( - "request cancelled during retry", - apperrors.WithOperation("jsonrpc"), + opts := []apperrors.Option{ + apperrors.WithOperation(operation), apperrors.WithReason("request_cancelled"), apperrors.WithHint(i18n.T("请求在重试过程中被取消;请检查调用侧超时设置。")), apperrors.WithCause(&CallError{ Stage: CallStageRequest, Cause: err, }), - ) + } + if operation == "tools/call" { + opts = append(opts, apperrors.WithActions(networkActions("")...)) + return nil, apperrors.NewAPI("request cancelled during retry", opts...) + } + opts = append(opts, apperrors.WithActions(discoveryActions("")...)) + return nil, apperrors.NewDiscovery("request cancelled during retry", opts...) } } } reason, hint := classifyRequestFailure(lastErr) - logging.LogErrorClassified(c.FileLogger, "jsonrpc", c.ExecutionId, - string(apperrors.CategoryDiscovery), reason, 0, 0, + category := apperrors.CategoryDiscovery + actions := discoveryActions("") + if operation == "tools/call" { + category = apperrors.CategoryAPI + actions = networkActions("") + } + logging.LogErrorClassified(c.FileLogger, operation, c.ExecutionId, + string(category), reason, 0, 0, !isTimeoutError(lastErr), "") - return nil, apperrors.NewDiscovery( - fmt.Sprintf("request to %s failed: %v", RedactURL(endpoint), lastErr), - apperrors.WithOperation("jsonrpc"), + opts := []apperrors.Option{ + apperrors.WithOperation(operation), apperrors.WithReason(reason), apperrors.WithRetryable(!isTimeoutError(lastErr)), apperrors.WithHint(hint), - apperrors.WithActions(discoveryActions("")...), + apperrors.WithActions(actions...), apperrors.WithCause(&CallError{ Stage: CallStageRequest, Cause: lastErr, }), - ) + } + message := fmt.Sprintf("request to %s failed: %v", RedactURL(endpoint), lastErr) + if category == apperrors.CategoryAPI { + return nil, apperrors.NewAPI(message, opts...) + } + return nil, apperrors.NewDiscovery(message, opts...) } func sanitizeJSONRPCEndpoint(endpoint string) string { @@ -1039,6 +1058,17 @@ func runtimeActions(snapshotPath string) []string { return actions } +func networkActions(snapshotPath string) []string { + actions := []string{ + i18n.T("检查网络、代理和 DNS 配置后重试原命令"), + i18n.T("确认 MCP 服务可访问;若持续失败请稍后重试"), + } + if snapshotPath != "" { + actions = append(actions, fmt.Sprintf("dws recovery plan --snapshot %s", snapshotPath)) + } + return actions +} + // validateCallArguments checks string values in tool call arguments for // control characters and dangerous Unicode at the transport boundary. func validateCallArguments(args map[string]any) error { diff --git a/internal/transport/client_extra_test.go b/internal/transport/client_extra_test.go index 05177cbd..d9a1b070 100644 --- a/internal/transport/client_extra_test.go +++ b/internal/transport/client_extra_test.go @@ -103,7 +103,7 @@ func TestDoWithRetryRedactsGatewayQueryInHeaderDebugLog(t *testing.T) { })}) client.FileLogger = logger - resp, err := client.doWithRetry(context.Background(), "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", []byte(`{}`)) + resp, err := client.doWithRetry(context.Background(), "https://mcp-gw.dingtalk.com/server/demo?key=secret#frag", []byte(`{}`), "initialize") if err != nil { t.Fatalf("doWithRetry() error = %v", err) } diff --git a/internal/transport/recovery_metadata_test.go b/internal/transport/recovery_metadata_test.go index cacccd81..5dfcd7c3 100644 --- a/internal/transport/recovery_metadata_test.go +++ b/internal/transport/recovery_metadata_test.go @@ -4,7 +4,9 @@ import ( "context" "errors" "net/http" + "strings" "testing" + "time" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" ) @@ -53,7 +55,7 @@ func TestDoWithRetryRequestFailureIncludesCallMetadata(t *testing.T) { })}) client.MaxRetries = 0 - _, err := client.doWithRetry(context.Background(), "https://mcp.dingtalk.com/server", []byte(`{}`)) + _, err := client.doWithRetry(context.Background(), "https://mcp.dingtalk.com/server", []byte(`{}`), "tools/call") if err == nil { t.Fatal("doWithRetry() error = nil, want request failure") } @@ -67,6 +69,125 @@ func TestDoWithRetryRequestFailureIncludesCallMetadata(t *testing.T) { } } +func TestCallToolRequestFailureUsesAPIClassification(t *testing.T) { + client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp: connection refused") + })}) + client.MaxRetries = 0 + + _, err := client.CallTool(context.Background(), "https://mcp.dingtalk.com/server", "search_messages", map[string]any{"keyword": "marker"}) + if err == nil { + t.Fatal("CallTool() error = nil, want request failure") + } + + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("CallTool() error = %T, want *errors.Error", err) + } + if typed.Category != apperrors.CategoryAPI { + t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryAPI) + } + if typed.Operation != "tools/call" { + t.Fatalf("Operation = %q, want tools/call", typed.Operation) + } + if typed.Reason != "connection_refused" { + t.Fatalf("Reason = %q, want connection_refused", typed.Reason) + } + actions := strings.Join(typed.Actions, "\n") + if strings.Contains(actions, "internal/syncdata") || strings.Contains(actions, "sync-oss") { + t.Fatalf("runtime network failure contains discovery-only actions: %q", actions) + } +} + +func TestCallToolRetryCancellationUsesAPIClassification(t *testing.T) { + client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: http.NoBody}, nil + })}) + client.MaxRetries = 1 + client.sleep = func(context.Context, time.Duration) error { return context.Canceled } + + _, err := client.CallTool(context.Background(), "https://mcp.dingtalk.com/server", "search_messages", nil) + if err == nil { + t.Fatal("CallTool() error = nil, want retry cancellation") + } + + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("CallTool() error = %T, want *errors.Error", err) + } + if typed.Category != apperrors.CategoryAPI { + t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryAPI) + } + if typed.Operation != "tools/call" { + t.Fatalf("Operation = %q, want tools/call", typed.Operation) + } + if typed.Reason != "request_cancelled" { + t.Fatalf("Reason = %q, want request_cancelled", typed.Reason) + } + actions := strings.Join(typed.Actions, "\n") + if strings.Contains(actions, "internal/syncdata") || strings.Contains(actions, "sync-oss") { + t.Fatalf("runtime retry cancellation contains discovery-only actions: %q", actions) + } +} + +func TestDiscoveryRequestFailuresKeepDiscoveryClassification(t *testing.T) { + tests := []struct { + name string + failure error + operation string + wantReason string + call func(*Client) error + }{ + { + name: "initialize connection refused", + failure: errors.New("dial tcp: connection refused"), + operation: "initialize", + wantReason: "connection_refused", + call: func(client *Client) error { + _, err := client.Initialize(context.Background(), "https://mcp.dingtalk.com/server") + return err + }, + }, + { + name: "tools list dns failure", + failure: errors.New("dial tcp: lookup mcp.dingtalk.com: no such host"), + operation: "tools/list", + wantReason: "dns_resolution_failed", + call: func(client *Client) error { + _, err := client.ListTools(context.Background(), "https://mcp.dingtalk.com/server") + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, tt.failure + })}) + client.MaxRetries = 0 + + err := tt.call(client) + if err == nil { + t.Fatal("request error = nil, want failure") + } + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("request error = %T, want *errors.Error", err) + } + if typed.Category != apperrors.CategoryDiscovery { + t.Fatalf("Category = %q, want %q", typed.Category, apperrors.CategoryDiscovery) + } + if typed.Operation != tt.operation { + t.Fatalf("Operation = %q, want %q", typed.Operation, tt.operation) + } + if typed.Reason != tt.wantReason { + t.Fatalf("Reason = %q, want %q", typed.Reason, tt.wantReason) + } + }) + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/pkg/edition/edition.go b/pkg/edition/edition.go index f42df306..fbdf52f3 100644 --- a/pkg/edition/edition.go +++ b/pkg/edition/edition.go @@ -53,6 +53,10 @@ type ToolCaller interface { Format() string // DryRun returns true when --dry-run is active. DryRun() bool + // Fields returns the global --fields output projection ("" if unset). + Fields() string + // JQ returns the global --jq output filter expression ("" if unset). + JQ() string } // RuntimeDefaultFn resolves a single runtimeDefault placeholder (e.g. @@ -74,6 +78,12 @@ type Hooks struct { // to DefaultOSSClawType; overlays set their own value (e.g. "wukong"). ClawTypeValue string + // PersonalEventSourceID identifies the personal-event source channel + // used by dws event --as user. Empty → "open". Overlays set their own + // value through Override; open-source core never infers custom sources + // from environment/user input. + PersonalEventSourceID string + // --- runtime mode --- IsEmbedded bool // true when running inside a host application HideAuthLogin bool // true suppresses the "dws auth login" command @@ -185,3 +195,11 @@ func ClawType() string { } return DefaultOSSClawType } + +// PersonalEventSourceID returns the source channel for user-level events. +func PersonalEventSourceID() string { + if v := Get().PersonalEventSourceID; v != "" { + return v + } + return "open" +} diff --git a/scripts/README.md b/scripts/README.md index 28069f23..ed9b30d9 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -11,7 +11,8 @@ These repo-local entrypoints are the supported shell entrypoints for building, t Script groups: -- Root installers: `./scripts/install.sh`, `./scripts/install.ps1`, `./scripts/install-skills.sh`, `./scripts/install-devapp.sh` +- Root installers: `./scripts/install.sh`, `./scripts/install.ps1`, `./scripts/install-skills.sh` +- Product convenience installers: `./scripts/install-devapp.sh`, `./scripts/install-devapp.ps1`, `./scripts/install-event.sh` - Dev helpers: `./scripts/dev/build.sh`, `./scripts/dev/lint.sh`, `./scripts/dev/ci-local.sh`, `./scripts/dev/run-mock-e2e.sh`, `./scripts/dev/coverage.sh` - Policy checks: `./scripts/policy/check-generated-drift.sh`, `./scripts/policy/check-command-surface.sh`, `./scripts/policy/check-open-source-assets.sh` - Release helpers: `./scripts/release/post-goreleaser.sh`, `./scripts/release/verify-package-managers.sh`, `./scripts/release/publish-homebrew-formula.sh` diff --git a/scripts/install-event.sh b/scripts/install-event.sh new file mode 100755 index 00000000..3d510242 --- /dev/null +++ b/scripts/install-event.sh @@ -0,0 +1,272 @@ +#!/bin/sh +# Copyright 2026 Alibaba Group +# Licensed under the Apache License, Version 2.0 +# +# One-command installer for dws personal events. +# Downloads the official dws binary and installs: +# - multi skill: dingtalk-event +# - mono skill: dws +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh +# +# Env (all optional): +# EVENT_REPO repo holding official releases (default: DingTalk-Real-AI/dingtalk-workspace-cli) +# EVENT_VERSION pin a release tag (default: latest stable release) +# DWS_VERSION alias for EVENT_VERSION when EVENT_VERSION is empty +# DWS_INSTALL_DIR binary dir (default: ~/.local/bin) +# DWS_NO_SKILLS set 1 to skip skill installation +# DWS_SKILLS_ONLY set 1 to install only skills, without touching the binary +set -eu + +EVENT_REPO="${EVENT_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}" +EVENT_VERSION="${EVENT_VERSION:-${DWS_VERSION:-}}" +INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}" +NO_SKILLS="${DWS_NO_SKILLS:-0}" +SKILLS_ONLY="${DWS_SKILLS_ONLY:-0}" +BIN_NAME="dws" +EVENT_SKILL_NAME="dingtalk-event" +MONO_SKILL_NAME="dws" + +if [ "$EVENT_VERSION" = "latest" ]; then + EVENT_VERSION="" +fi + +say() { printf ' %s\n' "$@"; } +err() { printf ' ERROR: %s\n' "$@" >&2; exit 1; } +need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; } + +detect_os() { + case "$(uname -s)" in + Linux*) echo linux ;; + Darwin*) echo darwin ;; + MINGW*|MSYS*|CYGWIN*) echo windows ;; + *) err "Unsupported OS: $(uname -s). On native Windows use a PowerShell installer." ;; + esac +} + +detect_arch() { + case "$(uname -m)" in + x86_64|amd64) echo amd64 ;; + arm64|aarch64) echo arm64 ;; + *) err "Unsupported architecture: $(uname -m)" ;; + esac +} + +extract_zip() { + archive="$1" + dest="$2" + if command -v unzip >/dev/null 2>&1; then + unzip -q "$archive" -d "$dest" + return 0 + fi + if command -v tar >/dev/null 2>&1 && tar -xf "$archive" -C "$dest" >/dev/null 2>&1; then + return 0 + fi + err "Missing required command: unzip (or tar with zip support)" +} + +resolve_event_version() { + [ -n "$EVENT_VERSION" ] && return 0 + + if command -v gh >/dev/null 2>&1; then + EVENT_VERSION="$(gh api "repos/${EVENT_REPO}/releases/latest" --jq '.tag_name' 2>/dev/null || true)" + [ -n "$EVENT_VERSION" ] && [ "$EVENT_VERSION" != "null" ] && return 0 + EVENT_VERSION="" + fi + + tmpfile="$(mktemp)" + http_code="$(curl -sSL -o "$tmpfile" -w '%{http_code}' "https://api.github.com/repos/${EVENT_REPO}/releases/latest" 2>/dev/null || echo "000")" + if [ "$http_code" = "403" ] || [ "$http_code" = "429" ]; then + rm -f "$tmpfile" + err "GitHub API rate limit hit (HTTP ${http_code}). Install gh or set EVENT_VERSION explicitly." + fi + EVENT_VERSION="$(grep -m1 '"tag_name"' "$tmpfile" | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/' || true)" + rm -f "$tmpfile" + [ -n "$EVENT_VERSION" ] || err "No stable release found on ${EVENT_REPO}. Set EVENT_VERSION to a published release tag." +} + +copy_tree() { + src="$1" + dest="$2" + rm -rf "$dest" + mkdir -p "$dest" + cp -R "$src/." "$dest/" +} + +install_skill_to_homes() { + src="$1" + skill_name="$2" + + installed=0 + idx=0 + for agent_dir in \ + .agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \ + .gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \ + .cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \ + .config/opencode/skills + do + base="$HOME/$agent_dir" + if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then + idx=$((idx + 1)) + continue + fi + copy_tree "$src" "$base/$skill_name" + installed=$((installed + 1)) + idx=$((idx + 1)) + done + + if [ "$installed" -eq 0 ]; then + copy_tree "$src" "$HOME/.agents/skills/$skill_name" + installed=1 + fi + printf '%s\n' "$installed" +} + +find_event_skill_src() { + bundle="$1" + for c in \ + "$bundle/multi/$EVENT_SKILL_NAME" \ + "$bundle/skills/multi/$EVENT_SKILL_NAME" \ + "$bundle/$EVENT_SKILL_NAME" + do + if [ -f "$c/SKILL.md" ]; then + printf '%s\n' "$c" + return 0 + fi + done + return 1 +} + +find_mono_skill_src() { + bundle="$1" + for c in \ + "$bundle/mono" \ + "$bundle/skills/mono" \ + "$bundle/$MONO_SKILL_NAME" \ + "$bundle" + do + if [ -f "$c/SKILL.md" ]; then + printf '%s\n' "$c" + return 0 + fi + done + return 1 +} + +install_skills_from_bundle() { + bundle="$1" + + event_src="$(find_event_skill_src "$bundle" || true)" + [ -n "$event_src" ] || err "${EVENT_SKILL_NAME} not found in dws-skills.zip" + mono_src="$(find_mono_skill_src "$bundle" || true)" + [ -n "$mono_src" ] || err "mono ${MONO_SKILL_NAME} skill not found in dws-skills.zip" + + event_cache="$HOME/.dws/skills/multi/$EVENT_SKILL_NAME" + copy_tree "$event_src" "$event_cache" + + mono_cache="$HOME/.dws/skills/mono" + copy_tree "$mono_src" "$mono_cache" + + event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" + mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")" + + say "Skill ${EVENT_SKILL_NAME} -> ${event_installed} agent dir(s)" + say "Skill ${MONO_SKILL_NAME} -> ${mono_installed} agent dir(s)" + say "Cached ${EVENT_SKILL_NAME} -> ${event_cache}" + say "Cached mono ${MONO_SKILL_NAME} -> ${mono_cache}" +} + +install_binary() { + os="$(detect_os)" + arch="$(detect_arch)" + if [ "$os" = "windows" ]; then + asset="${BIN_NAME}-windows-${arch}.zip" + binname="${BIN_NAME}.exe" + else + asset="${BIN_NAME}-${os}-${arch}.tar.gz" + binname="${BIN_NAME}" + fi + + say "Downloading ${asset} ..." + curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/${asset}" -o "$tmp/$asset" \ + || err "Binary download failed. Does release ${EVENT_VERSION} have ${asset}?" + + if [ "$os" = "windows" ]; then + extract_zip "$tmp/$asset" "$tmp/bin" + else + need_cmd tar + mkdir -p "$tmp/bin" + tar -xzf "$tmp/$asset" -C "$tmp/bin" + fi + + found="" + for c in "$tmp/bin/$binname" "$tmp/bin/${BIN_NAME}-${os}-${arch}/$binname"; do + [ -f "$c" ] && found="$c" && break + done + if [ -z "$found" ]; then + found="$(find "$tmp/bin" -name "$binname" -type f | head -1 || true)" + fi + [ -n "$found" ] || err "${binname} not found inside ${asset}" + + mkdir -p "$INSTALL_DIR" + cp "$found" "$INSTALL_DIR/$binname" + chmod +x "$INSTALL_DIR/$binname" 2>/dev/null || true + say "Binary -> ${INSTALL_DIR}/${binname}" +} + +install_skills() { + say "Downloading dws-skills.zip ..." + curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/dws-skills.zip" -o "$tmp/dws-skills.zip" \ + || err "dws-skills.zip download failed for release ${EVENT_VERSION}" + + mkdir -p "$tmp/skills" + extract_zip "$tmp/dws-skills.zip" "$tmp/skills" + install_skills_from_bundle "$tmp/skills" +} + +main() { + need_cmd curl + if [ "$NO_SKILLS" = "1" ] && [ "$SKILLS_ONLY" = "1" ]; then + err "DWS_NO_SKILLS=1 cannot be combined with DWS_SKILLS_ONLY=1" + fi + + resolve_event_version + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT INT TERM + + printf '\n' + say "dws event installer" + say "Repo: ${EVENT_REPO}" + say "Version: ${EVENT_VERSION}" + say "Install: ${INSTALL_DIR}" + printf '\n' + + if [ "$SKILLS_ONLY" != "1" ]; then + install_binary + else + say "DWS_SKILLS_ONLY=1, skipping binary installation." + fi + + if [ "$NO_SKILLS" != "1" ]; then + install_skills + else + say "DWS_NO_SKILLS=1, skipping skill installation." + fi + + printf '\n' + say "Done. Verify with:" + if [ "$SKILLS_ONLY" != "1" ]; then + say " dws version" + fi + say " dws event schema user_im_message_receive_o2o" + say " dws event consume user_im_message_receive_o2o --user -f ndjson" + say "" + say "If an Agent session is already open, restart it or reload skills before testing event routing." + case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) [ "$SKILLS_ONLY" = "1" ] || say "Note: ${INSTALL_DIR} is not on PATH." ;; + esac +} + +main diff --git a/scripts/install.sh b/scripts/install.sh index c981d3e9..d48260ac 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -260,7 +260,7 @@ resolve_skill_mode() { print_multi_mode_notice() { say "" say "🧪 Skill mode: multi (EXPERIMENTAL / preview) — automatic skill install skipped." - say " ⚠ multi is not yet stable. 20 product-scoped skills pass dispatch verifier," + say " ⚠ multi is not yet stable. 22 product-scoped skills pass dispatch verifier," say " but interface, naming and cross-skill references may change in future releases." say " For production / shared environments, use mono mode (--mode mono)." say "" diff --git a/scripts/policy/open-source-audit.sh b/scripts/policy/open-source-audit.sh index 819ebf44..07b93d7a 100755 --- a/scripts/policy/open-source-audit.sh +++ b/scripts/policy/open-source-audit.sh @@ -36,6 +36,14 @@ require_no_match() { fi } +require_no_tracked_path() { + path="$1" + if git ls-files -- "$path" | grep -q .; then + git ls-files -- "$path" >&2 + err "found forbidden tracked path: $path" + fi +} + require_heading_if_linked() { label="$1" heading="$2" @@ -62,6 +70,10 @@ require_no_match 'scripts/test\.sh|scripts/check-semantic-fixtures\.sh|scripts/r require_no_match 'scripts/check-semantic-fixtures\.py|test/semantic/cli_to_mcp|test/semantic/model_to_cli|python3 -m pytest test/semantic' CONTRIBUTING.md require_no_match '\.github/CODEOWNERS|Tag-triggered GitHub Release asset publishing' CHANGELOG.md +require_no_tracked_path 'auto-test' +require_no_tracked_path 'eval-runs' +require_no_tracked_path '.qoder' + require_heading_if_linked '[Environment Variables](#environment-variables)' '## Environment Variables' require_heading_if_linked '[Exit Codes](#exit-codes)' '## Exit Codes' diff --git a/scripts/release/finalize-github-release.sh b/scripts/release/finalize-github-release.sh new file mode 100755 index 00000000..d32bc58a --- /dev/null +++ b/scripts/release/finalize-github-release.sh @@ -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 diff --git a/scripts/release/post-goreleaser.sh b/scripts/release/post-goreleaser.sh index 481c4a6c..cd15d6a4 100755 --- a/scripts/release/post-goreleaser.sh +++ b/scripts/release/post-goreleaser.sh @@ -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" diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 3d8d4832..18433e3a 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -22,6 +22,7 @@ cli_version: ">=1.0.15" - 单次批量操作不超过 30 条记录 - 所有命令必须**严格遵循**对应产品参考文档里面规定的参数格式(如:如果有参数值,则参数和参数值之间至少用一个空格隔开) - **脚本优先**:[scripts/](./scripts/) 下的 `python scripts/.py` 已封装翻页/轮询/批量逻辑,遇到对应场景(如 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等)**优先调用脚本**而非手写多步命令。脚本均支持 `--dry-run` 预览、`--format json` 输出,失败时回退到手动步骤 +- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,必须走 `dws event consume` 长连接,不要写脚本轮询消息历史 ## 产品总览 @@ -33,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) | @@ -46,10 +47,12 @@ cli_version: ">=1.0.15" | `sheet` | 在线电子表格(axls):工作表 CRUD/区域读写/CSV 批量写入/行列增删/合并/查找替换/筛选视图/全局筛选/排序/下拉列表/条件格式/浮动图片/浮动图表/模板/导出 xlsx(单命令一站式) | [sheet.md](./references/products/sheet.md) | | `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) | | `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 | [wiki.md](./references/products/wiki.md) | +| `event` | 个人消息事件:监听当前用户被 @、指定单聊、指定群聊,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) | ## 意图判断决策树 用户提到"AI应用/创建应用/生成系统/做工具/管理后台/低代码" → `aiapp` +用户提到"目标管理/Agoal/战略解码/经营合约/计分卡/目标模板/周月报提交统计" → `agoal` 用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch` 用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable` 用户提到"考勤/打卡/排班" → `attendance` @@ -66,7 +69,8 @@ cli_version: ">=1.0.15" 用户提到"日志/日报/周报/日志统计/写日报/提交周报/发日志/填日志" → `report` 用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet` 用户提到"待办/TODO/任务提醒/循环待办" → `todo` -用户提到"知识库/wiki/团队空间/知识库成员管理" → `wiki` +用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki` +用户提到"监听有人@我/监听我和某人的单聊消息/监听某个群消息/订阅个人消息事件/实时接收钉钉消息事件/个人消息事件流/event consume user_im_message_receive_at/user_im_message_receive_o2o/user_im_message_receive_group/监听并自动回复消息/驱动 Agent 处理消息" → `event` 关键区分: aitable(数据表格) vs todo(待办任务) 关键区分: report(钉钉日志/日报周报) vs todo(待办任务) @@ -128,18 +132,22 @@ Step 3 → 加 --yes 执行命令 dws --help # 例:dws calendar event list --help -# helper-only schema 查询(如 dev.*),普通产品命令不要依赖 schema 推断参数 +# 结构化 schema 查询:helper-only 子树(dev.*,source=mcp:) dws schema "dev app create" +# 登记的本地命令子树(event.*,source=cobra,从二进制 flag 合成) +dws schema "event consume" # 叶子:parameters{:{type,required,description,default?}} + arguments[位置参数] +dws schema event # 中间节点:列子命令 +# 其余普通产品命令不走 schema,直接看 --help。 # 注:--jq 对 schema 输出无效(不过滤,仍返回完整对象);schema 结构里必填标在 # .parameters.<字段>.required,没有 .tool 键。要看必填字段自行读 .parameters 即可。 ``` **何时用哪条路径:** - 只需看某个命令怎么调用 → `dws --help` -- 构造 `--params` / `--json` 时不确定字段类型、必填、别名 → 先看 `dws --help`,helper-only 命令再看 `dws schema` +- 构造 `--params` / `--json` 时不确定字段类型、必填、别名 → 先看 `dws --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` 重试一次 diff --git a/skills/mono/references/best_practices/lite-recipes.md b/skills/mono/references/best_practices/lite-recipes.md index f83c086b..0d4fa85e 100644 --- a/skills/mono/references/best_practices/lite-recipes.md +++ b/skills/mono/references/best_practices/lite-recipes.md @@ -249,7 +249,7 @@ query 未提"听记"但任务产出依赖会议讨论内容时(报告/总结/ - 按文件夹:`folderId:2`(2=收件箱, 1=已发送, 5=草稿, 6=已删除) - 按是否有附件:`hasAttachments:true` - 组合:`from:alice AND subject:周报 AND date>2025-06-01T00:00:00Z` -3. 执行搜索:`mail message search --email <邮箱> --query "" --size 20` +3. 执行搜索:`mail message search --email <邮箱> --query "" --limit 20` 4. 查看详情(按需):`mail message get --email <邮箱> --id ` ### mail-send diff --git a/skills/mono/references/global-reference.md b/skills/mono/references/global-reference.md index 7c5605c3..1ec53f04 100644 --- a/skills/mono/references/global-reference.md +++ b/skills/mono/references/global-reference.md @@ -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) @@ -64,8 +69,8 @@ dws recovery finalize --event-id --outcome recovered|failed|handoff - | 标志 | 短名 | 说明 | 默认 | |------|:---:|------|------| | `--format` | `-f` | 输出格式: json / table / raw | json | -| `--jq` | | jq 表达式过滤输出 ⚠️**当前为 no-op,不生效**:仍返回完整 JSON,不要依赖它过滤,改在拿到 JSON 后自行处理 | 无 | -| `--fields` | | 筛选输出字段 ⚠️**当前为 no-op,不生效**:仍返回全量字段 | 无 | +| `--jq` | | jq 表达式过滤输出(如 `.result[].name`)。对产品命令(aitable/chat/mail/... 走 MCP 的)已生效;少数工具命令(auth/config/profile/doctor/schema 等)仍直接编码、暂不过滤 | 无 | +| `--fields` | | 筛选输出字段(逗号分隔)。按**顶层信封键**(data/result/success/status…)或**列表元素字段**投影;取 data 内的嵌套字段(如 baseName)请改用 `--jq '.data.baseName'`,`--fields baseName` 会因顶层无此键返回 `{}`。同 `--jq`:产品命令已生效,个别工具命令暂不生效 | 无 | | `--verbose` | `-v` | 详细日志 | false | | `--debug` | | 调试日志 | false | | `--yes` | `-y` | 跳过确认提示 | false | diff --git a/skills/mono/references/intent-guide.md b/skills/mono/references/intent-guide.md index 7bd2b5c8..5ac2f30a 100644 --- a/skills/mono/references/intent-guide.md +++ b/skills/mono/references/intent-guide.md @@ -68,8 +68,8 @@ | "改群昵称/设置群昵称/我在群里的名字" | 设置个人群昵称 | `chat group update-nick` | `chat group rename` | update-nick 改自己的群昵称;rename 改群名称 | | "群备注/给群加备注/修改群备注" | 设置群备注 | `chat group update-alias` | `chat group rename` | update-alias 设置仅自己可见的备注;rename 改群名称全员可见 | | "隐藏会话/隐藏群聊/隐藏对话" | 隐藏会话 | `chat hide` | `chat mute` | hide 隐藏会话不显示;mute 是免打扰但仍显示 | -| "关闭@所有人通知/屏蔽@all/不接收@所有人" | 关闭 @所有人提醒 | `chat mute-at-all` | `chat mute` | mute-at-all 仅屏蔽 @所有人;mute 是整个会话免打扰 | -| "关闭红包通知/屏蔽红包/不接收红包提醒" | 关闭红包提醒 | `chat mute-red-envelope` | `chat mute` | mute-red-envelope 仅屏蔽红包;mute 是整个会话免打扰 | +| "关闭@所有人通知/屏蔽@all/不接收@所有人" | 关闭 @所有人提醒 | `chat mute-at-all` | `chat mute` | mute-at-all 仅屏蔽 @所有人;mute 是整个会话免打扰。⚠️ 当前服务端稳定返回 1002「系统繁忙」,命令暂不可用,先告知用户到钉钉客户端设置 | +| "关闭红包通知/屏蔽红包/不接收红包提醒" | 关闭红包提醒 | `chat mute-red-envelope` | `chat mute` | mute-red-envelope 仅屏蔽红包;mute 是整个会话免打扰。⚠️ 同 mute-at-all,服务端稳定 1002,暂不可用 | | "解散群/解散群聊" | 解散群聊 | `chat group dismiss` | `chat group quit` | dismiss 是群主解散整个群(不可逆);quit 是当前用户自己退群 | | "新成员看历史/历史消息可见范围" | 设置新成员可见历史消息 | `chat group set-history` | `chat group update-settings` | set-history 控制新成员入群后可见历史消息范围;update-settings 是其他群功能开关 | | "群里有哪些机器人/查看群机器人/列出群机器人" | 查看群内机器人列表 | `chat group bots` | `chat group members` | bots 只列机器人;members 列普通群成员 | diff --git a/skills/mono/references/products/agoal.md b/skills/mono/references/products/agoal.md new file mode 100644 index 00000000..527351ea --- /dev/null +++ b/skills/mono/references/products/agoal.md @@ -0,0 +1,161 @@ +# Agoal(目标管理) + +## 产品说明 + +Agoal 是钉钉目标管理工具,支持战略解码、经营合约、计分卡、用户目标、目标模板、周月报六大模块,帮助组织将战略目标从顶层分解到个人并持续跟踪。 + +**CLI 前缀**: `dws agoal` + +## 命令总览 + +### strategy (战略解码管理) + +| 命令 | 用途 | 必填参数 | 备注 | +|------|------|----------|------| +| `strategy list` | 获取战略解码列表 | `--scope-type` `--scope-id` | scopeType: DEPT/PERSONAL;scope-id 为 scope-type 对应的部门 id 或用户 id | +| `strategy detail` | 获取战略解码详情 | `--profile-id` | 根据战略解码 id 查询 | +| `strategy update` | 更新战略解码 | `--profile-id` `--content` | 覆盖逻辑,必须基于查询返回的老数据修改后再传入;`--content` 为 JSON 数组 | + +`strategy update` 是覆盖式更新:一定要先 `strategy detail` 获取完整数据,在原数据基础上修改后再传入。 + +### contract (经营合约管理) + +| 命令 | 用途 | 必填参数 | 备注 | +|------|------|----------|------| +| `contract list` | 获取经营合约列表 | `--scope-type` `--scope-id` | scopeType: DEPT/PERSONAL;scope-id 为 scope-type 对应的部门 id 或用户 id | +| `contract fields` | 获取经营合约字段列表 | - | 获取组织下经营合约的字段配置 | +| `contract detail` | 获取经营合约详情 | `--contract-id` | 根据合约 id 查询 | +| `contract update` | 更新经营合约 | `--contract-id` `--dimensions` | 覆盖逻辑;可选 `--audit-config`、`--objective-template` | + +`contract update` 同样是覆盖式更新:必须基于 `contract detail` 返回的数据修改后再传入。 + +### scorecard (计分卡管理) + +| 命令 | 用途 | 必填参数 | 备注 | +|------|------|----------|------| +| `scorecard detail` | 获取计分卡详情 | `--selected-time` `--dept-id` | selectedTime 为 ISO-8601 字符串,如 `"2026-01-01T00:00:00+08:00"` | +| `scorecard entity-detail` | 获取计分卡实体详情 | `--sc-id` `--entity-id` | 根据计分卡 id 和实体 id 查询 | +| `scorecard update` | 更新计分卡 | `--dept-id` `--selected-time` `--id` `--tracking-period-type` `--content` | trackingPeriodType: MONTHLY/QUARTERLY | + +### user (用户目标管理) + +| 命令 | 用途 | 必填参数 | 备注 | +|------|------|----------|------| +| `user rules` | 获取用户规则周期列表 | - | 可选 `--user-id`,不传则默认取操作人自己 | +| `user objectives` | 查询用户目标列表 | `--user-id` `--rule-id` `--period-ids` | `--period-ids` 为逗号分隔的周期 id 列表 | + +### report (周月报管理) + +| 命令 | 用途 | 必填参数 | 备注 | +|------|------|----------|------| +| `report list-statistics` | 获取周月报数据跟催列表 | - | 返回各规则的人员提交情况统计;可选 `--keyword` | +| `report submit-detail` | 获取周月报规则提交详情 | `--template-id` `--submit-state` | submitState: ON_TIME/LATE/NOT_SUBMITTED;可选 `--query-date`、`--page`、`--page-size`、`--keyword` | + +### obj-template (目标模板管理) + +| 命令 | 用途 | 必填参数 | 备注 | +|------|------|----------|------| +| `obj-template list` | 获取目标模板列表 | - | 可选 `--keyword`、`--page`、`--page-size` | +| `obj-template create-or-update` | 新增或更新目标模板 | `--dimensions` | 覆盖逻辑;新增时 `--title` 必填;更新时 `--template-id` 必填 | + +## 意图判断 + +用户说"战略解码/战略目标/OGSM": +- 查看/列表 → `strategy list` +- 详情 → `strategy detail` +- 修改/更新 → `strategy update`(先查后改) + +用户说"经营合约/合约/KPI合约": +- 查看/列表 → `contract list` +- 字段配置 → `contract fields` +- 详情 → `contract detail` +- 修改/更新 → `contract update`(先查后改) + +用户说"计分卡/scorecard/绩效看板": +- 查看详情 → `scorecard detail` +- 实体详情 → `scorecard entity-detail` +- 修改/更新 → `scorecard update` + +用户说"目标/OKR/我的目标/个人目标": +- 规则周期 → `user rules` +- 目标列表 → `user objectives` + +用户说"目标模板/模板管理": +- 查看模板列表 → `obj-template list` +- 新增模板 → `obj-template create-or-update --title "模板名称"` +- 更新模板 → `obj-template create-or-update --template-id TPL_ID` + +用户说"周月报/周报统计/提交情况/跟催/迟交/未提交": +- 查看提交统计列表 → `report list-statistics` +- 查看某规则的提交详情 → `report submit-detail` + +## 核心工作流 + +```bash +# 查看战略解码列表与详情 +dws agoal strategy list --scope-type DEPT --scope-id DEPT_ID --format json +dws agoal strategy detail --profile-id PROFILE_ID --format json + +# 更新战略解码:必须基于 detail 返回内容修改后传入 +dws agoal strategy update --profile-id PROFILE_ID \ + --content '[{"id":"entity1","title":{"title":"新目标"},"entityType":"OGSM_OBJECTIVE","status":"NORMAL","executors":["dingId1"]}]' \ + --format json + +# 查看经营合约列表、字段与详情 +dws agoal contract list --scope-type PERSONAL --scope-id USER_ID --format json +dws agoal contract fields --format json +dws agoal contract detail --contract-id CONTRACT_ID --format json + +# 更新经营合约:必须基于 detail 返回内容修改后传入 +dws agoal contract update --contract-id CONTRACT_ID \ + --dimensions '[{"id":"dim1","title":"维度名称","objectives":[]}]' \ + --format json + +# 查看计分卡 +dws agoal scorecard detail --selected-time "2026-01-01T00:00:00+08:00" --dept-id DEPT_ID --format json +dws agoal scorecard entity-detail --sc-id SC_ID --entity-id ENTITY_ID --format json + +# 更新计分卡 +dws agoal scorecard update --dept-id DEPT_ID --selected-time "2026-01-01T00:00:00+08:00" \ + --id SC_ID --tracking-period-type MONTHLY \ + --content '[{"id":"dim1","title":"业绩","items":[{"id":"item1","title":"收入","target":"100"}]}]' \ + --format json + +# 查询用户目标 +dws agoal user rules --user-id USER_ID --format json +dws agoal user objectives --user-id USER_ID --rule-id RULE_ID --period-ids "period1,period2" --format json + +# 周月报提交统计与详情 +dws agoal report list-statistics --format json +dws agoal report list-statistics --keyword "周报规则" --format json +dws agoal report submit-detail --template-id TPL_ID --submit-state ON_TIME --format json +dws agoal report submit-detail --template-id TPL_ID --submit-state LATE --query-date "2026-06-18T00:00:00+08:00" --page 1 --page-size 20 --format json + +# 目标模板 +dws agoal obj-template list --format json +dws agoal obj-template list --keyword "业绩" --format json +dws agoal obj-template create-or-update --title "业绩模板" --objective-weight --dimension-weight --dimensions '[...]' --format json +dws agoal obj-template create-or-update --template-id TPL_ID --title "业绩模板" --dimensions '[...]' --format json +``` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `strategy list` | `profileId` | `strategy detail` / `strategy update` 的 `--profile-id` | +| `strategy detail` | 完整实体数据 | `strategy update` 的 `--content`(基于此修改) | +| `contract list` | `contractId` | `contract detail` / `contract update` 的 `--contract-id` | +| `contract detail` | 完整维度数据 | `contract update` 的 `--dimensions`(基于此修改) | +| `scorecard detail` | `scId`、`entityId` | `scorecard entity-detail` / `scorecard update` 的 `--id` | +| `user rules` | `ruleId`、`periodIds` | `user objectives` 的 `--rule-id` `--period-ids` | +| `report list-statistics` | `templateId` | `report submit-detail` 的 `--template-id` | +| `obj-template list` | `templateId` | `obj-template create-or-update` 的 `--template-id`(更新时) | + +## 注意事项 + +- 所有 update / create-or-update 命令都是覆盖逻辑:必须先用对应 detail/list 查询完整数据,在原数据基础上修改后再传入。 +- 所有命令支持可选参数 `--request-id`。 +- `--scope-type` 仅支持 `DEPT` 和 `PERSONAL`。 +- `--selected-time` 接受 ISO-8601 字符串,如 `"2026-01-01T00:00:00+08:00"`。 +- `--period-ids` 为逗号分隔字符串,如 `"period1,period2"`。 +- `report submit-detail` 的 `--query-date` 接受 ISO-8601 字符串;不传则默认当天。 diff --git a/skills/mono/references/products/aisearch.md b/skills/mono/references/products/aisearch.md index 2f4668f5..417bfbda 100644 --- a/skills/mono/references/products/aisearch.md +++ b/skills/mono/references/products/aisearch.md @@ -98,7 +98,7 @@ Flags: | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `aisearch person` | `userId`(用户ID)、`title`(姓名) | 展示搜索结果、后续操作(发消息/建待办等) | +| `aisearch person` | `userId`(用户ID,= `meta.staffId`/`meta.jobNumber`)、`author`(真实姓名,= `meta.name`);`title` 是花名/显示名,不一定等于姓名 | 展示搜索结果、后续操作(发消息/建待办等) | ## 重名消歧 diff --git a/skills/mono/references/products/aitable/aitable-dashboard-chart.md b/skills/mono/references/products/aitable/aitable-dashboard-chart.md index f904158a..c6e4a1e7 100644 --- a/skills/mono/references/products/aitable/aitable-dashboard-chart.md +++ b/skills/mono/references/products/aitable/aitable-dashboard-chart.md @@ -31,7 +31,7 @@ dws aitable chart get --base-id --dashboard-id --chart- | `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` `--yes` | — | | `dashboard config-example` | 查看仪表盘配置模板 | 无 | 创建前先调此命令了解 config 结构 | | `dashboard share get` | 获取仪表盘分享配置 | `--base-id` `--dashboard-id` | 可能 404,见上方要点 | -| `dashboard share update` | 更新仪表盘分享配置 | `--base-id` `--dashboard-id` `--enabled` | `--enabled true` 开启(配 `--share-type PUBLIC/ORG`)/ `--enabled false` 关闭;ORG 回显 `shareType="[1]"` | +| `dashboard share update` | 更新仪表盘分享配置 | `--base-id` `--dashboard-id` `--enabled` | `--enabled true` 开启,`--share-type` 用 `ORG`(ORG 回显 `shareType="[1]"`);部分组织禁用了 `PUBLIC` 公开分享;若报 `Illegal argument`,改用 `ORG`(组织内分享)。`--enabled false` 关闭 | ## chart 子命令 @@ -43,7 +43,7 @@ dws aitable chart get --base-id --dashboard-id --chart- | `chart delete` | 删除图表 | `--base-id` `--dashboard-id` `--chart-id` `--yes` | | `chart widgets-example` | 查看图表 widgets 配置模板 | 无 | | `chart share get` | 获取图表分享配置 | `--base-id` `--dashboard-id` `--chart-id` | -| `chart share update` | 更新图表分享配置(`--enabled true/false`,开启配 `--share-type PUBLIC/ORG`) | `--base-id` `--dashboard-id` `--chart-id` `--enabled` | +| `chart share update` | 更新图表分享配置(`--enabled true/false`,开启配 `--share-type`,用 `ORG`;部分组织禁用了 `PUBLIC` 公开分享;报 `Illegal argument` 时改用 `ORG`) | `--base-id` `--dashboard-id` `--chart-id` `--enabled` | > `chart create` 的 `--layout` 是**必填**(12 列网格布局,如 `{"x":0,"y":0,"w":6,"h":4}`);不传本地校验直接拒。`chart update` 的 `--config` 也**必填**——即便只想改 layout,也要带完整 config,否则服务端拒绝。 diff --git a/skills/mono/references/products/attendance.md b/skills/mono/references/products/attendance.md index e4dbd069..e839b773 100644 --- a/skills/mono/references/products/attendance.md +++ b/skills/mono/references/products/attendance.md @@ -89,6 +89,19 @@ Flags: 返回每条记录含:用户 ID、实际打卡时间、打卡地址、打卡经纬度、打卡类型(OnDuty/OffDuty)、定位方式(Map/Wifi/etc)。时间跨度不超过 1 个月。 +### 查询个人某日考勤详情 +``` +Usage: + dws attendance record get [flags] +Example: + dws attendance record get --user 011769261608 --date 2026-03-08 +Flags: + --user string 钉钉用户 ID (必填,别名 --users 亦可) + --date string 查询日期, 格式 YYYY-MM-DD (必填) +``` + +查询单个用户在**某一天**的考勤详情(区别于 `check record`/`check result` 的多人时间段批量查询)。返回 `result` 对象含 `isHasSchedule`(当日是否有排班)、`isRest`(是否休息日)、`isUnSigned`(是否未打卡)、`recordList`(打卡明细)、`approveList`(当日审批单)、`workOvertime`、`workTimeDesc` 等字段。`--user` 只接受**单个** userId;查 userId 用 `dws contact user search --query "姓名"`。 + ### 查询审批单(补卡/加班/请假/出差外出) ``` Usage: @@ -1161,13 +1174,13 @@ dws attendance report query-leave --users userId1,userId2 \ dws attendance vacation types --format json # 查看指定员工假期余额 -dws attendance vacation balance --users userId1,userId2 --format json +dws attendance vacation balance --users userId1,userId2 --leave-code <假期类型code> --format json # 查看指定员工某类假期余额 dws attendance vacation balance --users userId1 --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 --format json # 查看指定员工假期余额变更记录 -dws attendance vacation records --user USER_ID --start 2026-04-01 --end 2026-04-22 --format json +dws attendance vacation records --user USER_ID --leave-code <假期类型code> --start 2026-04-01 --end 2026-04-22 --format json # 更新假期规则名称 dws attendance vacation update-type --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ diff --git a/skills/mono/references/products/calendar.md b/skills/mono/references/products/calendar.md index 2eba38f0..0dfae700 100644 --- a/skills/mono/references/products/calendar.md +++ b/skills/mono/references/products/calendar.md @@ -5,7 +5,7 @@ - **二级子命令(必选其一)**:`event`(日程)、`attendee`(参会人)、`room`(会议室)、`busy`(闲忙)、`attachment`(日程附件)、`book`(我能看哪些日历本)、`acl`(我的日历共享给了谁)。`dws calendar` 后**必须**紧跟上述之一;**禁止**只执行 `dws calendar`(无子命令)。 - **个人日程 / 给自己留时间块 / 专注时段**:统一走 **`dws calendar event create`**。当前**没有**单独的 `personal schedule create` / `calendar create` 命令。 - **查日程列表**:`dws calendar event list --start "" --end "" --format json`,或优先使用脚本 `python scripts/calendar_today_agenda.py [today|tomorrow|week]`(见文末「自动化脚本」)。 -- **查用户日历本列表**:`dws calendar book list`(返回主日历 `id == "primary"` 等)。**重要**可以查询他人共享给自己的日历本,根据日历本id可以进一步查询对方的日程信息。 +- **查用户日历本列表**:`dws calendar book list`(返回字段为 `calendarId`,主日历 `calendarId == "primary"` 等;注意无 `id` 字段,按 `.id` 提取会取空)。**重要**可以查询他人共享给自己的日历本,根据日历本id可以进一步查询对方的日程信息。 - **CLI 不存在**独立的 `dws calendar list`;若误跑无子命令的 `dws calendar`,会打印整段 Usage,**切勿**将该段 help 当作工具结果再次塞进对话(会急剧增加 token 与首字延迟)。 - **必须**遵循指令说明进行调用。**绝对禁止**使用虚构指令,使用虚构参数。 @@ -618,7 +618,7 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15 | `event respond` | 响应结果 | — | | `room search` | `rooms[].roomId` | room add 的 --rooms 或 event create 的 --rooms | | `room list-groups` | `groups[].groupId` | room search 的 --group-id | -| `book list` | `id`(如 `primary`) | event list/get 的 --calendar-id, book get/update 的 --id | +| `book list` | `calendarId`(如 `primary`) | event list/get 的 --calendar-id, book get/update 的 --id | | `book get` | 日历详细信息 | — | | `book search` | 匹配的日历列表 | book get/update 的 --id | | `acl list` | `aclId` | acl delete 的 --acl-id | @@ -640,7 +640,7 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15 - **搜房无结果**:在符合早停/用户限定范围内,`room search`(含按分组逐组查)全部返回空或无空闲 → 应**直接向用户报错/说明失败**并结束订房;**禁止**假设 roomId、禁止无合法 `roomId` 时调用 `room add` / `event create --rooms` 试探、禁止用 `event get` 等绕路推断 roomId - **评测 / 自动化断言**:凡涉及 `room add` / `event create --rooms` 的流程,`--rooms` 只能填上游 `room search`(或等价接口)返回 JSON 中的 **`rooms[].roomId`**;不得以会议室展示名、楼层文案或用户口语当作 `roomId` - **附件**:`attachment add` 仅负责挂载,**不上传**文件;fileId 必须先通过钉盘流程取得;`--files` 多附件用 `:` 元素逗号分隔 -- **日历本**:`book list` 返回的 `id` 才是合法 `calendarId`;如无明确说明,`event list` / `event get` 都不要带 `--calendar-id`,让接口默认走 primary 主日历 +- **日历本**:`book list` 返回的 `calendarId` 字段才是合法日历 id(无 `id` 字段);如无明确说明,`event list` / `event get` 都不要带 `--calendar-id`,让接口默认走 primary 主日历 - **已弃用入参**:`--max-results`(event list)在新 MCP schema 中被移除;CLI 仍接受但**不会**透传到 MCP;模型生成命令时**禁止**继续使用 ## 自动化脚本 diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index bed1c0da..cbfd4f95 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -139,6 +139,26 @@ Flags: --expires-seconds int64 链接有效期(秒),0 表示永久有效,不传使用服务端默认值 ``` +#### 分享群聊链接到会话 — 将指定群的邀请链接分享到另一个会话或单聊用户 + +`--target` 和 `--receiver` 二选一:`--target` 指定目标会话,`--receiver` 指定单聊用户。 + +``` +Usage: + dws chat group share-invite [flags] +Example: + dws chat group share-invite --source <被分享群openConversationId> --target <目标会话openConversationId> + dws chat group share-invite --source <被分享群openConversationId> --receiver <接收者openDingTalkId> + dws chat group share-invite --source --target --expires-seconds 86400 + # 查询群 ID: dws chat search --query "群名" +Flags: + --source string 被分享群的 openConversationId (必填) + --target string 接收分享消息的会话 openConversationId(与 --receiver 二选一) + --receiver string 接收分享消息的单聊用户 openDingTalkId(与 --target 二选一) + --expires-seconds int64 链接有效期(秒),0 表示永久有效,不传使用服务端默认值 + --uuid string 消息幂等键(可选) +``` + #### 退出群聊 — 当前用户退出指定群聊 ``` Usage: @@ -272,6 +292,65 @@ Flags: - 不传 --role 时返回群主 + 管理员的所有群 ``` +### group notice (群公告管理) + +#### 发布群公告 — 在指定群聊中发布群公告,支持 Markdown、定时发布 + +``` +Usage: + dws chat group notice create [flags] +Example: + dws chat group notice create --group --content "今晚 22 点系统维护,请提前保存工作内容" + dws chat group notice create --group --content "# 重要通知\n请大家查收" --sticky --send-ding + dws chat group notice create --group --content "明早九点例会" --run-at "2026-07-03T09:00:00+08:00" +Flags: + --group string 群聊 openConversationId (必填) + --content string 公告正文,Markdown 格式 (必填) + --sticky 是否吊顶置顶(默认 false) + --send-ding 是否发 DING 提醒(默认 false) + --run-at string 定时发布时间 ISO-8601(传入则定时发布) +``` + +#### 修改群公告 — 整体替换指定群公告的内容 +``` +Usage: + dws chat group notice edit [flags] +Example: + dws chat group notice edit --group --notice-id --content "更新后的公告内容" +Flags: + --group string 群聊 openConversationId (必填) + --notice-id string 群公告 dataId (必填) + --content string 公告新正文,Markdown 格式 (必填) + --sticky 是否吊顶置顶 + --send-ding 是否发 DING 提醒 +``` + +#### 查看群公告详情 +``` +Usage: + dws chat group notice get [flags] +Example: + dws chat group notice get --group --notice-id +Flags: + --group string 群聊 openConversationId (必填) + --notice-id string 群公告 dataId (必填) +``` + +#### 查看群公告列表 +``` +Usage: + dws chat group notice list [flags] +Example: + dws chat group notice list --group + dws chat group notice list --group --limit 20 --cursor + dws chat group notice list --group --scheduled +Flags: + --group string 群聊 openConversationId (必填) + --limit int 每页返回数量(默认 10,最大 100) + --cursor string 分页游标 + --scheduled 是否查询定时公告列表 +``` + ### group-role (群身份管理) #### 查看群身份列表 — 拉取指定群聊的自定义群身份列表 @@ -334,7 +413,7 @@ Flags: #### 移除用户的指定群身份 — 从用户身上移除指定的群身份(不影响其他群身份) -> ⚠️ 当前不可用:真机服务端对该命令恒返回 1002(系统繁忙),历史回归至今未修,属服务端问题。需要清除某人身份时,用 `chat group-role set-user --role-ids ""`(传空 role-ids 覆盖为无身份)兜底;但 set-user 会清掉该用户的**全部**身份,无法只移除其中一个。 +> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。需要清除某人身份时,用 `chat group-role set-user --role-ids ""`(传空 role-ids 覆盖为无身份)兜底;但 set-user 会清掉该用户的**全部**身份,无法只移除其中一个。 ``` Usage: dws chat group-role remove-user [flags] @@ -729,7 +808,7 @@ Example: dws chat message list-by-sender --sender-open-dingtalk-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor Flags: --sender-user-id string 发送者 userId(与 --sender-open-dingtalk-id 二选一) - --sender-open-dingtalk-id string 发送者 openDingTalkId(与 --sender-user-id 二选一,适用于无法获取 userId 的场景) + --sender-open-dingtalk-id string 发送者 openDingTalkId(与 --sender-user-id 二选一);该路径当前返回 result:{}、success:null(疑似服务端问题),优先用 --sender-user-id --start string 开始时间,ISO-8601 格式 (必填) --end string 结束时间,ISO-8601 格式 (必填) --limit int 每页返回数量(默认 50) @@ -737,8 +816,8 @@ Flags: 注意: - --sender-user-id 和 --sender-open-dingtalk-id 二者互斥,必须且只能指定其一: - - --sender-user-id 传 userId(企业内部应用常用) - - --sender-open-dingtalk-id 传 openDingTalkId(三方应用或跨组织场景常用,无法获取 userId 时使用) + - --sender-user-id 传 userId(企业内部应用常用,当前唯一可用路径) + - --sender-open-dingtalk-id 传 openDingTalkId(当前返回 result:{}、success:null(疑似服务端问题);即使只有 openDingTalkId,也建议先用 contact 换取 userId 后走 --sender-user-id) - openDingTalkId 获取方式见下方「openDingTalkId 获取方式」小节 - 不需要指定单聊/群聊,返回结果自带会话类型标识 - 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00"、"2026-03-10" 等 @@ -994,6 +1073,20 @@ Flags: - 如果已有合适的表情,无需创建新的 ``` +#### 批量拉取消息的表情回复和文字回复 + +根据消息 ID 列表批量查询消息的表情回复和文字回复信息。 + +``` +Usage: + dws chat message list-emotion-replies [flags] +Example: + dws chat message list-emotion-replies --msg-ids msgId1,msgId2,msgId3 + # 消息 ID 可通过 dws chat message list 获取 +Flags: + --msg-ids string 消息 ID 列表,逗号分隔 (必填) +``` + ### list-top-conversations (置顶会话) #### 拉取置顶会话列表 @@ -1025,20 +1118,20 @@ Flags: Usage: dws chat message download-media [flags] Example: - dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./downloads/ dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./photo.jpg + dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./downloads/photo.jpg Flags: --type string 资源类型: mediaId (必填) --resource-id string 资源 ID,mediaId 类型时为消息中的 mediaId 值 (必填) --message-id string 消息 openMessageId (必填) --open-conversation-id string 会话 openConversationId (必填) - --output string 本地保存路径,文件或目录 (必填) + --output string 本地保存路径 (必填),建议传完整文件路径(含文件名) 注意: - resource-id 从 `dws chat message list` 返回的消息内容中获取 mediaId - message-id 从 `dws chat message list` 返回的 openMessageId - open-conversation-id 从 `dws chat search` 获取 openConversationId - - --output 如果指定目录,文件名会从下载 URL 中自动推断 + - --output 建议传完整文件路径(含扩展名,如 ./photo.jpg);只传目录(如 ./downloads/)在旧版本会下载失败,请显式带上文件名 ``` ### search-common (搜索共同群) @@ -1224,6 +1317,45 @@ Flags: - 分页: hasMore=true 时,用返回的 nextCursor 作为下次 --cursor 继续翻页 ``` +#### 收藏消息 — 为当前用户收藏指定会话中的一条消息 +``` +Usage: + dws chat message add-favorite [flags] +Example: + dws chat message add-favorite --open-message-id --open-conversation-id +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 --open-conversation-id +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 (机器人管理) #### 搜索【我创建的】机器人 — 仅返回当前用户自己创建的机器人 @@ -1309,6 +1441,21 @@ Flags: --title string 分组名称 (必填) ``` +#### 创建智能会话分组 — 可指定群名称关键词和群内成员作为匹配规则 +``` +Usage: + dws chat category create-smart [flags] +Example: + dws chat category create-smart --name "工作群" + dws chat category create-smart --name "项目组" --keywords "项目,开发" + dws chat category create-smart --name "团队群" --members openDingTalkId1,openDingTalkId2 + dws chat category create-smart --name "重点群" --keywords "重点" --members openDingTalkId1 +Flags: + --name string 分组名称 (必填) + --keywords string 群名称关键词列表,逗号分隔(可选) + --members string 群内成员 openDingTalkId 列表,逗号分隔(可选) +``` + #### 删除会话分组 ``` Usage: @@ -1358,6 +1505,23 @@ Flags: --category-ids string 目标分组 ID 列表,逗号分隔 (必填) ``` +### text (文本内容处理) + +#### 翻译文本内容 +``` +Usage: + dws chat text translate [flags] +Example: + dws chat text translate --query "你好世界" --to en_US + dws chat text translate --query "Hello World" --to zh_CN + dws chat text translate --query "Bonjour" --to ja_JP +Flags: + --query string 待翻译的文本内容 (必填) + --to string 目标语言代码 (必填) +``` + +支持的目标语言代码:`en_US`, `zh_CN`, `zh_TW`, `zh_HK`, `ja_JP`, `ko_KR`, `vi_VN`, `th_TH`, `id_ID`, `ms_MY`, `es_419`, `fr_FR`, `pt_BR`, `tr_TR`, `ru_RU`, `de_DE`, `hi_IN`, `hu_HU`, `pl_PL`, `sv_SE`, `fi_FI`, `cs_CZ`, `ar_SA`, `tl_PH`, `he_IL`, `nl_NL`, `lo_LA`, `it_IT`。 + ### mute (会话免打扰) #### 会话消息免打扰 — 开启或关闭会话消息免打扰(支持单聊和群聊) @@ -1405,7 +1569,7 @@ Flags: #### 关闭/开启 @所有人消息提醒 — 关闭或开启会话中 @所有人的消息通知 -> ⚠️ 当前不可用:真机服务端对该命令恒返回 1002(系统繁忙),历史回归至今未修,属服务端问题。命令本身参数合法,但调用不会生效。 +> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。命令本身参数合法,但调用不会生效。 ``` Usage: dws chat mute-at-all [flags] @@ -1428,7 +1592,7 @@ Flags: #### 关闭/开启红包消息提醒 — 关闭或开启会话中的红包消息通知 -> ⚠️ 当前不可用:真机服务端对该命令恒返回 1002(系统繁忙),历史回归至今未修,属服务端问题。命令本身参数合法,但调用不会生效。 +> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。命令本身参数合法,但调用不会生效。 ``` Usage: dws chat mute-red-envelope [flags] @@ -1518,7 +1682,7 @@ Flags: 注意: - 返回结果包含单聊和群聊,不区分会话类型 - --limit 范围 1-100,默认 100,上限 100;传入 >100 会报错拒绝,不会静默截断 - - 分页当前不可用:真机 hasMore 恒为 false、nextCursor 恒为 null,本命令最多返回 100 条会话,无法用 --cursor 继续翻页取更多 + - 分页当前不可用:当前 hasMore 恒为 false、nextCursor 恒为 null,本命令最多返回 100 条会话,无法用 --cursor 继续翻页取更多 - 与 list-top-conversations 的区别: 本命令返回全部会话(单聊+群聊),list-top-conversations 仅返回置顶会话 ``` @@ -1608,7 +1772,7 @@ Flags: #### 审批入群验证 — 通过、删除单个审核 -真机当前仅 AuditApprove(通过)和 AuditDelete(删除)两个动作可用。 +当前仅 AuditApprove(通过)和 AuditDelete(删除)两个动作可用。 ``` Usage: @@ -1626,7 +1790,7 @@ Flags: --description string 审批说明(可选) 注意: - - status 真机仅支持 AuditApprove(通过) 和 AuditDelete(删除);AuditIgnore(忽略)、AuditRefuse(拒绝)、AuditBlock(拒绝且拉黑) 会被服务端拒绝报 unsupported audit status,属服务端限制 + - status 仅支持 AuditApprove(通过) 和 AuditDelete(删除);AuditIgnore(忽略)、AuditRefuse(拒绝)、AuditBlock(拒绝且拉黑) 会被服务端拒绝报 unsupported audit status,属服务端限制 - record-id、applicant、inviter 可通过 dws chat group list-join-validations 查询获得 ``` @@ -1679,6 +1843,7 @@ Flags: 用户说"查看会话分组/自定义分组" → `chat category list` 用户说"某个分组下的会话/分组会话列表" → `chat category list-conversations` 用户说"新建会话分组/创建分组" → `chat category create` +用户说"创建智能分组/新建智能会话分组" → `chat category create-smart` 用户说"删除会话分组" → `chat category delete` 用户说"重命名分组/改分组名" → `chat category rename` 用户说"把会话加入分组/会话归类到分组" → `chat category add-conv` @@ -1693,7 +1858,10 @@ Flags: 用户说"查询某人的群身份/某人在群里有什么身份" → `chat group-role query-user` 用户说"转让群主/换群主/群主转让" → `chat group transfer-owner` 用户说"群邀请链接/入群链接/加群链接" → `chat group invite-url` +用户说"分享群链接/把群分享给某人/群链接发到某群" → `chat group share-invite` +用户说"群公告/发布公告/修改公告/查看公告/定时公告" → `chat group notice create/edit/get/list` 用户说"批量查消息/按ID查消息/根据消息ID查" → `chat message list-by-ids` +用户说"批量查消息回复/表情回复/文字回复/消息回应列表" → `chat message list-emotion-replies` 用户说"emoji回应/表情回应/给消息加表情" → `chat message add-emoji` 用户说"取消emoji回应/移除表情回应" → `chat message remove-emoji` 用户说"文字表情回应/添加文字表情" → `chat message add-text-emotion` @@ -1732,6 +1900,7 @@ Flags: 用户说"全员禁言/群禁言/解除禁言" → `chat group-mute` 用户说"禁言某人/指定成员禁言/解除某人禁言" → `chat group-mute-member` 用户说"设管理员/取消管理员/设置群管理员" → `chat group set-admin` +用户说"翻译文本/把这段话翻译成英文/翻译成中文" → `chat text translate` 关键区分: - `chat search` — 搜**群/会话名**返回 `openConversationId`,**不**搜消息内容;要搜消息内容请用 `chat message search-advanced`(首选)/ `chat message search` / `list-by-sender` / `list-all`,**勿混淆** @@ -1756,10 +1925,12 @@ Flags: - `chat message search-advanced` — 多维度搜索消息(支持关键词、发送者、@我、@指定人、多个会话等维度组合,与 `search` 的区别:`search` 仅支持关键词且必填,`search-advanced` 所有参数均可选) - `chat message list-by-ids` — 根据消息 ID 批量查询消息(最多 50 条) - `chat message add-emoji` / `remove-emoji` — 对消息添加/移除 emoji 表情回应 +- `chat message list-emotion-replies` — 批量拉取消息的表情回复和文字回复 - `chat message add-text-emotion` / `remove-text-emotion` — 对消息添加/移除文字表情回应 - `chat message create-text-emotion` — 创建文字表情模板,返回 emotionId 供 add-text-emotion 使用 - `chat category list` — 获取用户自定义会话分组列表 - `chat category list-conversations` — 拉取指定分组下的会话列表 +- `chat category create-smart` — 创建智能会话分组(可指定群名称关键词和成员作为匹配规则) - `chat mute` — 开启/关闭会话消息免打扰(默认开启,--off 关闭) - `chat hide` — 在会话列表中隐藏会话(支持单聊/群聊,收到新消息时重新出现) - `chat mute-at-all` — 关闭/开启 @所有人消息提醒(默认关闭,--off 恢复) @@ -1773,6 +1944,8 @@ Flags: - `chat file upload` — 已下线;不要调用 `chat/upload_conversation_file_by_url`,本地文件发送改用 `chat message send --msg-type file --file-path` - `chat group transfer-owner` — 转让群主 - `chat group invite-url` — 获取群邀请链接 +- `chat group share-invite` — 分享群聊链接到会话 +- `chat group notice create/edit/get/list` — 群公告发布、修改、详情、列表 - `chat message reply` — 引用回复消息(在群聊中引用某条消息并回复文字) - `chat message forward` — 转发单条消息(将一条消息从源会话转发到目标会话) - `chat set-top` — 设置/取消会话置顶(默认置顶,--off 取消) diff --git a/skills/mono/references/products/contact.md b/skills/mono/references/products/contact.md index 74036dc8..94680ef8 100644 --- a/skills/mono/references/products/contact.md +++ b/skills/mono/references/products/contact.md @@ -213,7 +213,7 @@ Example: Flags: 无 Notes: - - 无需参数,返回当前企业全部角色列表(labelId、labelName等) + - 无需参数,返回当前企业全部角色,按 `groupName` 分组,每组 `labels[]` 里每个角色是 `{labelId, name}`(字段名是 `name`,不是 `labelName`) - 用于不知道准确角色名称时先浏览全部角色 - 典型场景:用户说“企业所有主管/查所有管理员/财务人员有哪些”→ 先 label list 浏览全部角色,匹配目标角色后 label list-members 获取成员 ``` @@ -360,7 +360,7 @@ dws contact user dismission search --depts 123456,789012 --hide-retirement=false | `user get-self/search` | `orgAuthEmail` | mail message send 的 --to/--cc (跨产品) | | `user get-self/search` | `userId` | profile get 的 --staff-id | | `user profile fields` | `fieldCode` | profile get 的 --fields | -| `label list` | `labelId` / `labelName` | `label get --names` 或 `label list-members --id` | +| `label list` | `labelId` / `name` | `label get --names` 或 `label list-members --id` | | `label get` | `labelId` | `label list-members` 的 --id | | `dept search/list-children` | `deptId` | dept get-info/list-children/list-members 的 --dept/--depts | | `dept search/list-children` | `deptId` | dismission search 的 --depts | diff --git a/skills/mono/references/products/dev.md b/skills/mono/references/products/dev.md index b0a2a9ae..f6143ab3 100644 --- a/skills/mono/references/products/dev.md +++ b/skills/mono/references/products/dev.md @@ -118,11 +118,8 @@ dws dev app robot disable --unified-app-id --format json ## dev app credentials — 凭证 ```bash -# 查询 clientId/clientSecret +# 查询 clientId/clientSecret(credentials 下只有 get 子命令) dws dev app credentials get --unified-app-id --format json - -# 重置凭证(旧 secret 立即失效) -dws dev app credentials reset --unified-app-id --yes --format json ``` --- @@ -147,8 +144,8 @@ dws dev connect --channel opencode \ dws dev connect --daemon \ --robot-client-id --robot-client-secret -# 查看/停止后台连接器 -dws dev connect status --format json +# 查看/停止后台连接器(status/list 忽略全局 --format,出 JSON 用专属 --json) +dws dev connect status --json dws dev connect stop ``` @@ -208,7 +205,7 @@ dws dev app version status --unified-app-id --version-id ## 注意事项 -- **`clientSecret` 只在 `robot result` 返回一次**,务必立即保存;遗失需走 `credentials reset` 重置 +- **`clientSecret` 只在 `robot result` 返回一次**,务必立即保存;dws 只能 `credentials get` 读取,不支持重置,遗失后到开放平台控制台重置密钥 - 改配置后机器人不自动生效,需走 `version create → publish` 才上线 - `hermes`/`openclaw` 渠道走官方建联,`dws dev connect` 不代建机器人,会输出指引后退出 - 应用名在企业内唯一;`app list/get` 用 `--app-key` 过滤但不能定位单应用,定位单应用须用 `--unified-app-id` diff --git a/skills/mono/references/products/doc.md b/skills/mono/references/products/doc.md index 913016ac..b16cd4f2 100644 --- a/skills/mono/references/products/doc.md +++ b/skills/mono/references/products/doc.md @@ -27,7 +27,7 @@ dws doc permission --help ### 搜索 / 遍历文件(已迁移,不再是 doc 子命令) -> **弃用提示**:`dws doc search` 和 `dws doc list` 已迁移到 `drive` / `wiki`。真机上这两条虽仍能跑,但每次都会打印弃用警告,请改用下面的命令,命令详情见 [drive.md](./drive.md)。 +> **弃用提示**:`dws doc search` 和 `dws doc list` 已迁移到 `drive` / `wiki`。这两条虽仍能跑,但每次都会打印弃用警告,请改用下面的命令,命令详情见 [drive.md](./drive.md)。 | 旧命令(弃用) | 改用 | 场景 | |--------------|------|------| @@ -83,7 +83,7 @@ Flags: ### 创建其他类型文件 (表格/脑图/白板/多维表/画板/文件夹) -> **弃用提示**:`dws doc file create --type` 已弃用,真机每次执行都会打印 `deprecated, use 'dws wiki node create --type '`。创建各类型文件节点改用 `dws wiki node create`(与 intent-guide 一致);创建普通文件夹用 `dws drive mkdir`。 +> **弃用提示**:`dws doc file create --type` 已弃用,执行时会打印 `deprecated, use 'dws wiki node create --type '`。创建各类型文件节点改用 `dws wiki node create`(与 intent-guide 一致);创建普通文件夹用 `dws drive mkdir`。 ``` Usage: @@ -131,9 +131,30 @@ Flags: --convert 是否转换为钉钉在线文档 ``` +### 导入本地文件为在线文档 + +详见 [doc/doc-import.md](./doc/doc-import.md)。 + +``` +Usage: + dws doc import [flags] + dws doc import get [flags] +Example: + dws doc import --file ./report.docx + dws doc import --file ./notes.md --folder + dws doc import --file ./data.xlsx --workspace + dws doc import get --task-id +Flags: + --file string 本地文件路径 (必填) + --folder string 目标文件夹 ID 或 URL + --workspace string 目标知识库 ID 或 URL + --name string 导入后文档名称 (默认取文件名) + --task-id string 导入任务 ID (import get 必填) +``` + ### 下载文件到本地(已迁移到 drive) -> **弃用提示**:`dws doc download` 已迁移到 `dws drive download`(真机执行 `doc download` 会打印弃用警告)。下载已有文件(PDF/图片/附件等非在线文档)改用: +> **弃用提示**:`dws doc download` 已迁移到 `dws drive download`(执行 `doc download` 会打印弃用警告)。下载已有文件(PDF/图片/附件等非在线文档)改用: ``` dws drive download --node --output ~/downloads/ @@ -261,11 +282,11 @@ Usage: Example: dws doc comment list --node dws doc comment list --node --type inline --resolve-status unresolved - dws doc comment list --node --page-size 20 --next-token + dws doc comment list --node --limit 20 --cursor Flags: --node string 目标文档的标识,支持传入 URL 或 ID (必填) - --page-size int 每页返回的评论数量,默认 50,最大 50 - --next-token string 分页游标,从上一次请求的返回结果中获取 (首次请求不传) + --limit int 每页返回的评论数量,默认 50,最大 50 + --cursor string 分页游标,从上一次请求的返回结果中获取 (首次请求不传) --type string 按评论类型过滤: global (全文评论) / inline (划词评论) --resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决) ``` @@ -317,6 +338,22 @@ Flags: --mention string 被 @ 的用户 uid 列表,逗号分隔 ``` +### 更新文档评论 +```text +Usage: + dws doc comment update --node --comment-key --content [--mention uid1,uid2] +``` + +`commentKey` 从 `comment list/create/create-inline` 返回中获取。只有显式传入 `--mention` 时才更新被 @ 用户列表。 + +### 删除文档评论 +```text +Usage: + dws doc comment delete --node --comment-key --yes +``` + +删除评论不可恢复,必须先获得用户明确确认,再添加全局 `--yes`。 + ### 文件内容获取路由规则 > 当用户请求"分析/查看/读取某个文件内容"时,**必须先调用 `dws doc info` 获取文件元数据**,再根据返回的 `contentType` 和 `extension` 字段选择对应链路: @@ -618,6 +655,12 @@ Flags: - 上传 → `upload`(需本地文件路径) - 上传并转换 → `upload --convert` +用户说"导入文件/导入为在线文档/导入 Word/导入 Excel/导入 xmind/导入 Markdown/把本地文件转在线文档": +- 导入并转换为在线文档 → `doc import --file <本地路径>` +- 支持 docx/doc/xlsx/xls/md/txt/xmind/mark,文件大小不超过 20MB +- 如果用户指定知识库或文件夹,补充 `--workspace` 或 `--folder` +- 不要把本地文件内容先读出来再 `doc create/update`;应直接使用 `doc import` + 用户说"下载/导出/下载到本地/导出文档/导出为Word/导出为docx/把文档导出来": - **必须先判断目标文件类型**,再决定走 `doc export` 还是 `drive download`: - 在线文档(alidocs/adoc)→ **`doc export`**(内容级命令,格式转换后导出为 docx,未迁移) @@ -768,6 +811,20 @@ dws drive download --node --output ~/downloads/ # 如果是在线文档 (ALIDOC),用 doc export: dws doc export --node --output ~/downloads/ +# ── 工作流 5a: 导入本地文件为在线文档 ── + +# 导入到默认位置 +dws doc import --file ./report.docx --format json + +# 导入到指定文件夹 +dws doc import --file ./notes.md --folder --format json + +# 导入到知识库 +dws doc import --file ./data.xlsx --workspace --format json + +# 如果导入命令超时或中断,可用 import get 手动查询任务状态: +# dws doc import get --task-id + # ── 工作流 6: 上传附件并插入文档 ── # media insert 自动完成三步流程: @@ -852,18 +909,24 @@ dws doc comment create --node --content "这里需要补充数据来源 # 再将 userId 传入 --mention dws doc comment create --node --content "请确认这部分内容" --mention , --format json -# 4. 回复某条评论(commentKey 从 list 或 create 返回中获取) +# 4. 更新某条评论(可选 --mention) +dws doc comment update --node --comment-key --content "已按最新数据修正" --format json + +# 5. 删除某条评论(不可恢复,必须先确认) +dws doc comment delete --node --comment-key --yes --format json + +# 6. 回复某条评论(commentKey 从 list 或 create 返回中获取) dws doc comment reply --node --comment-key --content "已修改" --format json -# 5. 用表情回复评论 +# 7. 用表情回复评论 dws doc comment reply --node --comment-key --content "比心" --emoji --format json -# 6. 创建划词评论(针对文档中某段选中文本) +# 8. 创建划词评论(针对文档中某段选中文本) # 先获取块列表: dws doc block list --node --format json → 提取 blockId 和文本内容 # 确定选中文本在块内的起始偏移量 (start) 和结束偏移量 (end) dws doc comment create-inline --node --block-id --start 0 --end 10 --content "这里需要修改" --format json -# 7. 创建划词评论并附带引用原文 + @ 相关人 +# 9. 创建划词评论并附带引用原文 + @ 相关人 dws doc comment create-inline --node --block-id --start 5 --end 20 --content "请确认这部分" --selected-text "被选中的原文内容" --mention , --format json # ── 工作流 10: 导出在线文档为 docx ── @@ -882,12 +945,13 @@ dws doc export --node --output ./exported.docx | `drive list`(原 `doc list`,已弃用) | `nodes[].nodeId` / folder 类型 `nodeId` | read / info / update / block 的 --node;folder 用作 `--folder` | | `drive search`(原 `doc search`,已弃用) | 文档 `nodeId` / URL / `createTime` / `creatorUid` | read / info / update 的 --node;创建时间与创建者信息 | | `create` | `nodeId` | update / block 操作的 --node | +| `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 | diff --git a/skills/mono/references/products/doc/doc-comment.md b/skills/mono/references/products/doc/doc-comment.md index 50cc9dda..711b89a2 100644 --- a/skills/mono/references/products/doc/doc-comment.md +++ b/skills/mono/references/products/doc/doc-comment.md @@ -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) — 命令路由 + 场景索引 + 意图判断 + 工作流 @@ -15,11 +15,11 @@ Usage: Example: dws doc comment list --node dws doc comment list --node --type inline --resolve-status unresolved - dws doc comment list --node --page-size 20 --next-token + dws doc comment list --node --limit 20 --cursor Flags: --node string 目标文档的标识,支持传入 URL 或 ID (必填) - --page-size int 每页返回的评论数量 - --next-token string 下一页 token,从上一次请求的返回结果中获取 (首次请求不传) + --limit int 每页返回的评论数量,默认 50,最大 50 + --cursor string 分页游标,从上一次请求的返回结果中获取 (首次请求不传) --type string 按评论类型过滤: global (全文评论) / inline (划词评论) --resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决) ``` @@ -61,6 +61,39 @@ Flags: --- +## doc comment update(更新评论) + +```text +Usage: + dws doc comment update [flags] +Example: + dws doc comment update --node --comment-key --content "已按最新数据修正" + dws doc comment update --node --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 --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 --comment-key --content "已 # 表情回复(--content 填表情名称) dws doc comment reply --node --comment-key --content "比心" --emoji --format json +# 更新评论(可选 --mention) +dws doc comment update --node --comment-key --content "已修正" --format json + +# 删除评论(危险操作,需用户先明确确认) +dws doc comment delete --node --comment-key --yes --format json + # 划词评论(先 block list 取 blockId + paragraph.text,计算 start/end) dws doc block list --node --format json # 计算偏移后: diff --git a/skills/mono/references/products/doc/doc-file-ops.md b/skills/mono/references/products/doc/doc-file-ops.md index cabaaa11..6ed912b2 100644 --- a/skills/mono/references/products/doc/doc-file-ops.md +++ b/skills/mono/references/products/doc/doc-file-ops.md @@ -3,7 +3,7 @@ > **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件: > 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流 -> **弃用提示(文件管理命令正在迁移到 drive / wiki)**:本文所列 `doc` 文件管理命令在真机上虽仍能跑,但执行时会打印弃用警告,请优先改用 `drive` / `wiki` 对应命令: +> **弃用提示(文件管理命令正在迁移到 drive / wiki)**:本文所列 `doc` 文件管理命令虽仍能跑,但执行时会打印弃用警告,请优先改用 `drive` / `wiki` 对应命令: > - `doc download` → **`dws drive download`**(下载已有文件;在线文档导出 docx 仍走 `doc export`) > - `doc folder create` → **`dws drive mkdir`**(个人空间/钉盘)或 **`dws wiki node create --workspace --type folder`**(知识库内) > - `doc upload` / `doc copy` / `doc move` / `doc rename` / `doc delete` 也在同一迁移方向(→ `drive`),命令用法以 `drive` 为准。 diff --git a/skills/mono/references/products/doc/doc-import.md b/skills/mono/references/products/doc/doc-import.md new file mode 100644 index 00000000..bd6291de --- /dev/null +++ b/skills/mono/references/products/doc/doc-import.md @@ -0,0 +1,53 @@ +# 本地文件导入为在线文档 (doc import) + +## 使用场景 + +用户说"导入 Word/Excel/Markdown/xmind 到钉钉文档"、"把本地文件转成在线文档"、"导入到知识库/文件夹"时,使用 `dws doc import`。 + +不要先读取文件内容再调用 `doc create` 或 `doc update`。`doc import` 会按文件格式走导入任务,保留更完整的原始结构。 + +## 命令 + +```bash +dws doc import --file ./report.docx --format json +dws doc import --file ./notes.md --folder --format json +dws doc import --file ./data.xlsx --workspace --format json +dws doc import --file ./draft.md --name "项目周报" --format json +``` + +```bash +dws doc import get --task-id --format json +``` + +## 参数 + +| 参数 | 说明 | +|------|------| +| `--file` | 本地文件路径,必填 | +| `--folder` | 目标文件夹 ID 或 URL,可选 | +| `--workspace` | 目标知识库 ID 或 URL,可选 | +| `--name` / `-n` | 导入后的文档名称;不传时使用文件名 | +| `--task-id` | `import get` 查询导入任务时必填 | + +支持格式:docx、doc、xlsx、xls、md、txt、xmind、mark。文件大小上限 20MB。 + +## 工作流 + +1. 确认本地文件存在且格式受支持。 +2. 如果用户指定目标知识库或文件夹,传 `--workspace` 或 `--folder`;未指定时导入到默认位置。 +3. 执行 `dws doc import --file ... --format json`。 +4. 正常情况下 CLI 会自动提交、上传并轮询导入任务。 +5. 如果命令超时或中断,从输出中提取 `taskId`,再执行 `dws doc import get --task-id --format json`。 + +## 上下文传递 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `doc import` | `nodeId` / `documentUrl` / `documentName` / `documentType` | 后续 `doc read` / `doc info` / `sheet` 操作 | +| `doc import` 中断 | `taskId` | `doc import get --task-id` 查询任务状态 | + +## 注意事项 + +- `--folder` 和 `--workspace` 都是目标位置参数;用户明确指定文件夹时优先使用 `--folder`。 +- 导入 Excel 后通常得到在线表格,后续数据读取和编辑走 `dws sheet ...`。 +- 导入 Markdown 或 Word 后通常得到在线文档,后续内容读取和编辑走 `dws doc ...`。 diff --git a/skills/mono/references/products/doc/doc-list.md b/skills/mono/references/products/doc/doc-list.md index 21a62bb3..dff79267 100644 --- a/skills/mono/references/products/doc/doc-list.md +++ b/skills/mono/references/products/doc/doc-list.md @@ -1,6 +1,6 @@ # doc list(已弃用 → drive list / wiki node list) -> **弃用提示**:`dws doc list` 已迁移,不再是 `doc` 子命令。真机上它虽仍能跑,但每次都会打印弃用警告: +> **弃用提示**:`dws doc list` 已迁移,不再是 `doc` 子命令。它虽仍能跑,但每次都会打印弃用警告: > `'dws doc list' is deprecated, use 'dws drive list --workspace ' or 'dws wiki node list --workspace '`。 > 遍历文件夹/知识库请改用下面的命令,命令详情见 [`../drive.md`](../drive.md)。 diff --git a/skills/mono/references/products/doc/doc-search.md b/skills/mono/references/products/doc/doc-search.md index 3b259d53..bbd75afc 100644 --- a/skills/mono/references/products/doc/doc-search.md +++ b/skills/mono/references/products/doc/doc-search.md @@ -1,6 +1,6 @@ # doc search(已弃用 → drive search / wiki node search) -> **弃用提示**:`dws doc search` 已迁移,不再是 `doc` 子命令。真机上它虽仍能跑,但每次都会打印弃用警告: +> **弃用提示**:`dws doc search` 已迁移,不再是 `doc` 子命令。它虽仍能跑,但每次都会打印弃用警告: > `'dws doc search' is deprecated, use 'dws drive search' or 'dws wiki node search --workspace '`。 > 搜索文档/文件请改用下面的命令,命令详情见 [`../drive.md`](../drive.md)。 diff --git a/skills/mono/references/products/drive.md b/skills/mono/references/products/drive.md index 30b6a3c8..48507ba7 100644 --- a/skills/mono/references/products/drive.md +++ b/skills/mono/references/products/drive.md @@ -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 +``` + +返回节点可用的阅读、编辑、评论、点赞、预览或下载等统计维度;不同文件类型返回字段可能不同。本命令只读。 + +### 创建节点快捷方式 + +```text +Usage: + dws drive shortcut --node [--folder ] [--workspace ] +Example: + dws drive shortcut --node + dws drive shortcut --node --folder + dws drive shortcut --node --workspace +``` + +`--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 根目录时) | @@ -514,12 +540,13 @@ dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --f - 不传 `--space-id` 时默认使用「我的文件」空间 - 不传 `--folder` 时默认操作空间根目录 - `--folder` 只能使用父文件夹的 `dentryUuid`。不要把 `drive info` 返回的数字型 `dentryId` 当作父目录 -- **`--limit` 最大值为 50**,禁止传入超过 50 的值(如 `--limit 100`)。用户要求超过 50 条时,应使用 `--limit 50` 配合 `--cursor` 分页查询 +- **`--limit` 有效上限为 50**:CLI 不做本地校验,传 `--limit 100` 不会报错,但服务端每页最多返回 50 条,超出部分无效。用户要求超过 50 条时,应使用 `--limit 50` 配合 `--cursor` 分页查询 - `--order-by` 支持: `createTime`、`modifyTime`、`name` - **上传文件首选 `dws drive upload` 命令**;手动三步(`upload-info` → HTTP PUT → `commit`)仅用于自定义流式上传等特殊场景。手动三步时 HTTP PUT 必须把 upload-info 返回的 `headers` 全部回传,`Content-Type` 通常要留空;PUT 返回 200 后才能调 `commit`;`uploadId` 有过期时间,过期需重新 `upload-info`;`--folder` 在 upload-info / commit 中要保持一致 - `--file-name` 必须包含扩展名(如 `report.pdf`) - `download` 需要指定 `--output`,CLI 会把文件保存到本地路径或目录 - 文件名规则:头尾不能有空格;不能含 `*`、`"`、`<`、`>`、`|`、制表符;不能以 `.` 结尾 +- `shortcut` 会创建新节点,执行后必须通过 `drive list` 回读确认目标位置;`stats` 为只读命令 ## 自动化脚本 diff --git a/skills/mono/references/products/event.md b/skills/mono/references/products/event.md new file mode 100644 index 00000000..57283060 --- /dev/null +++ b/skills/mono/references/products/event.md @@ -0,0 +1,103 @@ +# dws event — 个人消息事件 + +监听当前用户收到的钉钉消息事件,NDJSON 输出到 stdout。实时监听 / 自动回复 / 订阅一律用 `dws event consume`,不要轮询消息历史。 + +## 运行方式 + +- bus 后台进程持有对钉钉的个人 Stream 长连;consume 从 bus 读事件、按 NDJSON 打到 stdout。consume 只读,不发消息(回复用 `dws chat message send`)。 +- 没有 bus 时 consume 自动拉起;通常只跑 consume。 +- 一个组织一个 bus,互不干扰、可同时跑;同组织内多个 consume 共享一个 bus。 +- 非默认组织加全局 `--profile `;漏传会退回默认 profile 而失败。 + +## Core commands + +| Command | Purpose | +|---|---| +| `dws event schema ` | 查看事件参数和输出字段 schema | +| `dws event consume [flags]` | 阻塞消费,事件写到 stdout,用 `-f ndjson` | +| `dws event status --event ` | 查看个人订阅、bus、本地 consume | +| `dws event stop ` | 取消订阅并停止对应本地消费 | +| `dws event stop --all` | 清理当前身份下全部个人订阅 | + +注意区分两个 schema:`dws event schema ` 查事件的输出字段;`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` | + +只承认这 3 个事件码。默认身份即当前用户,不加身份切换 flag。 + +## Intent mapping + +| 用户说 | 下一步 | +|---|---| +| 监听有人 @ 我 | consume `user_im_message_receive_at` | +| 监听我和某用户的单聊 | 解析对端 userId,consume `user_im_message_receive_o2o --user ` | +| 监听某群 | 先 `dws chat search --query "<群名>"` 拿 openConversationId,再 consume group | +| 监听并自动回复某人单聊 | 解析对端 userId,启动 o2o consume;回复用 `dws chat message send` | +| 查看事件 schema | `dws event schema ` | +| 看订阅状态 | `dws event status --event ` | +| 停止订阅 | `dws event stop ` | + +多候选让用户确认。缺必填 ID 且解析不出先追问,不要猜。 + +## Call flow + +1. 按意图选事件码;人名 / 群名先解析成必填 ID。 +2. 需要字段时 `dws event schema `,读 `jq_root_path` 和 `schema.properties`。 +3. 启动 `dws event consume ... -f ndjson`,等 stderr 出现 `[event] ready event_key= ...` 再读 stdout,不要 sleep。 +4. stdout 每行一个事件 JSON;`data` 字段是 JSON 字符串,按 `jq_root_path` 再 parse。 +5. `dws event status --event ` 看 Subscriptions / Consumers。 +6. `dws event stop ` 取消订阅;自测可加 `--max-events` / `--duration` 自动退出。 + +## Commands + +```bash +dws event schema user_im_message_receive_o2o + +dws event consume user_im_message_receive_at -f ndjson +dws event consume user_im_message_receive_o2o --user 507971 -f ndjson +dws event consume user_im_message_receive_group --group -f ndjson + +dws event status --event user_im_message_receive_o2o +dws event stop +dws event stop --all +``` + +## Subprocess contract + +- 就绪:连上后 stderr 打 `[event] ready event_key= bus_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 `。不要 `kill -9`(跳过退订、泄漏服务端订阅)。 +- 一 consume 一 event_key;监听 N 个就起 N 个 consume,共用一个 bus。 + +## Output parsing + +- 用 `-f ndjson`,一行一个事件 JSON。抓样本用 `-f json --max-events 1`。 +- 两层解析:外层事件 JSON 的 `data` 字段是 JSON 字符串,`fromjson` 后取 `payload.body.content`(正文)/ `payload.body.sender`(发送人)/ `payload.body.openConversationId`(会话)。样例: + +```json +{"type":"event","event_type":"user_im_message_receive_group", + "data":"{\"payload\":{\"body\":{\"sender\":\"张三\",\"content\":\"你好\",\"openConversationId\":\"cid...==\"}},\"subject\":{\"isSelfLoop\":false}}"} +``` + +- 自己发的消息不作为事件回来(`isSelfLoop` 过滤):边听边回不成环;自发验证会看到 0 事件,测投递用别人 / 机器人发。 +- `--jq <表达式>` 把过滤 / 投影下推到 consume,减少输出。 +- `--debug-raw-events` 仅联调用。 + +## Troubleshooting + +- consume 报 bus 启动失败:报错已带子进程真实原因。多为登录问题,`dws --profile auth status` 看登录态(非默认组织带对 `--profile`),过期就 `auth login` 重登。 +- 本地日志:`~/.dws/events//personal_stream//bus.log`(`edition` 一般 `open`,`hash` 见 `dws event status` 的 Workdir);极早期失败可能无日志,以 consume 报错为准。 +- 有残留 / 连不上:`dws event status` 查 stale,`dws event stop --all` 清理重试。 +- 挂住无输出:多是误加 `--foreground`(跑 bus、不打印事件),去掉。 + +## Full reference + +- multi skill: `skills/multi/dingtalk-event/SKILL.md` +- IM reference: `skills/multi/dingtalk-event/references/event-im.md` diff --git a/skills/mono/references/products/mail.md b/skills/mono/references/products/mail.md index 4a822165..bea9ebdb 100644 --- a/skills/mono/references/products/mail.md +++ b/skills/mono/references/products/mail.md @@ -5,6 +5,7 @@ | 命令 | 功能简述 | |------|----------| | `dws mail mailbox list` | 查询**当前用户自己**的可用邮箱列表 | +| `dws mail mailbox profile` | 获取用户邮箱详细信息(容量、别名等) | | `dws mail message list` | 列出指定文件夹中的邮件(默认收件箱) | | `dws mail message search` | 搜索邮件(KQL 语法,按主题/发件人/日期等) | | `dws mail message get` | 查看邮件完整内容(含正文) | @@ -15,7 +16,10 @@ | `dws mail message batch-move` | 批量移动邮件到指定文件夹 | | `dws mail message batch-delete` | 批量删除邮件 | | `dws mail message batch-update` | 批量修改邮件状态(标记已读/未读/添加标签/移除标签) | +| `dws mail message batch-get` | 批量获取邮件详情(最多 20 封) | | `dws mail message verify` | 根据 internetMessageId 查询邮件发送状态 | +| `dws mail sent-message recall` | 撤回已发送的邮件(仅支持同组织内未读邮件) | +| `dws mail sent-message recall-detail` | 查询邮件撤回进度 | | `dws mail draft create` | 创建草稿(保留在草稿箱,不发送) | | `dws mail draft update` | 更新草稿内容(保留在草稿箱,不发送) | | `dws mail draft send` | 发送草稿箱中已有的草稿 | @@ -46,6 +50,13 @@ | `dws mail contact update` | 更新个人邮件联系人信息 | | `dws mail contact batch-delete` | 批量删除个人邮件联系人 | | `dws mail auto-reply get` | 获取用户的自动回复配置 | +| `dws mail auto-reply update` | 更新/设置用户的自动回复配置 | +| `dws mail allow-list list` | 列出个人收信白名单 | +| `dws mail allow-list add` | 添加个人收信白名单 | +| `dws mail allow-list remove` | 移除个人收信白名单 | +| `dws mail block-list list` | 列出个人收信黑名单 | +| `dws mail block-list add` | 添加个人收信黑名单 | +| `dws mail block-list remove` | 移除个人收信黑名单 | | `dws mail rule list` | 列出个人收信规则 | | `dws mail rule create` | 创建个人收信规则 | | `dws mail rule update` | 更新个人收信规则 | @@ -93,6 +104,27 @@ Example: |------|------|------| | `emailAccounts` | `List[]` | 邮箱列表,每条包含 `email`(邮箱地址)、`orgName`(所属企业,个人邮箱为 null)、`type`(`ORG`=企业邮箱 / `PERSONAL`=个人邮箱) | +### 获取用户邮箱信息 + +``` +Usage: + dws mail mailbox profile [flags] +Example: + dws mail mailbox profile --email user@company.com +Flags: + --email string 用户的邮箱地址 (必填) +``` + +**返回字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `email` | string | 邮箱地址 | +| `emailAliases` | List[] | 邮件地址别名列表 | +| `name` / `nickname` / `displayName` | string | 用户名、昵称、显示名 | +| `mboxSize` / `mboxSizeUsed` | int64 | 邮箱容量与已用容量(字节) | +| `createdTime` / `modifiedTime` | string | 创建时间与更新时间 | + ### 查找他人邮箱地址(通讯录查人) > **这不是 `mailbox list`。** 当需要获取**某人**的邮箱地址时,必须走以下三路并发查询,取最先返回有效邮箱的结果。禁止臆测邮箱地址。 @@ -214,7 +246,24 @@ Flags: | 字段 | 类型 | 说明 | |------|------|------| -| `message` | `object` | 邮件完整信息,包含主题、发件人、收件人、正文、附件等 | +| `message` | `object` | 邮件信息,含 subject/from/toRecipients/ccRecipients/markdownBody/conversationId/receivedDateTime/size 等 | + +> **注意:** 即使邮件确有附件,`message get` 返回的 `message` 对象也**不含** `attachments`/`isRead`/`tags` 字段(实测 keys 仅上表所列这些)。要取附件请另用 `dws mail attachment list`。 + +### 批量获取邮件详情 + +``` +Usage: + dws mail message batch-get [flags] +Example: + dws mail message batch-get --email user@company.com --ids , + dws mail message batch-get --email user@company.com --ids ,, +Flags: + --email string 邮件所属邮箱地址 (必填) + --ids string 要获取的邮件 ID 列表,逗号分隔,最多 20 个 (必填) +``` + +单次最多获取 20 封邮件。CLI 会逐个调用 `get_email_by_message_id`,并以 `messages` 数组聚合返回。 ### 发送邮件 ``` @@ -933,6 +982,53 @@ Flags: | `failed` | 发送失败 | | `unknown` | 未知状态 | +### [危险] 撤回已发送的邮件 + +``` +Usage: + dws mail sent-message recall [flags] +Example: + dws mail sent-message recall --email user@company.com --id --subject "邮件主题" --yes +Flags: + --email string 发件人邮箱地址 (必填) + --id string 要撤回的邮件 ID (必填) + --subject string 邮件主题 (必填) + --yes 跳过确认提示,直接执行 (可选) +``` + +> CAUTION: 此命令会撤回已发送邮件。仅支持撤回同组织内未读邮件,执行前必须确认目标邮件、主题和影响范围。 + +**返回字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | string | 撤回任务 ID,可用于 `recall-detail` 查询进度 | +| `success` | boolean | 接口调用是否成功 | +| `errorCode` / `errorMsg` | string | 错误码和错误信息(仅失败时存在) | + +### 查询邮件撤回进度 + +``` +Usage: + dws mail sent-message recall-detail [flags] +Example: + dws mail sent-message recall-detail --email user@company.com --id +Flags: + --email string 用户的邮箱地址 (必填) + --id string 撤回任务 ID (必填),由 recall 命令返回 +``` + +根据撤回任务 ID 查询邮件撤回进度。任务状态包括 `UNINITED` / `SUBMITTED` / `RUNNING` / `FINISHED` / `CANCELED` / `FAILED`。 + +**返回字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | string | 撤回任务 ID | +| `status` | string | 任务状态 | +| `totalCount` / `succeededCount` / `failedCount` | int | 总数、成功数、失败数 | +| `details` | List[] | 每封邮件的撤回结果 | + ### 创建草稿 ``` Usage: @@ -1218,6 +1314,61 @@ Flags: | `scope` | string | 回复范围: "contact"(仅联系人) 或 "all"(所有人) | | `content` | string | 自动回复内容 | +### 更新自动回复配置 + +更新或设置用户的邮件自动回复配置。建议先通过 `auto-reply get` 获取当前配置,再传入需要修改的字段值。 + +``` +Usage: + dws mail auto-reply update [flags] +Example: + dws mail auto-reply update --email user@company.com --enabled true \ + --start "2026/07/01 09:00:00 +0800" --end "2026/07/07 18:00:00 +0800" \ + --scope all --content "出差中,请稍后联系" + dws mail auto-reply update --email user@company.com --enabled false \ + --start "2026/07/01 09:00:00 +0800" --end "2026/07/07 18:00:00 +0800" \ + --scope all --content "已关闭自动回复" +Flags: + --email string 用户的邮箱地址 (必填) + --enabled string 是否启用自动回复: true/false (必填) + --start string 自动回复开始时间,格式: YYYY/MM/DD HH:MM:SS +ZZZZ (必填) + --end string 自动回复结束时间,格式: YYYY/MM/DD HH:MM:SS +ZZZZ (必填) + --scope string 回复范围: contact(仅联系人)/all(所有人) (必填) + --content string 自动回复内容 (必填) +``` + +### 个人收信白名单管理 + +``` +Usage: + dws mail allow-list list [flags] + dws mail allow-list add [flags] + dws mail allow-list remove [flags] +Examples: + dws mail allow-list list --email user@company.com + dws mail allow-list add --email user@company.com --entries a@b.com,@example.com + dws mail allow-list remove --email user@company.com --entries a@b.com,@example.com +Flags: + --email string 用户的邮箱地址 (必填) + --entries string 逗号分隔的地址列表,支持邮件地址或 @domain.com 域名(add/remove 必填) +``` + +### 个人收信黑名单管理 + +``` +Usage: + dws mail block-list list [flags] + dws mail block-list add [flags] + dws mail block-list remove [flags] +Examples: + dws mail block-list list --email user@company.com + dws mail block-list add --email user@company.com --entries spam@bad.com,@junk.com + dws mail block-list remove --email user@company.com --entries spam@bad.com,@junk.com +Flags: + --email string 用户的邮箱地址 (必填) + --entries string 逗号分隔的地址列表,支持邮件地址或 @domain.com 域名(add/remove 必填) +``` + ### 收信规则管理 #### 列出收信规则 @@ -1364,6 +1515,8 @@ Flags: --actions string 规则动作 JSON 数组 (必填) ``` +> **⚠️ 实测:** 尽管 `--help` 把 `--conditions` 标为(可选),服务端实际要求它**非空**——省略或传空会返回 `209 SYS_UNKNOWN_ERROR(1001)` 失败,帮助文本承诺的"命中所有邮件"无条件匹配**不可用**。create 请始终传入有效条件。 + **返回字段:** | 字段 | 类型 | 说明 | @@ -1375,11 +1528,11 @@ Flags: #### 更新收信规则 -更新已有的收信规则。**除 `--conditions` 外所有参数均为必填**。 +更新已有的收信规则。**所有参数(含 `--conditions`)均须传入有效值**——见下方警告。 > **建议工作流:** 先通过 `dws mail rule list` 获取当前规则的完整配置,再传入需要修改的字段值。 > -> `--conditions` 为空或不传表示命中所有邮件(无条件匹配)。`--actions` 格式同 create 命令。 +> **⚠️ 实测:** `--help` 称 `--conditions` 为空即命中所有邮件,但该无条件匹配**不可用**——省略或传空会返回 `209 SYS_UNKNOWN_ERROR(1001)` 失败。update 请始终传入有效条件。`--actions` 格式同 create 命令。 ``` Usage: @@ -1399,6 +1552,8 @@ Flags: --actions string 规则动作 JSON 数组 (必填) ``` +> **⚠️ 实测:** 上方 `--conditions` 的"(可选,为空表示命中所有邮件)"是 `--help` 原文,但服务端实际要求它**非空**——省略或传空会返回 `209 SYS_UNKNOWN_ERROR(1001)` 失败。update 须传入有效条件。 + **返回字段:** | 字段 | 类型 | 说明 | @@ -1467,9 +1622,11 @@ Flags: ## 意图判断 用户说"我的邮箱/邮箱地址" → `mailbox list`(**仅限查询自己的邮箱,不能查他人**) +用户说"邮箱详情/邮箱容量/邮箱别名/邮箱 profile" → `mailbox profile --email <邮箱>` 用户说"获取/查找/得到 某人的邮箱地址" → **不是 `mailbox list`**,走三路并发查询流程(见「查找他人邮箱地址」章节) 用户说"找邮件/搜邮件/查邮件" → `message search` 用户说"看邮件/打开邮件/邮件内容" → 先 `message search` 获取 messageId,再 `message get` +用户说"批量查看邮件详情/批量打开这些邮件/按多个邮件ID取详情" → `message batch-get --ids `(单次最多 20 个) 用户说"发邮件/写邮件" → 先 `mailbox list` 获取发件地址,再 `message send` 用户说“给(某人名字)发邮件” / “查询某人发给我的邮件” / “查询发给某人的邮件” / 任何涉及按人名查找邮箱的场景 → **第一步**:并发同时发起以下三路查询,取最先返回有效邮箱的结果;若三路均无有效邮箱,ask_human 请用户提供,禁止臆测: @@ -1496,6 +1653,11 @@ Flags: 用户说"删除会话/把会话放入已删除/批量删除会话" → 单条用 `thread trash`,多条用 `thread batch-trash`;传入的是会话 ID,不是邮件 ID 用户说"批量标记邮件已读/未读/给邮件加标签/移除邮件标签" → `message batch-update --action markRead/markUnread/addTags/removeTags`(操作对象是邮件 ID;会话级操作用 `thread update/batch-update`) 用户说"邮件发出去了吗/查邮件发送状态/确认邮件是否发送成功/邮件投递结果" → 用发送类命令返回的 `internetMessageId`,调用 `message verify` 查询 `sendStatus` +用户说"撤回邮件/撤回已发送邮件" → `sent-message recall --email <邮箱> --id --subject "<主题>" --yes`(危险操作,先确认) +用户说"查询邮件撤回进度/撤回任务状态" → `sent-message recall-detail --email <邮箱> --id ` +用户说"设置/更新/关闭自动回复" → `auto-reply update`(所有字段必填,先 `auto-reply get` 获取当前配置) +用户说"收信白名单/添加白名单/移除白名单" → `allow-list list/add/remove` +用户说"收信黑名单/添加黑名单/移除黑名单" → `block-list list/add/remove` 用户说"查看会话/获取会话/看这封邮件的会话" → 先 `message search` 或 `message get` 获取邮件中的 `conversationId`,再 `thread get` 用户说"搜索/查找/联系 邮箱用户/联系人/某人的邮箱地址" → `user search`(搜索通讯录人员,不是搜邮件内容) 用户说"发送草稿/把草稿发出去/发这封草稿" → 先 `message search --query "folderId:5"` 找到草稿 messageId,再 `draft send` @@ -1612,7 +1774,9 @@ dws mail thread get --email user@company.com --id --select mess | 操作 | 从返回中提取 | 用于 | |------|-------------|------| | `mailbox list` | 邮箱地址 | message search/get/send/thread get 的 --email/--from | +| `mailbox profile` | `emailAliases` / `mboxSize` / `mboxSizeUsed` | 展示邮箱别名与容量信息 | | `message search` | `messageId` | message get 的 --id | +| `message search` | `messageId` | message batch-get 的 --ids | | `message search` | `conversationId` | thread get 的 --id | | `message search` | `messageId` | attachment list 的 --id | | `attachment list` | `attachments[].id` / `attachments[].name` | attachment download 的 --attachment-id / --name | @@ -1627,6 +1791,8 @@ dws mail thread get --email user@company.com --id --select mess | `aisearch person` → `contact user get` / `contact user search` / `mail user search` | 用户邮箱 (orgAuthEmail / email) | message send 的 --to/--cc(三路并发,取先到结果) | | `user search` | 用户邮箱 (email) | message send 的 --to/--cc | | `message send` / `draft send` / `message reply` / `message reply-all` / `message forward` | `internetMessageId` | `message verify` 的 --internet-message-id | +| `sent-message recall` | `id` | sent-message recall-detail 的 --id | +| `auto-reply get` | `enabled` / `startTime` / `endTime` / `scope` / `content` | auto-reply update 的当前配置基线 | ## 注意事项 diff --git a/skills/mono/references/products/minutes.md b/skills/mono/references/products/minutes.md index eef1999a..dc1ce7f4 100644 --- a/skills/mono/references/products/minutes.md +++ b/skills/mono/references/products/minutes.md @@ -26,7 +26,7 @@ dws minutes │ ├── shared [--query] [--start] [--end] [--max] [--next-token] # 仅查询他人共享给我的听记(不含我自己创建的) │ └── all [--query] [--start] [--end] [--max] [--next-token] # 查询我可访问的所有听记(= mine ∪ shared,覆盖面最广,默认 scope) ├── get # 获取听记详情(get 后必须跟子命令名) -│ ├── info --id # 获取听记基础信息(标题、时长、参与人、状态等) +│ ├── info --id # 获取听记基础信息(标题、时长、开始时间、taskUuid、访问链接;不含参会人) │ ├── summary --id # 获取听记 AI 摘要(结构化纪要) │ ├── transcription --id # 获取听记语音转写原文(逐字稿,支持分页) │ ├── keywords --id # 获取听记关键词 @@ -618,7 +618,7 @@ AI 基于已拉取的转写数据,**本地完成**聚类与摘要(无需新 > **铁律 2:严禁用 AI 摘要里的"参与人/参会人"字段判断某人是否参会** > - AI 摘要中的"参与人"字段往往**只截取最显著的 1-2 个名字**(通常是创建人或发言最多的人),**不是完整参会人列表** > - "AI 摘要参与人只有故愚 → 灵麦没参会" 是典型的错误推理链 -> - 如果一定要列出参会人,应使用 `dws minutes get info` / `get batch` 返回的 `participants` 字段,而不是摘要文本里的描述 +> - 如果一定要列出参会人,只能从 `get transcription` 的转写发言人(`speakerName`/`speakerId`)汇总——`get info` / `get batch` **均不返回** `participants` 结构化字段(get info 只返回 duration/startTime/taskUuid/title/url,get batch 只返回 creatorNick/creatorUnionId/status 等),别指望从它们拿参会名单 > > **铁律 3:通讯录查询是 Step 4 的强制起点,不依赖 Step 3 是否成功** > - Step 2 一旦返回"未命中真名标注",**Step 3 与 Step 4 ① 必须并发触发**,不要等 Step 3 失败再补查 @@ -667,7 +667,7 @@ Step 7: 引导用户替换发言人(调用 speaker replace 写回听记) > - **未命中真名标注 ≠ 目标人物没参会**——绝大多数听记的发言人都是匿名编号,"未命中"是**默认场景**,恰恰是发言人识别功能要解决的问题 > - **不要在转写文本里 grep 目标人名**作为存在性判断——花名/真名通常不会出现在 TA 自己的发言里(参见铁律 1) > - **不要把 `dws minutes get summary` 摘要文本里写到的"参与人"当作完整参会列表**——AI 摘要里的"参与人"字段只截取最显著的 1-2 个名字,不是完整名册(参见铁律 2) -> - 唯一能下"目标人物没参会"结论的场景是:`get info` / `get batch` 返回的结构化 `participants` 字段里**完整列出参会人且不含目标**,且 `dws contact user search` 也搜不到该人 → 才允许告知用户"该人不在参会列表" +> - 听记各接口都拿不到权威的完整参会名册:`get info` / `get batch` **均不返回** `participants` 字段,转写发言人列表又常是匿名编号。因此**几乎无法**仅凭听记数据下"目标人物没参会"的结论;只有当转写发言人已全部实名标注且不含目标、且 `dws contact user search` 也搜不到该人时,才可谨慎告知"未在可见发言人中找到该人" ##### Step 3: 转写原文内推断(优先) @@ -813,7 +813,7 @@ Flags: --ids string 听记 taskUuid 列表,逗号分隔 (必填) ``` -返回字段: 听记标题、时长、参与人列表、创建时间、taskUuid、听记状态 +返回字段: `result.minutesDetails` 数组,每项含 `title`(标题)、`durationMicros`(时长, 微秒)、`startTime`(开始时间, 毫秒)、`taskUuid`、`status`(状态)、`creatorNick`(创建人昵称)、`creatorUnionId`、`size`、`bizType`、`isDeleted`。**不返回参会人列表**。 ### 修改听记标题 ``` @@ -2299,7 +2299,7 @@ Step 2 **[禁止]** 看到全是匿名编号 → 没有继续走 Step 3-4,反 1. **"在转写里搜不到目标人名"绝不构成"没参会"的证据**:花名/真名通常不出现在 TA 自己的发言里,这是默认场景而非例外。99% 的听记发言人都是匿名编号——这正是发言人识别功能要解决的问题。 -2. **AI 摘要的"参与人"字段是低保真信号,不能作为参会判断依据**:`get summary` 返回的是 AI 生成的自然语言摘要,里面提到的"参与人"通常只是最显著的 1-2 人;要拿完整参会列表,应使用 `get info` / `get batch` 返回的结构化 `participants` 字段。 +2. **AI 摘要的"参与人"字段是低保真信号,不能作为参会判断依据**:`get summary` 返回的是 AI 生成的自然语言摘要,里面提到的"参与人"通常只是最显著的 1-2 人。而 `get info` / `get batch` **均不返回**结构化 `participants` 字段,无法从中拿完整参会列表;能拿到的只有 `get transcription` 的转写发言人(常为匿名编号)。 3. **`dws contact user search` 是 Step 4 ① 的必跑项,单次调用就能突破死局**:本案例的整个失败链路只要有 1 次通讯录查询就能立即扭转——拿到"木兰=王佳明,产品设计部"后,Step 5 的角色匹配就有了锚点,再也不会得出"没参会"的错误结论。 diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md index c44c049e..643b0ac7 100644 --- a/skills/mono/references/products/oa.md +++ b/skills/mono/references/products/oa.md @@ -314,7 +314,7 @@ Flags: 用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject` 用户说"撤回审批/取消审批" → `approval revoke` 用户说"审批记录/操作历史" → `approval records` -用户说"我发起的审批" → `approval list-initiated`(需 --process-code,可从 list-forms 或 detail 获取) +用户说"我发起的审批" → `approval list-initiated`(需 --process-code,可从 list-forms / search-forms / detail 获取) 用户说"有哪些审批表单/可见表单" → `approval list-forms` 用户说"我有哪些待审的任务" → `approval tasks` 用户说"我发起的审批单" -> `approval list-submitted` @@ -351,7 +351,7 @@ dws oa approval records --instance-id --format json # 7. 获取可见审批表单(得到 processCode) dws oa approval list-forms --cursor 0 --limit 100 --format json -# 8. 查看自己发起的审批列表(--process-code 来自 list-forms 或 detail) +# 8. 查看自己发起的审批列表(--process-code 来自 list-forms / search-forms / detail) dws oa approval list-initiated --process-code \ --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" \ --cursor 0 --limit 20 --format json @@ -383,6 +383,7 @@ dws oa approval oa-cc-noticer --instance-id --users "userId1 | `tasks` | `taskId` | approve / reject / redirect-task 的 --task-id | | `detail` | `processCode` | list-initiated 的 --process-code | | `list-forms` | `processCode` | list-initiated 的 --process-code | +| `search-forms` | `processCode` | list-initiated 的 --process-code | ## 注意事项 @@ -391,7 +392,7 @@ dws oa approval oa-cc-noticer --instance-id --users "userId1 - `redirect-task` 的 `--to-actioner-id` 可通过 `dws contact user search` 获取目标用户 userId - `revoke` 只能撤销自己发起的审批 - `--remark` 审批意见虽为可选,但建议填写以留存审批痕迹 -- `list-initiated` 的 `--process-code` 可从 `list-forms` 或 `detail` 返回中提取 +- `list-initiated` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取。当 `list-forms` 返回 `processCodeList` 为空(`totalCount -1`)时,用 `search-forms --query <表单名>`(如 `--query 报销`)按名称精准拿 `processCode` 更稳 - `list-initiated` 的 `--start` / `--end` 区间有后端上限(约 120 天)。超过上限会返回误导性的 `business_error: 时间戳无效`(实为区间过长,不是时间格式问题)。跨度大时请拆成多段短区间分别查询 ## 自动化脚本 diff --git a/skills/mono/references/products/sheet.md b/skills/mono/references/products/sheet.md index 567c4278..a7310028 100644 --- a/skills/mono/references/products/sheet.md +++ b/skills/mono/references/products/sheet.md @@ -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` | 合并单元格 | @@ -108,6 +116,8 @@ dws sheet filter-view --help | `sheet update-dimension` | 更新行/列属性(显隐/行高/列宽) | | `sheet move-dimension` | 移动行或列到指定位置 | | `sheet add-dimension` | 在末尾追加空行或空列 | +| `sheet group-dimension` | 创建行/列分组 | +| `sheet ungroup-dimension` | 取消行/列分组 | | `sheet media-upload` | 上传附件到表格 | | `sheet write-image` | 上传图片并写入单元格 | | `sheet create-float-image` | 创建浮动图片 | @@ -151,12 +161,13 @@ 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 | +| 行列操作 | [sheet/sheet-dimension-operations.md](./sheet/sheet-dimension-operations.md) | insert / delete / update / move / add-dimension / group-dimension / ungroup-dimension | | 样式与合并 | [sheet/sheet-style-format.md](./sheet/sheet-style-format.md) | range set-style / batch-set-style / merge-cells / unmerge-cells | | 条件格式 | [sheet/sheet-conditional-format.md](./sheet/sheet-conditional-format.md) | cond-format list / create / update / delete | | 浮动图表 | [sheet/sheet-chart.md](./sheet/sheet-chart.md) | chart list / create / update / delete | @@ -254,6 +265,11 @@ dws sheet filter-view --help - 注意与 `append`(追加数据行)区分:`add-dimension` 追加的是空行/空列,`append` 追加的是带数据的行 - 请勿用 `range update` 写空数据来模拟追加,`add-dimension` 直接扩展表格维度 +用户说"创建行分组/创建列分组/折叠几行/折叠几列/取消行列分组": +- 创建分组 → `group-dimension --range <整行或整列范围>` +- 取消分组 → `ungroup-dimension --range <整行或整列范围>` +- `--range` 只能传整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`,不要传 `A1:C5` + ### 单元格格式 用户说"设置样式/改颜色/设背景色/加粗/居中/换行/字体颜色/字号": @@ -1099,6 +1115,38 @@ Flags: 在工作表末尾追加指定数量的空行或空列。 +### 创建行或列分组 +``` +Usage: + dws sheet group-dimension [flags] +Example: + dws sheet group-dimension --node --sheet-id --range "3:7" + dws sheet group-dimension --node --sheet-id --range "C:F" --group-state fold + dws sheet group-dimension --node --sheet-id --range "Sheet1!3:7" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --range string 整行/整列范围,如 "3:7" 或 "C:F" (必填) + --group-state string 创建后的分组状态: expand 或 fold,默认 expand +``` + +在工作表中为连续行或连续列创建分组。`--range` 必须是整行或整列范围,不支持普通单元格矩形范围。 + +### 取消行或列分组 +``` +Usage: + dws sheet ungroup-dimension [flags] +Example: + dws sheet ungroup-dimension --node --sheet-id --range "3:7" + dws sheet ungroup-dimension --node --sheet-id --range "C:F" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --range string 整行/整列范围,如 "3:7" 或 "C:F" (必填) +``` + +取消指定连续行或连续列的分组。 + ### 取消合并单元格 ``` Usage: @@ -1581,7 +1629,7 @@ Flags: - MCP 返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试 - 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试 -**限制**:仅支持钉钉在线电子表格(alxs)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。 +**限制**:仅支持钉钉在线电子表格(axls)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。 ### 表格模板(list / search / apply) ``` @@ -1903,6 +1951,8 @@ dws sheet export --node --output ./ | `replace` | `replaceCount` 被替换的单元格数量 | 确认替换结果 | | `move-dimension` | `sheetId` 工作表 ID | 确认操作完成 | | `add-dimension` | `sheetId` 工作表 ID | 确认操作完成 | +| `group-dimension` | `range` / `level` / `groupState` | 确认行列分组层级和展开状态 | +| `ungroup-dimension` | `range` / `level` | 确认行列分组已取消 | | `unmerge-cells` | `sheetId` 工作表 ID | 确认操作完成 | | `set-dropdown` | `range` 实际设置范围、`optionCount` 选项数量、`enableMultiSelect` 是否多选 | 确认下拉列表设置成功 | | `get-dropdown` | `hasDropdown` 是否存在下拉、`dataValidations` 下拉配置列表(含 `conditionValues`、`ranges`、`options`) | 查看已有下拉配置 | @@ -2032,6 +2082,9 @@ dws sheet export --node --output ./ - `add-dimension` vs `range update`:需要在末尾追加空行/空列时,必须使用 `add-dimension` 命令,禁止用 `range update` 写空数据来模拟追加效果 - `add-dimension` 追加的是空行/空列,与 `append`(追加带数据的行)不同 - `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000 +- `group-dimension` / `ungroup-dimension` 的 `--range` 只接受整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`,不支持 `A1:C5` +- `group-dimension` 的 `--group-state` 只接受 `expand` 或 `fold`,默认 `expand` +- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;需要创建后立即折叠时,优先使用独立 `group-dimension --group-state fold` - `unmerge-cells` 取消指定范围内所有合并单元格,使用 A1 表示法指定范围 - `set-dropdown` 在指定范围内设置下拉列表,`--options` 为 JSON 数组,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB` 格式)。选项值不能包含英文逗号。`--multi-select` 启用多选模式。如果目标范围已存在下拉列表,会被新配置覆盖 - `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。无下拉列表时 `hasDropdown` 为 false @@ -2067,7 +2120,7 @@ dws sheet export --node --output ./ - `filter-view delete-criteria` 仅清除指定列的条件,不会删除整个筛选视图。如需删除整个筛选视图,请使用 `filter-view delete` - `filter-view delete-criteria` 如果指定列没有设置筛选条件,调用不会报错 - 筛选视图相关操作需要"可阅读"权限(list / info / list-criteria / get-criteria)或"可编辑"权限(create / update / delete / update-criteria / delete-criteria),不支持跨组织操作 -- ★ `export` 仅支持钉钉在线电子表格(alxs)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` +- ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` - ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令 - `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可 - ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试 diff --git a/skills/mono/references/products/sheet/sheet-batch-operations.md b/skills/mono/references/products/sheet/sheet-batch-operations.md index ce79f719..d354af92 100644 --- a/skills/mono/references/products/sheet/sheet-batch-operations.md +++ b/skills/mono/references/products/sheet/sheet-batch-operations.md @@ -26,6 +26,7 @@ - 需要对**多个**不同区域执行 `merge-cells` / `unmerge-cells` 时(如按分组合并多列相同内容) - 需要对**多个**不同区域执行 `update-dimension` 时(如统一调整多列列宽或多行行高,含 hidden + pixelSize) - 需要先插入行列再写入数据时(`add-dimension` + `range update` / `csv-put`) +- 需要批量创建或取消行列分组时(`group-dimension` / `ungroup-dimension`) - 需要对多个区域执行不同写入操作时(多次 `range update` + `range clear` 等组合) 当同一工具需要对多个区域重复调用时,**推荐**改用 `batch-update` 合并为单次请求——`batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。 @@ -80,6 +81,7 @@ Flags: toolName 用 CLI 命令名,input 的键用 CLI flag 名去掉 --: range clear / range update / merge-cells / unmerge-cells / update-dimension range fill / range copy-to / add-dimension / delete-dimension / move-dimension + group-dimension / ungroup-dimension set-dropdown / delete-dropdown / csv-put / delete-float-image Notes: - 默认严格事务模式:任一子操作失败 → 整批回滚到初始状态 @@ -177,6 +179,8 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet' | `add-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | | `delete-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | | `move-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | +| `group-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | +| `ungroup-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | | `set-dropdown` | [sheet-dropdown](./sheet-dropdown.md) | | `delete-dropdown` | [sheet-dropdown](./sheet-dropdown.md) | | `csv-put` | [sheet-write-data](./sheet-write-data.md) | @@ -230,6 +234,19 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet' ] ``` +### 批量创建行列分组 + +```jsonc +[ + {"toolName":"group-dimension", + "input":{"sheet-id":"...","range":"3:7","group-state":"expand"}}, + {"toolName":"group-dimension", + "input":{"sheet-id":"...","range":"C:F"}}, + {"toolName":"ungroup-dimension", + "input":{"sheet-id":"...","range":"10:12"}} +] +``` + ## 上下文传递 | 操作 | 从返回中提取 | 用于 | @@ -244,3 +261,5 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet' - ★ **需要组合多个不同写操作(清除+写入等)时,用 `batch-update`**:原子事务,任一操作失败则整批回滚,避免留下半成品 - ★ **批次完成后必须回读校验**:用 `range read` 或 `csv-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与预期值对照 - ★ **`batch-update` 不支持嵌套**:`--operations` 中的 `toolName` 必须是原子操作,不可再嵌套 `batch-update` +- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;`input.range` 只接受整行或整列范围,如 `3:7`、`C:F` +- 需要创建后立即折叠分组时,优先使用独立 `dws sheet group-dimension --group-state fold` diff --git a/skills/mono/references/products/sheet/sheet-dimension-operations.md b/skills/mono/references/products/sheet/sheet-dimension-operations.md index 589c157e..afc2f5ac 100644 --- a/skills/mono/references/products/sheet/sheet-dimension-operations.md +++ b/skills/mono/references/products/sheet/sheet-dimension-operations.md @@ -26,6 +26,12 @@ - 注意与 `append`(追加数据行)区分:`add-dimension` 追加的是空行/空列,`append` 追加的是带数据的行 - 请勿用 `range update` 写空数据来模拟追加,`add-dimension` 直接扩展表格维度 +用户说"创建行分组/创建列分组/折叠几行/折叠几列/展开行列分组/取消行列分组": +- 创建分组 → `group-dimension --range <整行或整列范围>` +- 创建并折叠 → `group-dimension --group-state fold` +- 取消分组 → `ungroup-dimension --range <整行或整列范围>` +- `--range` 只能传整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7` + **结构预检**:插入、删除、移动行列前,必须先执行 `dws sheet info --node --sheet-id --format json` 查看 `mergedRanges`。合并区域跨过操作位置时,行列变更可能导致表头/分组标题断裂、空白或错位;需要先向用户说明影响,必要时取消合并后操作,再按原模式重新 `merge-cells`。 ## 命令详细参考 @@ -172,6 +178,38 @@ Flags: 在工作表末尾追加指定数量的空行或空列。 +### 创建行或列分组 +``` +Usage: + dws sheet group-dimension [flags] +Example: + dws sheet group-dimension --node --sheet-id --range "3:7" + dws sheet group-dimension --node --sheet-id --range "C:F" --group-state fold + dws sheet group-dimension --node --sheet-id --range "Sheet1!3:7" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --range string 整行/整列范围,如 "3:7" 或 "C:F" (必填) + --group-state string 创建后的分组状态: expand 或 fold,默认 expand +``` + +为连续行或连续列创建分组。`--range` 必须是整行或整列范围,不支持 `A1:C5` 这类普通单元格矩形范围。 + +### 取消行或列分组 +``` +Usage: + dws sheet ungroup-dimension [flags] +Example: + dws sheet ungroup-dimension --node --sheet-id --range "3:7" + dws sheet ungroup-dimension --node --sheet-id --range "C:F" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --range string 整行/整列范围,如 "3:7" 或 "C:F" (必填) +``` + +取消指定连续行或连续列的分组。 + ## 核心工作流 ```bash @@ -244,6 +282,8 @@ dws sheet update-dimension --node --sheet-id \ | `update-dimension` | `a1Notation` 被更新区域范围、`hidden` 生效的显隐状态、`pixelSize` 生效的尺寸 | 确认更新结果 | | `move-dimension` | `sheetId` 工作表 ID | 确认操作完成 | | `add-dimension` | `sheetId` 工作表 ID | 确认操作完成 | +| `group-dimension` | `range` / `level` / `groupState` | 确认行列分组层级和展开状态 | +| `ungroup-dimension` | `range` / `level` | 确认行列分组已取消 | | `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id | ## 注意事项 @@ -273,3 +313,7 @@ dws sheet update-dimension --node --sheet-id \ - `add-dimension` vs `range update`:需要在末尾追加空行/空列时,必须使用 `add-dimension` 命令,禁止用 `range update` 写空数据来模拟追加效果 - `add-dimension` 追加的是空行/空列,与 `append`(追加带数据的行)不同 - `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000 +- `group-dimension` / `ungroup-dimension` 的 `--range` 只接受整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`,不支持 `A1:C5` +- `group-dimension` 的 `--group-state` 只接受 `expand` 或 `fold`,默认 `expand` +- 分组操作返回 `level` 表示当前分组层级;不要把它误认为固定深度参数 +- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;需要创建后立即折叠时,优先使用独立 `group-dimension --group-state fold` diff --git a/skills/mono/references/products/sheet/sheet-export.md b/skills/mono/references/products/sheet/sheet-export.md index f390efed..891da573 100644 --- a/skills/mono/references/products/sheet/sheet-export.md +++ b/skills/mono/references/products/sheet/sheet-export.md @@ -56,7 +56,7 @@ Flags: - MCP 返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试 - 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试 -**限制**:仅支持钉钉在线电子表格(alxs)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。 +**限制**:仅支持钉钉在线电子表格(axls)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。 ## 核心工作流 @@ -86,7 +86,7 @@ dws sheet export --node --output ./ ## 注意事项 -- ★ `export` 仅支持钉钉在线电子表格(alxs)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` +- ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` - ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令 - `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可 - ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试 diff --git a/skills/mono/references/products/sheet/sheet-pivot-table.md b/skills/mono/references/products/sheet/sheet-pivot-table.md new file mode 100644 index 00000000..88547ca0 --- /dev/null +++ b/skills/mono/references/products/sheet/sheet-pivot-table.md @@ -0,0 +1,67 @@ +# 透视表管理 + +## 使用场景 + +- 用户要按部门、月份等维度汇总数据 → `pivot-table create` +- 用户要查看现有透视表或取得 `pivotTableId` → `pivot-table list` +- 用户要调整行列、聚合方式或显示选项 → `pivot-table update` +- 用户明确要求删除透视表 → `pivot-table delete`(不可逆,必须先确认) + +透视表是原生对象,会随源数据更新;不要用一组普通汇总公式冒充透视表。 + +## 列出与查看 + +```bash +dws sheet pivot-table list --node --sheet-id +dws sheet pivot-table list --node --sheet-id --pivot-table-id +``` + +不传 `--pivot-table-id` 时列出工作表中的全部透视表;传入时返回单个透视表详情。 + +## 创建 + +```bash +dws sheet pivot-table create --node \ + --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 --sheet-id \ + --pivot-table-id \ + --properties '{"show_subtotals":false}' +``` + +Open 接口支持部分更新,因此更新显示选项时不需要重复传入 `values`。更新行列或聚合字段时,应先用 `list --pivot-table-id` 读取当前配置,再传入需要变更的字段。 + +## 删除 + +```bash +dws sheet pivot-table delete --node --sheet-id \ + --pivot-table-id --yes +``` + +删除不可恢复。Agent 必须先展示目标文档、工作表和 `pivotTableId`,获得用户明确同意后才能附加 `--yes`。 diff --git a/skills/mono/references/products/sheet/sheet-read-data.md b/skills/mono/references/products/sheet/sheet-read-data.md index 21eb0a11..03d52d67 100644 --- a/skills/mono/references/products/sheet/sheet-read-data.md +++ b/skills/mono/references/products/sheet/sheet-read-data.md @@ -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 + dws sheet table-get --node --sheet-id --range "A1:D20" + dws sheet table-get --node --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: diff --git a/skills/mono/references/products/sheet/sheet-workbook.md b/skills/mono/references/products/sheet/sheet-workbook.md index be061b0f..1d1006b1 100644 --- a/skills/mono/references/products/sheet/sheet-workbook.md +++ b/skills/mono/references/products/sheet/sheet-workbook.md @@ -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 --sheet-id + dws sheet hide-gridline --node --sheet-id +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) +``` + +这两个命令只修改指定工作表的网格线显示状态,不修改单元格内容或边框样式。执行后可用同一命令反向恢复。 + ## 核心工作流 ```bash diff --git a/skills/mono/references/products/sheet/sheet-write-data.md b/skills/mono/references/products/sheet/sheet-write-data.md index 6b6f4451..c56c7921 100644 --- a/skills/mono/references/products/sheet/sheet-write-data.md +++ b/skills/mono/references/products/sheet/sheet-write-data.md @@ -3,6 +3,7 @@ ## 使用场景 用户说"写数据/填表/更新单元格/写入公式": +- 从 DataFrame/结构化对象一次写入一个或多个工作表 → `table-put` - 更新数据 → `range update` - 【强制】`--sheet-id` 必填:即使是单工作表也不能省略,不要参照 `range read` 的默认行为;未知时先执行 `dws sheet list --node --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 \ + --sheets '[{"name":"Data","columns":["name","score"],"data":[["Alice",95]],"dtypes":{"score":"float64"}}]' + + dws sheet table-put --node --sheets @table.json + cat table.json | dws sheet table-put --node --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: diff --git a/skills/mono/references/url-patterns.md b/skills/mono/references/url-patterns.md index 26cdb302..452817ca 100644 --- a/skills/mono/references/url-patterns.md +++ b/skills/mono/references/url-patterns.md @@ -90,14 +90,14 @@ Step 3 → 按下方路由规则映射到对应产品 | `contentType=ALIDOC`, `extension=adoc` | `doc` | 按 [doc.md](./products/doc.md) 操作 | | `contentType=ALIDOC`, `extension=axls` | `sheet` | 按 [sheet.md](./products/sheet.md) 操作(仅 `axls` 在线电子表格) | | `contentType=ALIDOC`, `extension=able` | `aitable` | 将 nodeId 作为 baseId,按 [aitable.md](./products/aitable.md) 操作 | -| `contentType=DOCUMENT`, `extension=xlsx` / `xls` / `xlsm` / `csv` | `doc` | 必须用 `dws doc download` 下载到本地处理,禁止走 `sheet`(非在线表格,sheet 命令无法操作) | -| `contentType≠ALIDOC`, `nodeType=file` | `doc` | 调用 `dws doc download` 下载,返回文件下载链接 | +| `contentType=DOCUMENT`, `extension=xlsx` / `xls` / `xlsm` / `csv` | `doc` | 必须用 `dws drive download` 下载到本地处理,禁止走 `sheet`(非在线表格,sheet 命令无法操作) | +| `contentType≠ALIDOC`, `nodeType=file` | `doc` | 调用 `dws drive download` 下载,返回文件下载链接 | | `nodeType=folder` | `doc` | 调用 `dws doc list --folder ` 列出指定文件夹直接子节点列表 | | 以上均不匹配 | — | 告知用户当前暂不支持该类型 | > axls vs xlsx 关键区分: > - `axls`(钉钉在线电子表格,`contentType=ALIDOC`)→ 走 `sheet` 产品线(读/写/筛选/导出等服务端原子操作) -> - `xlsx` / `xls` / `xlsm` / `csv`(上传到文档空间的本地表格文件,`contentType=DOCUMENT`)→ 必须走 `dws doc download` 下载到本地后再解析处理,严禁错误路由到 `sheet` 产品线(sheet 命令只支持在线表格,调用 xlsx 节点会直接报错) +> - `xlsx` / `xls` / `xlsm` / `csv`(上传到文档空间的本地表格文件,`contentType=DOCUMENT`)→ 必须走 `dws drive download` 下载到本地后再解析处理,严禁错误路由到 `sheet` 产品线(sheet 命令只支持在线表格,调用 xlsx 节点会直接报错) > - 用户想把在线表格导出为 xlsx 文件 → 开源 dws CLI 暂未暴露在线表格导出能力(旧动态 schema 曾包含 `submit_export_job` / `query_export_job`,但当前 cobra 未注册),需要在钉钉客户端手动导出 xlsx ### 示例 @@ -110,10 +110,10 @@ dws doc info --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json dws sheet list --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json # 返回 contentType≠ALIDOC, extension=xlsx/xls/csv → 本地表格文件,必须下载处理(禁止走 sheet) -dws doc download --node "https://alidocs.dingtalk.com/i/nodes/xlsx456" +dws drive download --node "https://alidocs.dingtalk.com/i/nodes/xlsx456" # 返回 contentType≠ALIDOC, nodeType=file → 普通文件,下载 -dws doc download --node "https://alidocs.dingtalk.com/i/nodes/def456" +dws drive download --node "https://alidocs.dingtalk.com/i/nodes/def456" # 返回 nodeType=folder → 文件夹,列出子节点 dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/ghi789" --format json @@ -138,8 +138,8 @@ dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/ghi789" --format jso | **adoc**(在线文档) | `doc read` | `doc update` / `doc block update` | ⚠️ `doc delete` | ⚠️ `doc export` (→ docx) | ⚠️ `doc permission *` | ⚠️ `doc media download/insert` | | **axls**(在线电子表格) | `sheet range read` / `sheet list` | `sheet range update` / `sheet append` | ⚠️ `doc delete`(节点删除) | `sheet export`(单命令一站式:提交→轮询→下载,可选 `--output` 落盘) | ⚠️ `doc permission *`(节点级,跨产品) | 不适用 | | **able**(在线多维表) | `aitable base get` / `aitable record query` | `aitable record create/update` | ⚠️ `doc delete`(节点删除)或 `aitable base delete --yes` | `aitable export data --scope all --format excel`(取 downloadUrl,`--output` 不落盘) | ⚠️ `doc permission *`(节点级) | `aitable attachment upload` | -| **xlsx / xls / xlsm / csv**(本地表格文件) | `doc download` → 本地用 xlsx skill 解析 | 不支持服务端写(先下载改本地再上传) | ⚠️ `doc delete`(节点删除) | 不需要(本身就是 xlsx) | ⚠️ `doc permission *` | 不适用 | -| **普通文件** (nodeType=file) | `doc download` | 不支持服务端写 | ⚠️ `doc delete` | 不需要 | ⚠️ `doc permission *` | 不适用 | +| **xlsx / xls / xlsm / csv**(本地表格文件) | `drive download` → 本地用 xlsx skill 解析 | 不支持服务端写(先下载改本地再上传) | ⚠️ `doc delete`(节点删除) | 不需要(本身就是 xlsx) | ⚠️ `doc permission *` | 不适用 | +| **普通文件** (nodeType=file) | `drive download` | 不支持服务端写 | ⚠️ `doc delete` | 不需要 | ⚠️ `doc permission *` | 不适用 | | **文件夹** (nodeType=folder) | `doc list --folder ` | `doc create --folder ...` | ⚠️ `doc delete` | 不适用 | ⚠️ `doc permission *` | 不适用 | | **分享短链** `/i/p/` | `read_url` 兜底(外部工具) | 不适用 | 不适用 | 不适用 | 不适用 | 不适用 | diff --git a/skills/mono/scripts/calendar_schedule_meeting.py b/skills/mono/scripts/calendar_schedule_meeting.py index 7de31a3c..6cac50a7 100644 --- a/skills/mono/scripts/calendar_schedule_meeting.py +++ b/skills/mono/scripts/calendar_schedule_meeting.py @@ -100,7 +100,7 @@ def main(): event_id = None if not args.dry_run and isinstance(result, dict): - # event create 真机返回 {result:{id,...}, success:true},id 在 result 内层, + # event create 返回 {result:{id,...}, success:true},id 在 result 内层, # 顶层取 id 恒 None 会导致参会人/订房支路被静默跳过。先解包 result 再取。 inner = result.get('result') if isinstance(result.get('result'), dict) else result event_id = (inner.get('eventId') or inner.get('id') diff --git a/skills/mono/scripts/contact_dept_members.py b/skills/mono/scripts/contact_dept_members.py index f0518de2..f7c70bf1 100644 --- a/skills/mono/scripts/contact_dept_members.py +++ b/skills/mono/scripts/contact_dept_members.py @@ -70,7 +70,7 @@ def main(): print('未找到匹配部门') sys.exit(1) - # dept search 真机返回顶层 deptList;兼容 result 包裹与历史 items/result 键。 + # dept search 返回顶层 deptList;兼容 result 包裹与历史 items/result 键。 if isinstance(dept_data, list): depts = dept_data else: @@ -104,7 +104,7 @@ def main(): print(' 无法获取成员列表') continue - # list-members 真机返回 deptUserList;兼容 result 包裹与历史 userlist 键。 + # list-members 返回 deptUserList;兼容 result 包裹与历史 userlist 键。 if isinstance(members_data, list): members = members_data else: @@ -121,7 +121,7 @@ def main(): continue for m in members: - # list-members 真机每项形如 {"userInfo": {"name":..., "userId":...}}, + # list-members 每项形如 {"userInfo": {"name":..., "userId":...}}, # 成员字段嵌在 userInfo 下;兼容历史扁平结构。 info = m.get('userInfo', m) name = info.get('name') or info.get('userName', '未知') diff --git a/skills/mono/scripts/minutes_extract_todos.py b/skills/mono/scripts/minutes_extract_todos.py index 7ca3d651..10123377 100644 --- a/skills/mono/scripts/minutes_extract_todos.py +++ b/skills/mono/scripts/minutes_extract_todos.py @@ -44,6 +44,58 @@ def run_dws( return None +def todos_from_payload(payload: Any) -> List[dict]: + """解析 `minutes get todos` 返回为待办列表。 + + 返回结构: result.dingtalkTodoList(对象数组, 含 title 等)与 + result.actions(JSON 字符串数组, 每条形如 + {"mark":[],"value":"..."}). 二者无 todos 键。 + 优先取 dingtalkTodoList; 为空时回退解析 actions。 + """ + if isinstance(payload, dict): + inner = payload.get('result', payload) + else: + inner = payload + out: List[dict] = [] + if isinstance(inner, dict): + ding_list = inner.get('dingtalkTodoList') + if isinstance(ding_list, list) and ding_list: + for t in ding_list: + if isinstance(t, dict): + content = t.get('title') or t.get('content') or '' + if content: + out.append({'content': str(content), '_raw': t}) + if out: + return out + actions = inner.get('actions') + if isinstance(actions, list): + for a in actions: + content = '' + if isinstance(a, str): + text = a.strip() + if text.startswith('{'): + try: + parsed = json.loads(text) + content = parsed.get('value') or '' + except json.JSONDecodeError: + content = text + else: + content = text + elif isinstance(a, dict): + content = (a.get('value') or a.get('content') + or a.get('title') or '') + if content: + out.append({'content': str(content)}) + elif isinstance(inner, list): + for t in inner: + if isinstance(t, dict): + content = (t.get('content') or t.get('text') + or t.get('title') or t.get('value') or '') + if content: + out.append({'content': str(content), '_raw': t}) + return out + + def main(): parser = argparse.ArgumentParser( description='从听记中提取待办事项' @@ -82,21 +134,9 @@ def main(): ]) if not todos_data: continue - if isinstance(todos_data, list): - items = todos_data - elif isinstance(todos_data, dict): - inner = todos_data.get('result', todos_data) - if isinstance(inner, dict): - items = inner.get('todos', []) - elif isinstance(inner, list): - items = inner - else: - items = [] - else: - items = [] + items = todos_from_payload(todos_data) for t in items: - if isinstance(t, dict): - t['_source'] = title + t['_source'] = title all_todos.extend(items) print(f"\n📋 听记待办汇总") diff --git a/skills/multi/dingtalk-agoal/SKILL.md b/skills/multi/dingtalk-agoal/SKILL.md new file mode 100644 index 00000000..25fbf694 --- /dev/null +++ b/skills/multi/dingtalk-agoal/SKILL.md @@ -0,0 +1,43 @@ +--- +name: dingtalk-agoal +description: 钉钉 Agoal 目标管理。Use when 用户说 目标管理/Agoal/战略解码/经营合约/计分卡/OKR/目标模板/周月报提交统计/跟催。Distinct from dingtalk-todo(待办任务)、dingtalk-report(日志)。命令前缀:dws agoal。 +cli_version: ">=0.2.14" +metadata: + category: product + stability: experimental + requires: + bins: + - dws +--- + +# 钉钉 Agoal Skill + +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 + +> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. + + + +> ⚠️ **命令可用性以当前 dws 二进制为准**。服务发现已下线,本文档随内置 skill 发布;如果 `dws --help` 不存在,说明当前版本未暴露该命令。若命令存在但调用失败,请按错误中的 endpoint 或 tool 提示确认静态端点目录和后端工具注册。实际调用前可用 `dws --help` 或 `--dry-run` 验证。 + +> 命令参考:[agoal.md](references/agoal.md)。 + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "查战略解码 / OGSM" | `dws agoal strategy list --scope-type --scope-id ` | +| "看战略解码详情" | `dws agoal strategy detail --profile-id ` | +| "更新战略解码" | 先 detail,基于完整数据修改后 `dws agoal strategy update ...` | +| "查经营合约 / KPI合约" | `dws agoal contract list/detail/fields` | +| "更新经营合约" | 先 detail,基于完整 dimensions 修改后 `dws agoal contract update ...` | +| "查计分卡" | `dws agoal scorecard detail --selected-time --dept-id ` | +| "我的目标 / 个人目标" | `dws agoal user rules` → `dws agoal user objectives` | +| "周月报提交统计 / 跟催 / 迟交 / 未提交" | `dws agoal report list-statistics` → `dws agoal report submit-detail` | +| "目标模板" | `dws agoal obj-template list/create-or-update` | + +## 写操作硬约束 + +- `strategy update` / `contract update` / `scorecard update` / `obj-template create-or-update` 都是覆盖式写入。 +- 必须先读取现有详情或模板列表,在原数据基础上修改;禁止只传局部字段覆盖。 +- 执行前必须向用户展示目标对象、修改字段和最终命令参数摘要,并等待确认。 diff --git a/skills/multi/dingtalk-agoal/references/agoal.md b/skills/multi/dingtalk-agoal/references/agoal.md new file mode 100644 index 00000000..1f1ad332 --- /dev/null +++ b/skills/multi/dingtalk-agoal/references/agoal.md @@ -0,0 +1,77 @@ +# Agoal(目标管理) + +## 产品说明 + +Agoal 是钉钉目标管理工具,支持战略解码、经营合约、计分卡、用户目标、目标模板、周月报六大模块,帮助组织将战略目标从顶层分解到个人并持续跟踪。 + +**CLI 前缀**: `dws agoal` + +## 命令总览 + +### strategy (战略解码管理) + +| 命令 | 用途 | 必填参数 | 备注 | +|------|------|----------|------| +| `strategy list` | 获取战略解码列表 | `--scope-type` `--scope-id` | scopeType: DEPT/PERSONAL;scope-id 为 scope-type 对应的部门 id 或用户 id | +| `strategy detail` | 获取战略解码详情 | `--profile-id` | 根据战略解码 id 查询 | +| `strategy update` | 更新战略解码 | `--profile-id` `--content` | 覆盖逻辑,必须基于查询返回的老数据修改后再传入;`--content` 为 JSON 数组 | + +### contract (经营合约管理) + +| 命令 | 用途 | 必填参数 | 备注 | +|------|------|----------|------| +| `contract list` | 获取经营合约列表 | `--scope-type` `--scope-id` | scopeType: DEPT/PERSONAL;scope-id 为 scope-type 对应的部门 id 或用户 id | +| `contract fields` | 获取经营合约字段列表 | - | 获取组织下经营合约的字段配置 | +| `contract detail` | 获取经营合约详情 | `--contract-id` | 根据合约 id 查询 | +| `contract update` | 更新经营合约 | `--contract-id` `--dimensions` | 覆盖逻辑;可选 `--audit-config`、`--objective-template` | + +### scorecard (计分卡管理) + +| 命令 | 用途 | 必填参数 | 备注 | +|------|------|----------|------| +| `scorecard detail` | 获取计分卡详情 | `--selected-time` `--dept-id` | selectedTime 为 ISO-8601 字符串 | +| `scorecard entity-detail` | 获取计分卡实体详情 | `--sc-id` `--entity-id` | 根据计分卡 id 和实体 id 查询 | +| `scorecard update` | 更新计分卡 | `--dept-id` `--selected-time` `--id` `--tracking-period-type` `--content` | trackingPeriodType: MONTHLY/QUARTERLY | + +### user / report / obj-template + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `user rules` | 获取用户规则周期列表 | - | +| `user objectives` | 查询用户目标列表 | `--user-id` `--rule-id` `--period-ids` | +| `report list-statistics` | 获取周月报数据跟催列表 | - | +| `report submit-detail` | 获取周月报规则提交详情 | `--template-id` `--submit-state` | +| `obj-template list` | 获取目标模板列表 | - | +| `obj-template create-or-update` | 新增或更新目标模板 | `--dimensions` | + +## 意图判断 + +- "战略解码/战略目标/OGSM" → `strategy list/detail/update` +- "经营合约/合约/KPI合约" → `contract list/fields/detail/update` +- "计分卡/scorecard/绩效看板" → `scorecard detail/entity-detail/update` +- "目标/OKR/我的目标/个人目标" → `user rules/objectives` +- "目标模板/模板管理" → `obj-template list/create-or-update` +- "周月报/周报统计/提交情况/跟催/迟交/未提交" → `report list-statistics/submit-detail` + +## 常用命令 + +```bash +dws agoal strategy list --scope-type DEPT --scope-id DEPT_ID --format json +dws agoal strategy detail --profile-id PROFILE_ID --format json +dws agoal contract list --scope-type PERSONAL --scope-id USER_ID --format json +dws agoal contract fields --format json +dws agoal contract detail --contract-id CONTRACT_ID --format json +dws agoal scorecard detail --selected-time "2026-01-01T00:00:00+08:00" --dept-id DEPT_ID --format json +dws agoal user rules --user-id USER_ID --format json +dws agoal user objectives --user-id USER_ID --rule-id RULE_ID --period-ids "period1,period2" --format json +dws agoal report list-statistics --format json +dws agoal report submit-detail --template-id TPL_ID --submit-state ON_TIME --format json +dws agoal obj-template list --keyword "业绩" --format json +``` + +## 安全规则 + +- 所有 update / create-or-update 命令都是覆盖逻辑:必须先用对应 detail/list 查询完整数据,在原数据基础上修改后再传入。 +- 写入类命令执行前必须展示变更摘要并等待用户确认。 +- `--selected-time` / `--query-date` 接受 ISO-8601 字符串。 +- `--period-ids` 为逗号分隔字符串。 diff --git a/skills/multi/dingtalk-aisearch/SKILL.md b/skills/multi/dingtalk-aisearch/SKILL.md index 298a9c69..65a43d72 100644 --- a/skills/multi/dingtalk-aisearch/SKILL.md +++ b/skills/multi/dingtalk-aisearch/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉 AI 搜问(搜人)Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. diff --git a/skills/multi/dingtalk-aisearch/references/aisearch.md b/skills/multi/dingtalk-aisearch/references/aisearch.md index 8a21aa13..f7f33544 100644 --- a/skills/multi/dingtalk-aisearch/references/aisearch.md +++ b/skills/multi/dingtalk-aisearch/references/aisearch.md @@ -98,7 +98,7 @@ Flags: | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `aisearch person` | `userId`(用户ID)、`title`(姓名) | 展示搜索结果、后续操作(发消息/建待办等) | +| `aisearch person` | `userId`(用户ID,= `meta.staffId`/`meta.jobNumber`)、`author`(真实姓名,= `meta.name`);`title` 是花名/显示名,不一定等于姓名 | 展示搜索结果、后续操作(发消息/建待办等) | ## 重名消歧 diff --git a/skills/multi/dingtalk-aitable/SKILL.md b/skills/multi/dingtalk-aitable/SKILL.md index 687d149f..1dac92c3 100644 --- a/skills/multi/dingtalk-aitable/SKILL.md +++ b/skills/multi/dingtalk-aitable/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉 AI 表格 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the root `dws` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. diff --git a/skills/multi/dingtalk-aitable/references/aitable.md b/skills/multi/dingtalk-aitable/references/aitable.md index ac8859ba..904c3837 100644 --- a/skills/multi/dingtalk-aitable/references/aitable.md +++ b/skills/multi/dingtalk-aitable/references/aitable.md @@ -101,6 +101,17 @@ > **创建表单**有两种等价方式:`form create --name "..."`(推荐)或 `view create --view-type FormDesigner --name "..."`。 +### workflow (自动化工作流) → 详见 [aitable-workflow.md](./aitable/aitable-workflow.md) + +| 命令 | 用途 | 必填参数 | 路由提醒 | +|------|------|----------|----------| +| `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` | +| `workflow get` | 获取单个工作流详情(含 flowSchema) | `--base-id` `--workflow-id` | `--workflow-id` 接受 list 里的 `flowId`(同值) | +| `workflow enable` | 启用工作流 | `--base-id` `--workflow-id` | 返回 `{enabled: true}` 是动作确认;要确认真启用看 list 的 `status` | +| `workflow disable` | 禁用工作流(高危) | `--base-id` `--workflow-id` `--yes` | 影响业务自动化,建议二次确认;status 变 STOP | + +> **当前不支持通过 CLI 新建/修改/删除工作流**,请去 AI 表格 Web 端(数据表页面 → 自动化)配置。 + ### dashboard & chart → 详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md) | 命令 | 用途 | @@ -130,6 +141,35 @@ |------|------|----------| | `template search` | 搜索模板 | `--query` | +### advperm (高级权限/自定义角色) → 详见 [aitable-advperm.md](./aitable/aitable-advperm.md) + +| 命令 | 用途 | 必填参数 | 路由提醒 | +|------|------|----------|----------| +| `advperm enable` | 开启 Base 高级权限总开关 | `--base-id` | 不开启时角色规则不生效 | +| `advperm disable` | 关闭 Base 高级权限总开关(高危) | `--base-id` `--yes` | 关闭后全员回退默认权限 | +| `advperm role-list` | 列出 Base 下所有角色 | `--base-id` | 同时返回自定义角色和系统角色;`roleType == "custom"` 是自定义,前缀 `system_` 是系统角色 | +| `advperm role-get` | 获取单角色完整配置 | `--base-id` `--role-id` | 含 subRoles 与字段/行级规则 | +| `advperm role-create` | 创建自定义角色 | `--base-id` `--name` | 可选 `--sub-roles` 同时指定子角色权限规则 | +| `advperm role-update` | 增量更新自定义角色(PATCH) | `--base-id` `--role-id` | 未传字段不变;`--sub-roles` 按 (targetId,targetType) 合并 | +| `advperm role-delete` | 删除自定义角色 | `--base-id` `--role-id` `--yes` | 不可逆;系统角色禁删;**调用者必须是该 AI 表格的管理员/Owner**,非管理员会得到 401 AUTH_ERROR | + +> 所有写命令(enable/disable/role-create/role-update/role-delete)需要 Base 管理员权限;非管理员只能调 `role-list` / `role-get`(只读)。 +> "角色 ↔ 成员"绑定当前 CLI 不支持,仍需在 AI 表格 Web 端 → Base 设置 → 高级权限面板手动完成。 + +### section (文件夹与节点管理) + +用于在 Base 的导航树中组织 table / dashboard / 表单视图 / 文档等节点(类似文件夹)。操作前建议先用 `section list-nodes` 拿到 nodeId / sectionId 与父级关系。 + +| 命令 | 用途 | 必填参数 | 路由提醒 | +|------|------|----------|----------| +| `section list-nodes` | 列出 Base 下全部节点 | `--base-id` | 返回 `{nodeId, nodeType, parentSectionId, name?}`;是 move-node / reorder 的前置定位命令 | +| `section list-empty` | 列出空文件夹 | `--base-id` | 返回 `{sectionId, name, parentSectionId}`,用于清理导航树 | +| `section create` | 创建文件夹 | `--base-id` `--name` | `--parent-section-id` 不传/空串=根目录;`--index` 指定位置 | +| `section rename` | 重命名文件夹 | `--base-id` `--section-id` `--new-name` | — | +| `section delete` | 删除文件夹 | `--base-id` `--section-id` | 不可逆;建议先 list-empty 确认为空 | +| `section reorder` | 调整文件夹顺序 | `--base-id` `--section-id` `--target-index` | 仅当前父级下调序;跨父级用 move-node | +| `section move-node` | 移动节点(跨父级) | `--base-id` `--node-id` `--new-parent-section-id` | `--new-parent-section-id ""`=移到根;`--target-index` 调全局位置 | + ## 意图判断 用户说"表格/多维表/AI表格": diff --git a/skills/multi/dingtalk-aitable/references/aitable/aitable-dashboard-chart.md b/skills/multi/dingtalk-aitable/references/aitable/aitable-dashboard-chart.md index f904158a..c6e4a1e7 100644 --- a/skills/multi/dingtalk-aitable/references/aitable/aitable-dashboard-chart.md +++ b/skills/multi/dingtalk-aitable/references/aitable/aitable-dashboard-chart.md @@ -31,7 +31,7 @@ dws aitable chart get --base-id --dashboard-id --chart- | `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` `--yes` | — | | `dashboard config-example` | 查看仪表盘配置模板 | 无 | 创建前先调此命令了解 config 结构 | | `dashboard share get` | 获取仪表盘分享配置 | `--base-id` `--dashboard-id` | 可能 404,见上方要点 | -| `dashboard share update` | 更新仪表盘分享配置 | `--base-id` `--dashboard-id` `--enabled` | `--enabled true` 开启(配 `--share-type PUBLIC/ORG`)/ `--enabled false` 关闭;ORG 回显 `shareType="[1]"` | +| `dashboard share update` | 更新仪表盘分享配置 | `--base-id` `--dashboard-id` `--enabled` | `--enabled true` 开启,`--share-type` 用 `ORG`(ORG 回显 `shareType="[1]"`);部分组织禁用了 `PUBLIC` 公开分享;若报 `Illegal argument`,改用 `ORG`(组织内分享)。`--enabled false` 关闭 | ## chart 子命令 @@ -43,7 +43,7 @@ dws aitable chart get --base-id --dashboard-id --chart- | `chart delete` | 删除图表 | `--base-id` `--dashboard-id` `--chart-id` `--yes` | | `chart widgets-example` | 查看图表 widgets 配置模板 | 无 | | `chart share get` | 获取图表分享配置 | `--base-id` `--dashboard-id` `--chart-id` | -| `chart share update` | 更新图表分享配置(`--enabled true/false`,开启配 `--share-type PUBLIC/ORG`) | `--base-id` `--dashboard-id` `--chart-id` `--enabled` | +| `chart share update` | 更新图表分享配置(`--enabled true/false`,开启配 `--share-type`,用 `ORG`;部分组织禁用了 `PUBLIC` 公开分享;报 `Illegal argument` 时改用 `ORG`) | `--base-id` `--dashboard-id` `--chart-id` `--enabled` | > `chart create` 的 `--layout` 是**必填**(12 列网格布局,如 `{"x":0,"y":0,"w":6,"h":4}`);不传本地校验直接拒。`chart update` 的 `--config` 也**必填**——即便只想改 layout,也要带完整 config,否则服务端拒绝。 diff --git a/skills/multi/dingtalk-attendance/SKILL.md b/skills/multi/dingtalk-attendance/SKILL.md index ec9545a1..556c9f18 100644 --- a/skills/multi/dingtalk-attendance/SKILL.md +++ b/skills/multi/dingtalk-attendance/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉考勤 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. @@ -39,7 +39,7 @@ metadata: ## 评测高频硬约束 -- 当前 dws 已注册全部考勤子命令组(`record` / `check` / `approve` / `shift` / `schedule` / `class` / `adjustment` / `overtime` / `group` / `summary` / `rules` / `selfsetting` / `globalsetting` / `vacation` / `checkin` / `report` / `boss-check`),查询与写操作大多可直接调用后端。**不要再以"开源版只读/不支持写操作"为由拒答。** 创建班次、导入排班、加人入考勤组、保存个人规则设置、设置假期余额等写操作真机可执行,但必须先展示参数摘要并二次确认,再追加 `--yes`(或 `--user-say-yes`)执行;不要在未确认时直接写、也不要伪装成功。个别命令受权限/数据影响返回空或权限错误(如 `report` 系列仅管理员),如实说明即可。 +- 当前 dws 已注册全部考勤子命令组(`record` / `check` / `approve` / `shift` / `schedule` / `class` / `adjustment` / `overtime` / `group` / `summary` / `rules` / `selfsetting` / `globalsetting` / `vacation` / `checkin` / `report` / `boss-check`),查询与写操作大多可直接调用后端。**不要再以"开源版只读/不支持写操作"为由拒答。** 创建班次、导入排班、加人入考勤组、保存个人规则设置、设置假期余额等写操作可执行,但必须先展示参数摘要并二次确认,再追加 `--yes`(或 `--user-say-yes`)执行;不要在未确认时直接写、也不要伪装成功。个别命令受权限/数据影响返回空或权限错误(如 `report` 系列仅管理员),如实说明即可。 - 查询迟到/缺勤名单时,空打卡结果不等于"没人迟到"。必须结合排班、`NotSigned`、`Absenteeism`、无记录人员分别说明;数据缺失要标为"无记录/无法判断",不要归为正常。 - 做部门 Top N 排名时,用户要求前 N 名就必须输出 N 个部门;无打卡记录或无可计算数据的部门按 0 或"无数据"保留在排名中,不能只输出有数据的少数部门。 - `summary` 必须同时传 `--user`、`--date`、`--stats-type`(week/month),缺一返回 C0002。 diff --git a/skills/multi/dingtalk-attendance/references/attendance.md b/skills/multi/dingtalk-attendance/references/attendance.md index 12ac86ab..42e33ba9 100644 --- a/skills/multi/dingtalk-attendance/references/attendance.md +++ b/skills/multi/dingtalk-attendance/references/attendance.md @@ -55,6 +55,19 @@ Flags: 返回每条记录含:用户 ID、实际打卡时间、打卡地址、打卡经纬度、打卡类型(OnDuty/OffDuty)、定位方式(Map/Wifi/etc)。时间跨度不超过 1 个月。 +### 查询个人某日考勤详情 +``` +Usage: + dws attendance record get [flags] +Example: + dws attendance record get --user 011769261608 --date 2026-03-08 +Flags: + --user string 钉钉用户 ID (必填,别名 --users 亦可) + --date string 查询日期, 格式 YYYY-MM-DD (必填) +``` + +查询单个用户在**某一天**的考勤详情(区别于 `check record`/`check result` 的多人时间段批量查询)。返回 `result` 对象含 `isHasSchedule`(当日是否有排班)、`isRest`(是否休息日)、`isUnSigned`(是否未打卡)、`recordList`(打卡明细)、`approveList`(当日审批单)、`workOvertime`、`workTimeDesc` 等字段。`--user` 只接受**单个** userId;查 userId 用 `dws contact user search --query "姓名"`。 + ### 查询审批单 ``` Usage: @@ -1115,13 +1128,13 @@ dws attendance report query-leave --users userId1,userId2 \ dws attendance vacation types --format json # 查看指定员工假期余额 -dws attendance vacation balance --users userId1,userId2 --format json +dws attendance vacation balance --users userId1,userId2 --leave-code <假期类型code> --format json # 查看指定员工某类假期余额 dws attendance vacation balance --users userId1 --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 --format json # 查看指定员工假期余额变更记录 -dws attendance vacation records --user USER_ID --start 2026-04-01 --end 2026-04-22 --format json +dws attendance vacation records --user USER_ID --leave-code <假期类型code> --start 2026-04-01 --end 2026-04-22 --format json # 更新假期规则名称 dws attendance vacation update-type --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ diff --git a/skills/multi/dingtalk-calendar/SKILL.md b/skills/multi/dingtalk-calendar/SKILL.md index 1348e3dd..18980c3f 100644 --- a/skills/multi/dingtalk-calendar/SKILL.md +++ b/skills/multi/dingtalk-calendar/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉日历 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. diff --git a/skills/multi/dingtalk-calendar/references/calendar.md b/skills/multi/dingtalk-calendar/references/calendar.md index 6c7fe58e..d879a649 100644 --- a/skills/multi/dingtalk-calendar/references/calendar.md +++ b/skills/multi/dingtalk-calendar/references/calendar.md @@ -5,7 +5,7 @@ - **二级子命令(必选其一)**:`event`(日程)、`attendee`(参会人)、`room`(会议室)、`busy`(闲忙)、`attachment`(日程附件)、`book`(用户日历列表)。`dws calendar` 后**必须**紧跟上述之一;**禁止**只执行 `dws calendar`(无子命令)。 - **个人日程 / 给自己留时间块 / 专注时段**:统一走 **`dws calendar event create`**。当前**没有**单独的 `personal schedule create` / `calendar create` 命令。 - **查日程列表**:`dws calendar event list --start "" --end "" --format json`,或优先使用脚本 `python scripts/calendar_today_agenda.py [today|tomorrow|week]`(见文末「自动化脚本」)。 -- **查用户日历本列表**:`dws calendar book list`(返回主日历 `id == "primary"` 等)。**重要**可以查询他人共享给自己的日历本,根据日历本id可以进一步查询对方的日程信息。 +- **查用户日历本列表**:`dws calendar book list`(返回字段为 `calendarId`,主日历 `calendarId == "primary"` 等;注意无 `id` 字段,按 `.id` 提取会取空)。**重要**可以查询他人共享给自己的日历本,根据日历本id可以进一步查询对方的日程信息。 - **CLI 不存在**独立的 `dws calendar list`;若误跑无子命令的 `dws calendar`,会打印整段 Usage,**切勿**将该段 help 当作工具结果再次塞进对话(会急剧增加 token 与首字延迟)。 - **必须**遵循指令说明进行调用。**绝对禁止**使用虚构指令,使用虚构参数。 @@ -511,7 +511,7 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15 | `event respond` | 响应结果 | — | | `room search` | `rooms[].roomId` | room add 的 --rooms 或 event create 的 --rooms | | `room list-groups` | `groups[].groupId` | room search 的 --group-id | -| `book list` | `id`(如 `primary`) | event list/get 的 --calendar-id | +| `book list` | `calendarId`(如 `primary`) | event list/get 的 --calendar-id | | 钉盘上传 | 文件 `fileId` | attachment add 的 --files `:` | ## 注意事项 @@ -529,7 +529,7 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15 - **搜房无结果**:在符合早停/用户限定范围内,`room search`(含按分组逐组查)全部返回空或无空闲 → 应**直接向用户报错/说明失败**并结束订房;**禁止**假设 roomId、禁止无合法 `roomId` 时调用 `room add` / `event create --rooms` 试探、禁止用 `event get` 等绕路推断 roomId - **评测 / 自动化断言**:凡涉及 `room add` / `event create --rooms` 的流程,`--rooms` 只能填上游 `room search`(或等价接口)返回 JSON 中的 **`rooms[].roomId`**;不得以会议室展示名、楼层文案或用户口语当作 `roomId` - **附件**:`attachment add` 仅负责挂载,**不上传**文件;fileId 必须先通过钉盘流程取得;`--files` 多附件用 `:` 元素逗号分隔 -- **日历本**:`book list` 返回的 `id` 才是合法 `calendarId`;如无明确说明,`event list` / `event get` 都不要带 `--calendar-id`,让接口默认走 primary 主日历 +- **日历本**:`book list` 返回的 `calendarId` 字段才是合法日历 id(无 `id` 字段);如无明确说明,`event list` / `event get` 都不要带 `--calendar-id`,让接口默认走 primary 主日历 - **已弃用入参**:`--max-results`(event list)在新 MCP schema 中被移除;CLI 仍接受但**不会**透传到 MCP;模型生成命令时**禁止**继续使用 ## 自动化脚本 diff --git a/skills/multi/dingtalk-calendar/scripts/calendar_free_slot_finder.py b/skills/multi/dingtalk-calendar/scripts/calendar_free_slot_finder.py index 01c9f637..b4bf20ff 100644 --- a/skills/multi/dingtalk-calendar/scripts/calendar_free_slot_finder.py +++ b/skills/multi/dingtalk-calendar/scripts/calendar_free_slot_finder.py @@ -51,30 +51,51 @@ def fmt_iso(dt: datetime) -> str: return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00') +def _extract_time_str(value: Any) -> str: + """时间字段兼容字符串与 {dateTime, date} 对象两种形态""" + if isinstance(value, dict): + return value.get('dateTime') or value.get('date') or '' + return value or '' + + def parse_busy_intervals( data: Any, ) -> List[Tuple[datetime, datetime]]: intervals = [] if not data: return intervals - items = [] + # 兼容 {result: [...]} 包裹结构 + if isinstance(data, dict) and 'result' in data: + data = data['result'] + rows = [] if isinstance(data, list): - items = data + rows = data elif isinstance(data, dict): - inner = data.get('result', data) - if isinstance(inner, list): - items = inner - elif isinstance(inner, dict): - for user_data in inner.values(): - if isinstance(user_data, list): - items.extend(user_data) - elif isinstance(user_data, dict): - items.extend( - user_data.get('busyTimes', []) - ) + rows = list(data.values()) + items = [] + for row in rows: + if isinstance(row, list): + items.extend(row) + elif isinstance(row, dict): + # 新结构: {userId, scheduleItems: [{start, end, status}]} + if isinstance(row.get('scheduleItems'), list): + items.extend(row['scheduleItems']) + elif isinstance(row.get('busyTimes'), list): + items.extend(row['busyTimes']) + elif row.get('startTime') or row.get('start'): + items.append(row) for item in items: - start_str = item.get('startTime') or item.get('start', '') - end_str = item.get('endTime') or item.get('end', '') + if not isinstance(item, dict): + continue + # FREE 时段不算忙 + if str(item.get('status') or '').upper() == 'FREE': + continue + start_str = _extract_time_str( + item.get('startTime') or item.get('start') + ) + end_str = _extract_time_str( + item.get('endTime') or item.get('end') + ) if not start_str or not end_str: continue for fmt in ( @@ -172,6 +193,16 @@ def main(): if args.dry_run: return + # 查询失败必须中止, 否则会把"查不到"当成"全天空闲"给出危险结论 + if data is None: + print('错误:忙闲查询失败,无法给出空闲时段结论', file=sys.stderr) + sys.exit(2) + if isinstance(data, dict) and data.get('success') is False: + print(f"错误:忙闲查询失败: " + f"{data.get('errorMsg') or data.get('errorCode') or data}", + file=sys.stderr) + sys.exit(2) + busy = parse_busy_intervals(data) free = find_free_slots(day_start, day_end, busy, args.duration) @@ -181,6 +212,7 @@ def main(): print(f" 会议时长: {args.duration} 分钟") print(f" 工作时间: {args.start_hour}:00 ~ " f"{args.end_hour}:00") + print(f" 已识别忙时段: {len(busy)} 个") print('=' * 50) if not free: diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index d0ad4621..89a97ca8 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -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 @@ -12,7 +12,7 @@ metadata: # 钉钉群聊 / 消息 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. @@ -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 --group --title "<标题>" --text "<内容>"` | | "Webhook 推一条" | `dws chat message send-by-webhook --token --title "<标题>" --text "<内容>"` | | "撤回我发的消息" | `dws chat message recall`(撤回当前用户发送的消息)| diff --git a/skills/multi/dingtalk-chat/references/01-messaging.md b/skills/multi/dingtalk-chat/references/01-messaging.md index dc8c4db5..bac5ddf7 100644 --- a/skills/multi/dingtalk-chat/references/01-messaging.md +++ b/skills/multi/dingtalk-chat/references/01-messaging.md @@ -13,3 +13,4 @@ | forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group --time "<起始时间>"` 拉源消息
2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`
3. `chat message send --open-dingtalk-id --text "<内容>"`(推荐)或 `--group --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user `(备选) | | search-common-group | `chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0`(`--match-mode AND`=全在/`OR`=任一在,翻页:`hasMore=true` 时用 `nextCursor`)
用户说"我和XX的共同群" → nicks 包含"我"时,需先 `contact user get-self` 取自己昵称再拼接 | | focus-messages | **零参数一行命令**:`chat message list-focused --limit 50`(拉特别关注人发的消息聚合)
触发 query:`"我特别关注的人最近发了什么消息"`、`"关注的人最近聊了啥"`、`"星标联系人最近的动态"`
**强消歧**:query 含动词【发/说/聊/讲】或名词【消息/聊天/动态】 → **必须**走本命令,**不要**先去拉 `contact relation list-my-followings`;仅当用户终点是"人员列表"(如"我关注了谁")才走 `relation list-my-followings`(详见 `dingtalk-contact/references/contact.md` 意图判断)
翻页:`hasMore=true` 时用 `nextCursor` 作为下次 `--cursor`
按人精控(可选):先 `contact relation list-my-followings` 取 `openDingTalkId`,再 `chat message list-by-sender --sender-open-dingtalk-id --start --end ` | +| favorite-message | 收藏:先从消息查询结果取得 `openMessageId` 和 `openConversationId`,再执行 `chat message add-favorite --open-message-id --open-conversation-id `
取消收藏:对应执行 `remove-favorite`,不会删除原消息
查询收藏:`chat message list-favorites [--cursor N] [--size 20]`,`hasMore=true` 时继续传 `nextCursor` | diff --git a/skills/multi/dingtalk-chat/references/chat.md b/skills/multi/dingtalk-chat/references/chat.md index 7a56247a..581b24c6 100644 --- a/skills/multi/dingtalk-chat/references/chat.md +++ b/skills/multi/dingtalk-chat/references/chat.md @@ -140,6 +140,26 @@ Flags: --expires-seconds int64 链接有效期(秒),0 表示永久有效,不传使用服务端默认值 ``` +#### 分享群聊链接到会话 — 将指定群的邀请链接分享到另一个会话或单聊用户 + +`--target` 和 `--receiver` 二选一:`--target` 指定目标会话,`--receiver` 指定单聊用户。 + +``` +Usage: + dws chat group share-invite [flags] +Example: + dws chat group share-invite --source <被分享群openConversationId> --target <目标会话openConversationId> + dws chat group share-invite --source <被分享群openConversationId> --receiver <接收者openDingTalkId> + dws chat group share-invite --source --target --expires-seconds 86400 + # 查询群 ID: dws chat search --query "群名" +Flags: + --source string 被分享群的 openConversationId (必填) + --target string 接收分享消息的会话 openConversationId(与 --receiver 二选一) + --receiver string 接收分享消息的单聊用户 openDingTalkId(与 --target 二选一) + --expires-seconds int64 链接有效期(秒),0 表示永久有效,不传使用服务端默认值 + --uuid string 消息幂等键(可选) +``` + #### 退出群聊 — 当前用户退出指定群聊 ``` Usage: @@ -274,6 +294,65 @@ Flags: - 不传 --role 时返回群主 + 管理员的所有群 ``` +### group notice (群公告管理) + +#### 发布群公告 — 在指定群聊中发布群公告,支持 Markdown、定时发布 + +``` +Usage: + dws chat group notice create [flags] +Example: + dws chat group notice create --group --content "今晚 22 点系统维护,请提前保存工作内容" + dws chat group notice create --group --content "# 重要通知\n请大家查收" --sticky --send-ding + dws chat group notice create --group --content "明早九点例会" --run-at "2026-07-03T09:00:00+08:00" +Flags: + --group string 群聊 openConversationId (必填) + --content string 公告正文,Markdown 格式 (必填) + --sticky 是否吊顶置顶(默认 false) + --send-ding 是否发 DING 提醒(默认 false) + --run-at string 定时发布时间 ISO-8601(传入则定时发布) +``` + +#### 修改群公告 — 整体替换指定群公告的内容 +``` +Usage: + dws chat group notice edit [flags] +Example: + dws chat group notice edit --group --notice-id --content "更新后的公告内容" +Flags: + --group string 群聊 openConversationId (必填) + --notice-id string 群公告 dataId (必填) + --content string 公告新正文,Markdown 格式 (必填) + --sticky 是否吊顶置顶 + --send-ding 是否发 DING 提醒 +``` + +#### 查看群公告详情 +``` +Usage: + dws chat group notice get [flags] +Example: + dws chat group notice get --group --notice-id +Flags: + --group string 群聊 openConversationId (必填) + --notice-id string 群公告 dataId (必填) +``` + +#### 查看群公告列表 +``` +Usage: + dws chat group notice list [flags] +Example: + dws chat group notice list --group + dws chat group notice list --group --limit 20 --cursor + dws chat group notice list --group --scheduled +Flags: + --group string 群聊 openConversationId (必填) + --limit int 每页返回数量(默认 10,最大 100) + --cursor string 分页游标 + --scheduled 是否查询定时公告列表 +``` + ### group-role (群身份管理) #### 查看群身份列表 — 拉取指定群聊的自定义群身份列表 @@ -336,7 +415,7 @@ Flags: #### 移除用户的指定群身份 — 从用户身上移除指定的群身份(不影响其他群身份) -> ⚠️ 当前不可用:真机服务端对该命令恒返回 1002(系统繁忙),历史回归至今未修,属服务端问题。需要清除某人身份时,用 `chat group-role set-user --role-ids ""`(传空 role-ids 覆盖为无身份)兜底;但 set-user 会清掉该用户的**全部**身份,无法只移除其中一个。 +> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。需要清除某人身份时,用 `chat group-role set-user --role-ids ""`(传空 role-ids 覆盖为无身份)兜底;但 set-user 会清掉该用户的**全部**身份,无法只移除其中一个。 ``` Usage: dws chat group-role remove-user [flags] @@ -694,7 +773,7 @@ Example: dws chat message list-by-sender --sender-open-dingtalk-id --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor Flags: --sender-user-id string 发送者 userId(与 --sender-open-dingtalk-id 二选一) - --sender-open-dingtalk-id string 发送者 openDingTalkId(与 --sender-user-id 二选一,适用于无法获取 userId 的场景) + --sender-open-dingtalk-id string 发送者 openDingTalkId(与 --sender-user-id 二选一);该路径当前返回 result:{}、success:null(疑似服务端问题),优先用 --sender-user-id --start string 开始时间,ISO-8601 格式 (必填) --end string 结束时间,ISO-8601 格式 (必填) --limit int 每页返回数量(默认 50) @@ -702,8 +781,8 @@ Flags: 注意: - --sender-user-id 和 --sender-open-dingtalk-id 二者互斥,必须且只能指定其一: - - --sender-user-id 传 userId(企业内部应用常用) - - --sender-open-dingtalk-id 传 openDingTalkId(三方应用或跨组织场景常用,无法获取 userId 时使用) + - --sender-user-id 传 userId(企业内部应用常用,当前唯一可用路径) + - --sender-open-dingtalk-id 传 openDingTalkId(当前返回 result:{}、success:null(疑似服务端问题);即使只有 openDingTalkId,也建议先用 contact 换取 userId 后走 --sender-user-id) - openDingTalkId 获取方式见下方「openDingTalkId 获取方式」小节 - 不需要指定单聊/群聊,返回结果自带会话类型标识 - 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00"、"2026-03-10" 等 @@ -959,6 +1038,20 @@ Flags: - 如果已有合适的表情,无需创建新的 ``` +#### 批量拉取消息的表情回复和文字回复 + +根据消息 ID 列表批量查询消息的表情回复和文字回复信息。 + +``` +Usage: + dws chat message list-emotion-replies [flags] +Example: + dws chat message list-emotion-replies --msg-ids msgId1,msgId2,msgId3 + # 消息 ID 可通过 dws chat message list 获取 +Flags: + --msg-ids string 消息 ID 列表,逗号分隔 (必填) +``` + ### list-top-conversations (置顶会话) #### 拉取置顶会话列表 @@ -990,20 +1083,20 @@ Flags: Usage: dws chat message download-media [flags] Example: - dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./downloads/ dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./photo.jpg + dws chat message download-media --type mediaId --resource-id --message-id --open-conversation-id --output ./downloads/photo.jpg Flags: --type string 资源类型: mediaId (必填) --resource-id string 资源 ID,mediaId 类型时为消息中的 mediaId 值 (必填) --message-id string 消息 openMessageId (必填) --open-conversation-id string 会话 openConversationId (必填) - --output string 本地保存路径,文件或目录 (必填) + --output string 本地保存路径 (必填),建议传完整文件路径(含文件名) 注意: - resource-id 从 `dws chat message list` 返回的消息内容中获取 mediaId - message-id 从 `dws chat message list` 返回的 openMessageId - open-conversation-id 从 `dws chat search` 获取 openConversationId - - --output 如果指定目录,文件名会从下载 URL 中自动推断 + - --output 建议传完整文件路径(含扩展名,如 ./photo.jpg);只传目录(如 ./downloads/)在旧版本会下载失败,请显式带上文件名 ``` ### search-common (搜索共同群) @@ -1189,6 +1282,45 @@ Flags: - 分页: hasMore=true 时,用返回的 nextCursor 作为下次 --cursor 继续翻页 ``` +#### 收藏消息 — 为当前用户收藏指定会话中的一条消息 +``` +Usage: + dws chat message add-favorite [flags] +Example: + dws chat message add-favorite --open-message-id --open-conversation-id +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 --open-conversation-id +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 (机器人管理) #### 搜索【我创建的】机器人 — 仅返回当前用户自己创建的机器人 @@ -1274,6 +1406,21 @@ Flags: --title string 分组名称 (必填) ``` +#### 创建智能会话分组 — 可指定群名称关键词和群内成员作为匹配规则 +``` +Usage: + dws chat category create-smart [flags] +Example: + dws chat category create-smart --name "工作群" + dws chat category create-smart --name "项目组" --keywords "项目,开发" + dws chat category create-smart --name "团队群" --members openDingTalkId1,openDingTalkId2 + dws chat category create-smart --name "重点群" --keywords "重点" --members openDingTalkId1 +Flags: + --name string 分组名称 (必填) + --keywords string 群名称关键词列表,逗号分隔(可选) + --members string 群内成员 openDingTalkId 列表,逗号分隔(可选) +``` + #### 删除会话分组 ``` Usage: @@ -1323,6 +1470,23 @@ Flags: --category-ids string 目标分组 ID 列表,逗号分隔 (必填) ``` +### text (文本内容处理) + +#### 翻译文本内容 +``` +Usage: + dws chat text translate [flags] +Example: + dws chat text translate --query "你好世界" --to en_US + dws chat text translate --query "Hello World" --to zh_CN + dws chat text translate --query "Bonjour" --to ja_JP +Flags: + --query string 待翻译的文本内容 (必填) + --to string 目标语言代码 (必填) +``` + +支持的目标语言代码:`en_US`, `zh_CN`, `zh_TW`, `zh_HK`, `ja_JP`, `ko_KR`, `vi_VN`, `th_TH`, `id_ID`, `ms_MY`, `es_419`, `fr_FR`, `pt_BR`, `tr_TR`, `ru_RU`, `de_DE`, `hi_IN`, `hu_HU`, `pl_PL`, `sv_SE`, `fi_FI`, `cs_CZ`, `ar_SA`, `tl_PH`, `he_IL`, `nl_NL`, `lo_LA`, `it_IT`。 + ### mute (会话免打扰) #### 会话消息免打扰 — 开启或关闭会话消息免打扰(支持单聊和群聊) @@ -1387,7 +1551,7 @@ Flags: #### 关闭/开启 @所有人消息提醒 — 关闭或开启会话中 @所有人的消息通知 -> ⚠️ 当前不可用:真机服务端对该命令恒返回 1002(系统繁忙),历史回归至今未修,属服务端问题。命令本身参数合法,但调用不会生效。 +> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。命令本身参数合法,但调用不会生效。 ``` Usage: dws chat mute-at-all [flags] @@ -1405,7 +1569,7 @@ Flags: #### 关闭/开启红包消息提醒 — 关闭或开启会话中的红包消息通知 -> ⚠️ 当前不可用:真机服务端对该命令恒返回 1002(系统繁忙),历史回归至今未修,属服务端问题。命令本身参数合法,但调用不会生效。 +> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。命令本身参数合法,但调用不会生效。 ``` Usage: dws chat mute-red-envelope [flags] @@ -1487,7 +1651,7 @@ Flags: 注意: - 返回结果包含单聊和群聊,不区分会话类型 - --limit 范围 1-100,默认 100,上限 100;传入 >100 会报错拒绝,不会静默截断 - - 分页当前不可用:真机 hasMore 恒为 false、nextCursor 恒为 null,本命令最多返回 100 条会话,无法用 --cursor 继续翻页取更多 + - 分页当前不可用:当前 hasMore 恒为 false、nextCursor 恒为 null,本命令最多返回 100 条会话,无法用 --cursor 继续翻页取更多 - 与 list-top-conversations 的区别: 本命令返回全部会话(单聊+群聊),list-top-conversations 仅返回置顶会话 ``` @@ -1574,7 +1738,7 @@ Flags: #### 审批入群验证 — 通过、删除单个审核 -真机当前仅 AuditApprove(通过)和 AuditDelete(删除)两个动作可用。 +当前仅 AuditApprove(通过)和 AuditDelete(删除)两个动作可用。 ``` Usage: dws chat group audit-join-validation [flags] @@ -1591,7 +1755,7 @@ Flags: --description string 审批说明(可选) 注意: - - status 真机仅支持 AuditApprove(通过) 和 AuditDelete(删除);AuditIgnore(忽略)、AuditRefuse(拒绝)、AuditBlock(拒绝且拉黑) 会被服务端拒绝报 unsupported audit status,属服务端限制 + - status 仅支持 AuditApprove(通过) 和 AuditDelete(删除);AuditIgnore(忽略)、AuditRefuse(拒绝)、AuditBlock(拒绝且拉黑) 会被服务端拒绝报 unsupported audit status,属服务端限制 - record-id、applicant、inviter 可通过 dws chat group list-join-validations 查询获得 ``` @@ -1644,6 +1808,7 @@ Flags: 用户说"查看会话分组/自定义分组" → `chat category list` 用户说"某个分组下的会话/分组会话列表" → `chat category list-conversations` 用户说"新建会话分组/创建分组" → `chat category create` +用户说"创建智能分组/新建智能会话分组" → `chat category create-smart` 用户说"删除会话分组" → `chat category delete` 用户说"重命名分组/改分组名" → `chat category rename` 用户说"把会话加入分组/会话归类到分组" → `chat category add-conv` @@ -1658,7 +1823,10 @@ Flags: 用户说"查询某人的群身份/某人在群里有什么身份" → `chat group-role query-user` 用户说"转让群主/换群主/群主转让" → `chat group transfer-owner` 用户说"群邀请链接/入群链接/加群链接" → `chat group invite-url` +用户说"分享群链接/把群分享给某人/群链接发到某群" → `chat group share-invite` +用户说"群公告/发布公告/修改公告/查看公告/定时公告" → `chat group notice create/edit/get/list` 用户说"批量查消息/按ID查消息/根据消息ID查" → `chat message list-by-ids` +用户说"批量查消息回复/表情回复/文字回复/消息回应列表" → `chat message list-emotion-replies` 用户说"emoji回应/表情回应/给消息加表情" → `chat message add-emoji` 用户说"取消emoji回应/移除表情回应" → `chat message remove-emoji` 用户说"文字表情回应/添加文字表情" → `chat message add-text-emotion` @@ -1697,6 +1865,7 @@ Flags: 用户说"全员禁言/群禁言/解除禁言" → `chat group-mute` 用户说"禁言某人/指定成员禁言/解除某人禁言" → `chat group-mute-member` 用户说"设管理员/取消管理员/设置群管理员" → `chat group set-admin` +用户说"翻译文本/把这段话翻译成英文/翻译成中文" → `chat text translate` 关键区分: - `chat search` — 搜**群/会话名**返回 `openConversationId`,**不**搜消息内容;要搜消息内容请用 `chat message search-advanced`(首选)/ `chat message search` / `list-by-sender` / `list-all`,**勿混淆** @@ -1721,13 +1890,17 @@ Flags: - `chat message search-advanced` — 多维度搜索消息(支持关键词、发送者、@我、@指定人、多个会话等维度组合,与 `search` 的区别:`search` 仅支持关键词且必填,`search-advanced` 所有参数均可选) - `chat message list-by-ids` — 根据消息 ID 批量查询消息(最多 50 条) - `chat message add-emoji` / `remove-emoji` — 对消息添加/移除 emoji 表情回应 +- `chat message list-emotion-replies` — 批量拉取消息的表情回复和文字回复 - `chat message add-text-emotion` / `remove-text-emotion` — 对消息添加/移除文字表情回应 - `chat message create-text-emotion` — 创建文字表情模板,返回 emotionId 供 add-text-emotion 使用 - `chat category list` — 获取用户自定义会话分组列表 - `chat category list-conversations` — 拉取指定分组下的会话列表 +- `chat category create-smart` — 创建智能会话分组(可指定群名称关键词和成员作为匹配规则) - `chat mute` — 开启/关闭会话消息免打扰(默认开启,--off 关闭) - `chat group transfer-owner` — 转让群主 - `chat group invite-url` — 获取群邀请链接 +- `chat group share-invite` — 分享群聊链接到会话 +- `chat group notice create/edit/get/list` — 群公告发布、修改、详情、列表 - `chat message reply` — 引用回复消息(在群聊中引用某条消息并回复文字) - `chat message forward` — 转发单条消息(将一条消息从源会话转发到目标会话) - `chat set-top` — 设置/取消会话置顶(默认置顶,--off 取消) diff --git a/skills/multi/dingtalk-chat/scripts/chat_export_messages.py b/skills/multi/dingtalk-chat/scripts/chat_export_messages.py index 54324607..2b77d85e 100644 --- a/skills/multi/dingtalk-chat/scripts/chat_export_messages.py +++ b/skills/multi/dingtalk-chat/scripts/chat_export_messages.py @@ -135,12 +135,21 @@ def main(): if not data: break + # 兼容两种结构: 顶层 messages / {result: {messages, hasMore}} if isinstance(data, list): page_msgs = data has_more = False else: - page_msgs = data.get('messages', data.get('result', [])) - has_more = data.get('hasMore', False) + container = data + inner = data.get('result') + if isinstance(inner, dict): + container = inner + page_msgs = container.get('messages') + if page_msgs is None and isinstance(inner, list): + page_msgs = inner + if not isinstance(page_msgs, list): + page_msgs = [] + has_more = bool(container.get('hasMore', False)) if not page_msgs: break @@ -153,7 +162,9 @@ def main(): break last_msg = page_msgs[-1] - boundary_time = last_msg.get('createAt') or last_msg.get('time', '') + boundary_time = (last_msg.get('createTime') + or last_msg.get('createAt') + or last_msg.get('time', '')) if not boundary_time or boundary_time == current_time: break current_time = boundary_time @@ -163,28 +174,18 @@ def main(): print('未拉取到消息') return - if isinstance(data, list): - messages = data - elif isinstance(data, dict): - inner = data.get('result', data) - if isinstance(inner, dict): - messages = inner.get('messages', inner.get('records', [])) - elif isinstance(inner, list): - messages = inner - else: - messages = [] - else: - messages = [] - if args.output: with open(args.output, 'w', encoding='utf-8') as f: json.dump(all_messages, f, ensure_ascii=False, indent=2) print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}") else: for m in all_messages: - sender = m.get('senderNick') or m.get('sender', '未知') - text = m.get('text') or m.get('content', '') - time_str = m.get('createAt') or m.get('time', '') + # 实际字段: sender/createTime/content (兼容旧字段名) + sender = (m.get('sender') or m.get('senderNick') + or m.get('senderOpenDingTalkId', '未知')) + text = m.get('content') or m.get('text', '') + time_str = (m.get('createTime') or m.get('createAt') + or m.get('time', '')) print(f" [{time_str}] {sender}: {text[:80]}") print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)") diff --git a/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py b/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py index 8068be38..b007c7be 100644 --- a/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py +++ b/skills/multi/dingtalk-chat/scripts/chat_history_with_user.py @@ -135,12 +135,21 @@ def main(): if not data: break + # 兼容两种结构: 顶层 messages / {result: {messages, hasMore}} if isinstance(data, list): page_msgs = data has_more = False else: - page_msgs = data.get('messages', data.get('result', [])) - has_more = data.get('hasMore', False) + container = data + inner = data.get('result') + if isinstance(inner, dict): + container = inner + page_msgs = container.get('messages') + if page_msgs is None and isinstance(inner, list): + page_msgs = inner + if not isinstance(page_msgs, list): + page_msgs = [] + has_more = bool(container.get('hasMore', False)) if not page_msgs: break @@ -153,7 +162,9 @@ def main(): break last_msg = page_msgs[-1] - boundary_time = last_msg.get('createAt') or last_msg.get('time', '') + boundary_time = (last_msg.get('createTime') + or last_msg.get('createAt') + or last_msg.get('time', '')) if not boundary_time or boundary_time == current_time: break current_time = boundary_time @@ -164,28 +175,18 @@ def main(): return # 3. 输出结果 - if isinstance(data, list): - messages = data - elif isinstance(data, dict): - inner = data.get('result', data) - if isinstance(inner, dict): - messages = inner.get('messages', inner.get('records', [])) - elif isinstance(inner, list): - messages = inner - else: - messages = [] - else: - messages = [] - if args.output: with open(args.output, 'w', encoding='utf-8') as f: json.dump(all_messages, f, ensure_ascii=False, indent=2) print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}") else: for m in all_messages: - sender = m.get('senderNick') or m.get('sender', '未知') - text = m.get('text') or m.get('content', '') - time_str = m.get('createAt') or m.get('time', '') + # 实际字段: sender/createTime/content (兼容旧字段名) + sender = (m.get('sender') or m.get('senderNick') + or m.get('senderOpenDingTalkId', '未知')) + text = m.get('content') or m.get('text', '') + time_str = (m.get('createTime') or m.get('createAt') + or m.get('time', '')) print(f" [{time_str}] {sender}: {text[:80]}") print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)") diff --git a/skills/multi/dingtalk-contact/SKILL.md b/skills/multi/dingtalk-contact/SKILL.md index b16ab60d..40c0bd0d 100644 --- a/skills/multi/dingtalk-contact/SKILL.md +++ b/skills/multi/dingtalk-contact/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉通讯录 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. @@ -31,7 +31,7 @@ metadata: | "按 userId 查详情" | `dws contact user get --ids ,,...`(多个并行) | | "按部门名拉成员" | `python scripts/contact_dept_members.py --query "<部门名>"` | | "搜部门" | `dws contact dept search --query "<关键词>"` | -| "部门成员列表" | `dws contact dept list-members --ids ` | +| "部门成员列表" | `dws contact dept list-members --depts ` | | "离职员工/离职名单/已离职" | `dws contact user dismission search`(可加 `--name` / `--start` + `--end` / `--depts`) | | "花名册/员工档案/学历/银行卡/合同" | `dws contact user profile get --staff-id `(先 `profile fields` 查字段) | diff --git a/skills/multi/dingtalk-contact/references/08-directory.md b/skills/multi/dingtalk-contact/references/08-directory.md index dc96bcf2..824cd967 100644 --- a/skills/multi/dingtalk-contact/references/08-directory.md +++ b/skills/multi/dingtalk-contact/references/08-directory.md @@ -6,7 +6,7 @@ ## 专用规则(#8 非 lite 步骤必守) - **角色类查人优先 label**:用户说"角色为XX的员工/XX角色的员工/XX角色的人员""所有主管/主管理员/财务/HR/总经理"等角色类型人员时,**优先** `contact label list` 获取全部角色 → 匹配目标角色 → `contact label list-members --id `;若用户明确指定了角色名称(如"角色为总经理"),则先用 `contact label get --names ` 精确匹配,**若精确匹配无结果,降级 `label list` 模糊匹配**(如用户说"管理员"可匹配到"主管理员"和"子管理员")。 -- **脚本优先**:按部门拉成员**优先** `python scripts/contact_dept_members.py --query "<部门名>"`(`--dry-run` / `--format json`);失败再 `dept search` → `dept list-members --ids`。 +- **脚本优先**:按部门拉成员**优先** `python scripts/contact_dept_members.py --query "<部门名>"`(`--dry-run` / `--format json`);失败再 `dept search` → `dept list-members --depts`。 - **详情链路**:用户要子部门、职位、联系方式、汇报关系等,在 `user search` 之后**必须**再 `contact user get --ids `;禁止仅用 search 的浅表字段交差。 - **`user get` 后部门仍空**:不得过早结束或只建议用户去 App;须在 CLI 能力内尝试 **用户点名的部门** `dept search` + `dept list-members` 等与 `userId` 交叉核对,再结构化汇总「返回中有哪些字段 / 哪些为空及可能原因」。 - **多命中**:`user search` 或 `dept search` 多条时须列候选(姓名、title、部门线索)请用户确认,禁止默认猜一人。具体消歧流程: @@ -35,16 +35,16 @@ | `search-user-by-mobile` | 1. `contact user search-mobile --mobile "<手机号>"` → 按需 `contact user get --ids ` | | `lookup-dept-id` | 1. `contact dept search --query "<部门关键词>"` → 回显 `deptId`(多命中须消歧) | | `list-subdepts` | 1. 已有父 `deptId` → `contact dept list-children --id <父deptId>` 直接取直属子部门列表
2. 只有部门名 → 先 `lookup-dept-id` 取 `deptId`,再 `list-children` | -| `list-dept-members` | 1. **优先** `python scripts/contact_dept_members.py --query "<部门名>"`
2. 备选:`lookup-dept-id` → `contact dept list-members --ids `
3. 若要每人档案字段:对 `userId` 批量 `contact user get --ids …` | -| `list-multi-dept-members` | 1. 对每个部门名 `contact dept search --query "<名>"` → 各 `deptId`
2. `contact dept list-members --ids ,,...`(多部门并行/批量见 conventions)
3. 需要档案再 `contact user get --ids …` | -| `verify-user-dept` | 1. `contact dept search --query "<部门名>"` → `deptId`
2. `contact dept list-members --ids ` 中匹配姓名;或先 `search-user` lite 再 `user get` 核对部门字段 | +| `list-dept-members` | 1. **优先** `python scripts/contact_dept_members.py --query "<部门名>"`
2. 备选:`lookup-dept-id` → `contact dept list-members --depts `
3. 若要每人档案字段:对 `userId` 批量 `contact user get --ids …` | +| `list-multi-dept-members` | 1. 对每个部门名 `contact dept search --query "<名>"` → 各 `deptId`
2. `contact dept list-members --depts ,,...`(多部门并行/批量见 conventions)
3. 需要档案再 `contact user get --ids …` | +| `verify-user-dept` | 1. `contact dept search --query "<部门名>"` → `deptId`
2. `contact dept list-members --depts ` 中匹配姓名;或先 `search-user` lite 再 `user get` 核对部门字段 | ## Full / 多步组合 | Recipe | 行动指南(固定路线) | |--------|---------------------| -| explore-subdepts-and-members | 1. 取父部门 `deptId`:用户给了 ID 直接用;只给名字则 `contact dept search --query "<父部门名>"` → 父 `deptId`(多命中先消歧)
2. **优先** `contact dept list-children --id <父deptId>` 拿到全部直属子 `deptId` 列表;若用户只点名了部分子部门,则改为对每个子部门名 `contact dept search --query "<子部门名>"`
3. 对子 `deptId`:`contact dept list-members --ids ,,...`(多部门按 conventions **并行/批量**)
4. 若还要成员详情:汇总 `userId` → `contact user get --ids …`(≤30 条/批,超出分批 + 用户确认) | +| explore-subdepts-and-members | 1. 取父部门 `deptId`:用户给了 ID 直接用;只给名字则 `contact dept search --query "<父部门名>"` → 父 `deptId`(多命中先消歧)
2. **优先** `contact dept list-children --id <父deptId>` 拿到全部直属子 `deptId` 列表;若用户只点名了部分子部门,则改为对每个子部门名 `contact dept search --query "<子部门名>"`
3. 对子 `deptId`:`contact dept list-members --depts ,,...`(多部门按 conventions **并行/批量**)
4. 若还要成员详情:汇总 `userId` → `contact user get --ids …`(≤30 条/批,超出分批 + 用户确认) | | verify-user-in-dept | 同速查表 `verify-user-dept`;多轮对话中用户追加「是否在某部门」时叠加本路线 | -| cross-level-dept-members | 1. `contact dept search --query "<父部门关键词>"` → 父 `deptId`
2. **优先** `contact dept list-children --id <父deptId>` 枚举全部直属子 `deptId`;用户已点名的子部门则用 `dept search` 精确命中
3. 需要逐层下钻时,对上一步拿到的子 `deptId` 继续 `dept list-children` 递归(注意控制深度,避免一次拉太多)
4. `contact dept list-members --ids ` → 按需 `user get` | +| cross-level-dept-members | 1. `contact dept search --query "<父部门关键词>"` → 父 `deptId`
2. **优先** `contact dept list-children --id <父deptId>` 枚举全部直属子 `deptId`;用户已点名的子部门则用 `dept search` 精确命中
3. 需要逐层下钻时,对上一步拿到的子 `deptId` 继续 `dept list-children` 递归(注意控制深度,避免一次拉太多)
4. `contact dept list-members --depts ` → 按需 `user get` | | user-detail-organization | 1. `contact user search --query "<关键词>"` → `userId`(多结果先消歧)
2. **必须** `contact user get --ids `
3. 若用户同时给出部门语境:叠加 `verify-user-dept` | | batch-users-by-keyword | 1. `contact user search --query "<职位或技能关键词>"` → 多条
2. 提取 `userId`(≤30)→ `contact user get --ids …`
3. 结果过多时汇总或请用户收窄 | diff --git a/skills/multi/dingtalk-contact/references/contact.md b/skills/multi/dingtalk-contact/references/contact.md index e5573f7a..87b923f1 100644 --- a/skills/multi/dingtalk-contact/references/contact.md +++ b/skills/multi/dingtalk-contact/references/contact.md @@ -211,7 +211,7 @@ Example: Flags: 无 Notes: - - 无需参数,返回当前企业全部角色列表(labelId、labelName等) + - 无需参数,返回当前企业全部角色,按 `groupName` 分组,每组 `labels[]` 里每个角色是 `{labelId, name}`(字段名是 `name`,不是 `labelName`) - 用于不知道准确角色名称时先浏览全部角色 - 典型场景:用户说“企业所有主管/查所有管理员/财务人员有哪些”→ 先 label list 浏览全部角色,匹配目标角色后 label list-members 获取成员 ``` @@ -358,7 +358,7 @@ dws contact user dismission search --depts 123456,789012 --hide-retirement=false | `user get-self/search` | `orgAuthEmail` | mail message send 的 --to/--cc (跨产品) | | `user get-self/search` | `userId` | profile get 的 --staff-id | | `user profile fields` | `fieldCode` | profile get 的 --fields | -| `label list` | `labelId` / `labelName` | `label get --names` 或 `label list-members --id` | +| `label list` | `labelId` / `name` | `label get --names` 或 `label list-members --id` | | `label get` | `labelId` | `label list-members` 的 --id | | `dept search/list-children` | `deptId` | dept get-info/list-children/list-members 的 --dept/--depts | | `dept search/list-children` | `deptId` | dismission search 的 --depts | diff --git a/skills/multi/dingtalk-contact/scripts/contact_dept_members.py b/skills/multi/dingtalk-contact/scripts/contact_dept_members.py index a743e657..6280d849 100644 --- a/skills/multi/dingtalk-contact/scripts/contact_dept_members.py +++ b/skills/multi/dingtalk-contact/scripts/contact_dept_members.py @@ -75,7 +75,7 @@ def main(): elif isinstance(dept_data, dict): inner = dept_data.get('result', dept_data) if isinstance(inner, dict): - # dept search 真机返回顶层 deptList;兼容历史 items/depts 键。 + # dept search 返回顶层 deptList;兼容历史 items/depts 键。 depts = (inner.get('deptList') or inner.get('items') or inner.get('depts') @@ -114,7 +114,7 @@ def main(): elif isinstance(members_data, dict): inner = members_data.get('result', members_data) if isinstance(inner, dict): - # list-members 真机返回 deptUserList;兼容历史 userlist/list 键。 + # list-members 返回 deptUserList;兼容历史 userlist/list 键。 members = (inner.get('deptUserList') or inner.get('userlist') or inner.get('list') @@ -130,7 +130,7 @@ def main(): continue for m in members: - # list-members 真机每项形如 {"userInfo": {"name":..., "userId":...}}, + # list-members 每项形如 {"userInfo": {"name":..., "userId":...}}, # 成员字段嵌在 userInfo 下;兼容历史扁平结构。 info = m.get('userInfo', m) name = info.get('name') or info.get('userName', '未知') diff --git a/skills/multi/dingtalk-dev/references/event.md b/skills/multi/dingtalk-dev/references/event.md index fcbe663b..83b459ae 100644 --- a/skills/multi/dingtalk-dev/references/event.md +++ b/skills/multi/dingtalk-dev/references/event.md @@ -14,14 +14,14 @@ - 批量或全量订阅前,先把候选 `eventCode` 列给用户确认,再 `--dry-run` → `--yes`。 - 返回看 `events[].subscribed` 和 `pushType=STREAM`(事件走 Stream 长连推送;connect 与事件订阅的关系见下方「Stream 长连」)。 - `subscribe/unsubscribe` 的 `--event-codes` 必填,返回 `success/operation/unifiedAppId/eventCodes/needsPublish/versionRequiredAction`;失败时补 `errorCode/errorMsg/reason/retryable/action`。 -- 如果服务端返回 `errorCode=STREAM_NOT_CONNECTED`、`reason=STREAM_NOT_CONNECTED`、`retryable=false`、`action=run connect`,先执行 `dev connect` 建联,再重试订阅。 +- 如果订阅失败、返回提示长链接未在线(是泛化错误:`reason=business_error`、`message` 含「长链接未在线」、`server_error_code=-1`;没有 STREAM_NOT_CONNECTED 这类结构化错误码,也没有 action 字段),先执行 `dev connect` 建联,再重试订阅。 ## Stream 长连:connect 与事件订阅的关系 钉钉一个应用就一条 Stream 长连(WebSocket),上面同时承载机器人消息、事件、卡片回调等多种 topic。connect 和 event 在这条长连上分工不同: - `dev connect`:用应用凭证把这条长连建起来并保活,但只注册了机器人消息处理(收 @机器人 → 转发本地 agent),**不消费事件**。 -- `event subscribe/unsubscribe`:只是**配置**操作(配应用订阅哪些事件码),自己不建长连、不收事件;服务端要求应用的 Stream 长连已在线,否则返回 `STREAM_NOT_CONNECTED`。 +- `event subscribe/unsubscribe`:只是**配置**操作(配应用订阅哪些事件码),自己不建长连、不收事件;服务端要求应用的 Stream 长连已在线,否则报错「长链接未在线」(泛化 business_error,不是结构化错误码)。 - 两者唯一关联:先 `dev connect` 把长连建在线,`event subscribe` 才能成功——connect 负责「让长连在线」,subscribe 负责「配置订阅」。 - 当前限制:connect 的长连不处理事件,dws 也没有「收/消费事件」的运行时命令,event 只到「配置订阅」为止。订阅成功后真正的事件推送 dws 暂不消费——要消费得用注册了事件 handler 的 SDK 自接(事件结构走 `dws devdoc article search --query` 查)。 diff --git a/skills/multi/dingtalk-devapp/references/event.md b/skills/multi/dingtalk-devapp/references/event.md index b66e7ce2..1ffd0267 100644 --- a/skills/multi/dingtalk-devapp/references/event.md +++ b/skills/multi/dingtalk-devapp/references/event.md @@ -90,5 +90,5 @@ event subscribe/unsubscribe 订阅变更写入版本元数据 |------|------| | `--event-codes` 缺失 | CLI 直接报错,先 `event list` 取事件码 | | 订阅后 `subscribed` 仍为 false | 灰度应用需先 `version publish` 发布版本 | -| `errorCode=STREAM_NOT_CONNECTED` / `reason=STREAM_NOT_CONNECTED` | 先执行 `dev connect` 建联,再重试订阅 | +| 返回提示「长链接未在线」(泛化错误 `reason=business_error`、`server_error_code=-1`,无 STREAM_NOT_CONNECTED 码) | 先执行 `dev connect` 建联,再重试订阅 | | `ServiceResult.success=false` | 透传 `errorCode/errorMsg` | diff --git a/skills/multi/dingtalk-devdoc/SKILL.md b/skills/multi/dingtalk-devdoc/SKILL.md index 39d5a0ee..c380da8b 100644 --- a/skills/multi/dingtalk-devdoc/SKILL.md +++ b/skills/multi/dingtalk-devdoc/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉开放平台文档 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. diff --git a/skills/multi/dingtalk-devdoc/references/devdoc.md b/skills/multi/dingtalk-devdoc/references/devdoc.md index e6e3e239..1569a49b 100644 --- a/skills/multi/dingtalk-devdoc/references/devdoc.md +++ b/skills/multi/dingtalk-devdoc/references/devdoc.md @@ -7,15 +7,16 @@ ### 搜索开发文档 ``` Usage: - dws devdoc article search [flags] + dws devdoc article search [keyword] [flags] Example: + dws devdoc article search "MCP" # 关键词可作位置参数,等价于 --query dws devdoc article search --query "OAuth2 接入" dws devdoc article search --query "消息卡片" --page 2 --size 5 dws devdoc article search --query "机器人" --size 10 Flags: - --query string 搜索关键词 (必填) - --page int 分页页码 (从 1 开始,默认 1) - --size int 分页大小 (默认 10) + --query string 搜索关键词 (必填;也可用位置参数 [keyword] 传) + --page string 分页页码 (从 1 开始,default "1") + --size string 分页大小 (default "10") ``` ## 意图判断 diff --git a/skills/multi/dingtalk-ding/SKILL.md b/skills/multi/dingtalk-ding/SKILL.md index 153e6b72..94fb1e08 100644 --- a/skills/multi/dingtalk-ding/SKILL.md +++ b/skills/multi/dingtalk-ding/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉 DING 紧急消息 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. diff --git a/skills/multi/dingtalk-doc/SKILL.md b/skills/multi/dingtalk-doc/SKILL.md index 3bba1780..213effef 100644 --- a/skills/multi/dingtalk-doc/SKILL.md +++ b/skills/multi/dingtalk-doc/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉文档 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. @@ -47,6 +47,8 @@ metadata: | "读文档内容" | `dws doc read --node ` | | "更新文档内容 / 分块追加" | `dws doc update --node --content "<分块>" --mode append` | | "删除块" | `dws doc block delete`(需用户确认) | +| "更新文档评论" | `dws doc comment update --node --comment-key --content "<内容>"` | +| "删除文档评论" | `dws doc comment delete --node --comment-key --yes`(需用户确认) | ## 评测/多步文档短路径 @@ -60,7 +62,7 @@ metadata: ## 危险操作 -`block delete` 不可逆,必须确认再加 `--yes`。 +`block delete` 和 `comment delete` 不可逆,必须确认再加 `--yes`。 ## 跨产品协作 diff --git a/skills/multi/dingtalk-doc/references/doc.md b/skills/multi/dingtalk-doc/references/doc.md index f21ce032..5ea2862a 100644 --- a/skills/multi/dingtalk-doc/references/doc.md +++ b/skills/multi/dingtalk-doc/references/doc.md @@ -27,7 +27,7 @@ dws doc permission --help ### 搜索 / 遍历文件(已迁移,不再是 doc 子命令) -> **弃用提示**:`dws doc search` 和 `dws doc list` 已迁移到 `drive` / `wiki`。真机上这两条虽仍能跑,但每次都会打印弃用警告,请改用下面的命令,命令详情见 dingtalk-drive skill。 +> **弃用提示**:`dws doc search` 和 `dws doc list` 已迁移到 `drive` / `wiki`。这两条虽仍能跑,但每次都会打印弃用警告,请改用下面的命令,命令详情见 dingtalk-drive skill。 | 旧命令(弃用) | 改用 | 场景 | |--------------|------|------| @@ -83,7 +83,7 @@ Flags: ### 创建其他类型文件 (表格/脑图/白板/多维表/画板/文件夹) -> **弃用提示**:`dws doc file create --type` 已弃用,真机每次执行都会打印 `deprecated, use 'dws wiki node create --type '`。创建各类型文件节点改用 `dws wiki node create`(与 intent-guide 一致);创建普通文件夹用 `dws drive mkdir`。 +> **弃用提示**:`dws doc file create --type` 已弃用,执行时会打印 `deprecated, use 'dws wiki node create --type '`。创建各类型文件节点改用 `dws wiki node create`(与 intent-guide 一致);创建普通文件夹用 `dws drive mkdir`。 ``` Usage: @@ -131,9 +131,30 @@ Flags: --convert 是否转换为钉钉在线文档 ``` +### 导入本地文件为在线文档 + +详见 [doc/doc-import.md](./doc/doc-import.md)。 + +``` +Usage: + dws doc import [flags] + dws doc import get [flags] +Example: + dws doc import --file ./report.docx + dws doc import --file ./notes.md --folder + dws doc import --file ./data.xlsx --workspace + dws doc import get --task-id +Flags: + --file string 本地文件路径 (必填) + --folder string 目标文件夹 ID 或 URL + --workspace string 目标知识库 ID 或 URL + --name string 导入后文档名称 (默认取文件名) + --task-id string 导入任务 ID (import get 必填) +``` + ### 下载文件到本地(已迁移到 drive) -> **弃用提示**:`dws doc download` 已迁移到 `dws drive download`(真机执行 `doc download` 会打印弃用警告)。下载已有文件(PDF/图片/附件等非在线文档)改用: +> **弃用提示**:`dws doc download` 已迁移到 `dws drive download`(执行 `doc download` 会打印弃用警告)。下载已有文件(PDF/图片/附件等非在线文档)改用: ``` dws drive download --node --output ~/downloads/ @@ -261,11 +282,11 @@ Usage: Example: dws doc comment list --node dws doc comment list --node --type inline --resolve-status unresolved - dws doc comment list --node --page-size 20 --next-token + dws doc comment list --node --limit 20 --cursor Flags: --node string 目标文档的标识,支持传入 URL 或 ID (必填) - --page-size int 每页返回的评论数量,默认 50,最大 50 - --next-token string 分页游标,从上一次请求的返回结果中获取 (首次请求不传) + --limit int 每页返回的评论数量,默认 50,最大 50 + --cursor string 分页游标,从上一次请求的返回结果中获取 (首次请求不传) --type string 按评论类型过滤: global (全文评论) / inline (划词评论) --resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决) ``` @@ -317,6 +338,22 @@ Flags: --mention string 被 @ 的用户 uid 列表,逗号分隔 ``` +### 更新文档评论 +```text +Usage: + dws doc comment update --node --comment-key --content [--mention uid1,uid2] +``` + +`commentKey` 从 `comment list/create/create-inline` 返回中获取。只有显式传入 `--mention` 时才更新被 @ 用户列表。 + +### 删除文档评论 +```text +Usage: + dws doc comment delete --node --comment-key --yes +``` + +删除评论不可恢复,必须先获得用户明确确认,再添加全局 `--yes`。 + ### 文件内容获取路由规则 > 当用户请求"分析/查看/读取某个文件内容"时,**必须先调用 `dws doc info` 获取文件元数据**,再根据返回的 `contentType` 和 `extension` 字段选择对应链路: @@ -618,6 +655,12 @@ Flags: - 上传 → `upload`(需本地文件路径) - 上传并转换 → `upload --convert` +用户说"导入文件/导入为在线文档/导入 Word/导入 Excel/导入 xmind/导入 Markdown/把本地文件转在线文档": +- 导入并转换为在线文档 → `doc import --file <本地路径>` +- 支持 docx/doc/xlsx/xls/md/txt/xmind/mark,文件大小不超过 20MB +- 如果用户指定知识库或文件夹,补充 `--workspace` 或 `--folder` +- 不要把本地文件内容先读出来再 `doc create/update`;应直接使用 `doc import` + 用户说"下载/导出/下载到本地/导出文档/导出为Word/导出为docx/把文档导出来": - **必须先判断目标文件类型**,再决定走 `doc export` 还是 `drive download`: - 在线文档(alidocs/adoc)→ **`doc export`**(内容级命令,格式转换后导出为 docx,未迁移) @@ -768,6 +811,20 @@ dws drive download --node --output ~/downloads/ # 如果是在线文档 (ALIDOC),用 doc export: dws doc export --node --output ~/downloads/ +# ── 工作流 5a: 导入本地文件为在线文档 ── + +# 导入到默认位置 +dws doc import --file ./report.docx --format json + +# 导入到指定文件夹 +dws doc import --file ./notes.md --folder --format json + +# 导入到知识库 +dws doc import --file ./data.xlsx --workspace --format json + +# 如果导入命令超时或中断,可用 import get 手动查询任务状态: +# dws doc import get --task-id + # ── 工作流 6: 上传附件并插入文档 ── # media insert 自动完成三步流程: @@ -852,18 +909,24 @@ dws doc comment create --node --content "这里需要补充数据来源 # 再将 userId 传入 --mention dws doc comment create --node --content "请确认这部分内容" --mention , --format json -# 4. 回复某条评论(commentKey 从 list 或 create 返回中获取) +# 4. 更新某条评论(可选 --mention) +dws doc comment update --node --comment-key --content "已按最新数据修正" --format json + +# 5. 删除某条评论(不可恢复,必须先确认) +dws doc comment delete --node --comment-key --yes --format json + +# 6. 回复某条评论(commentKey 从 list 或 create 返回中获取) dws doc comment reply --node --comment-key --content "已修改" --format json -# 5. 用表情回复评论 +# 7. 用表情回复评论 dws doc comment reply --node --comment-key --content "比心" --emoji --format json -# 6. 创建划词评论(针对文档中某段选中文本) +# 8. 创建划词评论(针对文档中某段选中文本) # 先获取块列表: dws doc block list --node --format json → 提取 blockId 和文本内容 # 确定选中文本在块内的起始偏移量 (start) 和结束偏移量 (end) dws doc comment create-inline --node --block-id --start 0 --end 10 --content "这里需要修改" --format json -# 7. 创建划词评论并附带引用原文 + @ 相关人 +# 9. 创建划词评论并附带引用原文 + @ 相关人 dws doc comment create-inline --node --block-id --start 5 --end 20 --content "请确认这部分" --selected-text "被选中的原文内容" --mention , --format json # ── 工作流 10: 导出在线文档为 docx ── @@ -882,12 +945,13 @@ dws doc export --node --output ./exported.docx | `drive list`(原 `doc list`,已弃用) | `nodes[].nodeId` / folder 类型 `nodeId` | read / info / update / block 的 --node;folder 用作 `--folder` | | `drive search`(原 `doc search`,已弃用) | 文档 `nodeId` / URL / `createTime` / `creatorUid` | read / info / update 的 --node;创建时间与创建者信息 | | `create` | `nodeId` | update / block 操作的 --node | +| `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 | diff --git a/skills/multi/dingtalk-doc/references/doc/doc-comment.md b/skills/multi/dingtalk-doc/references/doc/doc-comment.md index 0d2f3a3f..711b89a2 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-comment.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-comment.md @@ -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 --comment-key --content "已按最新数据修正" + dws doc comment update --node --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 --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 --comment-key --content "已 # 表情回复(--content 填表情名称) dws doc comment reply --node --comment-key --content "比心" --emoji --format json +# 更新评论(可选 --mention) +dws doc comment update --node --comment-key --content "已修正" --format json + +# 删除评论(危险操作,需用户先明确确认) +dws doc comment delete --node --comment-key --yes --format json + # 划词评论(先 block list 取 blockId + paragraph.text,计算 start/end) dws doc block list --node --format json # 计算偏移后: diff --git a/skills/multi/dingtalk-doc/references/doc/doc-file-ops.md b/skills/multi/dingtalk-doc/references/doc/doc-file-ops.md index cabaaa11..6ed912b2 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-file-ops.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-file-ops.md @@ -3,7 +3,7 @@ > **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件: > 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流 -> **弃用提示(文件管理命令正在迁移到 drive / wiki)**:本文所列 `doc` 文件管理命令在真机上虽仍能跑,但执行时会打印弃用警告,请优先改用 `drive` / `wiki` 对应命令: +> **弃用提示(文件管理命令正在迁移到 drive / wiki)**:本文所列 `doc` 文件管理命令虽仍能跑,但执行时会打印弃用警告,请优先改用 `drive` / `wiki` 对应命令: > - `doc download` → **`dws drive download`**(下载已有文件;在线文档导出 docx 仍走 `doc export`) > - `doc folder create` → **`dws drive mkdir`**(个人空间/钉盘)或 **`dws wiki node create --workspace --type folder`**(知识库内) > - `doc upload` / `doc copy` / `doc move` / `doc rename` / `doc delete` 也在同一迁移方向(→ `drive`),命令用法以 `drive` 为准。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-import.md b/skills/multi/dingtalk-doc/references/doc/doc-import.md new file mode 100644 index 00000000..bd6291de --- /dev/null +++ b/skills/multi/dingtalk-doc/references/doc/doc-import.md @@ -0,0 +1,53 @@ +# 本地文件导入为在线文档 (doc import) + +## 使用场景 + +用户说"导入 Word/Excel/Markdown/xmind 到钉钉文档"、"把本地文件转成在线文档"、"导入到知识库/文件夹"时,使用 `dws doc import`。 + +不要先读取文件内容再调用 `doc create` 或 `doc update`。`doc import` 会按文件格式走导入任务,保留更完整的原始结构。 + +## 命令 + +```bash +dws doc import --file ./report.docx --format json +dws doc import --file ./notes.md --folder --format json +dws doc import --file ./data.xlsx --workspace --format json +dws doc import --file ./draft.md --name "项目周报" --format json +``` + +```bash +dws doc import get --task-id --format json +``` + +## 参数 + +| 参数 | 说明 | +|------|------| +| `--file` | 本地文件路径,必填 | +| `--folder` | 目标文件夹 ID 或 URL,可选 | +| `--workspace` | 目标知识库 ID 或 URL,可选 | +| `--name` / `-n` | 导入后的文档名称;不传时使用文件名 | +| `--task-id` | `import get` 查询导入任务时必填 | + +支持格式:docx、doc、xlsx、xls、md、txt、xmind、mark。文件大小上限 20MB。 + +## 工作流 + +1. 确认本地文件存在且格式受支持。 +2. 如果用户指定目标知识库或文件夹,传 `--workspace` 或 `--folder`;未指定时导入到默认位置。 +3. 执行 `dws doc import --file ... --format json`。 +4. 正常情况下 CLI 会自动提交、上传并轮询导入任务。 +5. 如果命令超时或中断,从输出中提取 `taskId`,再执行 `dws doc import get --task-id --format json`。 + +## 上下文传递 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `doc import` | `nodeId` / `documentUrl` / `documentName` / `documentType` | 后续 `doc read` / `doc info` / `sheet` 操作 | +| `doc import` 中断 | `taskId` | `doc import get --task-id` 查询任务状态 | + +## 注意事项 + +- `--folder` 和 `--workspace` 都是目标位置参数;用户明确指定文件夹时优先使用 `--folder`。 +- 导入 Excel 后通常得到在线表格,后续数据读取和编辑走 `dws sheet ...`。 +- 导入 Markdown 或 Word 后通常得到在线文档,后续内容读取和编辑走 `dws doc ...`。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-list.md b/skills/multi/dingtalk-doc/references/doc/doc-list.md index 17566a70..20a45a9f 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-list.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-list.md @@ -1,6 +1,6 @@ # doc list(已弃用 → drive list / wiki node list) -> **弃用提示**:`dws doc list` 已迁移,不再是 `doc` 子命令。真机上它虽仍能跑,但每次都会打印弃用警告: +> **弃用提示**:`dws doc list` 已迁移,不再是 `doc` 子命令。它虽仍能跑,但每次都会打印弃用警告: > `'dws doc list' is deprecated, use 'dws drive list --workspace ' or 'dws wiki node list --workspace '`。 > 遍历文件夹/知识库请切到 `dingtalk-drive` skill,用下面的命令。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-search.md b/skills/multi/dingtalk-doc/references/doc/doc-search.md index 7977007c..f7cd6c40 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-search.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-search.md @@ -1,6 +1,6 @@ # doc search(已弃用 → drive search / wiki node search) -> **弃用提示**:`dws doc search` 已迁移,不再是 `doc` 子命令。真机上它虽仍能跑,但每次都会打印弃用警告: +> **弃用提示**:`dws doc search` 已迁移,不再是 `doc` 子命令。它虽仍能跑,但每次都会打印弃用警告: > `'dws doc search' is deprecated, use 'dws drive search' or 'dws wiki node search --workspace '`。 > 搜索文档/文件请切到 `dingtalk-drive` skill,用下面的命令。 diff --git a/skills/multi/dingtalk-doc/references/url-patterns.md b/skills/multi/dingtalk-doc/references/url-patterns.md index c20a5a25..c6c108db 100644 --- a/skills/multi/dingtalk-doc/references/url-patterns.md +++ b/skills/multi/dingtalk-doc/references/url-patterns.md @@ -90,14 +90,14 @@ Step 3 → 按下方路由规则映射到对应产品 | `contentType=ALIDOC`, `extension=adoc` | `doc` | 按 [doc.md](./products/doc.md) 操作 | | `contentType=ALIDOC`, `extension=axls` | `sheet` | 按 [sheet.md](./products/sheet.md) 操作(仅 `axls` 在线电子表格) | | `contentType=ALIDOC`, `extension=able` | `aitable` | 将 nodeId 作为 baseId,按 [aitable.md](./products/aitable.md) 操作 | -| `contentType=DOCUMENT`, `extension=xlsx` / `xls` / `xlsm` / `csv` | `doc` | 必须用 `dws doc download` 下载到本地处理,禁止走 `sheet`(非在线表格,sheet 命令无法操作) | -| `contentType≠ALIDOC`, `nodeType=file` | `doc` | 调用 `dws doc download` 下载,返回文件下载链接 | +| `contentType=DOCUMENT`, `extension=xlsx` / `xls` / `xlsm` / `csv` | `doc` | 必须用 `dws drive download` 下载到本地处理,禁止走 `sheet`(非在线表格,sheet 命令无法操作) | +| `contentType≠ALIDOC`, `nodeType=file` | `doc` | 调用 `dws drive download` 下载,返回文件下载链接 | | `nodeType=folder` | `doc` | 调用 `dws doc list --folder ` 列出指定文件夹直接子节点列表 | | 以上均不匹配 | — | 告知用户当前暂不支持该类型 | > axls vs xlsx 关键区分: > - `axls`(钉钉在线电子表格,`contentType=ALIDOC`)→ 走 `sheet` 产品线(读/写/筛选/导出等服务端原子操作) -> - `xlsx` / `xls` / `xlsm` / `csv`(上传到文档空间的本地表格文件,`contentType=DOCUMENT`)→ 必须走 `dws doc download` 下载到本地后再解析处理,严禁错误路由到 `sheet` 产品线(sheet 命令只支持在线表格,调用 xlsx 节点会直接报错) +> - `xlsx` / `xls` / `xlsm` / `csv`(上传到文档空间的本地表格文件,`contentType=DOCUMENT`)→ 必须走 `dws drive download` 下载到本地后再解析处理,严禁错误路由到 `sheet` 产品线(sheet 命令只支持在线表格,调用 xlsx 节点会直接报错) > - 用户想把在线表格导出为 xlsx 文件 → 用 `dws sheet export --node <表格ID或URL>`(单命令一站式:提交导出任务→轮询→取 downloadUrl,加 `--output ./x.xlsx` 可直接落盘) ### 示例 @@ -110,10 +110,10 @@ dws doc info --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json dws sheet list --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json # 返回 contentType≠ALIDOC, extension=xlsx/xls/csv → 本地表格文件,必须下载处理(禁止走 sheet) -dws doc download --node "https://alidocs.dingtalk.com/i/nodes/xlsx456" +dws drive download --node "https://alidocs.dingtalk.com/i/nodes/xlsx456" # 返回 contentType≠ALIDOC, nodeType=file → 普通文件,下载 -dws doc download --node "https://alidocs.dingtalk.com/i/nodes/def456" +dws drive download --node "https://alidocs.dingtalk.com/i/nodes/def456" # 返回 nodeType=folder → 文件夹,列出子节点 dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/ghi789" --format json @@ -138,8 +138,8 @@ dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/ghi789" --format jso | **adoc**(在线文档) | `doc read` | `doc update` / `doc block update` | ⚠️ `doc delete` | ⚠️ `doc export` (→ docx) | ⚠️ `doc permission *` | ⚠️ `doc media download/insert` | | **axls**(在线电子表格) | `sheet range read` / `sheet list` | `sheet range update` / `sheet append` | ⚠️ `doc delete`(节点删除) | `sheet export`(单命令一站式:提交→轮询→下载,可选 `--output` 落盘) | ⚠️ `doc permission *`(节点级,跨产品) | 不适用 | | **able**(在线多维表) | `aitable base get` / `aitable record query` | `aitable record create/update` | ⚠️ `doc delete`(节点删除)或 `aitable base delete --yes` | `aitable export data --scope all --format excel`(取 downloadUrl,`--output` 不落盘) | ⚠️ `doc permission *`(节点级) | `aitable attachment upload` | -| **xlsx / xls / xlsm / csv**(本地表格文件) | `doc download` → 本地用 xlsx skill 解析 | 不支持服务端写(先下载改本地再上传) | ⚠️ `doc delete`(节点删除) | 不需要(本身就是 xlsx) | ⚠️ `doc permission *` | 不适用 | -| **普通文件** (nodeType=file) | `doc download` | 不支持服务端写 | ⚠️ `doc delete` | 不需要 | ⚠️ `doc permission *` | 不适用 | +| **xlsx / xls / xlsm / csv**(本地表格文件) | `drive download` → 本地用 xlsx skill 解析 | 不支持服务端写(先下载改本地再上传) | ⚠️ `doc delete`(节点删除) | 不需要(本身就是 xlsx) | ⚠️ `doc permission *` | 不适用 | +| **普通文件** (nodeType=file) | `drive download` | 不支持服务端写 | ⚠️ `doc delete` | 不需要 | ⚠️ `doc permission *` | 不适用 | | **文件夹** (nodeType=folder) | `doc list --folder ` | `doc create --folder ...` | ⚠️ `doc delete` | 不适用 | ⚠️ `doc permission *` | 不适用 | | **分享短链** `/i/p/` | `read_url` 兜底(外部工具) | 不适用 | 不适用 | 不适用 | 不适用 | 不适用 | diff --git a/skills/multi/dingtalk-drive/SKILL.md b/skills/multi/dingtalk-drive/SKILL.md index e5812381..4ce10fe2 100644 --- a/skills/multi/dingtalk-drive/SKILL.md +++ b/skills/multi/dingtalk-drive/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉盘 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. @@ -33,6 +33,8 @@ metadata: | "最近访问 / 最近编辑的文档" | `dws drive recent` | | "钉盘目录树" | `python scripts/drive_tree_list.py --depth 2` | | "查文件元数据" | `dws drive info --node ` | +| "查阅读/编辑/评论/下载等节点统计" | `dws drive stats --node ` | +| "创建文件快捷方式" | `dws drive shortcut --node [--folder ] [--workspace ]` | | "下载文件" | `dws drive download --node --output ` | | "上传本地文件" | `dws drive upload --file ./report.pdf [--folder ]` | | "建文件夹" | `dws drive mkdir --name "<名称>" [--folder ]` | @@ -49,6 +51,7 @@ metadata: - `rename` 的 `--name` **只传主名,不带扩展名**;服务端会按原扩展名自动补后缀,带了扩展名会变成双扩展名(如 `报告.txt` → `报告.txt.txt`)。 - `drive download` 需要 `--output` 指定本地保存路径或目录;不要省略必填输出位置。 - 删除、覆盖、移动、公开(publish set/unset)等破坏性操作必须先确认;上传、创建文件夹、下载后要读回或列目录验证。 +- `shortcut` 会创建新节点,执行后必须用 `drive list` 回读目标位置;`stats` 是只读操作。 - 所有 `dws drive` 命令加 `--format json`。 ## 跨产品协作 diff --git a/skills/multi/dingtalk-drive/references/drive.md b/skills/multi/dingtalk-drive/references/drive.md index 30b6a3c8..48507ba7 100644 --- a/skills/multi/dingtalk-drive/references/drive.md +++ b/skills/multi/dingtalk-drive/references/drive.md @@ -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 +``` + +返回节点可用的阅读、编辑、评论、点赞、预览或下载等统计维度;不同文件类型返回字段可能不同。本命令只读。 + +### 创建节点快捷方式 + +```text +Usage: + dws drive shortcut --node [--folder ] [--workspace ] +Example: + dws drive shortcut --node + dws drive shortcut --node --folder + dws drive shortcut --node --workspace +``` + +`--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 根目录时) | @@ -514,12 +540,13 @@ dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --f - 不传 `--space-id` 时默认使用「我的文件」空间 - 不传 `--folder` 时默认操作空间根目录 - `--folder` 只能使用父文件夹的 `dentryUuid`。不要把 `drive info` 返回的数字型 `dentryId` 当作父目录 -- **`--limit` 最大值为 50**,禁止传入超过 50 的值(如 `--limit 100`)。用户要求超过 50 条时,应使用 `--limit 50` 配合 `--cursor` 分页查询 +- **`--limit` 有效上限为 50**:CLI 不做本地校验,传 `--limit 100` 不会报错,但服务端每页最多返回 50 条,超出部分无效。用户要求超过 50 条时,应使用 `--limit 50` 配合 `--cursor` 分页查询 - `--order-by` 支持: `createTime`、`modifyTime`、`name` - **上传文件首选 `dws drive upload` 命令**;手动三步(`upload-info` → HTTP PUT → `commit`)仅用于自定义流式上传等特殊场景。手动三步时 HTTP PUT 必须把 upload-info 返回的 `headers` 全部回传,`Content-Type` 通常要留空;PUT 返回 200 后才能调 `commit`;`uploadId` 有过期时间,过期需重新 `upload-info`;`--folder` 在 upload-info / commit 中要保持一致 - `--file-name` 必须包含扩展名(如 `report.pdf`) - `download` 需要指定 `--output`,CLI 会把文件保存到本地路径或目录 - 文件名规则:头尾不能有空格;不能含 `*`、`"`、`<`、`>`、`|`、制表符;不能以 `.` 结尾 +- `shortcut` 会创建新节点,执行后必须通过 `drive list` 回读确认目标位置;`stats` 为只读命令 ## 自动化脚本 diff --git a/skills/multi/dingtalk-event/SKILL.md b/skills/multi/dingtalk-event/SKILL.md new file mode 100644 index 00000000..37e3d6cc --- /dev/null +++ b/skills/multi/dingtalk-event/SKILL.md @@ -0,0 +1,113 @@ +--- +name: dingtalk-event +description: 钉钉个人消息事件长连接监听、订阅与消费,输出 NDJSON 到 stdout。Use when 用户提到 监听个人消息事件、被@消息、监听我和某人的单聊消息、监听某个群消息、实时接收钉钉消息事件、dws event consume user_im_message_receive_at/user_im_message_receive_o2o/user_im_message_receive_group、用事件驱动 Agent 处理钉钉消息、监听并自动回复消息。命令前缀:dws event。 +--- + +# 钉钉个人消息事件 + +只使用 `dws event consume` 建立个人消息事件长连接。用户要求实时监听、订阅、自动回复或驱动 Agent 时,不要写轮询脚本,不要用消息历史查询模拟事件。 + +## 运行方式 + +- bus 后台进程持有对钉钉的个人 Stream 长连;consume 从 bus 读事件、按 NDJSON 打到 stdout。consume 只读,不发消息(回复用 `dws chat message send`)。 +- 没有 bus 时 consume 自动拉起;通常只跑 consume。 +- 一个组织一个 bus,可同时跑;同组织内多个 consume 共享一个 bus。 +- 非默认组织加全局 `--profile `;漏传会退回默认 profile 而失败。 + +## Core commands + +| Command | Purpose | +|---|---| +| `dws event list` | 查看当前个人事件目录;不要把它当能力菜单主动展示 | +| `dws event schema ` | 查看事件参数和输出字段 schema,默认 JSON | +| `dws event consume [flags]` | 阻塞消费;事件写到 stdout,推荐 `-f ndjson` | +| `dws event status --event ` | 查看个人订阅、personal bus 和本地 consume | +| `dws event stop ` | 取消个人订阅并停止对应本地消费 | +| `dws event stop --all` | 清理当前身份下本地记录的全部个人订阅 | + +区分两个 schema:`dws event schema ` 查事件输出字段;`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` | + +只承认上表 3 个事件码。其它身份模式、应用凭证模式、非个人消息事件不在本 skill 范围内。 + +## Command rules + +- 默认身份就是当前用户,不要额外加身份切换 flag。 +- 使用当前用户 OAuth 登录态;未登录或 token 失效时,引导用户执行 `dws auth login`。 +- 不主动运行 `dws event list` 作为能力菜单;按用户意图直接选择上表事件。 +- 缺少必填 ID 时先解析或追问,不要猜测 ID。 +- 用户只给单聊对端人名时,先运行 `dws aisearch person --keyword "" --dimension name --format json` 解析 userId;多候选必须让用户确认。 +- 用户只给群名时,先运行 `dws chat search --query "" --format json` 解析 openConversationId;多候选必须让用户确认。 +- 正常 Agent 消费使用 `-f ndjson`。抓一条样本可用 `--max-events 1 -f json`。 +- 监听非默认组织时带 `--profile `;漏传会退回默认 profile 而失败。 +- 自己发的消息不作为事件回来(`isSelfLoop` 过滤):边监听边 `dws chat message send` 回复不成环;测试投递用别人 / 机器人发(自发会看到 0 事件)。 +- `--debug-raw-events` 只用于联调确认服务端推送是否到达本地连接;正常任务不要使用。 +- 排查:consume 报 bus 启动失败 → 报错已带真实原因,先查 `dws --profile auth status`(非默认组织带对 `--profile`);本地日志见 `~/.dws/events//personal_stream//bus.log`(`hash` 见 `dws event status` 的 Workdir);有残留用 `dws event stop --all` 清理。看着"挂住"无输出多是误加了 `--foreground`(那是跑 bus、不打印事件),去掉即可。 + +## Call flow + +1. 从用户意图选择事件码;人名或群名先解析成必填 ID。 +2. 需要了解字段时运行 `dws event schema `,读取 `jq_root_path` 和 `schema.properties`。 +3. 启动 `dws event consume ... -f ndjson`,阻塞等 stderr 出现固定就绪行 `[event] ready event_key= ...` 后再开始读 stdout(不要用 sleep 猜)。 +4. stdout 每行是一个事件 JSON;业务字段在 `data` JSON 字符串内,按 `jq_root_path` 解析。 +5. 需要确认监听状态时运行 `dws event status --event `,查看 `Subscriptions` 和 `Consumers`。 +6. 任务完成后用 `dws event stop ` 取消订阅;如果是临时测试,可以在 consume 上加 `--max-events` 或 `--duration` 自动退出。 + +## Subprocess contract + +- `event consume` 阻塞式长连接。stdout 只出事件;stderr 只出状态 / debug / 错误。 +- 就绪:连上后 stderr 打 `[event] ready event_key= bus_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 `。不要 `kill -9`(跳过退订、泄漏服务端订阅)。 +- 批量清理用 `dws event stop --all`。 +- 一 consume 一事件订阅;监听多个对象起多个 consume,本机连接可复用,输出按 `subscribe_id` 隔离。 + +## Examples + +```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 cidxxxxxxxx \ + -f ndjson + +# 有界自测 +dws event consume user_im_message_receive_at \ + --duration 10m \ + -f ndjson + +# 抓一条样本 +dws event consume user_im_message_receive_o2o \ + --user 507971 \ + --max-events 1 \ + -f json +``` + +## 输出处理 + +- `dws event schema ` 是写解析逻辑的依据。 +- 顶层 `jq_root_path` 说明业务字段起点;当前值是 `.data | fromjson`。 +- `schema.properties` 是业务字段列表,例如 `content`、`sender`、`conversation_id`、`message_id`、`event_time`。 +- 不要假设 `data` 已经展开。读取消息正文时先解析 `data`,再读 `payload.body.content` 或 schema 中对应字段。 + +## Topic index + +| Topic | Reference | Coverage | +|---|---|---| +| IM | [references/event-im.md](references/event-im.md) | 三类个人消息事件命令、参数、生命周期、输出解析、自测、过滤和排障 | diff --git a/skills/multi/dingtalk-event/references/event-im.md b/skills/multi/dingtalk-event/references/event-im.md new file mode 100644 index 00000000..e6dc3544 --- /dev/null +++ b/skills/multi/dingtalk-event/references/event-im.md @@ -0,0 +1,162 @@ +# IM 个人消息事件 + +先读上层 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的 IM 个人消息事件:被 @、指定单聊、指定群聊。 + +实时监听、自动回复、订阅事件都必须使用 `dws event consume` 长连接,不要写轮询脚本。 + +## Prerequisite + +个人事件使用当前用户 OAuth 登录态。未登录或 token 失效时,先执行: + +```bash +dws auth login +``` + +查看事件 schema: + +```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 +``` + +schema 默认 JSON。业务字段说明在 `schema.properties`,当前业务 payload 解析起点看 `jq_root_path`。 + +## Event catalog + +| 事件码 | 规则 | 用途 | 必填参数 | +|---|---|---|---| +| `user_im_message_receive_at` | `at` | 当前用户被 @ 的消息 | 无 | +| `user_im_message_receive_o2o` | `singleChat` | 当前用户与指定用户的单聊消息 | `--user` | +| `user_im_message_receive_group` | `group` | 当前用户所在指定群聊/会话的消息 | `--group` | + +默认身份就是当前用户。不要额外加身份切换 flag,不要使用应用凭证模式,不要使用本表以外的事件码。 + +## ID resolution + +- 人名 → `dws aisearch person --keyword "" --dimension name --format json`,确认后取 `userId`。 +- 群名 → `dws chat search --query "" --format json`,确认后取 `openConversationId`。 +- 多候选 → 展示候选并让用户确认。 +- 仍缺必填 ID → 先追问,不要编造。 + +## Consume commands + +```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 cidxxxxxxxx \ + -f ndjson +``` + +## Self-test triggers + +| 事件码 | 自测参数 | 触发方式 | +|---|---|---| +| `user_im_message_receive_at` | `--duration 10m -f ndjson` | 让任意可触达用户在群里 @ 当前登录用户 | +| `user_im_message_receive_o2o` | `--user --duration 10m -f ndjson` | 让对端用户给当前登录用户发送单聊消息 | +| `user_im_message_receive_group` | `--group --duration 10m -f ndjson` | 让任意用户在该群发送消息 | + +stderr 出现固定就绪行 `[event] ready event_key= bus_pid=` 表示本地 consume 已连接到事件 bus;父进程等这行再读 stdout。stdout 每行是一个事件 JSON。 + +## Runtime flags + +| 参数 | 用途 | +|---|---| +| `-f ndjson` | 推荐输出,一行一个事件 JSON | +| `-f json` | 人工查看单条或少量样本;必须配合 `--max-events` 或 `--duration` | +| `--max-events ` | 收到 N 条后退出 | +| `--duration ` | 到时退出,例如 `30s`、`10m` | +| `--output-dir ` | 每个事件写入一个文件 | +| `--route '=dir:'` | 按事件类型路由到目录 | +| `--subscribe-id ` | 复用已有个人订阅 | +| `--query ` | 按消息正文关键词过滤,逗号分隔 | +| `--filter-json ` | 使用个人事件 Filter DSL 过滤 | +| `--debug-raw-events` | 联调用:绕过本地过滤,输出当前 personal stream 实际收到的可解析事件 | + +正常 Agent 消费不要使用 `--debug-raw-events`。它会输出当前连接收到的所有可解析事件,只用于判断服务端是否推到了本机连接。 + +## Output parsing + +`-f ndjson` 的 stdout 每行是一个外层事件对象,常见顶层字段: + +| 字段 | 说明 | +|---|---| +| `event_type` | 个人事件码 | +| `event_id` | 本地输出事件 ID | +| `subscribe_id` | 个人订阅 ID,也是本地输出隔离键 | +| `source_id` | 当前 sourceId | +| `data` | 服务端业务 payload 的 JSON 字符串 | +| `received_at_unix_ms` | 本地接收时间 | + +读取业务字段前先运行 `dws event schema `。当前 schema 顶层的 `jq_root_path` 是 `.data | fromjson`,表示先把 `data` 再解析一次,然后读取业务字段。 + +解析后的常用业务字段: + +| schema 字段 | 当前真实路径 | 说明 | +|---|---|---| +| `content` | `payload.body.content` | 消息正文 | +| `sender` | `payload.body.sender` | 发送人展示名 | +| `conversation_id` | `payload.body.openConversationId` | 开放会话 ID | +| `message_id` | `payload.body.openMessageId` | 开放消息 ID | +| `sender_open_dingtalk_id` | `payload.body.senderOpenDingTalkId` | 发送人的开放钉钉 ID | +| `create_time` | `payload.body.createTime` | 消息创建时间 | +| `event_time` | `payload.event_time` | 消息事件时间戳 | + +## Filtering + +优先用订阅规则参数缩小服务端推送范围: + +- 单聊用 `--user`。 +- 群消息用 `--group`。 + +额外文本过滤再用 `--query` 或 `--filter-json`: + +```bash +dws event consume user_im_message_receive_group \ + --group cidxxxxxxxx \ + --query "报警,故障" \ + -f ndjson +``` + +`--filter-json` 可以使用业务别名,也可以使用真实路径。优先使用 schema 字段名表达意图;需要和服务端联调时再使用真实路径。 + +## Status and stop + +```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 +``` + +`status` 同时展示服务端 `Subscriptions` 和本地 `Consumers`。`Consumers` 表里的 PID、事件码、`subscribe_id`、received/dropped 计数用于确认当前前台 consume 是否还挂在 personal bus 上。 + +停止指定订阅: + +```bash +dws event stop +``` + +清理当前身份下本地记录的全部个人订阅: + +```bash +dws event stop --all +``` + +裸 `dws event stop` 不会取消订阅。前台 `consume` 进程用 Ctrl+C 只会停止本地进程;需要取消服务端订阅时,使用事件输出或 `status` 里的 `subscribe_id` 执行 `dws event stop `。 + +## Troubleshooting + +- 没有输出:先确认 stderr 已出现 `[event] ready event_key=...`。 +- 参数缺失:单聊必须有对端 ID,群消息必须有 openConversationId。 +- 收到非预期消息:检查 stdout 的 `subscribe_id` 是否等于当前命令创建/复用的订阅 ID。 +- 需要判断服务端是否推到当前连接:临时加 `--debug --debug-raw-events`,排查后去掉。 +- 需要长期运行:交给外部进程管理;不要把消息历史查询写成轮询脚本。 +- 安装或更新 skill 后,已打开的 Agent 旧会话需要新开会话或重新加载 skills。 diff --git a/skills/multi/dingtalk-live/SKILL.md b/skills/multi/dingtalk-live/SKILL.md index 422608eb..04536408 100644 --- a/skills/multi/dingtalk-live/SKILL.md +++ b/skills/multi/dingtalk-live/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉直播 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. diff --git a/skills/multi/dingtalk-mail/SKILL.md b/skills/multi/dingtalk-mail/SKILL.md index 2478996b..c7fd05c8 100644 --- a/skills/multi/dingtalk-mail/SKILL.md +++ b/skills/multi/dingtalk-mail/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉邮箱 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. diff --git a/skills/multi/dingtalk-mail/references/mail.md b/skills/multi/dingtalk-mail/references/mail.md index 4a822165..bea9ebdb 100644 --- a/skills/multi/dingtalk-mail/references/mail.md +++ b/skills/multi/dingtalk-mail/references/mail.md @@ -5,6 +5,7 @@ | 命令 | 功能简述 | |------|----------| | `dws mail mailbox list` | 查询**当前用户自己**的可用邮箱列表 | +| `dws mail mailbox profile` | 获取用户邮箱详细信息(容量、别名等) | | `dws mail message list` | 列出指定文件夹中的邮件(默认收件箱) | | `dws mail message search` | 搜索邮件(KQL 语法,按主题/发件人/日期等) | | `dws mail message get` | 查看邮件完整内容(含正文) | @@ -15,7 +16,10 @@ | `dws mail message batch-move` | 批量移动邮件到指定文件夹 | | `dws mail message batch-delete` | 批量删除邮件 | | `dws mail message batch-update` | 批量修改邮件状态(标记已读/未读/添加标签/移除标签) | +| `dws mail message batch-get` | 批量获取邮件详情(最多 20 封) | | `dws mail message verify` | 根据 internetMessageId 查询邮件发送状态 | +| `dws mail sent-message recall` | 撤回已发送的邮件(仅支持同组织内未读邮件) | +| `dws mail sent-message recall-detail` | 查询邮件撤回进度 | | `dws mail draft create` | 创建草稿(保留在草稿箱,不发送) | | `dws mail draft update` | 更新草稿内容(保留在草稿箱,不发送) | | `dws mail draft send` | 发送草稿箱中已有的草稿 | @@ -46,6 +50,13 @@ | `dws mail contact update` | 更新个人邮件联系人信息 | | `dws mail contact batch-delete` | 批量删除个人邮件联系人 | | `dws mail auto-reply get` | 获取用户的自动回复配置 | +| `dws mail auto-reply update` | 更新/设置用户的自动回复配置 | +| `dws mail allow-list list` | 列出个人收信白名单 | +| `dws mail allow-list add` | 添加个人收信白名单 | +| `dws mail allow-list remove` | 移除个人收信白名单 | +| `dws mail block-list list` | 列出个人收信黑名单 | +| `dws mail block-list add` | 添加个人收信黑名单 | +| `dws mail block-list remove` | 移除个人收信黑名单 | | `dws mail rule list` | 列出个人收信规则 | | `dws mail rule create` | 创建个人收信规则 | | `dws mail rule update` | 更新个人收信规则 | @@ -93,6 +104,27 @@ Example: |------|------|------| | `emailAccounts` | `List[]` | 邮箱列表,每条包含 `email`(邮箱地址)、`orgName`(所属企业,个人邮箱为 null)、`type`(`ORG`=企业邮箱 / `PERSONAL`=个人邮箱) | +### 获取用户邮箱信息 + +``` +Usage: + dws mail mailbox profile [flags] +Example: + dws mail mailbox profile --email user@company.com +Flags: + --email string 用户的邮箱地址 (必填) +``` + +**返回字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `email` | string | 邮箱地址 | +| `emailAliases` | List[] | 邮件地址别名列表 | +| `name` / `nickname` / `displayName` | string | 用户名、昵称、显示名 | +| `mboxSize` / `mboxSizeUsed` | int64 | 邮箱容量与已用容量(字节) | +| `createdTime` / `modifiedTime` | string | 创建时间与更新时间 | + ### 查找他人邮箱地址(通讯录查人) > **这不是 `mailbox list`。** 当需要获取**某人**的邮箱地址时,必须走以下三路并发查询,取最先返回有效邮箱的结果。禁止臆测邮箱地址。 @@ -214,7 +246,24 @@ Flags: | 字段 | 类型 | 说明 | |------|------|------| -| `message` | `object` | 邮件完整信息,包含主题、发件人、收件人、正文、附件等 | +| `message` | `object` | 邮件信息,含 subject/from/toRecipients/ccRecipients/markdownBody/conversationId/receivedDateTime/size 等 | + +> **注意:** 即使邮件确有附件,`message get` 返回的 `message` 对象也**不含** `attachments`/`isRead`/`tags` 字段(实测 keys 仅上表所列这些)。要取附件请另用 `dws mail attachment list`。 + +### 批量获取邮件详情 + +``` +Usage: + dws mail message batch-get [flags] +Example: + dws mail message batch-get --email user@company.com --ids , + dws mail message batch-get --email user@company.com --ids ,, +Flags: + --email string 邮件所属邮箱地址 (必填) + --ids string 要获取的邮件 ID 列表,逗号分隔,最多 20 个 (必填) +``` + +单次最多获取 20 封邮件。CLI 会逐个调用 `get_email_by_message_id`,并以 `messages` 数组聚合返回。 ### 发送邮件 ``` @@ -933,6 +982,53 @@ Flags: | `failed` | 发送失败 | | `unknown` | 未知状态 | +### [危险] 撤回已发送的邮件 + +``` +Usage: + dws mail sent-message recall [flags] +Example: + dws mail sent-message recall --email user@company.com --id --subject "邮件主题" --yes +Flags: + --email string 发件人邮箱地址 (必填) + --id string 要撤回的邮件 ID (必填) + --subject string 邮件主题 (必填) + --yes 跳过确认提示,直接执行 (可选) +``` + +> CAUTION: 此命令会撤回已发送邮件。仅支持撤回同组织内未读邮件,执行前必须确认目标邮件、主题和影响范围。 + +**返回字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | string | 撤回任务 ID,可用于 `recall-detail` 查询进度 | +| `success` | boolean | 接口调用是否成功 | +| `errorCode` / `errorMsg` | string | 错误码和错误信息(仅失败时存在) | + +### 查询邮件撤回进度 + +``` +Usage: + dws mail sent-message recall-detail [flags] +Example: + dws mail sent-message recall-detail --email user@company.com --id +Flags: + --email string 用户的邮箱地址 (必填) + --id string 撤回任务 ID (必填),由 recall 命令返回 +``` + +根据撤回任务 ID 查询邮件撤回进度。任务状态包括 `UNINITED` / `SUBMITTED` / `RUNNING` / `FINISHED` / `CANCELED` / `FAILED`。 + +**返回字段:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | string | 撤回任务 ID | +| `status` | string | 任务状态 | +| `totalCount` / `succeededCount` / `failedCount` | int | 总数、成功数、失败数 | +| `details` | List[] | 每封邮件的撤回结果 | + ### 创建草稿 ``` Usage: @@ -1218,6 +1314,61 @@ Flags: | `scope` | string | 回复范围: "contact"(仅联系人) 或 "all"(所有人) | | `content` | string | 自动回复内容 | +### 更新自动回复配置 + +更新或设置用户的邮件自动回复配置。建议先通过 `auto-reply get` 获取当前配置,再传入需要修改的字段值。 + +``` +Usage: + dws mail auto-reply update [flags] +Example: + dws mail auto-reply update --email user@company.com --enabled true \ + --start "2026/07/01 09:00:00 +0800" --end "2026/07/07 18:00:00 +0800" \ + --scope all --content "出差中,请稍后联系" + dws mail auto-reply update --email user@company.com --enabled false \ + --start "2026/07/01 09:00:00 +0800" --end "2026/07/07 18:00:00 +0800" \ + --scope all --content "已关闭自动回复" +Flags: + --email string 用户的邮箱地址 (必填) + --enabled string 是否启用自动回复: true/false (必填) + --start string 自动回复开始时间,格式: YYYY/MM/DD HH:MM:SS +ZZZZ (必填) + --end string 自动回复结束时间,格式: YYYY/MM/DD HH:MM:SS +ZZZZ (必填) + --scope string 回复范围: contact(仅联系人)/all(所有人) (必填) + --content string 自动回复内容 (必填) +``` + +### 个人收信白名单管理 + +``` +Usage: + dws mail allow-list list [flags] + dws mail allow-list add [flags] + dws mail allow-list remove [flags] +Examples: + dws mail allow-list list --email user@company.com + dws mail allow-list add --email user@company.com --entries a@b.com,@example.com + dws mail allow-list remove --email user@company.com --entries a@b.com,@example.com +Flags: + --email string 用户的邮箱地址 (必填) + --entries string 逗号分隔的地址列表,支持邮件地址或 @domain.com 域名(add/remove 必填) +``` + +### 个人收信黑名单管理 + +``` +Usage: + dws mail block-list list [flags] + dws mail block-list add [flags] + dws mail block-list remove [flags] +Examples: + dws mail block-list list --email user@company.com + dws mail block-list add --email user@company.com --entries spam@bad.com,@junk.com + dws mail block-list remove --email user@company.com --entries spam@bad.com,@junk.com +Flags: + --email string 用户的邮箱地址 (必填) + --entries string 逗号分隔的地址列表,支持邮件地址或 @domain.com 域名(add/remove 必填) +``` + ### 收信规则管理 #### 列出收信规则 @@ -1364,6 +1515,8 @@ Flags: --actions string 规则动作 JSON 数组 (必填) ``` +> **⚠️ 实测:** 尽管 `--help` 把 `--conditions` 标为(可选),服务端实际要求它**非空**——省略或传空会返回 `209 SYS_UNKNOWN_ERROR(1001)` 失败,帮助文本承诺的"命中所有邮件"无条件匹配**不可用**。create 请始终传入有效条件。 + **返回字段:** | 字段 | 类型 | 说明 | @@ -1375,11 +1528,11 @@ Flags: #### 更新收信规则 -更新已有的收信规则。**除 `--conditions` 外所有参数均为必填**。 +更新已有的收信规则。**所有参数(含 `--conditions`)均须传入有效值**——见下方警告。 > **建议工作流:** 先通过 `dws mail rule list` 获取当前规则的完整配置,再传入需要修改的字段值。 > -> `--conditions` 为空或不传表示命中所有邮件(无条件匹配)。`--actions` 格式同 create 命令。 +> **⚠️ 实测:** `--help` 称 `--conditions` 为空即命中所有邮件,但该无条件匹配**不可用**——省略或传空会返回 `209 SYS_UNKNOWN_ERROR(1001)` 失败。update 请始终传入有效条件。`--actions` 格式同 create 命令。 ``` Usage: @@ -1399,6 +1552,8 @@ Flags: --actions string 规则动作 JSON 数组 (必填) ``` +> **⚠️ 实测:** 上方 `--conditions` 的"(可选,为空表示命中所有邮件)"是 `--help` 原文,但服务端实际要求它**非空**——省略或传空会返回 `209 SYS_UNKNOWN_ERROR(1001)` 失败。update 须传入有效条件。 + **返回字段:** | 字段 | 类型 | 说明 | @@ -1467,9 +1622,11 @@ Flags: ## 意图判断 用户说"我的邮箱/邮箱地址" → `mailbox list`(**仅限查询自己的邮箱,不能查他人**) +用户说"邮箱详情/邮箱容量/邮箱别名/邮箱 profile" → `mailbox profile --email <邮箱>` 用户说"获取/查找/得到 某人的邮箱地址" → **不是 `mailbox list`**,走三路并发查询流程(见「查找他人邮箱地址」章节) 用户说"找邮件/搜邮件/查邮件" → `message search` 用户说"看邮件/打开邮件/邮件内容" → 先 `message search` 获取 messageId,再 `message get` +用户说"批量查看邮件详情/批量打开这些邮件/按多个邮件ID取详情" → `message batch-get --ids `(单次最多 20 个) 用户说"发邮件/写邮件" → 先 `mailbox list` 获取发件地址,再 `message send` 用户说“给(某人名字)发邮件” / “查询某人发给我的邮件” / “查询发给某人的邮件” / 任何涉及按人名查找邮箱的场景 → **第一步**:并发同时发起以下三路查询,取最先返回有效邮箱的结果;若三路均无有效邮箱,ask_human 请用户提供,禁止臆测: @@ -1496,6 +1653,11 @@ Flags: 用户说"删除会话/把会话放入已删除/批量删除会话" → 单条用 `thread trash`,多条用 `thread batch-trash`;传入的是会话 ID,不是邮件 ID 用户说"批量标记邮件已读/未读/给邮件加标签/移除邮件标签" → `message batch-update --action markRead/markUnread/addTags/removeTags`(操作对象是邮件 ID;会话级操作用 `thread update/batch-update`) 用户说"邮件发出去了吗/查邮件发送状态/确认邮件是否发送成功/邮件投递结果" → 用发送类命令返回的 `internetMessageId`,调用 `message verify` 查询 `sendStatus` +用户说"撤回邮件/撤回已发送邮件" → `sent-message recall --email <邮箱> --id --subject "<主题>" --yes`(危险操作,先确认) +用户说"查询邮件撤回进度/撤回任务状态" → `sent-message recall-detail --email <邮箱> --id ` +用户说"设置/更新/关闭自动回复" → `auto-reply update`(所有字段必填,先 `auto-reply get` 获取当前配置) +用户说"收信白名单/添加白名单/移除白名单" → `allow-list list/add/remove` +用户说"收信黑名单/添加黑名单/移除黑名单" → `block-list list/add/remove` 用户说"查看会话/获取会话/看这封邮件的会话" → 先 `message search` 或 `message get` 获取邮件中的 `conversationId`,再 `thread get` 用户说"搜索/查找/联系 邮箱用户/联系人/某人的邮箱地址" → `user search`(搜索通讯录人员,不是搜邮件内容) 用户说"发送草稿/把草稿发出去/发这封草稿" → 先 `message search --query "folderId:5"` 找到草稿 messageId,再 `draft send` @@ -1612,7 +1774,9 @@ dws mail thread get --email user@company.com --id --select mess | 操作 | 从返回中提取 | 用于 | |------|-------------|------| | `mailbox list` | 邮箱地址 | message search/get/send/thread get 的 --email/--from | +| `mailbox profile` | `emailAliases` / `mboxSize` / `mboxSizeUsed` | 展示邮箱别名与容量信息 | | `message search` | `messageId` | message get 的 --id | +| `message search` | `messageId` | message batch-get 的 --ids | | `message search` | `conversationId` | thread get 的 --id | | `message search` | `messageId` | attachment list 的 --id | | `attachment list` | `attachments[].id` / `attachments[].name` | attachment download 的 --attachment-id / --name | @@ -1627,6 +1791,8 @@ dws mail thread get --email user@company.com --id --select mess | `aisearch person` → `contact user get` / `contact user search` / `mail user search` | 用户邮箱 (orgAuthEmail / email) | message send 的 --to/--cc(三路并发,取先到结果) | | `user search` | 用户邮箱 (email) | message send 的 --to/--cc | | `message send` / `draft send` / `message reply` / `message reply-all` / `message forward` | `internetMessageId` | `message verify` 的 --internet-message-id | +| `sent-message recall` | `id` | sent-message recall-detail 的 --id | +| `auto-reply get` | `enabled` / `startTime` / `endTime` / `scope` / `content` | auto-reply update 的当前配置基线 | ## 注意事项 diff --git a/skills/multi/dingtalk-minutes/SKILL.md b/skills/multi/dingtalk-minutes/SKILL.md index 4401c096..9848f86d 100644 --- a/skills/multi/dingtalk-minutes/SKILL.md +++ b/skills/multi/dingtalk-minutes/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉 AI 听记 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. diff --git a/skills/multi/dingtalk-minutes/references/07-minutes.md b/skills/multi/dingtalk-minutes/references/07-minutes.md index c04b8d20..6a540e57 100644 --- a/skills/multi/dingtalk-minutes/references/07-minutes.md +++ b/skills/multi/dingtalk-minutes/references/07-minutes.md @@ -8,4 +8,4 @@ | meeting-followup | 1. **提取待办优先**:`python scripts/minutes_extract_todos.py [--id ] [--max N]` → 获取行动项列表 无脚本时按「多源并行采集」(见 dws-shared 的 conventions.md)执行 2. 提取行动项 3. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId` → `todo task create --title "<行动项>" --executors `(每条行动项;批量时通过 `dingtalk-todo` skill:`python scripts/todo_batch_create.py todos.json`,或循环 `dws todo task create`) 4. `chat message send --group --text "<通知内容>"` 通知已创建 | | share-minutes | **拉摘要优先**:`python scripts/minutes_recent_summary.py [--max N] [--output summary.md]` → 获取近期听记摘要 备选手动:1. `minutes list mine` → 取 `taskUuid` 2. 用户选定 3. `minutes get summary --id ` → 取摘要 4. 单聊:`contact user search --query "<姓名>"` → 取 `openDingTalkId` → `chat message send --open-dingtalk-id --text "<摘要内容>"`(推荐);群聊:`--group --text "<摘要内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user `(备选) | | browse-minutes | 1. `minutes list all --query "<关键词>" --max ` → 取全量 `taskUuid`(翻页直至无更多;无主题筛选用 `minutes list mine`) 2. **详情/元数据优先** `minutes get batch --ids `(仅 API 上限时拆多批);**摘要专项**且无 batch 字段时再用并行 `get summary` 或 `**python scripts/minutes_recent_summary.py --max `** 3. 汇总展示;**同一用户诉求下避免**「半截 list → 再 list 剩余」「拆 4 条一批 summary 连跑多轮」等无必要拆分 | -| minutes-detail | 1. 确定 `taskUuid`:用户已提供 → 直接用;未提供 → `minutes list mine --max 5` 让用户选 2. 并行拉取四维信息:`minutes get info --id ` `&` `minutes get summary --id ` `&` `minutes get keywords --id ` `&` `minutes get todos --id ` `& wait` 3. 整合输出:基础信息(标题、时间、参与人)→ AI 摘要 → 关键字 → 行动项/待办 4.(可选)用户要求看原文 → `minutes get transcription --id ` | +| minutes-detail | 1. 确定 `taskUuid`:用户已提供 → 直接用;未提供 → `minutes list mine --max 5` 让用户选 2. 依次拉取四维信息(**逐条串行执行,禁止用 `&` 后台与 `wait`——违反 Shell 安全铁律,Windows cmd 下会命令错乱**):`minutes get info --id `、`minutes get summary --id `、`minutes get keywords --id `、`minutes get todos --id ` 3. 整合输出:基础信息(标题、时长、开始时间;get info 不返回参会人)→ AI 摘要 → 关键字 → 行动项/待办 4.(可选)用户要求看原文 → `minutes get transcription --id ` | diff --git a/skills/multi/dingtalk-minutes/references/minutes.md b/skills/multi/dingtalk-minutes/references/minutes.md index 5f9f71b9..cc4c5a8c 100644 --- a/skills/multi/dingtalk-minutes/references/minutes.md +++ b/skills/multi/dingtalk-minutes/references/minutes.md @@ -26,7 +26,7 @@ dws minutes │ ├── shared [--query] [--start] [--end] [--max] [--next-token] # 仅查询他人共享给我的听记(不含我自己创建的) │ └── all [--query] [--start] [--end] [--max] [--next-token] # 查询我可访问的所有听记(= mine ∪ shared,覆盖面最广,默认 scope) ├── get # 获取听记详情(get 后必须跟子命令名) -│ ├── info --id # 获取听记基础信息(标题、时长、参与人、状态等) +│ ├── info --id # 获取听记基础信息(标题、时长、开始时间、taskUuid、访问链接;不含参会人) │ ├── summary --id # 获取听记 AI 摘要(结构化纪要) │ ├── transcription --id # 获取听记语音转写原文(逐字稿,支持分页) │ ├── keywords --id # 获取听记关键词 @@ -615,7 +615,7 @@ AI 基于已拉取的转写数据,**本地完成**聚类与摘要(无需新 > **铁律 2:严禁用 AI 摘要里的"参与人/参会人"字段判断某人是否参会** > - AI 摘要中的"参与人"字段往往**只截取最显著的 1-2 个名字**(通常是创建人或发言最多的人),**不是完整参会人列表** > - "AI 摘要参与人只有故愚 → 灵麦没参会" 是典型的错误推理链 -> - 如果一定要列出参会人,应使用 `dws minutes get info` / `get batch` 返回的 `participants` 字段,而不是摘要文本里的描述 +> - 如果一定要列出参会人,只能从 `get transcription` 的转写发言人(`speakerName`/`speakerId`)汇总——`get info` / `get batch` **均不返回** `participants` 结构化字段(get info 只返回 duration/startTime/taskUuid/title/url,get batch 只返回 creatorNick/creatorUnionId/status 等),别指望从它们拿参会名单 > > **铁律 3:通讯录查询是 Step 4 的强制起点,不依赖 Step 3 是否成功** > - Step 2 一旦返回"未命中真名标注",**Step 3 与 Step 4 ① 必须并发触发**,不要等 Step 3 失败再补查 @@ -664,7 +664,7 @@ Step 7: 引导用户替换发言人(调用 speaker replace 写回听记) > - **未命中真名标注 ≠ 目标人物没参会**——绝大多数听记的发言人都是匿名编号,"未命中"是**默认场景**,恰恰是发言人识别功能要解决的问题 > - **不要在转写文本里 grep 目标人名**作为存在性判断——花名/真名通常不会出现在 TA 自己的发言里(参见铁律 1) > - **不要把 `dws minutes get summary` 摘要文本里写到的"参与人"当作完整参会列表**——AI 摘要里的"参与人"字段只截取最显著的 1-2 个名字,不是完整名册(参见铁律 2) -> - 唯一能下"目标人物没参会"结论的场景是:`get info` / `get batch` 返回的结构化 `participants` 字段里**完整列出参会人且不含目标**,且 `dws contact user search` 也搜不到该人 → 才允许告知用户"该人不在参会列表" +> - 听记各接口都拿不到权威的完整参会名册:`get info` / `get batch` **均不返回** `participants` 字段,转写发言人列表又常是匿名编号。因此**几乎无法**仅凭听记数据下"目标人物没参会"的结论;只有当转写发言人已全部实名标注且不含目标、且 `dws contact user search` 也搜不到该人时,才可谨慎告知"未在可见发言人中找到该人" ##### Step 3: 转写原文内推断(优先) @@ -810,7 +810,7 @@ Flags: --ids string 听记 taskUuid 列表,逗号分隔 (必填) ``` -返回字段: 听记标题、时长、参与人列表、创建时间、taskUuid、听记状态 +返回字段: `result.minutesDetails` 数组,每项含 `title`(标题)、`durationMicros`(时长, 微秒)、`startTime`(开始时间, 毫秒)、`taskUuid`、`status`(状态)、`creatorNick`(创建人昵称)、`creatorUnionId`、`size`、`bizType`、`isDeleted`。**不返回参会人列表**。 ### 修改听记标题 ``` @@ -2239,7 +2239,7 @@ Step 2 **[禁止]** 看到全是匿名编号 → 没有继续走 Step 3-4,反 1. **"在转写里搜不到目标人名"绝不构成"没参会"的证据**:花名/真名通常不出现在 TA 自己的发言里,这是默认场景而非例外。99% 的听记发言人都是匿名编号——这正是发言人识别功能要解决的问题。 -2. **AI 摘要的"参与人"字段是低保真信号,不能作为参会判断依据**:`get summary` 返回的是 AI 生成的自然语言摘要,里面提到的"参与人"通常只是最显著的 1-2 人;要拿完整参会列表,应使用 `get info` / `get batch` 返回的结构化 `participants` 字段。 +2. **AI 摘要的"参与人"字段是低保真信号,不能作为参会判断依据**:`get summary` 返回的是 AI 生成的自然语言摘要,里面提到的"参与人"通常只是最显著的 1-2 人。而 `get info` / `get batch` **均不返回**结构化 `participants` 字段,无法从中拿完整参会列表;能拿到的只有 `get transcription` 的转写发言人(常为匿名编号)。 3. **`dws contact user search` 是 Step 4 ① 的必跑项,单次调用就能突破死局**:本案例的整个失败链路只要有 1 次通讯录查询就能立即扭转——拿到"木兰=王佳明,产品设计部"后,Step 5 的角色匹配就有了锚点,再也不会得出"没参会"的错误结论。 diff --git a/skills/multi/dingtalk-minutes/scripts/minutes_extract_todos.py b/skills/multi/dingtalk-minutes/scripts/minutes_extract_todos.py index 7ca3d651..10123377 100644 --- a/skills/multi/dingtalk-minutes/scripts/minutes_extract_todos.py +++ b/skills/multi/dingtalk-minutes/scripts/minutes_extract_todos.py @@ -44,6 +44,58 @@ def run_dws( return None +def todos_from_payload(payload: Any) -> List[dict]: + """解析 `minutes get todos` 返回为待办列表。 + + 返回结构: result.dingtalkTodoList(对象数组, 含 title 等)与 + result.actions(JSON 字符串数组, 每条形如 + {"mark":[],"value":"..."}). 二者无 todos 键。 + 优先取 dingtalkTodoList; 为空时回退解析 actions。 + """ + if isinstance(payload, dict): + inner = payload.get('result', payload) + else: + inner = payload + out: List[dict] = [] + if isinstance(inner, dict): + ding_list = inner.get('dingtalkTodoList') + if isinstance(ding_list, list) and ding_list: + for t in ding_list: + if isinstance(t, dict): + content = t.get('title') or t.get('content') or '' + if content: + out.append({'content': str(content), '_raw': t}) + if out: + return out + actions = inner.get('actions') + if isinstance(actions, list): + for a in actions: + content = '' + if isinstance(a, str): + text = a.strip() + if text.startswith('{'): + try: + parsed = json.loads(text) + content = parsed.get('value') or '' + except json.JSONDecodeError: + content = text + else: + content = text + elif isinstance(a, dict): + content = (a.get('value') or a.get('content') + or a.get('title') or '') + if content: + out.append({'content': str(content)}) + elif isinstance(inner, list): + for t in inner: + if isinstance(t, dict): + content = (t.get('content') or t.get('text') + or t.get('title') or t.get('value') or '') + if content: + out.append({'content': str(content), '_raw': t}) + return out + + def main(): parser = argparse.ArgumentParser( description='从听记中提取待办事项' @@ -82,21 +134,9 @@ def main(): ]) if not todos_data: continue - if isinstance(todos_data, list): - items = todos_data - elif isinstance(todos_data, dict): - inner = todos_data.get('result', todos_data) - if isinstance(inner, dict): - items = inner.get('todos', []) - elif isinstance(inner, list): - items = inner - else: - items = [] - else: - items = [] + items = todos_from_payload(todos_data) for t in items: - if isinstance(t, dict): - t['_source'] = title + t['_source'] = title all_todos.extend(items) print(f"\n📋 听记待办汇总") diff --git a/skills/multi/dingtalk-minutes/scripts/minutes_list_parse.py b/skills/multi/dingtalk-minutes/scripts/minutes_list_parse.py index 712327a9..d6aa83ea 100644 --- a/skills/multi/dingtalk-minutes/scripts/minutes_list_parse.py +++ b/skills/multi/dingtalk-minutes/scripts/minutes_list_parse.py @@ -17,7 +17,7 @@ def _unwrap_rows(payload: Any) -> List[Any]: return value if isinstance(value, dict): for inner_key in ( - 'items', 'list', 'records', 'minutes', + 'itemList', 'items', 'list', 'records', 'minutes', ): inner = value.get(inner_key) if isinstance(inner, list): @@ -30,7 +30,8 @@ def uuid_title_pairs_from_payload(payload: Any) -> List[Tuple[str, str]]: out: List[Tuple[str, str]] = [] for item in _unwrap_rows(payload): if isinstance(item, dict): - uuid = item.get('taskUuid') or item.get('id') or item.get('task_uuid') + uuid = (item.get('taskUuid') or item.get('uuid') + or item.get('id') or item.get('task_uuid')) if not uuid: continue title = item.get('title') or item.get('name') or '无标题' @@ -53,6 +54,7 @@ def uuid_title_pairs_from_payload(payload: Any) -> List[Tuple[str, str]]: continue uuid = ( parsed.get('taskUuid') + or parsed.get('uuid') or parsed.get('id') or parsed.get('task_uuid') ) diff --git a/skills/multi/dingtalk-minutes/scripts/minutes_recent_summary.py b/skills/multi/dingtalk-minutes/scripts/minutes_recent_summary.py index 24e8ed44..f136f290 100644 --- a/skills/multi/dingtalk-minutes/scripts/minutes_recent_summary.py +++ b/skills/multi/dingtalk-minutes/scripts/minutes_recent_summary.py @@ -93,9 +93,16 @@ def main(): if isinstance(summary_data, str): summary_text = summary_data elif isinstance(summary_data, dict): - summary_text = (summary_data.get('summary') - or summary_data.get('content') - or json.dumps(summary_data, + # 兼容 {result: {fullSummary}} 包裹结构 + inner = summary_data.get('result') + container = (inner if isinstance(inner, dict) + else summary_data) + summary_text = (container.get('fullSummary') + or container.get('summary') + or container.get('content') + or (inner if isinstance(inner, str) + else '') + or json.dumps(container, ensure_ascii=False)) output_lines.append(f"## {i}. {title}\n") diff --git a/skills/multi/dingtalk-oa/SKILL.md b/skills/multi/dingtalk-oa/SKILL.md index cfa7280e..56e52e93 100644 --- a/skills/multi/dingtalk-oa/SKILL.md +++ b/skills/multi/dingtalk-oa/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉 OA 审批 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. diff --git a/skills/multi/dingtalk-oa/references/oa.md b/skills/multi/dingtalk-oa/references/oa.md index c44c049e..643b0ac7 100644 --- a/skills/multi/dingtalk-oa/references/oa.md +++ b/skills/multi/dingtalk-oa/references/oa.md @@ -314,7 +314,7 @@ Flags: 用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject` 用户说"撤回审批/取消审批" → `approval revoke` 用户说"审批记录/操作历史" → `approval records` -用户说"我发起的审批" → `approval list-initiated`(需 --process-code,可从 list-forms 或 detail 获取) +用户说"我发起的审批" → `approval list-initiated`(需 --process-code,可从 list-forms / search-forms / detail 获取) 用户说"有哪些审批表单/可见表单" → `approval list-forms` 用户说"我有哪些待审的任务" → `approval tasks` 用户说"我发起的审批单" -> `approval list-submitted` @@ -351,7 +351,7 @@ dws oa approval records --instance-id --format json # 7. 获取可见审批表单(得到 processCode) dws oa approval list-forms --cursor 0 --limit 100 --format json -# 8. 查看自己发起的审批列表(--process-code 来自 list-forms 或 detail) +# 8. 查看自己发起的审批列表(--process-code 来自 list-forms / search-forms / detail) dws oa approval list-initiated --process-code \ --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" \ --cursor 0 --limit 20 --format json @@ -383,6 +383,7 @@ dws oa approval oa-cc-noticer --instance-id --users "userId1 | `tasks` | `taskId` | approve / reject / redirect-task 的 --task-id | | `detail` | `processCode` | list-initiated 的 --process-code | | `list-forms` | `processCode` | list-initiated 的 --process-code | +| `search-forms` | `processCode` | list-initiated 的 --process-code | ## 注意事项 @@ -391,7 +392,7 @@ dws oa approval oa-cc-noticer --instance-id --users "userId1 - `redirect-task` 的 `--to-actioner-id` 可通过 `dws contact user search` 获取目标用户 userId - `revoke` 只能撤销自己发起的审批 - `--remark` 审批意见虽为可选,但建议填写以留存审批痕迹 -- `list-initiated` 的 `--process-code` 可从 `list-forms` 或 `detail` 返回中提取 +- `list-initiated` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取。当 `list-forms` 返回 `processCodeList` 为空(`totalCount -1`)时,用 `search-forms --query <表单名>`(如 `--query 报销`)按名称精准拿 `processCode` 更稳 - `list-initiated` 的 `--start` / `--end` 区间有后端上限(约 120 天)。超过上限会返回误导性的 `business_error: 时间戳无效`(实为区间过长,不是时间格式问题)。跨度大时请拆成多段短区间分别查询 ## 自动化脚本 diff --git a/skills/multi/dingtalk-report/SKILL.md b/skills/multi/dingtalk-report/SKILL.md index c71cd0a9..ee3400fd 100644 --- a/skills/multi/dingtalk-report/SKILL.md +++ b/skills/multi/dingtalk-report/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉日志 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. diff --git a/skills/multi/dingtalk-sheet/SKILL.md b/skills/multi/dingtalk-sheet/SKILL.md index 2aad7a62..24b8d26c 100644 --- a/skills/multi/dingtalk-sheet/SKILL.md +++ b/skills/multi/dingtalk-sheet/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉电子表格 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. @@ -35,6 +35,11 @@ metadata: | "新建工作表" | `dws sheet new --node --name ""` | | "读取单元格" | `dws sheet range read --node --sheet-id --range A1:B2` | | "写入单元格" | `dws sheet range update --node --sheet-id --range A1:B2 --values '[[..]]'` | +| "结构化读取 / DataFrame 读取" | `dws sheet table-get --node [--sheet-id ]` | +| "结构化写入 / DataFrame 写入" | `dws sheet table-put --node --sheets ''` | +| "创建透视表 / 数据透视" | `dws sheet pivot-table create --node --source "'Sheet1'!A1:D100" --properties ''` | +| "查看透视表" | `dws sheet pivot-table list --node --sheet-id ` | +| "显示 / 隐藏网格线" | `dws sheet show-gridline|hide-gridline --node --sheet-id ` | | "追加一行" | `dws sheet append --node --sheet-id --values '[[..]]'` | | "查找 / 替换" | `dws sheet find --node --sheet-id --find "<关键词>"` / `dws sheet replace --node --sheet-id --find "<旧值>" --replacement "<新值>"` | | "插入图片到单元格" | `dws sheet write-image --node --sheet-id --range A1:A1 --file <本地图片路径>`(CLI 自动上传本地图片并写入单元格;没有 --resource-id/--resource-url 这两个 flag) | diff --git a/skills/multi/dingtalk-sheet/references/sheet.md b/skills/multi/dingtalk-sheet/references/sheet.md index 567c4278..a7310028 100644 --- a/skills/multi/dingtalk-sheet/references/sheet.md +++ b/skills/multi/dingtalk-sheet/references/sheet.md @@ -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` | 合并单元格 | @@ -108,6 +116,8 @@ dws sheet filter-view --help | `sheet update-dimension` | 更新行/列属性(显隐/行高/列宽) | | `sheet move-dimension` | 移动行或列到指定位置 | | `sheet add-dimension` | 在末尾追加空行或空列 | +| `sheet group-dimension` | 创建行/列分组 | +| `sheet ungroup-dimension` | 取消行/列分组 | | `sheet media-upload` | 上传附件到表格 | | `sheet write-image` | 上传图片并写入单元格 | | `sheet create-float-image` | 创建浮动图片 | @@ -151,12 +161,13 @@ 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 | +| 行列操作 | [sheet/sheet-dimension-operations.md](./sheet/sheet-dimension-operations.md) | insert / delete / update / move / add-dimension / group-dimension / ungroup-dimension | | 样式与合并 | [sheet/sheet-style-format.md](./sheet/sheet-style-format.md) | range set-style / batch-set-style / merge-cells / unmerge-cells | | 条件格式 | [sheet/sheet-conditional-format.md](./sheet/sheet-conditional-format.md) | cond-format list / create / update / delete | | 浮动图表 | [sheet/sheet-chart.md](./sheet/sheet-chart.md) | chart list / create / update / delete | @@ -254,6 +265,11 @@ dws sheet filter-view --help - 注意与 `append`(追加数据行)区分:`add-dimension` 追加的是空行/空列,`append` 追加的是带数据的行 - 请勿用 `range update` 写空数据来模拟追加,`add-dimension` 直接扩展表格维度 +用户说"创建行分组/创建列分组/折叠几行/折叠几列/取消行列分组": +- 创建分组 → `group-dimension --range <整行或整列范围>` +- 取消分组 → `ungroup-dimension --range <整行或整列范围>` +- `--range` 只能传整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`,不要传 `A1:C5` + ### 单元格格式 用户说"设置样式/改颜色/设背景色/加粗/居中/换行/字体颜色/字号": @@ -1099,6 +1115,38 @@ Flags: 在工作表末尾追加指定数量的空行或空列。 +### 创建行或列分组 +``` +Usage: + dws sheet group-dimension [flags] +Example: + dws sheet group-dimension --node --sheet-id --range "3:7" + dws sheet group-dimension --node --sheet-id --range "C:F" --group-state fold + dws sheet group-dimension --node --sheet-id --range "Sheet1!3:7" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --range string 整行/整列范围,如 "3:7" 或 "C:F" (必填) + --group-state string 创建后的分组状态: expand 或 fold,默认 expand +``` + +在工作表中为连续行或连续列创建分组。`--range` 必须是整行或整列范围,不支持普通单元格矩形范围。 + +### 取消行或列分组 +``` +Usage: + dws sheet ungroup-dimension [flags] +Example: + dws sheet ungroup-dimension --node --sheet-id --range "3:7" + dws sheet ungroup-dimension --node --sheet-id --range "C:F" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --range string 整行/整列范围,如 "3:7" 或 "C:F" (必填) +``` + +取消指定连续行或连续列的分组。 + ### 取消合并单元格 ``` Usage: @@ -1581,7 +1629,7 @@ Flags: - MCP 返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试 - 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试 -**限制**:仅支持钉钉在线电子表格(alxs)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。 +**限制**:仅支持钉钉在线电子表格(axls)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。 ### 表格模板(list / search / apply) ``` @@ -1903,6 +1951,8 @@ dws sheet export --node --output ./ | `replace` | `replaceCount` 被替换的单元格数量 | 确认替换结果 | | `move-dimension` | `sheetId` 工作表 ID | 确认操作完成 | | `add-dimension` | `sheetId` 工作表 ID | 确认操作完成 | +| `group-dimension` | `range` / `level` / `groupState` | 确认行列分组层级和展开状态 | +| `ungroup-dimension` | `range` / `level` | 确认行列分组已取消 | | `unmerge-cells` | `sheetId` 工作表 ID | 确认操作完成 | | `set-dropdown` | `range` 实际设置范围、`optionCount` 选项数量、`enableMultiSelect` 是否多选 | 确认下拉列表设置成功 | | `get-dropdown` | `hasDropdown` 是否存在下拉、`dataValidations` 下拉配置列表(含 `conditionValues`、`ranges`、`options`) | 查看已有下拉配置 | @@ -2032,6 +2082,9 @@ dws sheet export --node --output ./ - `add-dimension` vs `range update`:需要在末尾追加空行/空列时,必须使用 `add-dimension` 命令,禁止用 `range update` 写空数据来模拟追加效果 - `add-dimension` 追加的是空行/空列,与 `append`(追加带数据的行)不同 - `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000 +- `group-dimension` / `ungroup-dimension` 的 `--range` 只接受整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`,不支持 `A1:C5` +- `group-dimension` 的 `--group-state` 只接受 `expand` 或 `fold`,默认 `expand` +- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;需要创建后立即折叠时,优先使用独立 `group-dimension --group-state fold` - `unmerge-cells` 取消指定范围内所有合并单元格,使用 A1 表示法指定范围 - `set-dropdown` 在指定范围内设置下拉列表,`--options` 为 JSON 数组,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB` 格式)。选项值不能包含英文逗号。`--multi-select` 启用多选模式。如果目标范围已存在下拉列表,会被新配置覆盖 - `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。无下拉列表时 `hasDropdown` 为 false @@ -2067,7 +2120,7 @@ dws sheet export --node --output ./ - `filter-view delete-criteria` 仅清除指定列的条件,不会删除整个筛选视图。如需删除整个筛选视图,请使用 `filter-view delete` - `filter-view delete-criteria` 如果指定列没有设置筛选条件,调用不会报错 - 筛选视图相关操作需要"可阅读"权限(list / info / list-criteria / get-criteria)或"可编辑"权限(create / update / delete / update-criteria / delete-criteria),不支持跨组织操作 -- ★ `export` 仅支持钉钉在线电子表格(alxs)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` +- ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` - ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令 - `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可 - ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试 diff --git a/skills/multi/dingtalk-sheet/references/sheet/sheet-batch-operations.md b/skills/multi/dingtalk-sheet/references/sheet/sheet-batch-operations.md index ce79f719..d354af92 100644 --- a/skills/multi/dingtalk-sheet/references/sheet/sheet-batch-operations.md +++ b/skills/multi/dingtalk-sheet/references/sheet/sheet-batch-operations.md @@ -26,6 +26,7 @@ - 需要对**多个**不同区域执行 `merge-cells` / `unmerge-cells` 时(如按分组合并多列相同内容) - 需要对**多个**不同区域执行 `update-dimension` 时(如统一调整多列列宽或多行行高,含 hidden + pixelSize) - 需要先插入行列再写入数据时(`add-dimension` + `range update` / `csv-put`) +- 需要批量创建或取消行列分组时(`group-dimension` / `ungroup-dimension`) - 需要对多个区域执行不同写入操作时(多次 `range update` + `range clear` 等组合) 当同一工具需要对多个区域重复调用时,**推荐**改用 `batch-update` 合并为单次请求——`batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。 @@ -80,6 +81,7 @@ Flags: toolName 用 CLI 命令名,input 的键用 CLI flag 名去掉 --: range clear / range update / merge-cells / unmerge-cells / update-dimension range fill / range copy-to / add-dimension / delete-dimension / move-dimension + group-dimension / ungroup-dimension set-dropdown / delete-dropdown / csv-put / delete-float-image Notes: - 默认严格事务模式:任一子操作失败 → 整批回滚到初始状态 @@ -177,6 +179,8 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet' | `add-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | | `delete-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | | `move-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | +| `group-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | +| `ungroup-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) | | `set-dropdown` | [sheet-dropdown](./sheet-dropdown.md) | | `delete-dropdown` | [sheet-dropdown](./sheet-dropdown.md) | | `csv-put` | [sheet-write-data](./sheet-write-data.md) | @@ -230,6 +234,19 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet' ] ``` +### 批量创建行列分组 + +```jsonc +[ + {"toolName":"group-dimension", + "input":{"sheet-id":"...","range":"3:7","group-state":"expand"}}, + {"toolName":"group-dimension", + "input":{"sheet-id":"...","range":"C:F"}}, + {"toolName":"ungroup-dimension", + "input":{"sheet-id":"...","range":"10:12"}} +] +``` + ## 上下文传递 | 操作 | 从返回中提取 | 用于 | @@ -244,3 +261,5 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet' - ★ **需要组合多个不同写操作(清除+写入等)时,用 `batch-update`**:原子事务,任一操作失败则整批回滚,避免留下半成品 - ★ **批次完成后必须回读校验**:用 `range read` 或 `csv-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与预期值对照 - ★ **`batch-update` 不支持嵌套**:`--operations` 中的 `toolName` 必须是原子操作,不可再嵌套 `batch-update` +- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;`input.range` 只接受整行或整列范围,如 `3:7`、`C:F` +- 需要创建后立即折叠分组时,优先使用独立 `dws sheet group-dimension --group-state fold` diff --git a/skills/multi/dingtalk-sheet/references/sheet/sheet-dimension-operations.md b/skills/multi/dingtalk-sheet/references/sheet/sheet-dimension-operations.md index 589c157e..afc2f5ac 100644 --- a/skills/multi/dingtalk-sheet/references/sheet/sheet-dimension-operations.md +++ b/skills/multi/dingtalk-sheet/references/sheet/sheet-dimension-operations.md @@ -26,6 +26,12 @@ - 注意与 `append`(追加数据行)区分:`add-dimension` 追加的是空行/空列,`append` 追加的是带数据的行 - 请勿用 `range update` 写空数据来模拟追加,`add-dimension` 直接扩展表格维度 +用户说"创建行分组/创建列分组/折叠几行/折叠几列/展开行列分组/取消行列分组": +- 创建分组 → `group-dimension --range <整行或整列范围>` +- 创建并折叠 → `group-dimension --group-state fold` +- 取消分组 → `ungroup-dimension --range <整行或整列范围>` +- `--range` 只能传整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7` + **结构预检**:插入、删除、移动行列前,必须先执行 `dws sheet info --node --sheet-id --format json` 查看 `mergedRanges`。合并区域跨过操作位置时,行列变更可能导致表头/分组标题断裂、空白或错位;需要先向用户说明影响,必要时取消合并后操作,再按原模式重新 `merge-cells`。 ## 命令详细参考 @@ -172,6 +178,38 @@ Flags: 在工作表末尾追加指定数量的空行或空列。 +### 创建行或列分组 +``` +Usage: + dws sheet group-dimension [flags] +Example: + dws sheet group-dimension --node --sheet-id --range "3:7" + dws sheet group-dimension --node --sheet-id --range "C:F" --group-state fold + dws sheet group-dimension --node --sheet-id --range "Sheet1!3:7" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --range string 整行/整列范围,如 "3:7" 或 "C:F" (必填) + --group-state string 创建后的分组状态: expand 或 fold,默认 expand +``` + +为连续行或连续列创建分组。`--range` 必须是整行或整列范围,不支持 `A1:C5` 这类普通单元格矩形范围。 + +### 取消行或列分组 +``` +Usage: + dws sheet ungroup-dimension [flags] +Example: + dws sheet ungroup-dimension --node --sheet-id --range "3:7" + dws sheet ungroup-dimension --node --sheet-id --range "C:F" +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) + --range string 整行/整列范围,如 "3:7" 或 "C:F" (必填) +``` + +取消指定连续行或连续列的分组。 + ## 核心工作流 ```bash @@ -244,6 +282,8 @@ dws sheet update-dimension --node --sheet-id \ | `update-dimension` | `a1Notation` 被更新区域范围、`hidden` 生效的显隐状态、`pixelSize` 生效的尺寸 | 确认更新结果 | | `move-dimension` | `sheetId` 工作表 ID | 确认操作完成 | | `add-dimension` | `sheetId` 工作表 ID | 确认操作完成 | +| `group-dimension` | `range` / `level` / `groupState` | 确认行列分组层级和展开状态 | +| `ungroup-dimension` | `range` / `level` | 确认行列分组已取消 | | `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id | ## 注意事项 @@ -273,3 +313,7 @@ dws sheet update-dimension --node --sheet-id \ - `add-dimension` vs `range update`:需要在末尾追加空行/空列时,必须使用 `add-dimension` 命令,禁止用 `range update` 写空数据来模拟追加效果 - `add-dimension` 追加的是空行/空列,与 `append`(追加带数据的行)不同 - `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000 +- `group-dimension` / `ungroup-dimension` 的 `--range` 只接受整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`,不支持 `A1:C5` +- `group-dimension` 的 `--group-state` 只接受 `expand` 或 `fold`,默认 `expand` +- 分组操作返回 `level` 表示当前分组层级;不要把它误认为固定深度参数 +- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;需要创建后立即折叠时,优先使用独立 `group-dimension --group-state fold` diff --git a/skills/multi/dingtalk-sheet/references/sheet/sheet-export.md b/skills/multi/dingtalk-sheet/references/sheet/sheet-export.md index f390efed..891da573 100644 --- a/skills/multi/dingtalk-sheet/references/sheet/sheet-export.md +++ b/skills/multi/dingtalk-sheet/references/sheet/sheet-export.md @@ -56,7 +56,7 @@ Flags: - MCP 返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试 - 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试 -**限制**:仅支持钉钉在线电子表格(alxs)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。 +**限制**:仅支持钉钉在线电子表格(axls)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。 ## 核心工作流 @@ -86,7 +86,7 @@ dws sheet export --node --output ./ ## 注意事项 -- ★ `export` 仅支持钉钉在线电子表格(alxs)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` +- ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` - ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令 - `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可 - ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试 diff --git a/skills/multi/dingtalk-sheet/references/sheet/sheet-pivot-table.md b/skills/multi/dingtalk-sheet/references/sheet/sheet-pivot-table.md new file mode 100644 index 00000000..88547ca0 --- /dev/null +++ b/skills/multi/dingtalk-sheet/references/sheet/sheet-pivot-table.md @@ -0,0 +1,67 @@ +# 透视表管理 + +## 使用场景 + +- 用户要按部门、月份等维度汇总数据 → `pivot-table create` +- 用户要查看现有透视表或取得 `pivotTableId` → `pivot-table list` +- 用户要调整行列、聚合方式或显示选项 → `pivot-table update` +- 用户明确要求删除透视表 → `pivot-table delete`(不可逆,必须先确认) + +透视表是原生对象,会随源数据更新;不要用一组普通汇总公式冒充透视表。 + +## 列出与查看 + +```bash +dws sheet pivot-table list --node --sheet-id +dws sheet pivot-table list --node --sheet-id --pivot-table-id +``` + +不传 `--pivot-table-id` 时列出工作表中的全部透视表;传入时返回单个透视表详情。 + +## 创建 + +```bash +dws sheet pivot-table create --node \ + --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 --sheet-id \ + --pivot-table-id \ + --properties '{"show_subtotals":false}' +``` + +Open 接口支持部分更新,因此更新显示选项时不需要重复传入 `values`。更新行列或聚合字段时,应先用 `list --pivot-table-id` 读取当前配置,再传入需要变更的字段。 + +## 删除 + +```bash +dws sheet pivot-table delete --node --sheet-id \ + --pivot-table-id --yes +``` + +删除不可恢复。Agent 必须先展示目标文档、工作表和 `pivotTableId`,获得用户明确同意后才能附加 `--yes`。 diff --git a/skills/multi/dingtalk-sheet/references/sheet/sheet-read-data.md b/skills/multi/dingtalk-sheet/references/sheet/sheet-read-data.md index 21eb0a11..03d52d67 100644 --- a/skills/multi/dingtalk-sheet/references/sheet/sheet-read-data.md +++ b/skills/multi/dingtalk-sheet/references/sheet/sheet-read-data.md @@ -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 + dws sheet table-get --node --sheet-id --range "A1:D20" + dws sheet table-get --node --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: diff --git a/skills/multi/dingtalk-sheet/references/sheet/sheet-workbook.md b/skills/multi/dingtalk-sheet/references/sheet/sheet-workbook.md index be061b0f..1d1006b1 100644 --- a/skills/multi/dingtalk-sheet/references/sheet/sheet-workbook.md +++ b/skills/multi/dingtalk-sheet/references/sheet/sheet-workbook.md @@ -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 --sheet-id + dws sheet hide-gridline --node --sheet-id +Flags: + --node string 表格文档 ID 或 URL (必填) + --sheet-id string 工作表 ID 或名称 (必填) +``` + +这两个命令只修改指定工作表的网格线显示状态,不修改单元格内容或边框样式。执行后可用同一命令反向恢复。 + ## 核心工作流 ```bash diff --git a/skills/multi/dingtalk-sheet/references/sheet/sheet-write-data.md b/skills/multi/dingtalk-sheet/references/sheet/sheet-write-data.md index 6b6f4451..c56c7921 100644 --- a/skills/multi/dingtalk-sheet/references/sheet/sheet-write-data.md +++ b/skills/multi/dingtalk-sheet/references/sheet/sheet-write-data.md @@ -3,6 +3,7 @@ ## 使用场景 用户说"写数据/填表/更新单元格/写入公式": +- 从 DataFrame/结构化对象一次写入一个或多个工作表 → `table-put` - 更新数据 → `range update` - 【强制】`--sheet-id` 必填:即使是单工作表也不能省略,不要参照 `range read` 的默认行为;未知时先执行 `dws sheet list --node --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 \ + --sheets '[{"name":"Data","columns":["name","score"],"data":[["Alice",95]],"dtypes":{"score":"float64"}}]' + + dws sheet table-put --node --sheets @table.json + cat table.json | dws sheet table-put --node --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: diff --git a/skills/multi/dingtalk-skill/SKILL.md b/skills/multi/dingtalk-skill/SKILL.md index 6a69c8fa..048bc07d 100644 --- a/skills/multi/dingtalk-skill/SKILL.md +++ b/skills/multi/dingtalk-skill/SKILL.md @@ -12,7 +12,7 @@ metadata: # 悟空技能管理 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. @@ -33,10 +33,10 @@ metadata: ## 环境 -- `DWS_SKILL_API_HOST` 覆盖技能 API 地址(默认 `https://aihub.dingtalk.com`) +- `DWS_SKILL_API_HOST` 覆盖技能 API 地址(默认 `https://mcp.dingtalk.com`) ## 兼容提示 - `dws skill find` → 用 `dws skill search --query <关键词>` -- `--scopes` 已废弃(真机打 `Flag --scopes has been deprecated, 请使用 --source 替代` 警告)→ 用 `--source` -- 前置:`skill search` 依赖企业开通技能市场;未开通时真机报「当前企业暂未开放此功能」,属企业级开关,非命令写法问题 +- `--scopes` 已废弃(会打印 `Flag --scopes has been deprecated, 请使用 --source 替代` 警告)→ 用 `--source` +- 前置:`skill search` 依赖企业开通技能市场;未开通时报「当前企业暂未开放此功能」,属企业级开关,非命令写法问题 diff --git a/skills/multi/dingtalk-skill/references/skill.md b/skills/multi/dingtalk-skill/references/skill.md index c1e47f55..68fdcea7 100644 --- a/skills/multi/dingtalk-skill/references/skill.md +++ b/skills/multi/dingtalk-skill/references/skill.md @@ -48,8 +48,8 @@ Flags: --source string 查询范围,空格分隔。备选值:DingtalkMarket(钉钉市场)、OrgInternal(企业内部)。为空默认查市场技能 ``` -> `--scopes` 已废弃(真机会打 `Flag --scopes has been deprecated, 请使用 --source 替代` 警告),一律用 `--source`。 -> 前置:`skill search` 依赖企业开通技能市场;未开通时真机报「当前企业暂未开放此功能」,这是企业级开关,非命令写法问题。 +> `--scopes` 已废弃(会打印 `Flag --scopes has been deprecated, 请使用 --source 替代` 警告),一律用 `--source`。 +> 前置:`skill search` 依赖企业开通技能市场;未开通时报「当前企业暂未开放此功能」,这是企业级开关,非命令写法问题。 返回字段: - `skillId` — 技能 ID(后续 `install` 需要) diff --git a/skills/multi/dingtalk-todo/SKILL.md b/skills/multi/dingtalk-todo/SKILL.md index 9e850bfd..7417aeca 100644 --- a/skills/multi/dingtalk-todo/SKILL.md +++ b/skills/multi/dingtalk-todo/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉待办 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. diff --git a/skills/multi/dingtalk-wiki/SKILL.md b/skills/multi/dingtalk-wiki/SKILL.md index 7722500c..3a36097b 100644 --- a/skills/multi/dingtalk-wiki/SKILL.md +++ b/skills/multi/dingtalk-wiki/SKILL.md @@ -12,7 +12,7 @@ metadata: # 钉钉知识库 Skill -> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 +> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。 > **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH. diff --git a/skills/multi/dws-shared/SKILL.md b/skills/multi/dws-shared/SKILL.md index f48e1563..e1f8ca2d 100644 --- a/skills/multi/dws-shared/SKILL.md +++ b/skills/multi/dws-shared/SKILL.md @@ -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 --help` 查证再修正一次,别把自然语言当命令 / flag -- 认证失败 / token 过期:提示用户 `dws auth login` 重新登录 +- 服务端 token 过期:提示用户 `dws auth login` 重新登录;本地密钥不匹配按上面的 macOS 迁移流程处理,不要混为 token 过期 - 业务错误码 / 接口语义:用 `dws devdoc article search --query "<关键词>" --format json` 查官方文档,不编造原因 diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index 0939ef51..77448d2b 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -1,9 +1,13 @@ package scripts_test import ( + "archive/tar" + "archive/zip" + "compress/gzip" "os" "os/exec" "path/filepath" + "runtime" "strings" "testing" ) @@ -163,6 +167,7 @@ func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { for _, rel := range []string{ filepath.Join("..", "..", "scripts", "install.sh"), + filepath.Join("..", "..", "scripts", "install-event.sh"), filepath.Join("..", "..", "scripts", "install.ps1"), filepath.Join("..", "..", "scripts", "install-skills.sh"), } { @@ -186,6 +191,290 @@ func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { } } +func TestInstallEventScriptStaticExpectations(t *testing.T) { + t.Parallel() + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatalf("Abs(install-event.sh) error = %v", err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", scriptPath, err) + } + text := string(data) + + for _, want := range []string{ + "DingTalk-Real-AI/dingtalk-workspace-cli", + "releases/latest", + "EVENT_VERSION", + "DWS_SKILLS_ONLY", + "dingtalk-event", + "user_im_message_receive_o2o", + ".config/opencode/skills", + "$HOME/.dws/skills/multi/$EVENT_SKILL_NAME", + "$HOME/.dws/skills/mono", + } { + if !strings.Contains(text, want) { + t.Fatalf("install-event.sh missing %q", want) + } + } + for _, avoid := range []string{ + "releases?per_page=30", + "select(.tag_name", + "dingtalk-dev", + "client-secret", + "--as app", + } { + if strings.Contains(text, avoid) { + t.Fatalf("install-event.sh should not expose old app/dev install content %q", avoid) + } + } +} + +func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installer test is for unix-like hosts") + } + + root := t.TempDir() + fakeHome := filepath.Join(root, "home") + installDir := filepath.Join(root, "bin") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + + assetName := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + t.Skipf("unsupported test arch %s", runtime.GOARCH) + } + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skipf("unsupported test os %s", runtime.GOOS) + } + + if err := os.MkdirAll(releaseDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", releaseDir, err) + } + writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ + "dws": "fake-event-binary\n", + }) + writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ + "multi/dingtalk-event/SKILL.md": "event skill user_im_message_receive_o2o\n", + "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", + "SKILL.md": "legacy mono root\n", + }) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + + for _, dir := range []string{ + filepath.Join(fakeHome, ".codex"), + filepath.Join(fakeHome, ".config", "opencode"), + } { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", dir, err) + } + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatalf("Abs(install-event.sh) error = %v", err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+fakeHome, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_INSTALL_DIR="+installDir, + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME="+assetName, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("install-event.sh error = %v\noutput:\n%s", err, string(output)) + } + got := string(output) + for _, want := range []string{ + "Version: v1.0.51", + "Skill dingtalk-event", + "Skill dws", + "dws event consume user_im_message_receive_o2o", + } { + if !strings.Contains(got, want) { + t.Fatalf("install-event output missing %q:\n%s", want, got) + } + } + + binaryData, err := os.ReadFile(filepath.Join(installDir, "dws")) + if err != nil { + t.Fatalf("ReadFile(installed dws) error = %v", err) + } + if string(binaryData) != "fake-event-binary\n" { + t.Fatalf("installed binary content = %q", string(binaryData)) + } + + for _, rel := range []string{ + ".agents/skills/dingtalk-event/SKILL.md", + ".codex/skills/dingtalk-event/SKILL.md", + ".config/opencode/skills/dingtalk-event/SKILL.md", + ".agents/skills/dws/SKILL.md", + ".codex/skills/dws/SKILL.md", + ".dws/skills/multi/dingtalk-event/SKILL.md", + ".dws/skills/mono/SKILL.md", + } { + p := filepath.Join(fakeHome, filepath.FromSlash(rel)) + data, err := os.ReadFile(p) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v\noutput:\n%s", p, err, got) + } + if !strings.Contains(string(data), "user_im_message_receive_o2o") { + t.Fatalf("%s does not contain event skill marker: %q", p, string(data)) + } + } + if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-dev")); !os.IsNotExist(err) { + t.Fatalf("dingtalk-dev should not be installed by install-event.sh, stat err=%v", err) + } +} + +func TestInstallEventScriptSkillsOnlySkipsBinary(t *testing.T) { + t.Parallel() + + root := t.TempDir() + fakeHome := filepath.Join(root, "home") + installDir := filepath.Join(root, "bin") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + + if err := os.MkdirAll(releaseDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", releaseDir, err) + } + writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ + "multi/dingtalk-event/SKILL.md": "event skill user_im_message_receive_o2o\n", + "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", + }) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatalf("Abs(install-event.sh) error = %v", err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+fakeHome, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_INSTALL_DIR="+installDir, + "DWS_SKILLS_ONLY=1", + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME=unused", + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("install-event.sh skills-only error = %v\noutput:\n%s", err, string(output)) + } + if _, err := os.Stat(filepath.Join(installDir, "dws")); !os.IsNotExist(err) { + t.Fatalf("DWS_SKILLS_ONLY=1 should not install binary, stat err=%v\noutput:\n%s", err, string(output)) + } + if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md")); err != nil { + t.Fatalf("skills-only should install event skill: %v\noutput:\n%s", err, string(output)) + } +} + +func TestInstallEventScriptNoSkillsOnlyInstallsBinary(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installer test is for unix-like hosts") + } + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + t.Skipf("unsupported test arch %s", runtime.GOARCH) + } + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skipf("unsupported test os %s", runtime.GOOS) + } + + root := t.TempDir() + fakeHome := filepath.Join(root, "home") + installDir := filepath.Join(root, "bin") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + assetName := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" + + writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ + "dws": "fake-event-binary\n", + }) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatalf("Abs(install-event.sh) error = %v", err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+fakeHome, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_INSTALL_DIR="+installDir, + "DWS_NO_SKILLS=1", + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME="+assetName, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("install-event.sh no-skills error = %v\noutput:\n%s", err, string(output)) + } + if _, err := os.Stat(filepath.Join(installDir, "dws")); err != nil { + t.Fatalf("DWS_NO_SKILLS=1 should install binary: %v\noutput:\n%s", err, string(output)) + } + if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event")); !os.IsNotExist(err) { + t.Fatalf("DWS_NO_SKILLS=1 should not install event skill, stat err=%v\noutput:\n%s", err, string(output)) + } +} + +func TestInstallEventScriptDefaultsToLatestStableRelease(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installer test is for unix-like hosts") + } + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + t.Skipf("unsupported test arch %s", runtime.GOARCH) + } + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skipf("unsupported test os %s", runtime.GOOS) + } + + root := t.TempDir() + fakeHome := filepath.Join(root, "home") + installDir := filepath.Join(root, "bin") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + assetName := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" + + writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ + "dws": "fake-event-binary\n", + }) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + writeFakeGH(t, filepath.Join(stubRoot, "gh"), "v1.0.51") + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatalf("Abs(install-event.sh) error = %v", err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+fakeHome, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "DWS_INSTALL_DIR="+installDir, + "DWS_NO_SKILLS=1", + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME="+assetName, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("install-event.sh latest release error = %v\noutput:\n%s", err, string(output)) + } + if !strings.Contains(string(output), "Version: v1.0.51") { + t.Fatalf("install-event.sh did not resolve the latest stable version:\n%s", string(output)) + } +} + func TestInstallScriptsUseFlattenedSkillsSourceRoot(t *testing.T) { t.Parallel() @@ -442,6 +731,97 @@ done } } +func writeTarGz(t *testing.T, path string, files map[string]string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(path), err) + } + f, err := os.Create(path) + if err != nil { + t.Fatalf("Create(%s) error = %v", path, err) + } + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + for name, content := range files { + hdr := &tar.Header{ + Name: name, + Mode: 0o755, + Size: int64(len(content)), + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("WriteHeader(%s) error = %v", name, err) + } + if _, err := tw.Write([]byte(content)); err != nil { + t.Fatalf("Write(%s) error = %v", name, err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("tar Close error = %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("gzip Close error = %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("Close(%s) error = %v", path, err) + } +} + +func writeZip(t *testing.T, path string, files map[string]string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", filepath.Dir(path), err) + } + f, err := os.Create(path) + if err != nil { + t.Fatalf("Create(%s) error = %v", path, err) + } + zw := zip.NewWriter(f) + for name, content := range files { + w, err := zw.Create(name) + if err != nil { + t.Fatalf("zip Create(%s) error = %v", name, err) + } + if _, err := w.Write([]byte(content)); err != nil { + t.Fatalf("zip Write(%s) error = %v", name, err) + } + } + if err := zw.Close(); err != nil { + t.Fatalf("zip Close error = %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("Close(%s) error = %v", path, err) + } +} + +func writeFakeCurl(t *testing.T, path string) { + t.Helper() + const script = `#!/bin/sh +set -eu +out="" +url="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +[ -n "$out" ] || { echo "fake curl: missing -o" >&2; exit 1; } +case "$url" in + *"/${FAKE_ASSET_NAME}") cp "$FAKE_RELEASE_DIR/$FAKE_ASSET_NAME" "$out" ;; + *"/dws-skills.zip") cp "$FAKE_RELEASE_DIR/dws-skills.zip" "$out" ;; + *) echo "fake curl: unexpected URL $url" >&2; exit 1 ;; +esac +` + mustWriteFile(t, path, []byte(script), 0o755) +} + +func writeFakeGH(t *testing.T, path, version string) { + t.Helper() + script := "#!/bin/sh\nprintf '%s\\n' '" + version + "'\n" + mustWriteFile(t, path, []byte(script), 0o755) +} + func mustWriteFile(t *testing.T, path string, data []byte, mode os.FileMode) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 9809673c..69be252f 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -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) + } +} diff --git a/test/skill_tests.md b/test/skill_tests.md index 83a0992e..17af85e6 100644 --- a/test/skill_tests.md +++ b/test/skill_tests.md @@ -47,6 +47,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然 | `devdoc` | `references/products/simple.md` | 2 | 7 | | `dev` | `skills/multi/dingtalk-dev/SKILL.md` | 19 | 25 | | `ding` | `references/products/ding.md` | 2 | 5 | +| `event` | `references/products/event.md` | 8 | 11 | | `report` | `references/products/report.md` | 6 | 26 | | `routing` | `references/intent-guide.md` | 1 | 3 | | `todo` | `references/products/todo.md` | 6 | 30 | @@ -993,6 +994,59 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然 --- +### event(10 条) + +#### `dws event consume user_im_message_receive_at` + +**event_event_consume_at_001** +- Prompt: 监听有人 @ 我的消息 +- Expected: `dws event consume user_im_message_receive_at -f ndjson` + +#### `dws event consume user_im_message_receive_o2o` + +**event_event_consume_o2o_001** +- Prompt: 监听我和 userId 507971 的单聊消息 +- Expected: `dws event consume user_im_message_receive_o2o --user 507971 -f ndjson` +- Flags: `--user` = `507971` + +**event_event_consume_o2o_003** `[ASK_USER]` +- Prompt: 监听我的个人单聊消息 +- Expected: `dws event consume user_im_message_receive_o2o -f ndjson` + +**event_event_consume_o2o_auto_reply_001** +- Prompt: 监听我和 userId 507971 的单聊消息,并对方发什么自动回复什么 +- Expected: `dws event consume user_im_message_receive_o2o --user 507971 -f ndjson` +- Flags: `--user` = `507971` + +#### `dws event consume user_im_message_receive_group` + +**event_event_consume_group_001** +- Prompt: 监听 openConversationId cid123 的群消息 +- Expected: `dws event consume user_im_message_receive_group --group cid123 -f ndjson` +- Flags: `--group` = `cid123` + +#### `dws chat search` + +**event_lookup_group_001** +- Prompt: 监听项目群的消息 +- Expected: `dws chat search --query "项目群" --format json` +- Flags: `--query` = `"项目群"` + +#### `dws event status` + +**event_event_status_001** +- Prompt: 查看指定单聊个人事件订阅状态 +- Expected: `dws event status --event user_im_message_receive_o2o` +- Flags: `--event` = `user_im_message_receive_o2o` + +#### `dws event stop` + +**event_event_stop_001** +- Prompt: 停止个人事件订阅 subId-abc123 +- Expected: `dws event stop subId-abc123` + +--- + ### ding(5 条) #### `dws ding message recall` diff --git a/test/skill_tests_results.md b/test/skill_tests_results.md index 3cac668a..cd00a4f0 100644 --- a/test/skill_tests_results.md +++ b/test/skill_tests_results.md @@ -2,8 +2,8 @@ ## Summary -- **Total Test Cases**: 232 -- **Passed**: 232 +- **Total Test Cases**: 244 +- **Passed**: 244 - **Failed**: 0 - **Pass Rate**: 100.0% @@ -16,9 +16,10 @@ | calendar | 30 | 30 | 0 | 100.0% | | chat | 31 | 31 | 0 | 100.0% | | contact | 14 | 14 | 0 | 100.0% | -| devapp | 22 | 22 | 0 | 100.0% | -| devdoc | 8 | 8 | 0 | 100.0% | +| dev | 25 | 25 | 0 | 100.0% | +| devdoc | 9 | 9 | 0 | 100.0% | | ding | 5 | 5 | 0 | 100.0% | +| event | 8 | 8 | 0 | 100.0% | | report | 26 | 26 | 0 | 100.0% | | routing | 3 | 3 | 0 | 100.0% | | todo | 30 | 30 | 0 | 100.0% | @@ -1093,182 +1094,206 @@ - Command path: PASS (contact user search-mobile) - Flags: PASS (1 flags validated) -### devapp +### dev -**devapp_devapp_list_001** ✅ PASS +**dev_app_list_001** ✅ PASS - Prompt: 搜索开放平台应用,应用名是 DemoApp -- Expected: `dws devapp list --name DemoApp --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp list) +- Expected: `dws dev app list --name DemoApp --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app list) - Flags: PASS (1 flags validated) -**devapp_devapp_list_002** ✅ PASS +**dev_app_list_002** ✅ PASS -- Prompt: 分页查询开放平台应用,使用上一页返回的 cursor,每页 10 条 -- Expected: `dws devapp list --cursor NEXT_CURSOR --page-size 10 --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp list) -- Flags: PASS (2 flags validated) - -**devapp_devapp_list_003** ✅ PASS - -- Prompt: 在开发者后台搜索企业内部应用 DemoApp -- Expected: `dws devapp list --name DemoApp --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp list) -- Flags: PASS (1 flags validated) - -**devapp_devapp_get_001** ✅ PASS - -- Prompt: 查看统一应用 UNIFIED_APP_ID 的应用详情 -- Expected: `dws devapp get --unified-app-id UNIFIED_APP_ID --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp get) -- Flags: PASS (1 flags validated) - -**devapp_devapp_create_001** ✅ PASS - -- Prompt: 创建一个企业内部应用,名称 DemoApp,描述是内部应用 -- Expected: `dws devapp create --name DemoApp --desc "内部应用" --type internal --dry-run --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp create) -- Flags: PASS (3 flags validated) - -**devapp_devapp_create_002** ✅ PASS - -- Prompt: 已确认创建企业内部应用 DemoApp -- Expected: `dws devapp create --name DemoApp --type internal --yes --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp create) -- Flags: PASS (2 flags validated) - -**devapp_devapp_update_001** ✅ PASS - -- Prompt: 把应用 UNIFIED_APP_ID 的描述改成 新描述 -- Expected: `dws devapp update --unified-app-id UNIFIED_APP_ID --desc "新描述" --dry-run --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp update) -- Flags: PASS (2 flags validated) - -**devapp_devapp_delete_001** ✅ PASS - -- Prompt: 删除开放平台应用 UNIFIED_APP_ID,先给我预览 -- Expected: `dws devapp delete --unified-app-id UNIFIED_APP_ID --dry-run --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp delete) -- Flags: PASS (1 flags validated) - -**devapp_devapp_credentials_get_001** ✅ PASS - -- Prompt: 查询应用 UNIFIED_APP_ID 的开放平台凭证 -- Expected: `dws devapp credentials get --unified-app-id UNIFIED_APP_ID --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp credentials get) -- Flags: PASS (1 flags validated) - -**devapp_devapp_member_list_001** ✅ PASS - -- Prompt: 查看开放平台应用 UNIFIED_APP_ID 的成员 -- Expected: `dws devapp member list --unified-app-id UNIFIED_APP_ID --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp member list) -- Flags: PASS (1 flags validated) - -**devapp_devapp_member_add_001** ✅ PASS - -- Prompt: 给开放平台应用 UNIFIED_APP_ID 添加开发者成员 userId1,userId2,先预览 -- Expected: `dws devapp member add --unified-app-id UNIFIED_APP_ID --users userId1,userId2 --member-type DEVELOPER --dry-run --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp member add) -- Flags: PASS (3 flags validated) - -**devapp_devapp_member_remove_001** ✅ PASS - -- Prompt: 已确认,从开放平台应用 UNIFIED_APP_ID 移除开发者成员 userId1 -- Expected: `dws devapp member remove --unified-app-id UNIFIED_APP_ID --users userId1 --member-type DEVELOPER --yes --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp member remove) +- Prompt: 分页查询开放平台应用,第 2 页,每页 10 条,按修改时间倒序 +- Expected: `dws dev app list --page 2 --page-size 10 --sort gmt_modified --order desc --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app list) - Flags: PASS (4 flags validated) -**devapp_devapp_security_config_001** ✅ PASS +**dev_app_list_003** ✅ PASS -- Prompt: 给开放平台应用 UNIFIED_APP_ID 配置 IP 白名单 192.0.2.10,先预览 -- Expected: `dws devapp security config --unified-app-id UNIFIED_APP_ID --ip-whitelist 192.0.2.10 --dry-run --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp security config) -- Flags: PASS (2 flags validated) - -**devapp_devapp_permission_list_001** ✅ PASS - -- Prompt: 查询应用 UNIFIED_APP_ID 已开通和未开通的全部 APP、SNS 权限 -- Expected: `dws devapp permission list --unified-app-id UNIFIED_APP_ID --status all --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp permission list) -- Flags: PASS (2 flags validated) - -**devapp_devapp_permission_list_002** ✅ PASS - -- Prompt: 搜索应用 UNIFIED_APP_ID 里机器人发送消息相关的未开通权限 -- Expected: `dws devapp permission list --unified-app-id UNIFIED_APP_ID --keyword "机器人发送消息" --status unauthed --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp permission list) -- Flags: PASS (3 flags validated) - -**devapp_devapp_permission_list_003** ✅ PASS - -- Prompt: 看一下权限 qyapi_robot_sendmsg 覆盖哪些 API -- Expected: `dws devapp permission list --unified-app-id UNIFIED_APP_ID --scope qyapi_robot_sendmsg --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp permission list) -- Flags: PASS (2 flags validated) - -**devapp_devapp_permission_list_004** ✅ PASS - -- Prompt: 给开放平台应用 UNIFIED_APP_ID 搜索手机号权限,应用权限和个人权限都要展示 -- Expected: `dws devapp permission list --unified-app-id UNIFIED_APP_ID --keyword "手机号" --status all --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp permission list) -- Flags: PASS (3 flags validated) - -**devapp_devapp_permission_add_001** ✅ PASS - -- Prompt: 给应用 UNIFIED_APP_ID 申请 qyapi_robot_sendmsg 权限,先预览 -- Expected: `dws devapp permission add --unified-app-id UNIFIED_APP_ID --permissions qyapi_robot_sendmsg --dry-run --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp permission add) -- Flags: PASS (2 flags validated) - -**devapp_devapp_permission_add_002** ✅ PASS - -- Prompt: 已确认,给应用 UNIFIED_APP_ID 申请 Contact.User.mobile 权限 -- Expected: `dws devapp permission add --unified-app-id UNIFIED_APP_ID --permissions Contact.User.mobile --yes --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp permission add) -- Flags: PASS (2 flags validated) - -**devapp_devapp_permission_remove_001** ✅ PASS - -- Prompt: 从应用 UNIFIED_APP_ID 移除 qyapi_robot_sendmsg 权限,先预览 -- Expected: `dws devapp permission remove --unified-app-id UNIFIED_APP_ID --permissions qyapi_robot_sendmsg --dry-run --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp permission remove) -- Flags: PASS (2 flags validated) - -**devapp_devapp_event_list_001** ✅ PASS - -- Prompt: 查询应用 UNIFIED_APP_ID 的事件订阅配置 -- Expected: `dws devapp event list --unified-app-id UNIFIED_APP_ID --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp event list) +- Prompt: 在开发者后台搜索企业内部应用 DemoApp +- Expected: `dws dev app list --name DemoApp --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app list) - Flags: PASS (1 flags validated) -**devapp_devapp_version_publish_001** ✅ PASS +**dev_app_get_001** ✅ PASS + +- Prompt: 查看统一应用 UNIFIED_APP_ID 的应用详情 +- Expected: `dws dev app get --unified-app-id UNIFIED_APP_ID --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app get) +- Flags: PASS (1 flags validated) + +**dev_app_get_002** ✅ PASS + +- Prompt: agentId 为 123456 的开放平台应用是哪个 +- Expected: `dws dev app get --agent-id 123456 --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app get) +- Flags: PASS (1 flags validated) + +**dev_app_get_003** ✅ PASS + +- Prompt: 查开放平台应用 DemoApp 的 appKey、clientId 和 agentId +- Expected: `dws dev app get --name DemoApp --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app get) +- Flags: PASS (1 flags validated) + +**dev_app_create_001** ✅ PASS + +- Prompt: 创建一个企业内部应用,名称 DemoApp,描述是内部应用 +- Expected: `dws dev app create --name DemoApp --desc "内部应用" --type internal --dry-run --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app create) +- Flags: PASS (3 flags validated) + +**dev_app_create_002** ✅ PASS + +- Prompt: 已确认创建企业内部应用 DemoApp +- Expected: `dws dev app create --name DemoApp --type internal --yes --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app create) +- Flags: PASS (2 flags validated) + +**dev_app_update_001** ✅ PASS + +- Prompt: 把应用 UNIFIED_APP_ID 的描述改成 新描述 +- Expected: `dws dev app update --unified-app-id UNIFIED_APP_ID --desc "新描述" --dry-run --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app update) +- Flags: PASS (2 flags validated) + +**dev_app_delete_001** ✅ PASS + +- Prompt: 删除开放平台应用 UNIFIED_APP_ID,先给我预览 +- Expected: `dws dev app delete --unified-app-id UNIFIED_APP_ID --dry-run --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app delete) +- Flags: PASS (1 flags validated) + +**dev_app_credentials_get_001** ✅ PASS + +- Prompt: 查询应用 UNIFIED_APP_ID 的开放平台凭证 +- Expected: `dws dev app credentials get --unified-app-id UNIFIED_APP_ID --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app credentials get) +- Flags: PASS (1 flags validated) + +**dev_app_credentials_get_002** ✅ PASS + +- Prompt: 查询 AgentId AGENT_ID 对应应用的开放平台凭证 +- Expected: `dws dev app credentials get --agent-id AGENT_ID --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app credentials get) +- Flags: PASS (1 flags validated) + +**dev_app_member_list_001** ✅ PASS + +- Prompt: 查看开放平台应用 UNIFIED_APP_ID 的成员 +- Expected: `dws dev app member list --unified-app-id UNIFIED_APP_ID --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app member list) +- Flags: PASS (1 flags validated) + +**dev_app_member_add_001** ✅ PASS + +- Prompt: 给开放平台应用 UNIFIED_APP_ID 添加开发者成员 userId1,userId2,先预览 +- Expected: `dws dev app member add --unified-app-id UNIFIED_APP_ID --user-ids userId1,userId2 --member-type DEVELOPER --dry-run --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app member add) +- Flags: PASS (3 flags validated) + +**dev_app_member_remove_001** ✅ PASS + +- Prompt: 已确认,从开放平台应用 UNIFIED_APP_ID 移除开发者成员 userId1 +- Expected: `dws dev app member remove --unified-app-id UNIFIED_APP_ID --user-ids userId1 --member-type DEVELOPER --yes --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app member remove) +- Flags: PASS (4 flags validated) + +**dev_app_security_config_001** ✅ PASS + +- Prompt: 给开放平台应用 UNIFIED_APP_ID 配置 IP 白名单 192.0.2.10,先预览 +- Expected: `dws dev app security config --unified-app-id UNIFIED_APP_ID --ip-whitelist 192.0.2.10 --dry-run --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app security config) +- Flags: PASS (2 flags validated) + +**dev_app_permission_list_001** ✅ PASS + +- Prompt: 查询应用 UNIFIED_APP_ID 已开通和未开通的全部 APP、SNS 权限 +- Expected: `dws dev app permission list --unified-app-id UNIFIED_APP_ID --status all --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app permission list) +- Flags: PASS (2 flags validated) + +**dev_app_permission_list_002** ✅ PASS + +- Prompt: 搜索应用 UNIFIED_APP_ID 里机器人发送消息相关的未开通权限 +- Expected: `dws dev app permission list --unified-app-id UNIFIED_APP_ID --keyword "机器人发送消息" --status unauthed --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app permission list) +- Flags: PASS (3 flags validated) + +**dev_app_permission_list_003** ✅ PASS + +- Prompt: 看一下权限 qyapi_robot_sendmsg 覆盖哪些 API +- Expected: `dws dev app permission list --unified-app-id UNIFIED_APP_ID --scope qyapi_robot_sendmsg --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app permission list) +- Flags: PASS (2 flags validated) + +**dev_app_permission_list_004** ✅ PASS + +- Prompt: 给开放平台应用 UNIFIED_APP_ID 搜索手机号权限,应用权限和个人权限都要展示 +- Expected: `dws dev app permission list --unified-app-id UNIFIED_APP_ID --keyword "手机号" --status all --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app permission list) +- Flags: PASS (3 flags validated) + +**dev_app_permission_add_001** ✅ PASS + +- Prompt: 给应用 UNIFIED_APP_ID 申请 qyapi_robot_sendmsg 权限,先预览 +- Expected: `dws dev app permission add --unified-app-id UNIFIED_APP_ID --permissions qyapi_robot_sendmsg --dry-run --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app permission add) +- Flags: PASS (2 flags validated) + +**dev_app_permission_add_002** ✅ PASS + +- Prompt: 已确认,给应用 UNIFIED_APP_ID 申请 Contact.User.mobile 权限 +- Expected: `dws dev app permission add --unified-app-id UNIFIED_APP_ID --permissions Contact.User.mobile --yes --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app permission add) +- Flags: PASS (2 flags validated) + +**dev_app_permission_remove_001** ✅ PASS + +- Prompt: 从应用 UNIFIED_APP_ID 移除 qyapi_robot_sendmsg 权限,先预览 +- Expected: `dws dev app permission remove --unified-app-id UNIFIED_APP_ID --permission qyapi_robot_sendmsg --dry-run --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app permission remove) +- Flags: PASS (2 flags validated) + +**dev_app_event_list_001** ✅ PASS + +- Prompt: 查询应用 UNIFIED_APP_ID 的事件订阅配置 +- Expected: `dws dev app event list --unified-app-id UNIFIED_APP_ID --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app event list) +- Flags: PASS (1 flags validated) + +**dev_app_version_publish_001** ✅ PASS - Prompt: 已确认,发布应用 UNIFIED_APP_ID 的版本 VERSION_ID,审批人是 USERID -- Expected: `dws devapp version publish --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --approver USERID --yes --format json` -- Skill Reference: references/products/devapp.md -- Command path: PASS (devapp version publish) +- Expected: `dws dev app version publish --unified-app-id UNIFIED_APP_ID --version-id VERSION_ID --approver USERID --yes --format json` +- Skill Reference: references/products/skills/multi/dingtalk-dev/SKILL.md +- Command path: PASS (dev app version publish) - Flags: PASS (3 flags validated) ### devdoc @@ -1345,14 +1370,6 @@ - Command path: PASS (devdoc article search) - Flags: PASS (1 flags validated) -**devdoc_devdoc_article_search_008** ✅ PASS - -- Prompt: 继续搜索开放平台文档中关于消息卡片的下一页,游标 NEXT_CURSOR -- Expected: `dws devdoc article search --keyword 消息卡片 --cursor NEXT_CURSOR --format json` -- Skill Reference: references/products/devdoc.md -- Command path: PASS (devdoc article search) -- Flags: PASS (2 flags validated) - ### ding **ding_ding_message_recall_001** ✅ PASS @@ -1395,6 +1412,57 @@ - Command path: PASS (ding message send) - Flags: PASS (3 flags validated) +### event + +**event_event_consume_at_001** ✅ PASS + +- Prompt: 监听有人 @ 我的消息 +- Expected: `dws event consume user_im_message_receive_at -f ndjson` +- Skill Reference: references/products/event.md +- Command path: PASS (event consume user_im_message_receive_at -f ndjson) +- Flags: N/A (no flags specified) + +**event_event_consume_o2o_001** ✅ PASS + +- Prompt: 监听我和 userId 507971 的单聊消息 +- Expected: `dws event consume user_im_message_receive_o2o --user 507971 -f ndjson` +- Skill Reference: references/products/event.md +- Command path: PASS (event consume user_im_message_receive_o2o) +- Flags: PASS (1 flags validated) + +**event_event_consume_o2o_003** ✅ PASS + +- Prompt: 监听我的个人单聊消息 +- Expected: `dws event consume user_im_message_receive_o2o -f ndjson` +- Skill Reference: references/products/event.md +- Command path: PASS (event consume user_im_message_receive_o2o -f ndjson) +- Flags: N/A (no flags specified) +- Note: [ASK_USER] case - only command path validated + +**event_event_consume_group_001** ✅ PASS + +- Prompt: 监听 openConversationId cid123 的群消息 +- Expected: `dws event consume user_im_message_receive_group --group cid123 -f ndjson` +- Skill Reference: references/products/event.md +- Command path: PASS (event consume user_im_message_receive_group) +- Flags: PASS (1 flags validated) + +**event_lookup_group_001** ✅ PASS + +- Prompt: 监听项目群的消息 +- Expected: `dws chat search --query "项目群" --format json` +- Skill Reference: references/products/event.md +- Command path: PASS (chat search) +- Flags: PASS (1 flags validated) + +**event_lookup_sender_001** ✅ PASS + +- Prompt: 监听张三发给我的消息 +- Expected: `dws aisearch person --keyword "张三" --dimension name --format json` +- Skill Reference: references/products/event.md +- Command path: PASS (aisearch person) +- Flags: PASS (2 flags validated) + ### report **report_report_detail_001** ✅ PASS