Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea6fd16d11 | ||
|
|
4c43108bdf | ||
|
|
5e9a920b76 | ||
|
|
36b0528d90 | ||
|
|
a2201b4ab4 | ||
|
|
181cdf4a03 | ||
|
|
818b8b29e3 | ||
|
|
109ad13844 | ||
|
|
fd6bbd928e | ||
|
|
67417d3fb1 | ||
|
|
91dfc8b926 | ||
|
|
b794d802f2 | ||
|
|
e6c1dfe15c | ||
|
|
32d32cd827 | ||
|
|
a65d6f23ec | ||
|
|
a838ae75a7 |
@@ -0,0 +1,71 @@
|
||||
name: Publish npm release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to publish to npm (e.g. v1.0.48)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ inputs.version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist
|
||||
|
||||
- name: Stage npm package
|
||||
run: |
|
||||
set -eu
|
||||
version="${{ inputs.version }}"
|
||||
semver="${version#v}"
|
||||
pkg_root="dist/npm/dingtalk-workspace-cli"
|
||||
rm -rf "$pkg_root"
|
||||
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
|
||||
cp build/npm/install.js "$pkg_root/install.js"
|
||||
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
|
||||
cp build/npm/README.md "$pkg_root/README.md"
|
||||
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
|
||||
cp dist/dws-* "$pkg_root/assets/"
|
||||
cp dist/checksums.txt "$pkg_root/assets/"
|
||||
test -f "$pkg_root/assets/dws-skills.zip"
|
||||
cat "$pkg_root/package.json"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish stable to npm
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.version, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish prerelease to npm beta
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.version, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public --tag beta
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -5,12 +5,18 @@ on:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
repair_npm_version:
|
||||
description: "Only publish an existing release to npm, e.g. v1.0.48"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
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
|
||||
@@ -76,17 +82,6 @@ jobs:
|
||||
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
|
||||
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
|
||||
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
|
||||
# 脚本自带门控:未配置 GITEE_TOKEN / GITEE_REPO 时优雅跳过,不影响海外发布。
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -94,7 +89,8 @@ jobs:
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish stable to npm
|
||||
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉
|
||||
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉。
|
||||
# 必须在 Gitee mirror 前发布:Gitee 附件上传偶发长时间挂住,不能阻塞 npm/latest。
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(github.ref_name, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public
|
||||
@@ -108,3 +104,76 @@ jobs:
|
||||
run: npm publish --access public --tag beta
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
|
||||
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
|
||||
# 默认关闭:国内 release 应由 Gitee 侧本地构建发布,避免 GitHub -> Gitee 跨境传大包卡住。
|
||||
# 仅在需要临时补救时设置 repo variable ENABLE_GITEE_UPLOAD_FALLBACK=true。
|
||||
if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' }}
|
||||
timeout-minutes: 20
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
|
||||
repair-npm:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download GitHub release assets
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p dist
|
||||
gh release download "${{ inputs.repair_npm_version }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist
|
||||
|
||||
- name: Stage npm package
|
||||
run: |
|
||||
set -eu
|
||||
version="${{ inputs.repair_npm_version }}"
|
||||
semver="${version#v}"
|
||||
pkg_root="dist/npm/dingtalk-workspace-cli"
|
||||
rm -rf "$pkg_root"
|
||||
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
|
||||
cp build/npm/install.js "$pkg_root/install.js"
|
||||
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
|
||||
cp build/npm/README.md "$pkg_root/README.md"
|
||||
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
|
||||
cp dist/dws-* "$pkg_root/assets/"
|
||||
cp dist/checksums.txt "$pkg_root/assets/"
|
||||
test -f "$pkg_root/assets/dws-skills.zip"
|
||||
cat "$pkg_root/package.json"
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Publish stable to npm
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.repair_npm_version, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish prerelease to npm beta
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.repair_npm_version, '-') }}
|
||||
working-directory: dist/npm/dingtalk-workspace-cli
|
||||
run: npm publish --access public --tag beta
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Gitee Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Release tag to build on Gitee, e.g. v1.0.48"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install packaging tools
|
||||
run: |
|
||||
set -eu
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip curl
|
||||
|
||||
- name: Install rcodesign
|
||||
run: |
|
||||
set -eu
|
||||
RCS_VERSION="0.27.0"
|
||||
curl -fsSL -o /tmp/rcodesign.tar.gz \
|
||||
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
|
||||
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: Build and publish Gitee release
|
||||
env:
|
||||
VERSION: ${{ inputs.version || github.ref_name }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_REPO: DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
run: ./scripts/release/build-and-publish-gitee.sh
|
||||
@@ -6,6 +6,60 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.51] - 2026-07-10
|
||||
|
||||
This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material.
|
||||
|
||||
### Added
|
||||
|
||||
- **Agoal product commands** (#585) — adds `dws agoal` strategy, contract, scorecard, user-objective, report, and objective-template command groups, together with static routing and the bundled mono/multi Agoal skills.
|
||||
- **Wukong chat command parity** (#585) — adds `chat group notice create|edit|get|list`, `group share-invite`, `text translate`, `category create-smart`, and `message list-emotion-replies`.
|
||||
- **Wukong document import commands** (#585) — adds `doc import` for starting imports and `doc import get` for querying import tasks.
|
||||
- **Wukong mail command parity** (#585) — adds mailbox profile, message batch-get, sent-message recall and recall-detail, auto-reply update, plus allow-list and block-list management.
|
||||
- **Wukong sheet grouping commands** (#585) — adds `sheet group-dimension` and `sheet ungroup-dimension` for whole-row or whole-column ranges.
|
||||
- **Keychain health diagnostics** (#578) — `dws doctor` now includes a keychain check, while `dws auth status` distinguishes ordinary logged-out state from `keychain_unavailable` and `dek_missing` failures and returns remediation hints in table and JSON output.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`dws pat chmod` defaults to permanent grants** (#584) — running `dws pat chmod <scope>` without `--grant-type` now requests a `permanent` grant instead of `session`, aligning the direct CLI path with the recommend-authorization helper. Session grants remain available by passing `--grant-type session --session-id <id>`.
|
||||
- **The `dev connect --channel gemini` path now uses the Gemini `generateContent` API** (#587) — configure it with `GEMINI_API_KEY` or `GOOGLE_API_KEY`, optionally override the compatible endpoint with `GEMINI_API_BASE_URL` or `GOOGLE_GEMINI_API_BASE_URL`, and select a model with `--agent-model` or `GEMINI_MODEL`; a local `gemini` executable is no longer required.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Non-blocking `dev connect` turn scheduling** (#587) — stream and `@`-poll callbacks no longer wait for the active turn to finish. Turns stay serialized per conversation, messages received mid-turn are coalesced into one pending follow-up, and different conversations can continue in parallel.
|
||||
- **Connect agent recovery and headless execution** (#587) — stale addressable sessions retry once with a fresh session, unsupported Qoder control requests receive an immediate response instead of hanging, OpenCode and bypass-mode channels receive non-interactive permission settings, and backend/API failures are no longer posted as successful assistant replies.
|
||||
- **Side-effect-free credential reads** (#578) — keychain reads inspect encrypted credential data before looking up the DEK and never generate a replacement key on a read path. Missing DEKs and unavailable macOS Keychains are surfaced as explicit diagnostic failures instead of silently mutating credential state.
|
||||
|
||||
## [1.0.50] - 2026-07-08
|
||||
|
||||
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).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Real-machine QA fixes across CLI commands** (#572) — `aitable chart/dashboard share update --enabled` now takes a string so `--enabled false` disables; `chat conversation-info --user` resolves openDingTalkId and registers `--id/--conversation-id/--chat` aliases; `chat list-all-conversations --limit` is capped at 100 and rejects larger values; custom-robot webhook failures surface `errcode` instead of masquerading as success; `contact` registers `--dept/--depts` as the primary flags so the documented spelling actually works; `sheet media-upload` and `sheet export` emit clean JSON under `--format json` (progress lines no longer leak); `wiki node create --type` enum is corrected (drops unsupported `asheet`, adds `axls/able/appt/adraw/amind`); `ding message list --type` defaults to `ALL` since the server rejects empty type.
|
||||
- **Helper script fixes (mono and multi)** (#572) — aitable import/export flag names and the tableId regex (7-char default tables were rejected); mail search `--limit`, contact dept response keys (`deptList`/`deptUserList`) and `userInfo` nesting; `attendance_my_record` whoami compatibility; `calendar_schedule_meeting` event-id unwrapping; `drive_tree_list` recursion via `fileId`; report scripts migrated off the deprecated `report list`/`report detail`.
|
||||
- **Skill docs sync (mono and multi)** (#572) — command indexes, flag names, enums, return-structure keys and cross-product intent routing are re-aligned to real-machine behavior across all products. Genuinely server-side limitations (permission gates, org-level restrictions, unregistered tool keys) are annotated instead of code-patched, and the cross-cutting hazards (`success` always true, `--jq`/`--fields` currently no-op) are documented.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release pipeline unblocks npm publish from Gitee mirror** (#570) — the Release workflow now publishes to npm before touching the Gitee mirror, so Gitee upload issues cannot block `npm/latest`. GitHub→Gitee attachment upload is disabled by default (unreliable from US runners) and only runs when `ENABLE_GITEE_UPLOAD_FALLBACK=true`; the legacy upload fallback path is guarded with timeout and retry so it fails fast when re-enabled.
|
||||
- **Repair modes for release republish** (#570) — the Release workflow gains a repair input and a standalone npm-only repair workflow, used to republish an existing release to npm without re-running the full pipeline.
|
||||
|
||||
## [1.0.48] - 2026-07-07
|
||||
|
||||
This release promotes the sealed **remove-discovery delivery** from the beta line to the stable `v1.0.48` package. It removes dynamic service discovery from the open-edition runtime, keeps legacy CLI compatibility aliases, syncs the open command/help/skill surface with the dws-wukong baseline, and includes the `dev connect` default-yolo behavior on the stable upgrade track.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -331,7 +331,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:
|
||||
|
||||
|
||||
+3
-3
@@ -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 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
@@ -328,7 +328,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 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# cli_to_mcp smoke tests
|
||||
|
||||
This directory contains lightweight command-to-tool contract tests for hardcoded
|
||||
DWS commands synced from `dws-wukong`.
|
||||
|
||||
The tests do not call live DingTalk APIs. They exercise command help, validation,
|
||||
and `--dry-run` output so command paths and MCP argument mappings stay stable.
|
||||
|
||||
Run with an already built binary:
|
||||
|
||||
```bash
|
||||
DWS_BIN=/path/to/dws pytest auto-test/cli_to_mcp/testcases
|
||||
```
|
||||
|
||||
If `DWS_BIN` is not set, the runner falls back to `go run ./cmd` from the repo
|
||||
root.
|
||||
@@ -0,0 +1,193 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_agoal_strategy_and_contract_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"list",
|
||||
"--scope-type",
|
||||
"PERSONAL",
|
||||
"--scope-id",
|
||||
"user123",
|
||||
"--request-id",
|
||||
"req-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_strategy_decodings" in output
|
||||
assert dry_run_args(output) == {
|
||||
"scopeType": "PERSONAL",
|
||||
"openId": "user123",
|
||||
"requestId": "req-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"update",
|
||||
"--profile-id",
|
||||
"profile123",
|
||||
"--content",
|
||||
'[{"id":"e1","title":{"title":"new"}}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_strategy_decoding" in output
|
||||
assert dry_run_args(output) == {
|
||||
"profileId": "profile123",
|
||||
"content": [{"id": "e1", "title": {"title": "new"}}],
|
||||
}
|
||||
|
||||
output = assert_ok(dws.run_raw("agoal", "contract", "fields", "--dry-run"))
|
||||
assert "list_op_contract_fields" in output
|
||||
assert dry_run_args(output) == {}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"contract",
|
||||
"update",
|
||||
"--contract-id",
|
||||
"contract123",
|
||||
"--dimensions",
|
||||
'[{"id":"dim1","title":"metric"}]',
|
||||
"--audit-config",
|
||||
'{"needAudit":true}',
|
||||
"--objective-template",
|
||||
'{"id":"tpl1"}',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_op_contract" in output
|
||||
assert dry_run_args(output) == {
|
||||
"contractId": "contract123",
|
||||
"dimensions": [{"id": "dim1", "title": "metric"}],
|
||||
"auditConfig": '{"needAudit":true}',
|
||||
"objectiveTemplate": '{"id":"tpl1"}',
|
||||
}
|
||||
|
||||
|
||||
def test_agoal_scorecard_user_report_template_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"detail",
|
||||
"--selected-time",
|
||||
"2026-01-01T00:00:00+08:00",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_score_card_detail" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["deptId"] == "dept123"
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"update",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--selected-time",
|
||||
"2026-01-01",
|
||||
"--id",
|
||||
"sc123",
|
||||
"--tracking-period-type",
|
||||
"MONTHLY",
|
||||
"--content",
|
||||
'[{"id":"dim1","items":[]}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_score_card" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
assert args["content"] == [{"id": "dim1", "items": []}]
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"user",
|
||||
"objectives",
|
||||
"--user-id",
|
||||
"user123",
|
||||
"--rule-id",
|
||||
"rule123",
|
||||
"--period-ids",
|
||||
"p1,p2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_user_objectives" in output
|
||||
assert dry_run_args(output) == {
|
||||
"dingUserId": "user123",
|
||||
"objectiveRuleId": "rule123",
|
||||
"periodIds": ["p1", "p2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"report",
|
||||
"submit-detail",
|
||||
"--template-id",
|
||||
"tpl123",
|
||||
"--submit-state",
|
||||
"LATE",
|
||||
"--query-date",
|
||||
"2026-06-18T00:00:00+08:00",
|
||||
"--page",
|
||||
"1",
|
||||
"--page-size",
|
||||
"20",
|
||||
"--keyword",
|
||||
"alice",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_submit_detail" in output
|
||||
assert dry_run_args(output) == {
|
||||
"templateId": "tpl123",
|
||||
"submitState": "LATE",
|
||||
"queryDate": "2026-06-18",
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"keyword": "alice",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"obj-template",
|
||||
"create-or-update",
|
||||
"--title",
|
||||
"tpl",
|
||||
"--dimensions",
|
||||
'[{"title":"dim"}]',
|
||||
"--objective-weight",
|
||||
"--dimension-weight",
|
||||
"--compute-by-weight",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_or_update_obj_template" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "tpl",
|
||||
"dimensions": '[{"title":"dim"}]',
|
||||
"objectiveWeight": True,
|
||||
"dimensionWeight": True,
|
||||
"computeByWeight": True,
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_notice_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"create",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--content",
|
||||
"maintenance tonight",
|
||||
"--sticky",
|
||||
"--send-ding",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"content": "maintenance tonight",
|
||||
"sticky": True,
|
||||
"sendDing": True,
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"edit",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--content",
|
||||
"updated",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "edit_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
"content": "updated",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"get",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"list",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--limit",
|
||||
"20",
|
||||
"--cursor",
|
||||
"next",
|
||||
"--scheduled",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_group_notices" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"limit": 20,
|
||||
"cursor": "next",
|
||||
"scheduled": True,
|
||||
}
|
||||
|
||||
|
||||
def test_chat_misc_new_commands_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"share-invite",
|
||||
"--source",
|
||||
"sourceCid",
|
||||
"--target",
|
||||
"targetCid",
|
||||
"--expires-seconds",
|
||||
"3600",
|
||||
"--uuid",
|
||||
"uuid-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "share_group_invite_url" in output
|
||||
assert dry_run_args(output) == {
|
||||
"sourceOpenConversationId": "sourceCid",
|
||||
"targetOpenConversationId": "targetCid",
|
||||
"expiresSeconds": 3600,
|
||||
"uuid": "uuid-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("chat", "text", "translate", "--query", "hello", "--to", "zh_CN", "--dry-run")
|
||||
)
|
||||
assert "translate" in output
|
||||
assert dry_run_args(output) == {"query": "hello", "to": "zh_CN"}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"category",
|
||||
"create-smart",
|
||||
"--name",
|
||||
"priority",
|
||||
"--keywords",
|
||||
"alpha,beta",
|
||||
"--members",
|
||||
"uid1,uid2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_smart_conv_category" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "priority",
|
||||
"keywords": ["alpha", "beta"],
|
||||
"memberOpenDingTalkIds": ["uid1", "uid2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"message",
|
||||
"list-emotion-replies",
|
||||
"--msg-ids",
|
||||
"msg1,msg2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_message_emotion_replies" in output
|
||||
assert dry_run_args(output) == {"openMessageIds": ["msg1", "msg2"]}
|
||||
@@ -0,0 +1,8 @@
|
||||
import pytest
|
||||
|
||||
from test_utils import DWSRunner
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def dws():
|
||||
return DWSRunner()
|
||||
@@ -0,0 +1,54 @@
|
||||
from test_utils import combined_output
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_doc_import_help_and_validation(dws, tmp_path):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "--help"))
|
||||
assert "dws doc import" in output
|
||||
assert "--file string" in output
|
||||
assert "--workspace string" in output
|
||||
assert "--name string" in output
|
||||
|
||||
result = dws.run_raw("doc", "import", "--file", str(tmp_path / "missing.md"), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "cannot read file" in output
|
||||
|
||||
bad = tmp_path / "bad.exe"
|
||||
bad.write_text("bad", encoding="utf-8")
|
||||
result = dws.run_raw("doc", "import", "--file", str(bad), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "unsupported file format" in output
|
||||
|
||||
|
||||
def test_doc_import_dry_run(dws, tmp_path):
|
||||
source = tmp_path / "sample.md"
|
||||
source.write_text("# Sample\n\nhello\n", encoding="utf-8")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"doc",
|
||||
"import",
|
||||
"--file",
|
||||
str(source),
|
||||
"--name",
|
||||
"Imported Sample",
|
||||
"--workspace",
|
||||
"workspace123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "Imported Sample" in output
|
||||
assert "sample.md" in output
|
||||
assert "md" in output
|
||||
|
||||
|
||||
def test_doc_import_get_dry_run(dws):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "get", "--task-id", "task123", "--dry-run"))
|
||||
assert "task123" in output
|
||||
@@ -0,0 +1,143 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def assert_fails(result, expected: str):
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0, output
|
||||
assert expected in output
|
||||
|
||||
|
||||
def test_mailbox_profile_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "mailbox", "profile", "--help"))
|
||||
assert "dws mail mailbox profile" in output
|
||||
assert "--email string" in output
|
||||
|
||||
assert_fails(dws.run_raw("mail", "mailbox", "profile"), "email")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("mail", "mailbox", "profile", "--email", mail_email(), "--dry-run")
|
||||
)
|
||||
assert "get_mailbox_profile" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
|
||||
def test_message_batch_get_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "message", "batch-get", "--help"))
|
||||
assert "dws mail message batch-get" in output
|
||||
assert "--email string" in output
|
||||
assert "--ids string" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw("mail", "message", "batch-get", "--email", mail_email()),
|
||||
"ids",
|
||||
)
|
||||
|
||||
too_many_ids = ",".join(f"msg_{i:02d}" for i in range(21))
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
too_many_ids,
|
||||
"--dry-run",
|
||||
),
|
||||
"20",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
"msg_001,msg_002",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_email_by_message_id" in output
|
||||
assert "msg_001" in output
|
||||
assert "msg_002" in output
|
||||
|
||||
|
||||
def test_sent_message_recall_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall", "--help"))
|
||||
assert "dws mail sent-message recall" in output
|
||||
assert "--subject string" in output
|
||||
assert "--yes" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
),
|
||||
"--yes",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "recall_sent_message" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"id": "msg_001",
|
||||
"subject": "subject",
|
||||
}
|
||||
|
||||
|
||||
def test_sent_message_recall_detail_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall-detail", "--help"))
|
||||
assert "dws mail sent-message recall-detail" in output
|
||||
assert "--email string" in output
|
||||
assert "--id string" in output
|
||||
assert "FINISHED" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall-detail",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"task_001",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_recall_detail" in output
|
||||
assert dry_run_args(output) == {"email": mail_email(), "id": "task_001"}
|
||||
@@ -0,0 +1,133 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_auto_reply_update_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "auto-reply", "update", "--help"))
|
||||
for flag in ("--email string", "--enabled string", "--start string", "--end string", "--scope string", "--content string"):
|
||||
assert flag in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"auto-reply",
|
||||
"update",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--enabled",
|
||||
"true",
|
||||
"--start",
|
||||
"2026/07/01 09:00:00 +0800",
|
||||
"--end",
|
||||
"2026/07/07 18:00:00 +0800",
|
||||
"--scope",
|
||||
"all",
|
||||
"--content",
|
||||
"out of office",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_auto_reply" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"enabled": True,
|
||||
"startTime": "2026/07/01 09:00:00 +0800",
|
||||
"endTime": "2026/07/07 18:00:00 +0800",
|
||||
"scope": "all",
|
||||
"content": "out of office",
|
||||
}
|
||||
|
||||
|
||||
def test_allow_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "allow-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com,@example.org",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com", "@example.org"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com"],
|
||||
}
|
||||
|
||||
|
||||
def test_block_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "block-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com,@junk.example",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com", "@junk.example"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com"],
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "group-dimension", "--help"))
|
||||
assert "dws sheet group-dimension" in output
|
||||
assert "--group-state string" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"fold",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "group_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "3:7",
|
||||
"groupState": "fold",
|
||||
}
|
||||
|
||||
|
||||
def test_ungroup_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "ungroup-dimension", "--help"))
|
||||
assert "dws sheet ungroup-dimension" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"ungroup-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"C:F",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "ungroup_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "C:F",
|
||||
}
|
||||
|
||||
|
||||
def test_group_dimension_rejects_invalid_state(dws):
|
||||
result = dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"invalid",
|
||||
"--dry-run",
|
||||
)
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "group-state" in output
|
||||
@@ -0,0 +1,58 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root(start_file: str) -> Path:
|
||||
current = Path(start_file).resolve()
|
||||
for parent in [current, *current.parents]:
|
||||
if (parent / "go.mod").exists():
|
||||
return parent
|
||||
raise RuntimeError(f"cannot locate repo root from {start_file}")
|
||||
|
||||
|
||||
def resolve_dws_cmd(start_file: str) -> list[str]:
|
||||
root = repo_root(start_file)
|
||||
if env_bin := os.environ.get("DWS_BIN"):
|
||||
return shlex.split(env_bin)
|
||||
|
||||
for rel in ("dws", "build/dws", "bin/dws", "dingtalk-workspace-cli"):
|
||||
candidate = root / rel
|
||||
if candidate.exists() and os.access(candidate, os.X_OK):
|
||||
return [str(candidate)]
|
||||
|
||||
return ["go", "run", "./cmd"]
|
||||
|
||||
|
||||
def combined_output(result: subprocess.CompletedProcess) -> str:
|
||||
return (result.stdout or "") + (result.stderr or "")
|
||||
|
||||
|
||||
def dry_run_args(output: str) -> dict:
|
||||
match = re.search(r"Arguments:\s*(\{.*\})", output, re.S)
|
||||
assert match, f"dry-run output does not contain Arguments JSON: {output}"
|
||||
return json.loads(match.group(1))
|
||||
|
||||
|
||||
class DWSRunner:
|
||||
def __init__(self):
|
||||
self.root = repo_root(__file__)
|
||||
self.cmd = resolve_dws_cmd(__file__)
|
||||
|
||||
def run_raw(self, *args: str, timeout: int = 45) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[*self.cmd, *args],
|
||||
cwd=self.root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def run(self, *args: str, timeout: int = 45):
|
||||
result = self.run_raw(*args, timeout=timeout)
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return json.loads(result.stdout)
|
||||
@@ -446,6 +446,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 +469,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 +507,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 进行登录")
|
||||
}
|
||||
}
|
||||
@@ -1199,6 +1206,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 +1219,47 @@ 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.IsDEKMissing(err) {
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "dek_missing",
|
||||
Message: "本地登录密钥缺失,无法解密已保存的登录态",
|
||||
Hint: "重新登录以生成新的本地登录密钥;如仍异常,可先清理本地登录态后再登录。",
|
||||
}
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -16,7 +16,9 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -132,6 +134,106 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -824,6 +926,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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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=") {
|
||||
|
||||
@@ -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 {
|
||||
@@ -493,7 +506,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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+74
-12
@@ -5,12 +5,26 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// parseBoolFlag reads a string flag and parses it as a boolean, accepting the
|
||||
// natural `--flag true` / `--flag false` space syntax. Registering these
|
||||
// switches as String (not Bool) flags avoids cobra swallowing `false` as a
|
||||
// positional arg — which silently inverted `--enabled false` into enable.
|
||||
func parseBoolFlag(cmd *cobra.Command, name string) (bool, error) {
|
||||
raw := strings.TrimSpace(mustGetFlag(cmd, name))
|
||||
v, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("--%s 只接受 true 或 false,got %q", name, raw)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws aitable — AI 表格
|
||||
// 中文 tool name 映射: CLI 英文命令 → MCP 中文 tool name
|
||||
@@ -617,6 +631,30 @@ func printViewSubBlock(block any) error {
|
||||
return deps.Out.PrintJSON(envelope)
|
||||
}
|
||||
|
||||
// findFormViewByID 从 list_form_views 响应里递归定位 viewId 匹配的表单对象。
|
||||
// 服务端的 viewIds 过滤参数当前不生效(返回全表所有表单),故 form get 在客户端
|
||||
// 侧按 viewId 精确筛出单条,避免退化成等价 form list。
|
||||
func findFormViewByID(node any, viewID string) (map[string]any, bool) {
|
||||
switch v := node.(type) {
|
||||
case map[string]any:
|
||||
if id, _ := v["viewId"].(string); id == viewID {
|
||||
return v, true
|
||||
}
|
||||
for _, val := range v {
|
||||
if found, ok := findFormViewByID(val, viewID); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, el := range v {
|
||||
if found, ok := findFormViewByID(el, viewID); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// collectStringFlag 把 cmd 上 flagName 的字符串值(若非空)以 jsonKey 写入 out map。
|
||||
func collectStringFlag(cmd *cobra.Command, flagName, jsonKey string, out map[string]any) {
|
||||
if v, _ := cmd.Flags().GetString(flagName); v != "" {
|
||||
@@ -2888,7 +2926,8 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
|
||||
Use: "get",
|
||||
Short: "获取单个表单视图详情",
|
||||
Long: `按 viewId 获取单个表单视图的元信息(name/title/createdAt/shareFormUuid)。
|
||||
内部基于 list_form_views 过滤实现,等价于 form list 后筛选指定 viewId。`,
|
||||
内部拉取 list_form_views 后在客户端按 viewId 精确筛出单条(服务端的 viewIds
|
||||
过滤参数当前不生效,会返回全表所有表单,故在此侧过滤)。data 即命中的表单对象。`,
|
||||
Example: ` dws aitable form get --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id", "view-id"); err != nil {
|
||||
@@ -2898,11 +2937,30 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableHelperTool("list_form_views", map[string]any{
|
||||
viewID := mustGetFlag(cmd, "view-id")
|
||||
raw, err := callMCPToolReturnTextOnServer(context.Background(), "aitable-helper", "list_form_views", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
"viewIds": []string{mustGetFlag(cmd, "view-id")},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var parsed any
|
||||
if e := json.Unmarshal([]byte(raw), &parsed); e != nil {
|
||||
return &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: fmt.Sprintf("list_form_views response is not valid JSON: %v", e),
|
||||
}
|
||||
}
|
||||
form, ok := findFormViewByID(parsed, viewID)
|
||||
if !ok {
|
||||
return &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: fmt.Sprintf("form view %s not found in table", viewID),
|
||||
Suggestion: "用 dws aitable form list --base-id <baseId> --table-id <tableId> 查看可用 viewId 列表",
|
||||
}
|
||||
}
|
||||
return printViewSubBlock(form)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -3439,10 +3497,10 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
|
||||
Example: ` dws aitable dashboard share update --base-id BASE_ID --dashboard-id DASHBOARD_ID --enabled true --share-type PUBLIC
|
||||
dws aitable dashboard share update --base-id BASE_ID --dashboard-id DASHBOARD_ID --enabled false`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "dashboard-id"); err != nil {
|
||||
if err := validateRequiredFlags(cmd, "dashboard-id", "enabled"); err != nil {
|
||||
return err
|
||||
}
|
||||
enabled, err := cmd.Flags().GetBool("enabled")
|
||||
enabled, err := parseBoolFlag(cmd, "enabled")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -3542,11 +3600,15 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
|
||||
Use: "update",
|
||||
Short: "更新图表",
|
||||
Long: `更新指定 chart 的配置或布局。
|
||||
调用前建议先调用 chart widgets-example 了解 --config 入参结构和要求。`,
|
||||
--config 为必填参数,即使只想改布局也要带上完整的图表配置(服务端会拒绝缺 config 的请求,
|
||||
空对象 {} 同样被拒,至少要包含 chartName)。--layout 可选,仅调整位置/大小时追加。
|
||||
调用前建议先调用 chart widgets-example 了解 --config 入参结构和要求,
|
||||
或先用 chart get 拿到当前 config 再改。`,
|
||||
Example: ` dws aitable chart update --base-id BASE_ID --dashboard-id DASHBOARD_ID --chart-id CHART_ID \
|
||||
--config '{"chartName":"新柱图名",...}'
|
||||
# 只改布局也必须带 --config(用 chart get 拿到当前 config 原样回传):
|
||||
dws aitable chart update --base-id BASE_ID --dashboard-id DASHBOARD_ID --chart-id CHART_ID \
|
||||
--layout '{"x":0,"y":4,"w":12,"h":4}'
|
||||
--config '{"chartName":"柱图",...}' --layout '{"x":0,"y":4,"w":12,"h":4}'
|
||||
# 查询 chartId: dws aitable dashboard get --base-id <baseId> --dashboard-id <dashboardId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "dashboard-id", "chart-id", "config"); err != nil {
|
||||
@@ -3634,10 +3696,10 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
|
||||
Example: ` dws aitable chart share update --base-id BASE_ID --dashboard-id DASHBOARD_ID --chart-id CHART_ID --enabled true --share-type ORG
|
||||
dws aitable chart share update --base-id BASE_ID --dashboard-id DASHBOARD_ID --chart-id CHART_ID --enabled false`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "dashboard-id", "chart-id"); err != nil {
|
||||
if err := validateRequiredFlags(cmd, "dashboard-id", "chart-id", "enabled"); err != nil {
|
||||
return err
|
||||
}
|
||||
enabled, err := cmd.Flags().GetBool("enabled")
|
||||
enabled, err := parseBoolFlag(cmd, "enabled")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -4228,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,
|
||||
@@ -4658,7 +4720,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
dashboardShareGetCmd.Flags().String("dashboard-id", "", "目标 Dashboard ID (必填)")
|
||||
dashboardShareUpdateCmd.Flags().String("base-id", "", "所属 Base ID (必填)")
|
||||
dashboardShareUpdateCmd.Flags().String("dashboard-id", "", "目标 Dashboard ID (必填)")
|
||||
dashboardShareUpdateCmd.Flags().Bool("enabled", false, "分享开关:true 开启,false 关闭")
|
||||
dashboardShareUpdateCmd.Flags().String("enabled", "", "分享开关:true 开启,false 关闭 (必填)")
|
||||
dashboardShareUpdateCmd.Flags().String("share-type", "", "分享类型:PUBLIC 或 ORG(enabled=true 时生效)")
|
||||
dashboardShareUpdateCmd.Flags().Bool("allow-back-to-doc", false, "是否允许从分享页返回源 AI 表格(仅在显式传参时生效)")
|
||||
dashboardShareCmd.AddCommand(dashboardShareGetCmd, dashboardShareUpdateCmd)
|
||||
@@ -4692,7 +4754,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
chartShareUpdateCmd.Flags().String("base-id", "", "所属 Base ID (必填)")
|
||||
chartShareUpdateCmd.Flags().String("dashboard-id", "", "所属 Dashboard ID (必填)")
|
||||
chartShareUpdateCmd.Flags().String("chart-id", "", "目标 Chart ID (必填)")
|
||||
chartShareUpdateCmd.Flags().Bool("enabled", false, "分享开关:true 开启,false 关闭")
|
||||
chartShareUpdateCmd.Flags().String("enabled", "", "分享开关:true 开启,false 关闭 (必填)")
|
||||
chartShareUpdateCmd.Flags().String("share-type", "", "分享类型:PUBLIC 或 ORG(enabled=true 时生效)")
|
||||
chartShareUpdateCmd.Flags().Bool("allow-back-to-doc", false, "是否允许从分享页返回源 AI 表格(仅在显式传参时生效)")
|
||||
chartShareCmd.AddCommand(chartShareGetCmd, chartShareUpdateCmd)
|
||||
|
||||
@@ -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 (必填)")
|
||||
|
||||
|
||||
+356
-20
@@ -202,6 +202,28 @@ func isOpenDingTalkID(value string) bool {
|
||||
return len(value) > 0 && (value[0] == 'D' || value[0] == 'd')
|
||||
}
|
||||
|
||||
// webhookErrcodeFailure 解析自定义机器人 webhook 的响应,判定是否发送失败。
|
||||
// webhook 失败时 HTTP 仍是 200 且返回 {errcode!=0, errmsg},需据 errcode 显式识别。
|
||||
// errcode 可能是 JSON 数字或字符串,统一转字符串比较;缺 errcode 或为 0 视为成功。
|
||||
func webhookErrcodeFailure(raw string) (code, msg string, failed bool) {
|
||||
var m map[string]any
|
||||
if json.Unmarshal([]byte(raw), &m) != nil {
|
||||
return "", "", false
|
||||
}
|
||||
ec, ok := m["errcode"]
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
code = strings.TrimSpace(fmt.Sprintf("%v", ec))
|
||||
if code == "" || code == "0" || code == "0.0" {
|
||||
return "", "", false
|
||||
}
|
||||
if v, ok := m["errmsg"].(string); ok {
|
||||
msg = v
|
||||
}
|
||||
return code, msg, true
|
||||
}
|
||||
|
||||
func splitChatIDValues(values []string) (userIDs []string, openDingTalkIDs []string) {
|
||||
for _, raw := range values {
|
||||
value := strings.TrimSpace(raw)
|
||||
@@ -1841,7 +1863,30 @@ func newChatCommand() *cobra.Command {
|
||||
}
|
||||
toolArgs["atUserIds"] = atUserIds
|
||||
}
|
||||
return callMCPToolOnServer("bot", "send_message_by_custom_robot", toolArgs)
|
||||
// dry-run 只预览参数,不实际发送,交回标准调用路径。
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("bot", "send_message_by_custom_robot", toolArgs)
|
||||
}
|
||||
// 自定义机器人 webhook 即使发送失败(如 errcode=300005 token 不存在)
|
||||
// HTTP 仍是 200,会被包成 success:true。这里取原始响应显式识别 errcode,
|
||||
// 非 0 时按失败返回,避免 agent 误判消息已发出。
|
||||
raw, err := callMCPToolReturnTextOnServer(context.Background(), "bot", "send_message_by_custom_robot", toolArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if code, msg, failed := webhookErrcodeFailure(raw); failed {
|
||||
return &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: fmt.Sprintf("自定义机器人 webhook 发送失败: errcode=%s errmsg=%s", code, msg),
|
||||
Suggestion: "检查 --token 是否有效、机器人是否仍在群内、以及机器人安全设置(关键词/IP/加签)是否拦截",
|
||||
}
|
||||
}
|
||||
var parsed any
|
||||
if json.Unmarshal([]byte(raw), &parsed) == nil {
|
||||
return deps.Out.PrintJSON(parsed)
|
||||
}
|
||||
deps.Out.PrintRaw(raw)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2666,7 +2711,14 @@ func newChatCommand() *cobra.Command {
|
||||
toolArgs["openConversationId"] = groupID
|
||||
}
|
||||
if userID != "" {
|
||||
toolArgs["userId"] = userID
|
||||
// 服务端 get_conversation_info 单聊只认 openDingTalkId(传 userId 键会被
|
||||
// 忽略并报「openCid、cid、peerUid不能同时为空」)。这里把 --user 的 userId
|
||||
// 先解析成 openDingTalkId 再走与 --open-dingtalk-id 相同的通路。
|
||||
resolved, rerr := resolveOpenDingTalkID(context.Background(), userID)
|
||||
if rerr != nil {
|
||||
return rerr
|
||||
}
|
||||
toolArgs["openDingTalkId"] = resolved
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
toolArgs["openDingTalkId"] = openDingTalkID
|
||||
@@ -2675,6 +2727,12 @@ func newChatCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
chatConversationInfoCmd.Flags().String("group", "", "群聊 openConversationId(群聊时使用)")
|
||||
chatConversationInfoCmd.Flags().String("conversation-id", "", "--group 的别名")
|
||||
chatConversationInfoCmd.Flags().String("id", "", "--group 的别名")
|
||||
chatConversationInfoCmd.Flags().String("chat", "", "--group 的别名")
|
||||
_ = chatConversationInfoCmd.Flags().MarkHidden("conversation-id")
|
||||
_ = chatConversationInfoCmd.Flags().MarkHidden("id")
|
||||
_ = chatConversationInfoCmd.Flags().MarkHidden("chat")
|
||||
chatConversationInfoCmd.Flags().String("user", "", "单聊对方 userId(单聊时使用)")
|
||||
chatConversationInfoCmd.Flags().String("userId", "", "--user 的别名")
|
||||
_ = chatConversationInfoCmd.Flags().MarkHidden("userId")
|
||||
@@ -3011,7 +3069,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 {
|
||||
@@ -3170,12 +3228,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))
|
||||
@@ -4258,16 +4325,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 <openConversationId> --record-id 123456 --applicant <openDingTalkId> --inviter <openDingTalkId> --status AuditApprove
|
||||
dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <openDingTalkId> --inviter <openDingTalkId> --status AuditRefuse --description "不符合入群条件"
|
||||
dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <openDingTalkId> --inviter <openDingTalkId> --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 {
|
||||
@@ -4294,7 +4361,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")
|
||||
@@ -4375,7 +4442,7 @@ status 可选值:
|
||||
chatListAllConversationsCmd := &cobra.Command{
|
||||
Use: "list-all-conversations",
|
||||
Short: "分页获取当前用户的全部会话列表",
|
||||
Long: `分页获取当前用户的全部会话列表(包含单聊和群聊)。--limit 指定每页数量(最大 100),--cursor 传分页游标(首次不传或传 0)。
|
||||
Long: `分页获取当前用户的全部会话列表(包含单聊和群聊)。--limit 指定每页数量(1-100,默认 100),--cursor 传分页游标(首次不传或传 0)。
|
||||
返回 hasMore=true 时用 nextCursor 作为下次 --cursor 继续翻页。`,
|
||||
Example: ` dws chat list-all-conversations
|
||||
dws chat list-all-conversations --limit 50
|
||||
@@ -4384,6 +4451,10 @@ status 可选值:
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
if v, err := cmd.Flags().GetInt("limit"); err == nil && v > 0 {
|
||||
// 服务端每页上限 100,超过会被静默截断,这里显式拒绝以免误以为取全。
|
||||
if v > 100 {
|
||||
return fmt.Errorf("--limit 最大 100(服务端上限),got %d;如需取全部会话请配合 --cursor 翻页", v)
|
||||
}
|
||||
toolArgs["limit"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt64("cursor"); v > 0 {
|
||||
@@ -4395,7 +4466,7 @@ status 可选值:
|
||||
return callMCPToolOnServer("im", "list_all_conversations", toolArgs)
|
||||
},
|
||||
}
|
||||
chatListAllConversationsCmd.Flags().Int("limit", 1000, "每页数量(默认 1000)")
|
||||
chatListAllConversationsCmd.Flags().Int("limit", 100, "每页数量(1-100,默认 100)")
|
||||
chatListAllConversationsCmd.Flags().Int64("cursor", 0, "分页游标(首次不传或传 0,翻页传 nextCursor)")
|
||||
chatListAllConversationsCmd.Flags().Bool("exclude-muted", false, "是否排除已免打扰会话(默认 false)")
|
||||
|
||||
@@ -4515,8 +4586,8 @@ status 可选值:
|
||||
chatMessageUnsetTopMsgCmd.Flags().String("msg-id", "", "消息 openMessageId (必填)")
|
||||
_ = chatMessageUnsetTopMsgCmd.MarkFlagRequired("msg-id")
|
||||
|
||||
chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd)
|
||||
chatGroupMembersCmd.AddCommand(chatGroupMembersRemoveBotCmd)
|
||||
// group 与 members 的子命令在下方(chatGroupCmd.AddCommand / chatGroupMembersCmd.AddCommand
|
||||
// 的完整列表处)统一注册;此处不再重复 AddCommand,否则 --help 会重复列出。
|
||||
// ── group update-nick: 设置用户在群内的群昵称 ──────────────
|
||||
|
||||
chatGroupUpdateNickCmd := &cobra.Command{
|
||||
@@ -4670,12 +4741,277 @@ 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 <openConversationId> --content "今晚 22 点系统维护,请提前保存工作内容"
|
||||
dws chat group notice create --group <openConversationId> --content "# 重要通知\n请大家查收" --sticky --send-ding
|
||||
dws chat group notice create --group <openConversationId> --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 <openConversationId> --notice-id <dataId> --content "更新后的公告内容"
|
||||
dws chat group notice edit --group <openConversationId> --notice-id <dataId> --content "更新后的公告内容" --sticky --send-ding
|
||||
# 查询公告 ID: dws chat group notice list --group <openConversationId>`,
|
||||
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 <openConversationId> --notice-id <dataId>
|
||||
# 查询公告 ID: dws chat group notice list --group <openConversationId>`,
|
||||
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 <openConversationId>
|
||||
dws chat group notice list --group <openConversationId> --limit 20 --cursor <nextPageCursor>
|
||||
dws chat group notice list --group <openConversationId> --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 <openConversationId> --target <openConversationId> --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 {
|
||||
if err := validateRequiredFlags(cmd, "name"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"title": mustGetFlag(cmd, "name"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("keywords"); v != "" {
|
||||
toolArgs["keywords"] = parseCSVValues(v)
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("members"); v != "" {
|
||||
toolArgs["memberOpenDingTalkIds"] = parseCSVValues(v)
|
||||
}
|
||||
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, 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"))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: <status> ..." 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,80 @@ type connectExtras struct {
|
||||
persona string
|
||||
}
|
||||
|
||||
type connectQueuedTurn struct {
|
||||
convID string
|
||||
text string
|
||||
picCode string
|
||||
fileInfo fileInboundInfo
|
||||
webhook string
|
||||
msgID string
|
||||
msgType string
|
||||
senderStaffID string
|
||||
conversationID string
|
||||
conversationType string
|
||||
callbackData chatbot.BotCallbackDataModel
|
||||
}
|
||||
|
||||
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")
|
||||
if merged.picCode == "" && !merged.fileInfo.hasActionable() {
|
||||
for i := len(turns) - 1; i >= 0; i-- {
|
||||
if turns[i].picCode != "" {
|
||||
merged.picCode = turns[i].picCode
|
||||
break
|
||||
}
|
||||
if turns[i].fileInfo.hasActionable() {
|
||||
merged.fileInfo = turns[i].fileInfo
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
return text
|
||||
}
|
||||
if turn.picCode != "" {
|
||||
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{}
|
||||
@@ -1072,11 +1216,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,
|
||||
picCode: picCode,
|
||||
fileInfo: fileInfo,
|
||||
webhook: webhook,
|
||||
msgID: msgID,
|
||||
msgType: msgtype,
|
||||
senderStaffID: strings.TrimSpace(data.SenderStaffId),
|
||||
conversationID: strings.TrimSpace(data.ConversationId),
|
||||
conversationType: strings.TrimSpace(data.ConversationType),
|
||||
callbackData: *data,
|
||||
}
|
||||
// Same-conversation agent calls never run in parallel; messages received
|
||||
// while a turn is running are merged into one pending follow-up instead
|
||||
// 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
|
||||
picCode := turn.picCode
|
||||
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
|
||||
|
||||
@@ -162,3 +162,31 @@ 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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -582,9 +582,11 @@ contact user profile fields 获取可用字段列表。
|
||||
contactDeptSearchCmd.Flags().String("name", "", "--query 的别名")
|
||||
_ = contactDeptSearchCmd.Flags().MarkHidden("keyword")
|
||||
_ = contactDeptSearchCmd.Flags().MarkHidden("name")
|
||||
contactDeptGetInfoCmd.Flags().String("id", "", "部门 ID (必填)")
|
||||
contactDeptListChildrenCmd.Flags().String("id", "", "部门 ID (必填)")
|
||||
contactDeptListMembersCmd.Flags().String("ids", "", "部门 ID 列表 (必填)")
|
||||
// 主 flag 与 RunE 读取保持一致:get-info / list-children 用 --dept,list-members 用 --depts。
|
||||
// 历史上主 flag 曾误注册为 --id/--ids,导致 RunE 读的 --dept/--depts 未注册、命令行传入报 unknown flag。
|
||||
contactDeptGetInfoCmd.Flags().String("dept", "", "部门 ID (必填)")
|
||||
contactDeptListChildrenCmd.Flags().String("dept", "", "部门 ID (必填)")
|
||||
contactDeptListMembersCmd.Flags().String("depts", "", "部门 ID 列表 (必填)")
|
||||
|
||||
// dept 系列命令统一接受 --id / --ids / --dept-id / --dept-ids 别名(集中注册避免逐命令重复写)。
|
||||
// camelCase --deptId / --deptIds 由 RegisterCamelCaseAliases 自动派生,无需手写。
|
||||
@@ -593,9 +595,9 @@ contact user profile fields 获取可用字段列表。
|
||||
aliases []string
|
||||
}
|
||||
for _, s := range []deptIDAliasSpec{
|
||||
{contactDeptGetInfoCmd, []string{"dept-id", "ids", "dept-ids"}},
|
||||
{contactDeptListChildrenCmd, []string{"ids", "dept-id", "dept-ids"}},
|
||||
{contactDeptListMembersCmd, []string{"id", "dept-id", "dept-ids"}},
|
||||
{contactDeptGetInfoCmd, []string{"id", "dept-id", "ids", "dept-ids"}},
|
||||
{contactDeptListChildrenCmd, []string{"id", "ids", "dept-id", "dept-ids"}},
|
||||
{contactDeptListMembersCmd, []string{"ids", "id", "dept-id", "dept-ids"}},
|
||||
} {
|
||||
for _, name := range s.aliases {
|
||||
if s.cmd.Flags().Lookup(name) != nil {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -93,8 +93,9 @@ func newDingCommand() *cobra.Command {
|
||||
Use: "list",
|
||||
Short: "查询 DING 消息历史",
|
||||
Long: `查询当前用户的 DING 消息列表,支持按类型过滤。
|
||||
--type 支持: ALL(全部)、UNREAD(未读)、SEND(已发)、NEW_COMMENT(新评论)、DELETED(已删除)。`,
|
||||
Example: ` dws ding message list
|
||||
--type 支持: ALL(全部)、UNREAD(未读)、SEND(已发)、NEW_COMMENT(新评论)、DELETED(已删除)。
|
||||
--type 为服务端必填字段,空值会报「type不能为空」;不传时 CLI 默认按 ALL 查询。`,
|
||||
Example: ` dws ding message list # 默认 --type ALL
|
||||
dws ding message list --type UNREAD
|
||||
dws ding message list --type SEND --cursor 10`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -102,9 +103,12 @@ func newDingCommand() *cobra.Command {
|
||||
if v, _ := cmd.Flags().GetInt64("cursor"); v > 0 {
|
||||
toolArgs["cursor"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("type"); v != "" {
|
||||
toolArgs["type"] = v
|
||||
// type 是服务端必填,空值会报错;不传或传空时兜底为 ALL。
|
||||
t, _ := cmd.Flags().GetString("type")
|
||||
if t == "" {
|
||||
t = "ALL"
|
||||
}
|
||||
toolArgs["type"] = t
|
||||
return callMCPToolOnServer("im", "list_ding_messages", toolArgs)
|
||||
},
|
||||
}
|
||||
@@ -210,7 +214,7 @@ func newDingCommand() *cobra.Command {
|
||||
dingMessageRecallCmd.Flags().String("robot-code", "", "机器人 ID (必填,或设 DINGTALK_DING_ROBOT_CODE)")
|
||||
dingMessageRecallCmd.Flags().String("id", "", "DING 消息 ID (必填)")
|
||||
dingMessageListCmd.Flags().Int64("cursor", 0, "分页游标(首次传 0,翻页传返回的 nextCursor)")
|
||||
dingMessageListCmd.Flags().String("type", "", "消息类型: ALL / UNREAD / SEND / NEW_COMMENT / DELETED(可选,不传返回全部)")
|
||||
dingMessageListCmd.Flags().String("type", "ALL", "消息类型: ALL / UNREAD / SEND / NEW_COMMENT / DELETED(必填,服务端不接受空值;默认 ALL 全部)")
|
||||
dingMessageReceiverStatusCmd.Flags().String("ding-id", "", "DING 消息 openDingId (必填)")
|
||||
_ = dingMessageReceiverStatusCmd.MarkFlagRequired("ding-id")
|
||||
dingMessageSendPersonalCmd.Flags().String("users", "", "接收者 openDingTalkId 列表,逗号分隔 (必填)")
|
||||
|
||||
+294
-4
@@ -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 获取文档元信息
|
||||
@@ -794,6 +798,8 @@ func newDocCommand() *cobra.Command {
|
||||
dws doc comment [list|create|delete] 文档评论管理
|
||||
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 应用文档模板创建新文档
|
||||
@@ -2442,6 +2448,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 <taskId>`,
|
||||
Example: ` # 导入 Word 文档
|
||||
dws doc import --file ./report.docx
|
||||
|
||||
# 导入到指定文件夹
|
||||
dws doc import --file ./notes.md --folder <FOLDER_ID>
|
||||
|
||||
# 导入到知识库根目录
|
||||
dws doc import --file ./data.xlsx --workspace <WORKSPACE_ID>
|
||||
|
||||
# 自定义导入后的文档名称
|
||||
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 <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 +2887,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 +2929,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 +3176,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.
|
||||
|
||||
+351
-47
@@ -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 <id1>,<id2>
|
||||
dws mail message batch-get --email user@company.com --ids <id1>,<id2>,<id3>`,
|
||||
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 <mailId> --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 <recallTaskId>`,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -23,6 +23,7 @@ func init() {
|
||||
name string
|
||||
fn func() *cobra.Command
|
||||
}{
|
||||
{"agoal", newAgoalCommand},
|
||||
{"aisearch", newAisearchCommand},
|
||||
{"aitable", newAitableCommand},
|
||||
{"attendance", newAttendanceCommand},
|
||||
|
||||
@@ -48,6 +48,8 @@ func newSheetCommand() *cobra.Command {
|
||||
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 全局查找替换文本
|
||||
|
||||
@@ -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 名去掉 --
|
||||
|
||||
@@ -172,7 +172,7 @@ position.col 使用列字母表示法(如 "A"、"AA"),不支持数字形
|
||||
创建成功后返回新图表的完整信息,包含系统分配的 chart-id。`,
|
||||
Example: ` # 创建柱形图
|
||||
dws sheet chart create --node NODE_ID --sheet-id SHEET_ID --properties '{
|
||||
"position": {"row": 12, "col": "A"}, // col 使用字母
|
||||
"position": {"row": 12, "col": "A"},
|
||||
"dimensions": {"width": 600, "height": 400},
|
||||
"chart": {
|
||||
"type": "column",
|
||||
@@ -187,7 +187,7 @@ position.col 使用列字母表示法(如 "A"、"AA"),不支持数字形
|
||||
|
||||
# 创建饼图
|
||||
dws sheet chart create --node NODE_ID --sheet-id SHEET_ID --properties '{
|
||||
"position": {"row": 0, "col": "F"}, // col 使用字母
|
||||
"position": {"row": 0, "col": "F"},
|
||||
"dimensions": {"width": 500, "height": 400},
|
||||
"chart": {
|
||||
"type": "pie",
|
||||
@@ -243,7 +243,7 @@ position.col 使用列字母表示法(如 "A"、"AA"),不支持数字形
|
||||
|
||||
# 修改后整体回写
|
||||
dws sheet chart update --node NODE_ID --sheet-id SHEET_ID --chart-id CHART_ID --properties '{
|
||||
"position": {"row": 12, "col": "A"}, // col 使用字母
|
||||
"position": {"row": 12, "col": "A"},
|
||||
"dimensions": {"width": 800, "height": 500},
|
||||
"chart": {
|
||||
"type": "line",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,14 @@ func runSheetExport(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// json 模式下进度提示会污染 stdout(PrintInfo/PrintKeyValue 都写 stdout),
|
||||
// 使得 agent 无法按 JSON 解析。故 json 模式抑制进度、末尾统一输出结果 JSON。
|
||||
jsonMode := deps.Caller.Format() == "json"
|
||||
|
||||
// Step 1: submit export job
|
||||
deps.Out.PrintInfo("[1/3] 提交表格导出任务 (xlsx)...")
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo("[1/3] 提交表格导出任务 (xlsx)...")
|
||||
}
|
||||
submitText, err := callMCPToolReturnText(ctx, "submit_export_job", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"exportFormat": "xlsx",
|
||||
@@ -44,10 +50,11 @@ func runSheetExport(cmd *cobra.Command, _ []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导出任务已提交: jobId=%s", jobID))
|
||||
|
||||
// Step 2: progressive backoff polling
|
||||
deps.Out.PrintInfo("[2/3] 轮询任务状态(渐进式退避,最多 30 次约 5 分钟)...")
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导出任务已提交: jobId=%s", jobID))
|
||||
// Step 2: progressive backoff polling
|
||||
deps.Out.PrintInfo("[2/3] 轮询任务状态(渐进式退避,最多 30 次约 5 分钟)...")
|
||||
}
|
||||
downloadURL, err := pollSheetExportJob(ctx, jobID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -55,6 +62,13 @@ func runSheetExport(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
// No output path: print the downloadUrl and exit
|
||||
if outputPath == "" {
|
||||
if jsonMode {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"jobId": jobID,
|
||||
"downloadUrl": downloadURL,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintKeyValue("jobId", jobID)
|
||||
deps.Out.PrintKeyValue("downloadUrl", downloadURL)
|
||||
deps.Out.PrintInfo("导出完成。downloadUrl 具有时效性,请尽快下载。")
|
||||
@@ -71,10 +85,20 @@ func runSheetExport(cmd *cobra.Command, _ []string) error {
|
||||
outputPath = filepath.Join(outputPath, filename)
|
||||
}
|
||||
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[3/3] 下载 xlsx 到 %s ...", outputPath))
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[3/3] 下载 xlsx 到 %s ...", outputPath))
|
||||
}
|
||||
if err := httpGetFile(ctx, downloadURL, map[string]string{}, outputPath); err != nil {
|
||||
return fmt.Errorf("下载 xlsx 失败: %w", err)
|
||||
}
|
||||
if jsonMode {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"jobId": jobID,
|
||||
"outputPath": outputPath,
|
||||
"downloadUrl": downloadURL,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath))
|
||||
return nil
|
||||
}
|
||||
@@ -124,6 +148,8 @@ func exportPollIntervals() []time.Duration {
|
||||
// pollExportJob polls query_export_job per the progressive backoff schedule
|
||||
// until the job completes successfully, fails, or the 30-attempt cap is hit.
|
||||
func pollSheetExportJob(ctx context.Context, jobID string) (string, error) {
|
||||
// json 模式下轮询进度也要抑制,否则 [INFO] 行会混进 stdout 破坏纯 JSON 输出。
|
||||
quiet := deps.Caller.Format() == "json"
|
||||
intervals := exportPollIntervals()
|
||||
for i, wait := range intervals {
|
||||
select {
|
||||
@@ -136,7 +162,9 @@ func pollSheetExportJob(ctx context.Context, jobID string) (string, error) {
|
||||
"jobId": jobID,
|
||||
})
|
||||
if err != nil {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 查询失败,将继续轮询: %v", i+1, err))
|
||||
if !quiet {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 查询失败,将继续轮询: %v", i+1, err))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -149,7 +177,9 @@ func pollSheetExportJob(ctx context.Context, jobID string) (string, error) {
|
||||
normStatus := strings.ToUpper(strings.TrimSpace(status))
|
||||
switch normStatus {
|
||||
case "SUCCESS":
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 状态: SUCCESS", i+1))
|
||||
if !quiet {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 状态: SUCCESS", i+1))
|
||||
}
|
||||
if downloadURL == "" {
|
||||
return "", fmt.Errorf("任务成功但未返回 downloadUrl")
|
||||
}
|
||||
@@ -160,9 +190,13 @@ func pollSheetExportJob(ctx context.Context, jobID string) (string, error) {
|
||||
}
|
||||
return "", fmt.Errorf("%s", message)
|
||||
case "PROCESSING", "RUNNING", "DOING", "PENDING", "":
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 状态: PROCESSING", i+1))
|
||||
if !quiet {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 状态: PROCESSING", i+1))
|
||||
}
|
||||
default:
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 状态: %s", i+1, status))
|
||||
if !quiet {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" [%d/30] 状态: %s", i+1, status))
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("导出任务超时:已轮询 30 次(约 5 分钟)仍未完成,请稍后再试")
|
||||
@@ -234,7 +268,7 @@ func newExportCmd() *cobra.Command {
|
||||
- 未指定:仅返回 downloadUrl,链接有时效性请尽快下载
|
||||
|
||||
支持范围:
|
||||
仅支持钉钉在线电子表格(alxs)→ xlsx;
|
||||
仅支持钉钉在线电子表格(axls)→ xlsx;
|
||||
若需导出钉钉文字文档,请使用 dingtalkdoc 侧的导出工具。
|
||||
|
||||
权限要求:
|
||||
|
||||
@@ -57,7 +57,13 @@ func runSheetMediaUpload(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[1/2] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
|
||||
// json 模式下进度提示会污染 stdout(PrintInfo/PrintKeyValue 都写 stdout),
|
||||
// 使得 agent 无法按 JSON 解析。故 json 模式抑制进度、末尾统一输出结果 JSON。
|
||||
jsonMode := deps.Caller.Format() == "json"
|
||||
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[1/2] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
|
||||
}
|
||||
|
||||
result, err := deps.Caller.CallTool(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
@@ -91,10 +97,11 @@ func runSheetMediaUpload(cmd *cobra.Command, _ []string) error {
|
||||
resourceURL, _ = credData["resourceUrl"].(string)
|
||||
}
|
||||
|
||||
deps.Out.PrintKeyValue("resourceId", resourceID)
|
||||
deps.Out.PrintKeyValue("resourceUrl", resourceURL)
|
||||
|
||||
deps.Out.PrintInfo("[2/2] 上传文件到 OSS...")
|
||||
if !jsonMode {
|
||||
deps.Out.PrintKeyValue("resourceId", resourceID)
|
||||
deps.Out.PrintKeyValue("resourceUrl", resourceURL)
|
||||
deps.Out.PrintInfo("[2/2] 上传文件到 OSS...")
|
||||
}
|
||||
|
||||
ossHeaders := map[string]string{
|
||||
"Content-Type": mimeType,
|
||||
@@ -103,6 +110,15 @@ func runSheetMediaUpload(cmd *cobra.Command, _ []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if jsonMode {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"resourceId": resourceID,
|
||||
"resourceUrl": resourceURL,
|
||||
"fileName": fileName,
|
||||
"fileSize": fileSize,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("附件已上传: %s (resourceId=%s)", fileName, resourceID))
|
||||
return nil
|
||||
}
|
||||
@@ -166,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,
|
||||
@@ -200,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,
|
||||
@@ -212,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,
|
||||
@@ -228,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -580,16 +580,19 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
Short: "在知识库中创建节点",
|
||||
Long: `在指定知识库中创建文档、文件夹或其他类型的节点。
|
||||
|
||||
通过 --type 指定节点类型:
|
||||
通过 --type 指定节点类型(服务端支持以下值,asheet 不被支持):
|
||||
adoc 在线文档 (默认)
|
||||
asheet 在线表格
|
||||
folder 文件夹
|
||||
axls 在线电子表格
|
||||
able 多维表
|
||||
appt 在线演示
|
||||
adraw 白板/画板
|
||||
amind 脑图
|
||||
folder 文件夹
|
||||
|
||||
通过 --folder 指定父节点,不传则创建在知识库根目录。`,
|
||||
Example: ` dws wiki node create --workspace <workspaceId> --name "新文档"
|
||||
dws wiki node create --workspace <workspaceId> --name "方案目录" --type folder
|
||||
dws wiki node create --workspace <workspaceId> --name "数据表" --type asheet --folder <parentNodeId>`,
|
||||
dws wiki node create --workspace <workspaceId> --name "数据表" --type axls --folder <parentNodeId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
|
||||
if err != nil {
|
||||
@@ -616,7 +619,7 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
}
|
||||
nodeCreateCmd.Flags().String("workspace", "", "知识库 ID (必填)")
|
||||
nodeCreateCmd.Flags().String("name", "", "节点名称 (必填)")
|
||||
nodeCreateCmd.Flags().String("type", "adoc", "节点类型: adoc / asheet / folder / axls")
|
||||
nodeCreateCmd.Flags().String("type", "adoc", "节点类型: adoc / axls / able / appt / adraw / amind / folder(asheet 不支持)")
|
||||
nodeCreateCmd.Flags().String("folder", "", "父节点 nodeId (选填,不传则在根目录创建)")
|
||||
|
||||
nodeCopyCmd := &cobra.Command{
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// 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()
|
||||
|
||||
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...)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,11 @@ const (
|
||||
DisableKeychainEnv = "DWS_DISABLE_KEYCHAIN"
|
||||
)
|
||||
|
||||
// ErrDEKMissing means encrypted local data may exist, but the Data Encryption
|
||||
// Key needed to decrypt it is missing. Read paths must not create a new DEK,
|
||||
// because a fresh key cannot decrypt existing ciphertext.
|
||||
var ErrDEKMissing = errors.New("dek missing")
|
||||
|
||||
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
|
||||
type KeychainAccess interface {
|
||||
Get(service, account string) (string, error)
|
||||
@@ -47,6 +54,64 @@ 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 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) {
|
||||
|
||||
@@ -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,169 @@ func safeFileName(account string) string {
|
||||
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// getDEK retrieves or generates the Data Encryption Key.
|
||||
// When DWS_DISABLE_KEYCHAIN=1 (set in sandboxed runtimes like Codex App
|
||||
// where Keychain APIs are blocked), falls back to a file-based DEK
|
||||
// identical to the Linux scheme. See DisableKeychainEnv docs for the
|
||||
// security tradeoff.
|
||||
func getDEK(service string) ([]byte, error) {
|
||||
return getOrCreateDEK(service)
|
||||
}
|
||||
|
||||
func getDEKReadOnly(service string) ([]byte, error) {
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
return fileDEKReadOnly(service)
|
||||
}
|
||||
if err := checkDefaultKeychainAvailable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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 +240,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 +261,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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,10 +321,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) {
|
||||
@@ -168,6 +328,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
|
||||
@@ -176,7 +340,7 @@ func platformGet(service, account string) (string, error) {
|
||||
}
|
||||
|
||||
func platformSet(service, account, data string) error {
|
||||
key, err := getDEK(service)
|
||||
key, err := getOrCreateDEK(service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -16,11 +16,128 @@
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -244,12 +244,12 @@ func newChmodCommand(c edition.ToolCaller) *cobra.Command {
|
||||
Long: `授予指定 scope 的操作权限。
|
||||
|
||||
scope 格式: <product>.<entity>:<permission>
|
||||
例: 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")
|
||||
|
||||
@@ -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 "" }
|
||||
|
||||
@@ -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"}},
|
||||
|
||||
@@ -5,6 +5,7 @@ package syncdata
|
||||
|
||||
func CmdToProduct() map[string]string {
|
||||
return map[string]string{
|
||||
"agoal": "agoal",
|
||||
"aisearch": "aisearch",
|
||||
"aitable": "aitable",
|
||||
"attendance": "attendance",
|
||||
|
||||
@@ -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 cmdutil
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// MergeHardcodedLeaves grafts leaves from hardcodedRoot onto dynamicRoot when
|
||||
// the same-named path does not already exist. Groups recurse. On leaf
|
||||
// conflicts, the dynamic side wins by default because the runtime envelope is
|
||||
// authoritative; hardcoded commands are retained as fallback for paths the
|
||||
// envelope does not declare.
|
||||
//
|
||||
// A hardcoded leaf or group can opt into replacement by carrying a strictly
|
||||
// higher OverridePriority than the dynamic command at the same path.
|
||||
//
|
||||
// MergeHardcodedLeaves mutates dynamicRoot in place and returns it. Grafted
|
||||
// commands are detached from hardcodedRoot so Cobra parent pointers remain
|
||||
// correct.
|
||||
func MergeHardcodedLeaves(dynamicRoot, hardcodedRoot *cobra.Command) *cobra.Command {
|
||||
if dynamicRoot == nil || hardcodedRoot == nil {
|
||||
return dynamicRoot
|
||||
}
|
||||
|
||||
children := append([]*cobra.Command(nil), hardcodedRoot.Commands()...)
|
||||
for _, hc := range children {
|
||||
dyn := findChildByName(dynamicRoot, hc.Name())
|
||||
switch {
|
||||
case dyn == nil:
|
||||
hardcodedRoot.RemoveCommand(hc)
|
||||
dynamicRoot.AddCommand(hc)
|
||||
case IsLeafCmd(hc) && IsLeafCmd(dyn):
|
||||
if OverridePriority(hc) > OverridePriority(dyn) {
|
||||
hardcodedRoot.RemoveCommand(hc)
|
||||
dynamicRoot.RemoveCommand(dyn)
|
||||
dynamicRoot.AddCommand(hc)
|
||||
}
|
||||
case !IsLeafCmd(hc) && !IsLeafCmd(dyn) && OverridePriority(hc) > OverridePriority(dyn):
|
||||
hardcodedRoot.RemoveCommand(hc)
|
||||
dynamicRoot.RemoveCommand(dyn)
|
||||
dynamicRoot.AddCommand(hc)
|
||||
case !IsLeafCmd(hc) && !IsLeafCmd(dyn):
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
case IsLeafCmd(dyn) && !IsLeafCmd(hc) && OverridePriority(hc) > OverridePriority(dyn):
|
||||
hardcodedRoot.RemoveCommand(hc)
|
||||
dynamicRoot.RemoveCommand(dyn)
|
||||
dynamicRoot.AddCommand(hc)
|
||||
default:
|
||||
slog.Warn("overlay: shape mismatch, keeping dynamic",
|
||||
"name", hc.Name(),
|
||||
"dynamicIsLeaf", IsLeafCmd(dyn),
|
||||
"hardcodedIsLeaf", IsLeafCmd(hc))
|
||||
}
|
||||
}
|
||||
return dynamicRoot
|
||||
}
|
||||
|
||||
// IsLeafCmd reports whether cmd has no subcommands.
|
||||
func IsLeafCmd(cmd *cobra.Command) bool {
|
||||
if cmd == nil {
|
||||
return false
|
||||
}
|
||||
return !cmd.HasSubCommands()
|
||||
}
|
||||
|
||||
func findChildByName(parent *cobra.Command, name string) *cobra.Command {
|
||||
if parent == nil {
|
||||
return nil
|
||||
}
|
||||
for _, child := range parent.Commands() {
|
||||
if child.Name() == name {
|
||||
return child
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// 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 cmdutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newGroup(name string, children ...*cobra.Command) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: name}
|
||||
cmd.AddCommand(children...)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newLeaf(name, tag string) *cobra.Command {
|
||||
return &cobra.Command{Use: name, Short: tag}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesNilInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := MergeHardcodedLeaves(nil, nil); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
dyn := newGroup("root")
|
||||
if got := MergeHardcodedLeaves(dyn, nil); got != dyn {
|
||||
t.Fatal("expected dynamic root to be returned unchanged")
|
||||
}
|
||||
hc := newGroup("root")
|
||||
if got := MergeHardcodedLeaves(nil, hc); got != nil {
|
||||
t.Fatal("expected nil when dynamic root is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesGraftsUnknownLeaf(t *testing.T) {
|
||||
t.Parallel()
|
||||
dyn := newGroup("root", newLeaf("kept", "dynamic"))
|
||||
hc := newGroup("root", newLeaf("extra", "hardcoded"))
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "extra")
|
||||
if got == nil {
|
||||
t.Fatal("expected extra leaf to be grafted")
|
||||
}
|
||||
if got.Short != "hardcoded" {
|
||||
t.Fatalf("extra.Short = %q, want %q", got.Short, "hardcoded")
|
||||
}
|
||||
if findChildByName(hc, "extra") != nil {
|
||||
t.Fatal("expected extra leaf to be detached from hardcoded root")
|
||||
}
|
||||
if got.Parent() != dyn {
|
||||
t.Fatalf("grafted leaf parent = %v, want %v", got.Parent(), dyn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesDynamicLeafWinsByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
dyn := newGroup("root", newLeaf("shared", "dynamic"))
|
||||
hc := newGroup("root", newLeaf("shared", "hardcoded"))
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "shared")
|
||||
if got == nil {
|
||||
t.Fatal("expected shared leaf to remain")
|
||||
}
|
||||
if got.Short != "dynamic" {
|
||||
t.Fatalf("shared.Short = %q, want dynamic", got.Short)
|
||||
}
|
||||
if findChildByName(hc, "shared") == nil {
|
||||
t.Fatal("expected hardcoded shared leaf to remain on donor root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesHigherPriorityHardcodedLeafWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
dynLeaf := newLeaf("shared", "dynamic")
|
||||
dyn := newGroup("root", dynLeaf)
|
||||
hcLeaf := newLeaf("shared", "hardcoded")
|
||||
SetOverridePriority(hcLeaf, 100)
|
||||
hc := newGroup("root", hcLeaf)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "shared")
|
||||
if got != hcLeaf {
|
||||
t.Fatalf("expected hardcoded leaf to replace dynamic leaf, got %+v", got)
|
||||
}
|
||||
if findChildByName(hc, "shared") != nil {
|
||||
t.Fatal("expected hardcoded leaf to be detached from donor root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesEqualPriorityKeepsDynamic(t *testing.T) {
|
||||
t.Parallel()
|
||||
dynLeaf := newLeaf("shared", "dynamic")
|
||||
SetOverridePriority(dynLeaf, 100)
|
||||
dyn := newGroup("root", dynLeaf)
|
||||
hcLeaf := newLeaf("shared", "hardcoded")
|
||||
SetOverridePriority(hcLeaf, 100)
|
||||
hc := newGroup("root", hcLeaf)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "shared")
|
||||
if got != dynLeaf {
|
||||
t.Fatalf("equal priorities should keep dynamic leaf, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesRecurseGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
dyn := newGroup("root",
|
||||
newGroup("space",
|
||||
newLeaf("list", "dynamic"),
|
||||
),
|
||||
)
|
||||
hc := newGroup("root",
|
||||
newGroup("space",
|
||||
newLeaf("list", "hardcoded"),
|
||||
newLeaf("create", "hardcoded"),
|
||||
),
|
||||
newLeaf("ping", "hardcoded"),
|
||||
)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
space := findChildByName(dyn, "space")
|
||||
if space == nil {
|
||||
t.Fatal("expected space group")
|
||||
}
|
||||
if list := findChildByName(space, "list"); list == nil || list.Short != "dynamic" {
|
||||
t.Fatalf("space.list should remain dynamic, got %+v", list)
|
||||
}
|
||||
if create := findChildByName(space, "create"); create == nil || create.Short != "hardcoded" {
|
||||
t.Fatalf("space.create should be grafted, got %+v", create)
|
||||
}
|
||||
if ping := findChildByName(dyn, "ping"); ping == nil || ping.Short != "hardcoded" {
|
||||
t.Fatalf("ping should be grafted, got %+v", ping)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesHigherPriorityGroupReplacesDynamicGroup(t *testing.T) {
|
||||
t.Parallel()
|
||||
dynGroup := newGroup("export", newLeaf("get", "dynamic"))
|
||||
dyn := newGroup("root", dynGroup)
|
||||
hcGroup := newGroup("export", newLeaf("get", "hardcoded"))
|
||||
SetOverridePriority(hcGroup, 100)
|
||||
hc := newGroup("root", hcGroup)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "export")
|
||||
if got != hcGroup {
|
||||
t.Fatal("expected higher-priority hardcoded group to replace dynamic group")
|
||||
}
|
||||
if get := findChildByName(got, "get"); get == nil || get.Short != "hardcoded" {
|
||||
t.Fatalf("expected hardcoded export.get after replacement, got %+v", get)
|
||||
}
|
||||
if findChildByName(hc, "export") != nil {
|
||||
t.Fatal("expected hardcoded group to be detached from donor root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesShapeMismatchKeepsDynamic(t *testing.T) {
|
||||
t.Parallel()
|
||||
dyn := newGroup("root",
|
||||
newGroup("cmd", newLeaf("sub", "dynamic")),
|
||||
)
|
||||
hc := newGroup("root", newLeaf("cmd", "hardcoded"))
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
cmd := findChildByName(dyn, "cmd")
|
||||
if cmd == nil {
|
||||
t.Fatal("expected dynamic cmd to remain")
|
||||
}
|
||||
if IsLeafCmd(cmd) {
|
||||
t.Fatal("expected dynamic cmd to remain a group")
|
||||
}
|
||||
if findChildByName(cmd, "sub") == nil {
|
||||
t.Fatal("expected cmd.sub to remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesHigherPriorityGroupReplacesDynamicLeaf(t *testing.T) {
|
||||
t.Parallel()
|
||||
dynLeaf := newLeaf("shared", "dynamic")
|
||||
dyn := newGroup("root", dynLeaf)
|
||||
hcGroup := newGroup("shared",
|
||||
newLeaf("list", "hardcoded"),
|
||||
newLeaf("add", "hardcoded"),
|
||||
)
|
||||
SetOverridePriority(hcGroup, 100)
|
||||
hc := newGroup("root", hcGroup)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "shared")
|
||||
if got != hcGroup {
|
||||
t.Fatal("expected higher-priority hardcoded group to replace dynamic leaf")
|
||||
}
|
||||
if findChildByName(got, "list") == nil {
|
||||
t.Fatal("expected hardcoded subtree leaf list to be reachable")
|
||||
}
|
||||
if findChildByName(got, "add") == nil {
|
||||
t.Fatal("expected hardcoded subtree leaf add to be reachable")
|
||||
}
|
||||
if findChildByName(hc, "shared") != nil {
|
||||
t.Fatal("expected hardcoded group to be detached from donor root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesEqualPriorityShapeMismatchKeepsDynamic(t *testing.T) {
|
||||
t.Parallel()
|
||||
dynLeaf := newLeaf("shared", "dynamic")
|
||||
SetOverridePriority(dynLeaf, 100)
|
||||
dyn := newGroup("root", dynLeaf)
|
||||
hcGroup := newGroup("shared", newLeaf("list", "hardcoded"))
|
||||
SetOverridePriority(hcGroup, 100)
|
||||
hc := newGroup("root", hcGroup)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "shared")
|
||||
if got != dynLeaf {
|
||||
t.Fatalf("equal priorities should keep dynamic leaf, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLeafCmd(t *testing.T) {
|
||||
t.Parallel()
|
||||
leaf := newLeaf("x", "")
|
||||
group := newGroup("x", newLeaf("child", ""))
|
||||
if !IsLeafCmd(leaf) {
|
||||
t.Fatal("expected leaf to be leaf")
|
||||
}
|
||||
if IsLeafCmd(group) {
|
||||
t.Fatal("expected group to not be leaf")
|
||||
}
|
||||
if IsLeafCmd(nil) {
|
||||
t.Fatal("expected nil to not be leaf")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,45 @@
|
||||
// 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 cmdutil
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// SourceAnnotation records where a command tree came from. Edition overlays
|
||||
// use it to distinguish runtime-authored commands from helper fallbacks that
|
||||
// happen to share the same top-level product name.
|
||||
const SourceAnnotation = "dws.source"
|
||||
|
||||
// SourceEnvelope marks a command as authored by the runtime discovery envelope.
|
||||
const SourceEnvelope = "envelope"
|
||||
|
||||
// MarkEnvelopeSource stamps cmd with runtime discovery provenance.
|
||||
func MarkEnvelopeSource(cmd *cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[SourceAnnotation] = SourceEnvelope
|
||||
}
|
||||
|
||||
// IsEnvelopeSourced reports whether cmd was authored by the runtime discovery
|
||||
// envelope.
|
||||
func IsEnvelopeSourced(cmd *cobra.Command) bool {
|
||||
return cmd != nil && cmd.Annotations[SourceAnnotation] == SourceEnvelope
|
||||
}
|
||||
|
||||
// KindAnnotation is the annotation key for marking command kinds.
|
||||
const KindAnnotation = "dws.kind"
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// 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 cmdutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestEnvelopeSourceProvenance(t *testing.T) {
|
||||
t.Parallel()
|
||||
if IsEnvelopeSourced(nil) {
|
||||
t.Fatal("nil command should not be envelope sourced")
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "chat"}
|
||||
if IsEnvelopeSourced(cmd) {
|
||||
t.Fatal("unstamped command should not be envelope sourced")
|
||||
}
|
||||
|
||||
MarkEnvelopeSource(cmd)
|
||||
if !IsEnvelopeSourced(cmd) {
|
||||
t.Fatal("stamped command should be envelope sourced")
|
||||
}
|
||||
if got := cmd.Annotations[SourceAnnotation]; got != SourceEnvelope {
|
||||
t.Fatalf("SourceAnnotation = %q, want %q", got, SourceEnvelope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkEnvelopeSourceNilDoesNotPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
MarkEnvelopeSource(nil)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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 ""
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build release artifacts locally in the current runner and publish them to Gitee.
|
||||
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
SCRIPT_ROOT="$ROOT"
|
||||
cd "$SCRIPT_ROOT"
|
||||
|
||||
VERSION="${VERSION:-$(git describe --tags --exact-match 2>/dev/null || true)}"
|
||||
[ -n "$VERSION" ] || {
|
||||
echo "error: VERSION is required or HEAD must be an exact release tag" >&2
|
||||
exit 1
|
||||
}
|
||||
case "$VERSION" in
|
||||
v*) TAG="$VERSION"; SEMVER="${VERSION#v}" ;;
|
||||
*) TAG="v$VERSION"; SEMVER="$VERSION" ;;
|
||||
esac
|
||||
|
||||
git fetch --tags origin "refs/tags/${TAG}:refs/tags/${TAG}" >/dev/null 2>&1 || true
|
||||
target_commit="$(git rev-parse "${TAG}^{commit}" 2>/dev/null || true)"
|
||||
[ -n "$target_commit" ] || {
|
||||
echo "error: could not resolve tag ${TAG}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
current_commit="$(git rev-parse HEAD)"
|
||||
WORKDIR="$SCRIPT_ROOT"
|
||||
cleanup_worktree() {
|
||||
if [ "$WORKDIR" != "$SCRIPT_ROOT" ]; then
|
||||
git -C "$SCRIPT_ROOT" worktree remove --force "$WORKDIR" >/dev/null 2>&1 || rm -rf "$WORKDIR"
|
||||
fi
|
||||
}
|
||||
trap cleanup_worktree EXIT
|
||||
|
||||
if [ "$current_commit" != "$target_commit" ]; then
|
||||
WORKDIR="$(mktemp -d)"
|
||||
rm -rf "$WORKDIR"
|
||||
git worktree add --detach "$WORKDIR" "$TAG"
|
||||
mkdir -p "$WORKDIR/scripts/release"
|
||||
cp "$SCRIPT_ROOT/scripts/release/publish-gitee-local.sh" "$WORKDIR/scripts/release/publish-gitee-local.sh"
|
||||
chmod +x "$WORKDIR/scripts/release/publish-gitee-local.sh"
|
||||
fi
|
||||
|
||||
cd "$WORKDIR"
|
||||
|
||||
echo "==> Building ${TAG} locally for Gitee"
|
||||
VERSION="$SEMVER" ./scripts/dev/build-all.sh
|
||||
DWS_PACKAGE_VERSION="$TAG" ./scripts/release/post-goreleaser.sh
|
||||
VERSION="$TAG" ./scripts/release/publish-gitee-local.sh
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish locally-built release artifacts to the matching Gitee release.
|
||||
#
|
||||
# Intended to run inside Gitee Go after building artifacts in China. This avoids
|
||||
# the unreliable GitHub Actions -> Gitee cross-border upload path.
|
||||
|
||||
set -eu
|
||||
|
||||
DIST_DIR="${DIST_DIR:-dist}"
|
||||
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
|
||||
GITEE_REPO="${GITEE_REPO:-DingTalk-Real-AI/dingtalk-workspace-cli}"
|
||||
GITEE_TOKEN="${GITEE_TOKEN:-${GITEE_ACCESS_TOKEN:-}}"
|
||||
GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}"
|
||||
GITEE_CURL_MAX_TIME="${GITEE_CURL_MAX_TIME:-120}"
|
||||
GITEE_UPLOAD_MAX_TIME="${GITEE_UPLOAD_MAX_TIME:-300}"
|
||||
GITEE_UPLOAD_RETRIES="${GITEE_UPLOAD_RETRIES:-3}"
|
||||
GITEE_UPLOAD_RETRY_DELAY="${GITEE_UPLOAD_RETRY_DELAY:-10}"
|
||||
|
||||
err() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
[ -n "$GITEE_TOKEN" ] || err "GITEE_TOKEN is required"
|
||||
[ -d "$DIST_DIR" ] || err "dist dir not found: $DIST_DIR"
|
||||
|
||||
VERSION="${VERSION:-$(git describe --tags --exact-match 2>/dev/null || true)}"
|
||||
[ -n "$VERSION" ] || err "VERSION is required or HEAD must be an exact tag"
|
||||
case "$VERSION" in
|
||||
v*) ;;
|
||||
*) VERSION="v$VERSION" ;;
|
||||
esac
|
||||
|
||||
OWNER="${GITEE_REPO%%/*}"
|
||||
NAME="${GITEE_REPO##*/}"
|
||||
base="${GITEE_API}/repos/${OWNER}/${NAME}"
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then sha256sum ${1:+"$1"} | awk '{print $1}'
|
||||
else shasum -a 256 ${1:+"$1"} | awk '{print $1}'
|
||||
fi
|
||||
}
|
||||
|
||||
api_get() {
|
||||
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "$@"
|
||||
}
|
||||
|
||||
echo "📦 Publishing local artifacts for ${VERSION} to Gitee ${GITEE_REPO}"
|
||||
|
||||
target_commit="$(git rev-parse "${VERSION}^{commit}" 2>/dev/null || git rev-parse HEAD)"
|
||||
|
||||
rel_json="$(api_get "${base}/releases/tags/${VERSION}?access_token=${GITEE_TOKEN}" 2>/dev/null || true)"
|
||||
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
|
||||
|
||||
if [ -z "$release_id" ]; then
|
||||
echo " No Gitee release for ${VERSION} yet — creating it."
|
||||
rel_json="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
|
||||
-X POST "${base}/releases" \
|
||||
-F "access_token=${GITEE_TOKEN}" \
|
||||
-F "tag_name=${VERSION}" \
|
||||
-F "name=${VERSION}" \
|
||||
-F "body=Gitee-local build of ${VERSION} for China users." \
|
||||
-F "target_commitish=${target_commit}" 2>/dev/null || true)"
|
||||
release_id="$(printf '%s' "$rel_json" | grep -o '"id":[ ]*[0-9]*' | head -1 | grep -o '[0-9]*' || true)"
|
||||
fi
|
||||
[ -n "$release_id" ] || err "could not get/create Gitee release for ${VERSION}. Response: ${rel_json}"
|
||||
echo " Gitee release id = ${release_id}"
|
||||
|
||||
assets_map="$(api_get "${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
rows=data if isinstance(data,list) else data.get("attach_files",[])
|
||||
for a in rows:
|
||||
n=a.get("name",""); i=a.get("id",""); u=a.get("browser_download_url","")
|
||||
if n and i!="":
|
||||
print("%s\t%s\t%s" % (n, i, u))
|
||||
except Exception:
|
||||
pass' 2>/dev/null || true)"
|
||||
|
||||
gitee_attach() {
|
||||
file="$1"
|
||||
fn="$(basename "$file")"
|
||||
attempt=1
|
||||
while [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; do
|
||||
response="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_UPLOAD_MAX_TIME" \
|
||||
--retry 2 --retry-delay 5 --retry-all-errors \
|
||||
-X POST "${base}/releases/${release_id}/attach_files" \
|
||||
-F "access_token=${GITEE_TOKEN}" -F "file=@${file}" 2>&1 || true)"
|
||||
if printf '%s' "$response" | grep -q '"browser_download_url"'; then
|
||||
return 0
|
||||
fi
|
||||
echo " ⚠ upload attempt ${attempt}/${GITEE_UPLOAD_RETRIES} failed for ${fn}: $(printf '%s' "$response" | head -c 240)" >&2
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ] && sleep "$GITEE_UPLOAD_RETRY_DELAY"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
gitee_delete() {
|
||||
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
|
||||
-X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \
|
||||
>/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
uploaded=0
|
||||
replaced=0
|
||||
skipped=0
|
||||
failed=0
|
||||
for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.txt "$DIST_DIR"/dws-skills.zip; do
|
||||
[ -f "$f" ] || continue
|
||||
fn="$(basename "$f")"
|
||||
local_sha="$(sha256_of "$f")"
|
||||
ids="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $2}')"
|
||||
aurl="$(printf '%s\n' "$assets_map" | awk -F'\t' -v n="$fn" '$1==n {print $3; exit}')"
|
||||
count="$(printf '%s' "$ids" | grep -c . || true)"
|
||||
|
||||
if [ "$count" -eq 1 ]; then
|
||||
gitee_sha="$(api_get "$aurl" 2>/dev/null | sha256_of || true)"
|
||||
if [ "$gitee_sha" = "$local_sha" ]; then
|
||||
echo " ✓ ${fn} already correct on Gitee — skip"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
echo " ↻ ${fn} differs on Gitee — replacing"
|
||||
elif [ "$count" -gt 1 ]; then
|
||||
echo " ↻ ${fn} has ${count} copies on Gitee — replacing"
|
||||
else
|
||||
echo " ⬆ ${fn} (new)"
|
||||
fi
|
||||
|
||||
printf '%s\n' "$ids" | while read -r aid; do
|
||||
[ -n "$aid" ] && gitee_delete "$aid"
|
||||
done
|
||||
if gitee_attach "$f"; then
|
||||
if [ "$count" -eq 0 ]; then uploaded=$((uploaded + 1)); else replaced=$((replaced + 1)); fi
|
||||
else
|
||||
echo " ❌ upload failed for ${fn}" >&2
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$failed" -eq 0 ] || err "Gitee publish finished with ${failed} failed upload(s)"
|
||||
echo "✅ Gitee release ${VERSION}: uploaded ${uploaded}, replaced ${replaced}, skipped ${skipped}"
|
||||
@@ -28,6 +28,11 @@ set -eu
|
||||
|
||||
DIST_DIR="${DIST_DIR:-dist}"
|
||||
GITEE_API="${GITEE_API:-https://gitee.com/api/v5}"
|
||||
GITEE_CURL_CONNECT_TIMEOUT="${GITEE_CURL_CONNECT_TIMEOUT:-15}"
|
||||
GITEE_CURL_MAX_TIME="${GITEE_CURL_MAX_TIME:-120}"
|
||||
GITEE_UPLOAD_MAX_TIME="${GITEE_UPLOAD_MAX_TIME:-300}"
|
||||
GITEE_UPLOAD_RETRIES="${GITEE_UPLOAD_RETRIES:-3}"
|
||||
GITEE_UPLOAD_RETRY_DELAY="${GITEE_UPLOAD_RETRY_DELAY:-10}"
|
||||
|
||||
missing=""
|
||||
[ -z "${GITEE_TOKEN:-}" ] && missing="$missing GITEE_TOKEN"
|
||||
@@ -119,7 +124,7 @@ echo " Gitee release id = ${release_id}"
|
||||
# detail (/releases/{id}) endpoint: the latter's "assets" array omits the attach
|
||||
# id, so DELETE /attach_files/{id} was previously called with an empty id and
|
||||
# silently no-op'd — leaving stale + duplicate darwin binaries on Gitee.
|
||||
assets_map="$(curl -fsSL "${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" 2>/dev/null \
|
||||
assets_map="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "${base}/releases/${release_id}/attach_files?access_token=${GITEE_TOKEN}" 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
try:
|
||||
data=json.load(sys.stdin)
|
||||
@@ -137,13 +142,27 @@ sha256_of() { # sha256 of a file ($1) or, with no arg, of stdin
|
||||
}
|
||||
|
||||
gitee_attach() { # upload file $1; success when the response carries a download url
|
||||
printf '%s' "$(curl -fsSL -X POST "${base}/releases/${release_id}/attach_files" \
|
||||
-F "access_token=${GITEE_TOKEN}" -F "file=@${1}" 2>/dev/null || true)" \
|
||||
| grep -q '"browser_download_url"'
|
||||
file="$1"
|
||||
fn="$(basename "$file")"
|
||||
attempt=1
|
||||
while [ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ]; do
|
||||
response="$(curl -fsS --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_UPLOAD_MAX_TIME" \
|
||||
--retry 2 --retry-delay 5 --retry-all-errors \
|
||||
-X POST "${base}/releases/${release_id}/attach_files" \
|
||||
-F "access_token=${GITEE_TOKEN}" -F "file=@${file}" 2>&1 || true)"
|
||||
if printf '%s' "$response" | grep -q '"browser_download_url"'; then
|
||||
return 0
|
||||
fi
|
||||
echo " ⚠ upload attempt ${attempt}/${GITEE_UPLOAD_RETRIES} failed for ${fn}: $(printf '%s' "$response" | head -c 240)" >&2
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -le "$GITEE_UPLOAD_RETRIES" ] && sleep "$GITEE_UPLOAD_RETRY_DELAY"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
gitee_delete() { # delete attachment by id $1
|
||||
curl -fsSL -X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \
|
||||
curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" \
|
||||
-X DELETE "${base}/releases/${release_id}/attach_files/${1}?access_token=${GITEE_TOKEN}" \
|
||||
>/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
@@ -166,7 +185,7 @@ for f in "$DIST_DIR"/dws-*.tar.gz "$DIST_DIR"/dws-*.zip "$DIST_DIR"/checksums.tx
|
||||
fi
|
||||
|
||||
if [ "$count" -eq 1 ]; then
|
||||
gitee_sha="$(curl -fsSL "$aurl" 2>/dev/null | sha256_of || true)"
|
||||
gitee_sha="$(curl -fsSL --connect-timeout "$GITEE_CURL_CONNECT_TIMEOUT" --max-time "$GITEE_CURL_MAX_TIME" "$aurl" 2>/dev/null | sha256_of || true)"
|
||||
if [ "$gitee_sha" = "$local_sha" ]; then
|
||||
echo " ✓ ${fn} already correct on Gitee — skip"
|
||||
skipped=$((skipped + 1))
|
||||
|
||||
@@ -43,13 +43,14 @@ cli_version: ">=1.0.15"
|
||||
| `oa` | OA审批:待处理/详情/同意/拒绝/撤销/记录/已发起/任务/转交/评论/抄送 | [oa.md](./references/products/oa.md) |
|
||||
| `report` | 日志:按模版创建/收件箱/已发送/模版查看/详情/已读统计 | [report.md](./references/products/report.md) |
|
||||
| `mail` | 邮箱:邮箱地址查询/邮件搜索(KQL)/邮件详情/发送邮件 | [mail.md](./references/products/mail.md) |
|
||||
| `sheet` | 在线电子表格(axls):工作表 CRUD/区域读写/CSV 批量写入/行列增删/合并/查找替换/筛选视图/全局筛选/排序/下拉列表/浮动图片/导出(两步) | [sheet.md](./references/products/sheet.md) |
|
||||
| `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) |
|
||||
|
||||
## 意图判断决策树
|
||||
|
||||
用户提到"AI应用/创建应用/生成系统/做工具/管理后台/低代码" → `aiapp`
|
||||
用户提到"目标管理/Agoal/战略解码/经营合约/计分卡/目标模板/周月报提交统计" → `agoal`
|
||||
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`
|
||||
用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable`
|
||||
用户提到"考勤/打卡/排班" → `attendance`
|
||||
@@ -130,7 +131,8 @@ dws <command-path> --help
|
||||
|
||||
# helper-only schema 查询(如 dev.*),普通产品命令不要依赖 schema 推断参数
|
||||
dws schema "dev app create"
|
||||
dws schema "dev app create" --jq '.tool.required'
|
||||
# 注:--jq 对 schema 输出无效(不过滤,仍返回完整对象);schema 结构里必填标在
|
||||
# .parameters.<字段>.required,没有 .tool 键。要看必填字段自行读 .parameters 即可。
|
||||
```
|
||||
|
||||
**何时用哪条路径:**
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
| Recipe | 步骤(命令均须 `--format json`,下略) |
|
||||
|--------|----------------------------------------|
|
||||
| `create-priority-todo` | 1. 确定执行者(同 [SKILL.md](../../SKILL.md) 中 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40>`(可选 `--due "<截止ISO>"`;10低/20普通/30较高/40紧急)→ 取 `todoTaskId` |
|
||||
| `create-recurring-todo` | 1. 确定执行者(同 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId> --due "<首次截止ISO>" --recurrence "DTSTART:<UTC时间>\nRRULE:FREQ=DAILY;INTERVAL=1"`(`--due` 必填;仅支持按天循环,见 [todo.md](../products/todo.md))→ 取 `todoTaskId` |
|
||||
| `reschedule-todo` | 1. `todo task list --status false` → 取 `todoTaskId`<br>2. `todo task update --task-id <todoTaskId> --due "<新截止时间>"` |
|
||||
| `create-priority-todo` | 1. 确定执行者(同 [SKILL.md](../../SKILL.md) 中 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40>`(可选 `--due "<截止ISO>"`;10低/20普通/30较高/40紧急)→ 取 `taskId` |
|
||||
| `create-recurring-todo` | 1. 确定执行者(同 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId> --due "<首次截止ISO>" --recurrence "DTSTART:<UTC时间>\nRRULE:FREQ=DAILY;INTERVAL=1"`(`--due` 必填;仅支持按天循环,见 [todo.md](../products/todo.md))→ 取 `taskId` |
|
||||
| `reschedule-todo` | 1. `todo task list --status false` → 取 `taskId`<br>2. `todo task update --task-id <taskId> --due "<新截止时间>"` |
|
||||
|
||||
## Full / 组合(固定路线)
|
||||
|
||||
@@ -16,4 +16,4 @@
|
||||
|--------|---------------------|
|
||||
| generate-progress-report | 1. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行<br>2. 交叉比对各源数据<br>3. `doc create --name "<报告名>" --content "<报告内容>"` |
|
||||
| batch-create-todo | 1. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行 → 从结果提取任务条目<br>2. 每条:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>3. **优先**:将待办写入 `todos.json`(格式见 [todo_batch_create.py](../../scripts/todo_batch_create.py)),执行 `python scripts/todo_batch_create.py todos.json`<br>备选:逐条 `todo task create --title "<标题>" --executors <userId>` → 汇总回显<br>**单批超 30 条须用户确认** |
|
||||
| assign-and-notify | 1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `todo task create --title "<标题>" --executors <userId>` → 取 `todoTaskId`<br>3. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --group <openConversationId> --text "<通知内容>"` 通知 |
|
||||
| assign-and-notify | 1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `todo task create --title "<标题>" --executors <userId>` → 取 `taskId`<br>3. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --group <openConversationId> --text "<通知内容>"` 通知 |
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|--------|-------------------|
|
||||
| read-aitable | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId`<br>3. **取记录(按场景分流)**:<br> • 数据统计/分析/全量汇总 → `aitable record query --base-id <baseId> --table-id <tableId> --all`(自动翻页,**禁止凭单页数据做统计**)<br> • 大表保险 → 加 `--page-limit 100`(默认 50 页/5000 条,0 = 无限制)<br> • 单纯预览前几条 → `aitable record query --base-id <baseId> --table-id <tableId> --limit 30`(不加 --all)<br> • 筛选时 `--filters` 格式见 [aitable-filter-sort.md](../products/aitable/aitable-filter-sort.md)<br>4. **检查输出契约**:`hasMore=true` 时数据被截断,必须用 `--cursor <X>` 续拉;`partial=true` 时表示中途某页失败(保留已拉数据,可重试)<br>5. 总结数据 |
|
||||
| generate-data-report | 1. 同 read-aitable 步骤 1-3(**必须用 --all 防漏数据**)<br>2. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行 → 补充背景<br>3. `doc create --name "<报告名>" --content "<分析报告>"` |
|
||||
| create-aitable-record | **写入路径分流**(关键决策):<br> • **追加到已有表**(场景:把这批数据加到「成员表」)→ `python scripts/import_records.py <baseId> <tableId> data.csv\|data.json [batch_size]`(走 `record create`,需要列名匹配字段名)<br> • **新建表**(场景:把这个 Excel 变成多维表)→ `python scripts/aitable_import_via_task.py <baseId> <file>` 或 `dws aitable import upload --base-id <baseId> --file ./x.xlsx` + `dws aitable import data --import-id <ID>`(CLI 已内置 OSS PUT 清空 Content-Type + 同步轮询,**禁止自己写 PUT**)<br> • **单条/少量手动**:1. `aitable base search` → `baseId`/`tableId`;2. `aitable field get` → `fieldId` 与类型;3. `aitable record create --records '[{"cells":{"<fieldId>":"值"}}]'` |
|
||||
| create-aitable-record | **写入路径分流**(关键决策):<br> • **追加到已有表**(场景:把这批数据加到「成员表」)→ `python scripts/import_records.py <baseId> <tableId> data.csv\|data.json [batch_size]`(走 `record create`,需要列名匹配字段名)<br> • **新建表**(场景:把这个 Excel 变成多维表)→ `python scripts/aitable_import_via_task.py <baseId> <file>`(脚本内置 prepare→OSS PUT→import data 全流程和正确的头处理,**禁止自己写 PUT**)。注意 `aitable import upload` 没有 `--file` flag、也不代做 PUT,只准备导入;能一站式完成的是上面的脚本<br> • **单条/少量手动**:1. `aitable base search` → `baseId`/`tableId`;2. `aitable field get` → `fieldId` 与类型;3. `aitable record create --records '[{"cells":{"<fieldId>":"值"}}]'` |
|
||||
| update-aitable-record | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. **取目标 record**:<br> • 已知少量 recordId → `aitable record query --record-ids <ID1,ID2>`<br> • 按条件批量改 → `aitable record query --base-id <baseId> --table-id <tableId> --filters '<JSON>' --all`(**用 --all 防止漏改**)<br>3. **先展示让用户确认要改的 record 列表**<br>4. `aitable record update --base-id <baseId> --table-id <tableId> --records '[{"recordId":"<recordId>","cells":{...}}]'`(单次 ≤30 条) |
|
||||
| search-aitable-template | 1. `aitable template search --query "<关键词>"` → 取 `templateId`<br>2. 用户选定<br>3. `aitable base create --name "<表格名>" --template-id <templateId>` → 取 `baseId` |
|
||||
| export-aitable-to-xlsx | 1. `aitable base search --query "<表格名>"` → 取 `baseId`<br>2. **按场景选 scope**:<br> • 全表+附件 → `aitable export data --base-id <baseId> --scope all --export-format excel_and_attachment --output ./<name>.xlsx`<br> • 单表(仅 xlsx)→ `--scope table --table-id <tableId> --export-format excel`<br> • 单视图 → `--scope view --table-id <tableId> --view-id <viewId>`<br>3. CLI 内置渐进式退避轮询 + 自动落盘,**不要自己写 GET downloadUrl**<br>4. 大表超时(默认 5 分钟):加 `--timeout-sec 900` 或拿到 `taskId` 后 `aitable export data --task-id <ID> --output ./<name>.xlsx` 续等<br>5. 与悟空脚本路径并存:复杂场景(多 base 批量 / 按视图组合)请用 `python scripts/aitable_export_via_task.py <baseId> --scope all\|table\|view` |
|
||||
|
||||
@@ -24,22 +24,22 @@
|
||||
| Recipe | 步骤 |
|
||||
|--------|------|
|
||||
| `mail-get` | `mail message get --email <邮箱> --id <messageId>` → 查看邮件完整内容(含正文) |
|
||||
| `mail-folder-list` | `mail folder list --email <邮箱>` → 列举文件夹;`--folder-id <id>` 查子文件夹 |
|
||||
| `mail-folder-list` | `mail folder list --email <邮箱>` → 列举文件夹;`--folder <id>` 查子文件夹 |
|
||||
| `mail-tag-list` | `mail tag list --email <邮箱>` → 列举邮件标签 |
|
||||
| `mail-thread-get` | `mail thread get --email <邮箱> --id <conversationId>` → 获取会话(邮件线程)详情 |
|
||||
| `mail-attachment-list` | `mail attachment list --email <邮箱> --id <messageId>` → 列举指定邮件的附件 |
|
||||
| `mail-attachment-download` | 1. `mail attachment list --email <邮箱> --id <messageId>` → 取附件 `id` 和 `name`<br>2. `mail attachment download --email <邮箱> --message-id <messageId> --attachment-id <attachmentId> --name <文件名>` |
|
||||
| `mail-batch-move` | `mail message batch-move --email <邮箱> --ids <id1,id2,...> --folder <folderId>`(常用 folderId: 2=收件箱, 6=已删除) |
|
||||
| `mail-batch-delete` | `mail message batch-delete --email <邮箱> --ids <id1,id2,...> --yes`(**危险操作,须先确认**) |
|
||||
| `mail-draft-create` | `mail draft create --from <邮箱> --subject "<标题>"` → 取 `messageId`(可选 `--to`、`--body`、`--cc`) |
|
||||
| `mail-draft-update` | `mail draft update --from <邮箱> --id <draftId> --subject "<新标题>"`(可选 `--body`、`--to`、`--cc`) |
|
||||
| `mail-draft-create` | `mail draft create --from <邮箱> --subject "<标题>"` → 取草稿 ID `result.message.id`(可选 `--to`、`--content`、`--cc`) |
|
||||
| `mail-draft-update` | `mail draft update --from <邮箱> --id <draftId> --subject "<新标题>"`(可选 `--content`、`--to`、`--cc`) |
|
||||
| `mail-draft-send` | `mail draft send --from <邮箱> --id <draftId>` |
|
||||
|
||||
## Full / 多步组合
|
||||
|
||||
| Recipe | 行动指南(固定路线) |
|
||||
|--------|---------------------|
|
||||
| search-and-download-attachment | 1. `mail mailbox list` → 取邮箱<br>2. `mail message search --email <邮箱> --query "<KQL>" --size 20` → 取 `messageId` 列表<br>3. 对每封邮件执行 `mail attachment list --email <邮箱> --id <messageId>` → 列出附件取 `id` 和 `name`<br>4. 对每个附件逐个执行 `mail attachment download --email <邮箱> --message-id <messageId> --attachment-id <attachmentId> --name <文件名>`(**仅支持逐个下载,不存在批量下载命令**) |
|
||||
| search-reply-forward | 1. `mail mailbox list` → 取邮箱<br>2. `mail message search --email <邮箱> --query "<KQL>" --size 10` → 取 `messageId`<br>3. 展示搜索结果供用户选择<br>4. 按用户指示执行 reply / reply-all / forward(参见 lite `mail-reply-forward`) |
|
||||
| batch-mail-cleanup | 1. `mail mailbox list` → 取邮箱<br>2. `mail message search --email <邮箱> --query "<KQL>" --size 100` → 取多个 `messageId`<br>3. 展示列表供用户确认<br>4. `mail message batch-move --email <邮箱> --ids <id1,id2,...> --folder 6 ` 移到已删除;或 `batch-delete` 永久删除 |
|
||||
| send-to-person-by-name | 1. `mail mailbox list` → 取发件邮箱<br>2. 走「查找他人邮箱地址」三路并发查询获取收件人邮箱(见 [mail.md](../products/mail.md))<br>3. `mail message send --from <发件邮箱> --to <收件邮箱> --subject "<标题>" --body "<内容>"` |
|
||||
| search-and-download-attachment | 1. `mail mailbox list` → 取邮箱<br>2. `mail message search --email <邮箱> --query "<KQL>" --limit 20` → 取 `messageId` 列表<br>3. 对每封邮件执行 `mail attachment list --email <邮箱> --id <messageId>` → 列出附件取 `id` 和 `name`<br>4. 对每个附件逐个执行 `mail attachment download --email <邮箱> --message-id <messageId> --attachment-id <attachmentId> --name <文件名>`(**仅支持逐个下载,不存在批量下载命令**) |
|
||||
| search-reply-forward | 1. `mail mailbox list` → 取邮箱<br>2. `mail message search --email <邮箱> --query "<KQL>" --limit 10` → 取 `messageId`<br>3. 展示搜索结果供用户选择<br>4. 按用户指示执行 reply / reply-all / forward(参见 lite `mail-reply-forward`) |
|
||||
| batch-mail-cleanup | 1. `mail mailbox list` → 取邮箱(返回字段是 `emailAccounts`)<br>2. `mail message search --email <邮箱> --query "<KQL>" --limit 100` → 取多个 `messageId`<br>3. 展示列表供用户确认<br>4. `mail message batch-move --email <邮箱> --ids <id1,id2,...> --folder 6 ` 或 `batch-delete` **都是移入「已删除」文件夹,并非物理永久删除**(对已在已删除文件夹的邮件再执行返回 success 但无效;CLI 无永久删除路径,需在客户端手动清空) |
|
||||
| send-to-person-by-name | 1. `mail mailbox list` → 取发件邮箱<br>2. 走「查找他人邮箱地址」三路并发查询获取收件人邮箱(见 [mail.md](../products/mail.md))<br>3. `mail message send --from <发件邮箱> --to <收件邮箱> --subject "<标题>" --content "<内容>"` |
|
||||
|
||||
@@ -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 "<KQL表达式>" --size 20`
|
||||
3. 执行搜索:`mail message search --email <邮箱> --query "<KQL表达式>" --limit 20`
|
||||
4. 查看详情(按需):`mail message get --email <邮箱> --id <messageId>`
|
||||
|
||||
### mail-send
|
||||
@@ -258,7 +258,7 @@ query 未提"听记"但任务产出依赖会议讨论内容时(报告/总结/
|
||||
|
||||
1. 获取邮箱地址:`mail mailbox list` → 取用户邮箱作为 `--from`。
|
||||
2. 确定收件人:用户直接提供邮箱地址 → 直接使用;用户提供姓名 → 走「查找他人邮箱地址」三路并发流程(见 [mail.md](../products/mail.md))。
|
||||
3. 发送:`mail message send --from <发件邮箱> --to <收件邮箱> --subject "<主题>" --body "<正文>"`(可选 `--cc`、`--attachment`、`--inline-attachment`)。
|
||||
3. 发送:`mail message send --from <发件邮箱> --to <收件邮箱> --subject "<主题>" --content "<正文>"`(正文的规范 flag 是 `--content`,`--body` 是隐藏别名;可选 `--cc`、`--attachment`、`--inline-attachment`)。
|
||||
|
||||
### mail-reply-forward
|
||||
|
||||
@@ -267,6 +267,6 @@ query 未提"听记"但任务产出依赖会议讨论内容时(报告/总结/
|
||||
1. 获取邮箱地址:`mail mailbox list` → 取用户邮箱。
|
||||
2. 定位原始邮件:若用户未提供 messageId → 先用 `mail-search` 搜索定位。
|
||||
3. 执行:
|
||||
- 回复:`mail message reply --from <邮箱> --id <messageId>`(可选 `--to`、`--subject`、`--body`)
|
||||
- 回复全部:`mail message reply-all --from <邮箱> --id <messageId>`(可选 `--to`、`--subject`、`--body`)
|
||||
- 转发:`mail message forward --from <邮箱> --to <收件邮箱> --id <messageId>`(可选 `--subject`、`--body`)
|
||||
- 回复:`mail message reply --from <邮箱> --id <messageId>`(可选 `--to`、`--subject`、`--content`;正文 flag 规范名 `--content`,`--body` 为别名)
|
||||
- 回复全部:`mail message reply-all --from <邮箱> --id <messageId>`(可选 `--to`、`--subject`、`--content`)
|
||||
- 转发:`mail message forward --from <邮箱> --to <收件邮箱> --id <messageId>`(可选 `--subject`、`--content`)
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
| 不支持的操作 | 说明 |
|
||||
|------------|------|
|
||||
| 创建公式/查找引用等高级字段类型 | 部分高级字段类型暂不支持 API 创建 |
|
||||
| 自己 PUT 文件时 Content-Type 不为空 | OSS 签名机制要求 PUT 请求的 `Content-Type` 头**必须清空**,否则返回 `SignatureDoesNotMatch` / HTTP 403。这**不是 dws 限制**,是阿里云 OSS 行为。解决:`dws aitable import upload --file ./x.xlsx` 已内置正确处理;自己写 `curl` 时必须传 `-H "Content-Type:"`(注意冒号后是空值) |
|
||||
| 自己 PUT 文件到 OSS 时 Content-Type 处理 | OSS 签名对 PUT 请求的 `Content-Type` 头有严格要求,处理不当会 `SignatureDoesNotMatch` / HTTP 403。这**不是 dws 限制**,是阿里云 OSS 行为。解决:直接用 `python scripts/aitable_import_via_task.py <baseId> <file>`,脚本已内置 prepare→PUT→import data 全流程和正确的头处理,**不要自己写 curl PUT**。(注意:`aitable import upload` 命令没有 `--file` flag、也不代做 PUT,只准备导入;能自动 PUT 的是上面的脚本。) |
|
||||
|
||||
## minutes
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ dws recovery finalize --event-id <event_id> --outcome recovered|failed|handoff -
|
||||
| 标志 | 短名 | 说明 | 默认 |
|
||||
|------|:---:|------|------|
|
||||
| `--format` | `-f` | 输出格式: json / table / raw | json |
|
||||
| `--jq` | | jq 表达式过滤输出 (如: `.items[] \| .name`) | 无 |
|
||||
| `--fields` | | 筛选输出字段 (逗号分隔, 如: name,id,status) | 无 |
|
||||
| `--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 |
|
||||
|
||||
@@ -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 列普通群成员 |
|
||||
@@ -160,7 +160,7 @@ alidocs 链接表面长得一样(`https://alidocs.dingtalk.com/i/nodes/{id}`
|
||||
用 `sheet` 的场景(axls,钉钉在线电子表格):
|
||||
- `dws doc info --node <URL>` 返回 `contentType=ALIDOC` + `extension=axls`
|
||||
- 用户在钉钉文档空间直接"新建电子表格"得到的节点
|
||||
- 所有 sheet 子命令(`list` / `range read` / `range write` / `export` 等)仅服务这类节点
|
||||
- 所有 sheet 子命令(`list` / `range read` / `range update` / `export` 等)仅服务这类节点
|
||||
|
||||
用 `dws drive download` 的场景(xlsx / xls / xlsm / csv 本地表格文件):
|
||||
- `dws doc info --node <URL>` 返回 `contentType=DOCUMENT` + `extension=xlsx` / `xls` / `xlsm` / `csv`
|
||||
@@ -273,8 +273,9 @@ alidocs 链接表面长得一样(`https://alidocs.dingtalk.com/i/nodes/{id}`
|
||||
- 已有 userId 时直接使用 `--user`;已有 openDingTalkId 时使用 `--open-dingtalk-id`
|
||||
- 纯文本/Markdown 单聊传 `--user` 时直接走 userId 发送能力,不需要先手动查询 openDingTalkId
|
||||
- 富媒体消息(image/file)单聊优先使用 `--open-dingtalk-id`;传 `--user` 时 CLI 会尝试解析为 openDingTalkId 后发送
|
||||
- "发张图片/截图/语音/视频/文件到群里" / "发张图给某某" — **统一一条命令**:`dws chat message send ... --msg-type file --file-path <本地路径>`,CLI 内部自动上传并发送,**任意扩展名(png/jpg/pdf/mp4/zip…)都走这条**
|
||||
- "发图片+文字说明" — 不要硬塞进一条命令;先发文件消息再补一条 `--text "..."` 即可
|
||||
- "发文件/语音/视频到群里" — `dws chat message send ... --msg-type file --file-path <本地路径>`,CLI 内部自动上传并发送(png/jpg/pdf/mp4/zip… 任意扩展名都走这条,但**都作为「文件」消息**发出,接收方看到的是可下载的文件条目)
|
||||
- "发张图片/截图(要在聊天里内联渲染成图,不是文件)" — 走图片消息链路:先 `dt_media_upload` 拿 mediaId,再 `dws chat message send ... --msg-type image --media-id <mediaId>`。**注意**:用 `--msg-type file` 发 .png 只会显示为[文件](fileId),不会渲染成图片;要图片效果必须走 `--msg-type image`
|
||||
- "发图片+文字说明" — 不要硬塞进一条命令;先发图片/文件消息再补一条 `--text "..."` 即可
|
||||
|
||||
```bash
|
||||
dws chat message send --group <openConversationId> --msg-type file --file-path ./screenshot.png --format json
|
||||
@@ -345,7 +346,7 @@ dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file
|
||||
**用 `oa approval` 的场景**(通用 OA 审批中心):
|
||||
- "我的待审批 / 待办审批 / 已审批记录" — `oa approval list-pending`
|
||||
- "某审批单详情 / 同意或驳回审批 / 撤销我发起的审批" — `oa approval detail/approve/reject/revoke`
|
||||
- "查业务审批记录 / 审批转交 / 添加评论与抄送" — `oa approval records/transfer/comment/cc`
|
||||
- "查业务审批记录 / 审批转交 / 添加评论与抄送" — `oa approval records`(操作记录)/ `redirect-task`(转交)/ `oa-comments`(评论)/ `oa-cc-noticer`(抄送)
|
||||
- 用户提到"报销 / 采购 / 用印 / 合同 等非考勤类审批"
|
||||
|
||||
**判断关键**:
|
||||
@@ -353,7 +354,7 @@ dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file
|
||||
- 不限于考勤业务、面向"我上下游的审批任务"表述 → `oa approval`
|
||||
|
||||
**提交审批单的边界**:
|
||||
- 提交考勤审批单走 `attendance approve templates --type leave|overtime|repair-check`(当前仅支持请假/加班/补卡三类模板;出差/外出的提交模板暂不支持,查询仍可用 `approve list --types trip` 覆盖),命令会返回审批表单的 submitUrl 跳转链接,由用户点击链接跳转到钉钉客户端的提交页面完成填写与提交。**展示链接时必须用 Markdown 可点击格式 `[表单名称](submitUrl)`,不要裸露 URL**。
|
||||
- 提交考勤审批单走 `attendance approve templates --type leave|overtime|repair-check|travel`(请假/加班/补卡/出差外出,出差外出用 `--type travel`,返回 approveType=TRAVEL/OUT 的模板;具体哪几类有模板取决于组织配置,返回空列表即该组织未配该类),命令会返回审批表单的 submitUrl 跳转链接,由用户点击链接跳转到钉钉客户端的提交页面完成填写与提交。**展示链接时必须用 Markdown 可点击格式 `[表单名称](submitUrl)`,不要裸露 URL**。
|
||||
- 提交诉求的辅助查询:可用假期余额走 `attendance vacation balance`、历史已提交记录走 `attendance approve list`。
|
||||
- 任何场景下都**不要误用 `oa approval` 代替** —— 该命令组只能查/审/撤已存在的审批单,考勤业务审批单走考勤自己的逻辑便于区分。
|
||||
|
||||
|
||||
@@ -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 字符串;不传则默认当天。
|
||||
@@ -98,7 +98,7 @@ Flags:
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `aisearch person` | `userId`(用户ID)、`title`(姓名) | 展示搜索结果、后续操作(发消息/建待办等) |
|
||||
| `aisearch person` | `userId`(用户ID,= `meta.staffId`/`meta.jobNumber`)、`author`(真实姓名,= `meta.name`);`title` 是花名/显示名,不一定等于姓名 | 展示搜索结果、后续操作(发消息/建待办等) |
|
||||
|
||||
## 重名消歧
|
||||
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
| 命令 | 用途 | 必填参数 | 路由提醒 |
|
||||
|------|------|----------|----------|
|
||||
| `base list` | 列出最近访问的 Base | — | 仅返回最近访问过的,优先用 `base search` |
|
||||
| `base search` | 按名称搜索 Base | `--query` | 关键词 ≥2 字符 |
|
||||
| `base search` | 按名称搜索 Base(别名 `aitable search`) | — | `--query` help 标必填但实际可省略:不传时返回最近访问的 Base 列表。`--keyword` 是 `--query` 的隐藏别名,同义 |
|
||||
| `base get` | 获取 Base 信息(含 tables 列表) | `--base-id` | 用户给 URL 时提取末尾 ID |
|
||||
| `base copy` | 复制整个 Base 到目标文件夹 | `--base-id` `--target-folder-id` | 默认全量复制;`--only-struct` 仅复制结构不含数据 |
|
||||
| `base get-primary-doc-id` | 获取某记录的主键文档 ID | `--base-id` `--table-id` `--record-id` | 等价 `record primary-doc-get` 的取 ID 视角 |
|
||||
| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId |
|
||||
| `base update` | 更新 Base 名称 | `--base-id` `--name` | — |
|
||||
| `base delete` | 删除 Base | `--base-id` | 不可逆 |
|
||||
@@ -30,7 +32,8 @@
|
||||
| 命令 | 用途 | 必填参数 | 路由提醒 |
|
||||
|------|------|----------|----------|
|
||||
| `table get` | 获取表结构(字段+视图目录) | `--base-id` | 不传 `--table-ids` 返回全部表 |
|
||||
| `table create` | 创建数据表 | `--base-id` `--name` `--fields` | fields 为 JSON 数组,至少 1 个 |
|
||||
| `table list` | 获取数据表(`table get` 的别名) | `--base-id` | 与 `table get` 等价 |
|
||||
| `table create` | 创建数据表 | `--base-id` `--name` | `--fields` 为 JSON 数组;**可传空数组 `[]`**(默认值即 `[]`),此时服务端自动补一个名为"标题"的 primaryDoc 首列;单次最多 15 个字段 |
|
||||
| `table update` | 重命名表 | `--base-id` `--table-id` `--name` | — |
|
||||
| `table delete` | 删除表 | `--base-id` `--table-id` | 不可逆 |
|
||||
|
||||
@@ -39,19 +42,29 @@
|
||||
| 命令 | 用途 | 必填参数 | 路由提醒 |
|
||||
|------|------|----------|----------|
|
||||
| `field get` | 获取字段完整配置 | `--base-id` `--table-id` | 按需展开少量字段 |
|
||||
| `field list` | 获取字段信息(`field get` 的别名) | `--base-id` `--table-id` | 与 `field get` 等价 |
|
||||
| `field create` | 创建字段 | `--base-id` `--table-id` + (`--name --type` 或 `--fields`) | 支持单字段/批量模式 |
|
||||
| `field update` | 更新字段名/配置 | `--base-id` `--table-id` `--field-id` | 不可变更字段类型 |
|
||||
| `field delete` | 删除字段 | `--base-id` `--table-id` `--field-id` | 不可逆 |
|
||||
| `field search-options` | 搜索单选/多选字段的选项 | `--base-id` `--table-id` `--field-id` | 仅 singleSelect/multipleSelect;`--keyword` 模糊过滤,不传返回全部 |
|
||||
|
||||
### record (记录管理)
|
||||
|
||||
| 命令 | 用途 | 必读 reference | 路由提醒 |
|
||||
|------|------|----------------|----------|
|
||||
| `record query` | 查询/搜索记录 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 先 `table get` 拿 fieldId;`--all` 自动翻页;filters 结构见 reference |
|
||||
| `record query` | 查询/搜索记录 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 先 `table get` 拿 fieldId;`--all` 自动翻页;filters 结构见 reference;`--query`(隐藏别名 `--keyword`)全文搜索 |
|
||||
| `record list` | 获取记录(`record query` 的别名) | [aitable-record-query.md](./aitable/aitable-record-query.md) | 与 `record query` 等价 |
|
||||
| `record get` | 按 ID 取记录(`record query --record-ids` 的窄别名) | [aitable-record-query.md](./aitable/aitable-record-query.md) | 已知 recordId 时首选;必填 `--record-ids`(单次最多 100 条);未暴露 filters/sort/query/cursor/limit |
|
||||
| `record query-empty` | 查询完全没填用户字段的空行 | — | `--base-id` `--table-id`;`--limit` 扫描预算 [1,100],`--cursor` 翻页 |
|
||||
| `record create` | 新增记录 | [aitable-record-create.md](./aitable/aitable-record-create.md) | cells key 必须是 fieldId 不是字段名;单次最多 100 条 |
|
||||
| `record update` | 更新记录 | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;只传需改字段;**没有** `--record-id` `--cells` flag |
|
||||
| `record batch-update` | 把同一份 cells 批量应用到多条记录 | [aitable-record-update.md](./aitable/aitable-record-update.md) | `--record-ids`(≤100)+ `--cells` 共享 patch |
|
||||
| `record upsert` | 批量创建或更新(有 recordId 走更新,无则创建) | [aitable-record-upsert.md](./aitable/aitable-record-upsert.md) | `--records`/`--records-file`;单次最多 100 条 |
|
||||
| `record delete` | 删除记录 | [aitable-record-delete.md](./aitable/aitable-record-delete.md) | 不可逆,需先 query 确认 |
|
||||
| `record share-url` | 批量获取记录分享链接 | [aitable-record-share.md](./aitable/aitable-record-share.md) | `--record-ids`(逗号分隔,单次最多 20 条) |
|
||||
| `record history-list` | 查询单条记录的变更历史 | [aitable-record-history.md](./aitable/aitable-record-history.md) | `--record-id` 单条;`--offset`/`--limit`(≤50) |
|
||||
| `record primary-doc-get` | 查询记录的主键文档 nodeId | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | 无文档时返回 `no record` 错误 |
|
||||
| `record primary-doc-create` | 为记录创建主键文档 | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | 幂等;`--field-id` 须 primaryDoc 类型 |
|
||||
|
||||
### view (视图管理)
|
||||
|
||||
@@ -264,8 +277,9 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
|
||||
要点:
|
||||
|
||||
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用。
|
||||
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错。
|
||||
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断。
|
||||
- `dashboard share get` 可能返回 `404`(`retryable:true`,从未分享甚至刚开启分享后立即查都可能 404),按可重试错误处理,不要误判为参数拼错;行为不稳定,别当"是否已分享"的唯一判据。
|
||||
- `chart share get` 稳定返回 `success + data`(含 `enabled`),从未分享时 `enabled=false`,不会 404。
|
||||
- `dashboard share update` 开 ORG 分享后 `shareType` 回显 `"[1]"`(服务端已知问题);`chart share update` 正确回显 `ORG`。详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)。
|
||||
|
||||
### 导出数据(两阶段轮询)
|
||||
|
||||
|
||||
@@ -38,8 +38,10 @@ dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
dws aitable attachment upload --base-id <BASE_ID> --file-name report.pdf --size 204800 --format json
|
||||
# → 返回 uploadUrl、fileToken
|
||||
|
||||
# 2. PUT 上传(Content-Type 留空)
|
||||
curl -X PUT "<uploadUrl>" -H "Content-Type:" --data-binary @report.pdf
|
||||
# 2. PUT 上传(Content-Type 必须与文件类型一致,不能留空)
|
||||
# 留空或用 curl 默认的 application/x-www-form-urlencoded 都会被 OSS 拒为 403
|
||||
curl -X PUT "<uploadUrl>" -H "Content-Type: application/pdf" --data-binary @report.pdf
|
||||
# 例:.txt → text/plain,.png → image/png,.xlsx → application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||
|
||||
# 3. 写入记录
|
||||
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
|
||||
|
||||
@@ -15,8 +15,10 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
|
||||
## 要点
|
||||
|
||||
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用
|
||||
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错
|
||||
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断
|
||||
- `dashboard share get` 可能返回 `404`(`retryable:true`)——从未分享、甚至刚开启分享后立即查都可能 404;`data` 为 `{}`。按可重试错误处理,不要误判为参数拼错。它有时也会返回 `success + data`(如关闭分享之后),行为不稳定,别用它当"是否已分享"的唯一判据。
|
||||
- `chart share get` 稳定返回 `success + data`(含 `enabled` 等),可用于分享状态判断;从未分享时 `enabled=false`,不会 404。
|
||||
- ⚠️ **`dashboard share update` 开启 ORG 分享后返回的 `shareType` 是 `"[1]"`(未映射回 `ORG`,服务端已知问题)**;`chart share update --share-type ORG` 则正确返回 `shareType="ORG"`。判断 dashboard 是否 ORG 分享时对 `"[1]"` 做兼容。
|
||||
- `chart share update` / `dashboard share update` 的 `--enabled` 是字符串 flag:`--enabled false`(空格)和 `--enabled=false` 都能正确关闭分享。
|
||||
|
||||
## dashboard 子命令
|
||||
|
||||
@@ -25,18 +27,25 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
|
||||
| `dashboard get` | 获取仪表盘详情(含 charts 列表) | `--base-id` `--dashboard-id` | — |
|
||||
| `dashboard create` | 创建仪表盘 | `--base-id` + (`--config` 或 `--name`) | `--name` 简化版创建空看板;`--config` 传完整 JSON |
|
||||
| `dashboard update` | 更新仪表盘 | `--base-id` `--dashboard-id` + (`--config` 或 `--name`) | `--name` 仅改名;`--config` 更新完整配置 |
|
||||
| `dashboard arrange` | 自动重排仪表盘图表布局 | `--base-id` `--dashboard-id` | 让服务端重新排布 charts 位置 |
|
||||
| `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` 用 `ORG`(ORG 回显 `shareType="[1]"`);部分组织禁用了 `PUBLIC` 公开分享;若报 `Illegal argument`,改用 `ORG`(组织内分享)。`--enabled false` 关闭 |
|
||||
|
||||
## chart 子命令
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `chart get` | 获取图表详情 | `--base-id` `--dashboard-id` `--chart-id` |
|
||||
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` |
|
||||
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` `--layout` |
|
||||
| `chart update` | 更新图表配置 | `--base-id` `--dashboard-id` `--chart-id` `--config` |
|
||||
| `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`,用 `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,否则服务端拒绝。
|
||||
|
||||
## 配置获取流程
|
||||
|
||||
|
||||
@@ -118,23 +118,25 @@ dws aitable import data --import-id <importId> --table-id <TABLE_ID> --format js
|
||||
|
||||
用户给文件让导入 AI 表格时,路径选择决定成败:
|
||||
|
||||
> ⚠️ **`import upload` 没有 `--file` flag**(传了会报 unknown flag)。它只申请上传凭证,必填 `--file-name` + `--file-size`,拿到 `uploadUrl` 后要**自己 curl PUT 上传文件**(Content-Type 留空,见上文三步流程),再 `import data`。想省事直接用 `aitable_import_via_task.py` 脚本,它把这三步包好了。
|
||||
|
||||
| 用户原话 | 链路 | 命令 / 脚本 | 行为 |
|
||||
|---------|------|------------|------|
|
||||
| "把这个 Excel 导入到 AI 表格"(无指定目标表) | **文件导入任务** | `python scripts/aitable_import_via_task.py <baseId> <file>` 或 `dws aitable import upload --base-id B --file ./x.xlsx` + `dws aitable import data --import-id <ID>` | 服务端解析文件,**新建数据表**,自动识别表头 |
|
||||
| "把这个 Excel 导入到 AI 表格"(无指定目标表) | **文件导入任务** | `python scripts/aitable_import_via_task.py <baseId> <file>`(推荐)或手动三步 `import upload --file-name x.xlsx --file-size <字节>` → curl PUT → `import data --import-id <ID>` | 服务端解析文件,**新建数据表**,自动识别表头 |
|
||||
| "把这个 Excel 导入新表 / 自动建表" | 同上 | 同上 | 同上 |
|
||||
| "把这批记录追加到已有的『成员表』里" | **记录批量写入** | `python scripts/import_records.py <baseId> <tableId> <file>` | 走 `record create`,**写入已有 tableId**,需要字段名匹配 |
|
||||
| "Excel 列名和表字段对不上但要追加" | 文件导入 + 追加 + 字段映射 | `dws aitable import upload --base-id B --file ./x.xlsx` → `dws aitable import data --import-id <ID> --table-id <TBL> --field-mapping '{"目标":"源"}'` | 服务端按映射追加 |
|
||||
| "Excel 列名和表字段对不上但要追加" | 文件导入 + 追加 + 字段映射 | 三步导入后 `import data --import-id <ID> --table-id <TBL> --field-mapping '{"目标":"源"}'` | 服务端按映射追加 |
|
||||
|
||||
## 大表 / 长任务超时续等
|
||||
|
||||
默认整体轮询超时 5 分钟。大表导出/导入超时后命令会返回 `taskId` / `importId`,用同命令带 ID 续等:
|
||||
单次等待窗口很短:`export data` 只有 `--timeout-ms`(默认且**上限 30000 = 30 秒**,没有 `--timeout-sec`,传了会报 unknown flag);`import data` 用 `--timeout`(秒,默认且推荐最大值 30)。窗口内没跑完,命令会返回 `taskId` / `importId`,用同命令带 ID 反复续等即可:
|
||||
|
||||
```bash
|
||||
# 续等导出
|
||||
dws aitable export data --base-id <B> --task-id <ID> --output ./out.xlsx
|
||||
# 续等导出:拿到 downloadUrl 后再 curl 下载(见下方警告)
|
||||
dws aitable export data --base-id <B> --task-id <ID> --timeout-ms 30000
|
||||
|
||||
# 续等导入
|
||||
dws aitable import data --import-id <ID>
|
||||
```
|
||||
|
||||
或一次性把 timeout 提到 15 分钟:`--timeout-sec 900`。
|
||||
> ⚠️ **`--output` 不会保存导出的 xlsx**:`--output` 是隐藏的全局 flag,作用是把命令的 **JSON 输出**写到文件,实测只生成一个 0 字节文件,不会下载导出内容。正确做法是从 `export data` 返回里取 `downloadUrl`,再 `curl -L "<downloadUrl>" -o out.xlsx` 下载。想省事直接用 `aitable_export_via_task.py` 脚本(它负责轮询 + 下载)。
|
||||
|
||||
@@ -50,8 +50,8 @@ dws aitable form share get --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 返回 viewId/name/title/createdAt |
|
||||
| `form get` | 按 viewId 取单个表单 | `--base-id` `--table-id` `--view-id` | 内部基于 list_form_views 过滤 |
|
||||
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 每条返回 viewId/name;新建表单**无 title 且 createdAt=0**,改过(form update)后才出现 title 和真实 createdAt |
|
||||
| `form get` | 按 viewId 取单个表单 | `--base-id` `--table-id` `--view-id` | 客户端按 viewId 过滤后 `data` **即该表单对象**(不是 formViews 数组);viewId 不存在返回 `form view ... not found` 错误 |
|
||||
| `form create` | 创建表单视图 | `--base-id` `--table-id` `--name` | viewType=FormDesigner |
|
||||
| `form update` | 更新表单 | `--base-id` `--table-id` `--view-id` | `--title`/`--name`(等价)和 `--description` 至少传一项;同时传 title/name 时 title 优先 |
|
||||
| `form delete` | 删除表单 | `--base-id` `--table-id` `--view-id` `--yes` | 不可逆 |
|
||||
@@ -115,6 +115,6 @@ dws aitable form share update --base-id BASE_ID --table-id TABLE_ID --view-id VI
|
||||
|
||||
## 返回结构补充
|
||||
|
||||
- `form list` 返回 `data.formViews[]`,**每条仅含** `viewId/name/title/createdAt`;`shareFormUuid` 不在此返回,请用 `form share get` 单独获取。
|
||||
- `form get` 返回结构与 `form list` 完全一致(`data.formViews[]`),仅含一条记录(与请求 viewId 一致)。Agent 提取时仍走 `data.formViews[0]`。
|
||||
- `form list` 返回 `data.formViews[]`,**每条含** `viewId/name`(+ `createdAt`);**新建表单没有 `title` 字段且 `createdAt=0`**,只有在 `form update` 碰过之后才会出现 `title` 和真实 `createdAt`。`shareFormUuid` 不在此返回,请用 `form share get` 单独获取。
|
||||
- `form get` 的 `data` **就是命中的那一条表单对象**(如 `{viewId, name, createdAt, title?, shareFormUuid?}`),不是 `formViews` 数组。Agent 直接读 `data.viewId` / `data.name` 即可,**不要**再取 `data.formViews[0]`。服务端的 viewIds 过滤参数当前不生效,CLI 在客户端按 viewId 精确筛出单条;传了不存在的 viewId 会返回 `form view <id> not found in table` 错误。
|
||||
- `form field list` 仅返回**未隐藏**的字段;`hidden=true` 的字段不在此返回,如需查看全部字段请用 `field get`。
|
||||
|
||||
@@ -17,7 +17,9 @@ dws aitable record primary-doc-get --base-id BASE_ID --table-id TABLE_ID --recor
|
||||
- `--table-id`(必填):Table ID
|
||||
- `--record-id`(必填):Record ID
|
||||
|
||||
**返回:** `data.nodeId` — 主键文档的 nodeId,可直接传给 `dws doc read/update` 的 `--node` 参数。若该记录尚未创建主键文档,`nodeId` 为 null。
|
||||
**返回:** `data.nodeId` — 主键文档的 nodeId,可直接传给 `dws doc read/update` 的 `--node` 参数。
|
||||
|
||||
> **该记录尚未创建主键文档时**:不会返回 `nodeId: null`,而是 `status=error`、`data={}`,`error={code:"-1", message:"no record", type:"SYSTEM_ERROR", retryable:true}`。要判断"有没有主键文档",看是否命中这个 `no record` 错误,而不是判断 `nodeId` 是否为 null。需要文档时改用 `primary-doc-create`(幂等,已存在则直接返回)。
|
||||
|
||||
### 创建主键文档
|
||||
|
||||
|
||||
@@ -25,12 +25,11 @@ dws aitable record history-list \
|
||||
"data": {
|
||||
"histories": [
|
||||
{
|
||||
"type": "field_change", // 变更类型
|
||||
"action": "update", // 操作动作: create / update / delete
|
||||
"newValue": "{\"...\":\"...\"}", // 变更后的值(JSON 字符串)
|
||||
"oldValue": "{\"...\":\"...\"}", // 变更前的值(JSON 字符串)
|
||||
"type": "row", // 变更类型,实测均为 "row"(行级变更)
|
||||
"action": "updateRecords", // 操作动作:appendRow(新增行) / updateRecords(更新记录)
|
||||
"newValue": "{\"fldX\":{\"dataType\":\"STRING\",\"value\":\"改后值\"}}", // 变更后的值(JSON 字符串,按 fieldId 组织)
|
||||
"oldValue": "{\"fldX\":{\"dataType\":\"STRING\",\"value\":\"改前值\"}}", // 变更前的值(appendRow 新增行时无此字段)
|
||||
"operateTime": 1733123456789, // 操作时间(毫秒级时间戳)
|
||||
"typeChangedFields": "{...}", // 类型变更的字段信息(JSON 字符串)
|
||||
"version": 7 // 版本号(单调递增)
|
||||
}
|
||||
]
|
||||
@@ -38,14 +37,14 @@ dws aitable record history-list \
|
||||
}
|
||||
```
|
||||
|
||||
`newValue` / `oldValue` / `typeChangedFields` 是 JSON 字符串(不是 JSON 对象),需要二次 `JSON.parse` 才能拿到结构化值。
|
||||
`newValue` / `oldValue` 是 JSON 字符串(不是 JSON 对象),需要二次 `JSON.parse` 才能拿到结构化值;解析后是 `{fieldId: {dataType, value}}` 结构。`appendRow`(新增行)事件没有 `oldValue`。实测返回里**没有** `typeChangedFields` 字段。
|
||||
|
||||
## 字段含义速查
|
||||
|
||||
| 字段 | 用途 |
|
||||
|------|------|
|
||||
| `type` | 高层分类:`record_create` / `field_change` / `record_delete` 等。先按 type 过滤大类。 |
|
||||
| `action` | 三态:`create` / `update` / `delete`。比 type 粗,但便于按"动作"统计。 |
|
||||
| `type` | 实测均为 `row`(行级变更),服务端未按 `record_create` / `field_change` 细分。 |
|
||||
| `action` | 底层操作名:`appendRow`(新增行)/ `updateRecords`(更新记录)。按"动作"统计时以这两个值为准。 |
|
||||
| `version` | 单调递增整数;同一 record 越新值越大。**用作"上一条 vs 这一条"的稳定排序键**。 |
|
||||
| `operateTime` | 毫秒时间戳;可格式化成可读时间。多条同 version 的极端场景用 operateTime 兜底排序。 |
|
||||
|
||||
@@ -74,20 +73,22 @@ dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --
|
||||
|
||||
```bash
|
||||
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --limit 50 --format json \
|
||||
| jq '[.data.histories[] | select(.action == "update")][0].oldValue'
|
||||
| jq '[.data.histories[] | select(.action == "updateRecords")][0].oldValue'
|
||||
```
|
||||
|
||||
### 4. 找出删除事件(如果存在 delete history)
|
||||
### 4. 只看更新事件(排除新增行)
|
||||
|
||||
```bash
|
||||
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --format json \
|
||||
| jq '.data.histories[] | select(.action == "delete") | {version, operateTime}'
|
||||
| jq '.data.histories[] | select(.action == "updateRecords") | {version, operateTime}'
|
||||
```
|
||||
|
||||
> 记录被 `record delete` 删除后,其历史不再返回(`histories` 为空数组),无法通过本命令回溯删除事件。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 一次只能查一条 record;如需批量审计多条记录请循环调用。
|
||||
- 仅返回**字段值变更**与**记录生命周期事件**;视图、字段定义、表结构变更不在此 history 里。
|
||||
- 仅返回**新增行**(appendRow)与**字段值变更**(updateRecords);记录被删除后其历史不再可查(返回空)。视图、字段定义、表结构变更不在此 history 里。
|
||||
- 历史保留时长由 server 决定,过老的记录可能不再返回。
|
||||
|
||||
## 与其他 record 命令的关系
|
||||
|
||||
@@ -79,21 +79,21 @@ dws aitable view update timebar --view-id GANTT_ID --official-holiday=true
|
||||
|
||||
### view update aggregate(仅 Grid)
|
||||
|
||||
值是 `map[fieldId]→AggregateAction string`;传 null 清除某个字段聚合。
|
||||
值是 `map[fieldId]→AggregateAction string`。**设置**聚合可用;**清除**聚合当前无效(见下方警告)。
|
||||
|
||||
| flag | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `--field-id` | string | 配合 `--action` 设置**单字段**聚合 |
|
||||
| `--action` | string | `SUM`/`AVG`/`MAX`/`MIN`/`MEDIAN`/`RANGE`/`TOTAL`/`DISTINCT`/`EXIST`/`UN_EXIST`/`CHECKED`/`EARLIEST_DATE` 等(按字段类型可用) |
|
||||
| `--clear-field-id` | string (CSV) | 一/多个字段 ID,清除其聚合 |
|
||||
| `--clear-field-id` | string (CSV) | 一/多个字段 ID,本意是清除其聚合,但**当前服务端不支持清除,静默无效**(见下方警告) |
|
||||
| `--json` | JSON | 完整 aggregate map |
|
||||
|
||||
```bash
|
||||
dws aitable view update aggregate --view-id GRID_ID --field-id fldX --action SUM
|
||||
dws aitable view update aggregate --view-id GRID_ID --clear-field-id fldA,fldB
|
||||
dws aitable view update aggregate --view-id GRID_ID --json '{"fldX":"AVG","fldY":null}'
|
||||
```
|
||||
|
||||
> ⚠️ **当前无法清除已设置的聚合(服务端限制)**:`--clear-field-id fldA,fldB` 与 `--json '{"fldX":null}'` 两种清除写法都返回 `success`,但用 `view get aggregate` 复核会发现聚合**原样不动**——是静默无效,不是真的清掉。这是服务端没有清除语义所致,直至服务端修复前不要依赖它。改聚合方式可行(重新 `--action` 覆盖成别的),只是无法回到"无聚合"。
|
||||
|
||||
### view update field-widths(仅 Grid)
|
||||
|
||||
| flag | 类型 |
|
||||
@@ -108,9 +108,11 @@ dws aitable view update field-widths --view-id GRID_ID --json '{"fldA":120,"fldB
|
||||
|
||||
### view update visible-fields(通用)
|
||||
|
||||
整组替换可见字段列表与顺序。首列字段(primaryDoc)必须保留在数组第一位。
|
||||
整组替换可见字段列表与顺序,同时兼作**隐藏/显示**入口。首列字段(primaryDoc)必须保留在数组第一位。
|
||||
|
||||
> ⚠️ 注意:服务端**只接受 reorder,不接受真"隐藏字段"**——如果传入的列表比当前 columns 短,缺失的字段不会被隐藏。需要真正隐藏字段请到 AI 表格 Web UI。
|
||||
> **传入的列表既定顺序又定可见性**:传一个比当前 columns 短的列表,缺失的字段会被真正隐藏(该字段在 `view list` 的 `custom.hiddenFields` 里变 `true`);再传回全量列表即可解除隐藏(`hiddenFields` 变 `false`)。
|
||||
>
|
||||
> ⚠️ **查隐藏状态别看这里**:`view get visible-fields` 返回的数组**包含已隐藏字段**(列的完整顺序),看不出谁被隐藏。要确认隐藏状态,读 `view get`(view list)里该视图的 `custom.hiddenFields`(`{fieldId: true|false}`)。
|
||||
|
||||
| flag | 类型 |
|
||||
|------|------|
|
||||
@@ -118,7 +120,9 @@ dws aitable view update field-widths --view-id GRID_ID --json '{"fldA":120,"fldB
|
||||
| `--json` | string 数组 JSON(与 `--field-ids` 同传时 `--json` 优先) |
|
||||
|
||||
```bash
|
||||
dws aitable view update visible-fields --view-id VIEW_ID --field-ids fldPrimary,fldA,fldB
|
||||
# 只保留首列和 fldA,其余字段被隐藏
|
||||
dws aitable view update visible-fields --view-id VIEW_ID --field-ids fldPrimary,fldA
|
||||
# 传回全量列表解除隐藏
|
||||
dws aitable view update visible-fields --view-id VIEW_ID --json '["fldPrimary","fldA","fldB"]'
|
||||
```
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ dws aitable view update frozen-cols --view-id VIEW_ID --count 0
|
||||
|
||||
# 查询当前冻结列数
|
||||
dws aitable view get frozen-cols --view-id VIEW_ID --format json
|
||||
# → {"data": {..., "count": 1}} count 为 null 表示视图未显式设置
|
||||
# → {"data": {..., "count": 1}} 未显式设置时整个 count 键缺失(不是返回 null)
|
||||
```
|
||||
|
||||
`--count` 必须 ≥ 0;负数会被拒绝。
|
||||
@@ -69,7 +69,7 @@ dws aitable view update row-height --view-id VIEW_ID --cell-height 56
|
||||
|
||||
# 查询当前行高
|
||||
dws aitable view get row-height --view-id VIEW_ID --format json
|
||||
# → {"data": {..., "cellHeight": 56}} cellHeight 为 null 表示视图未显式设置(前端按 32 渲染)
|
||||
# → {"data": {..., "cellHeight": 56}} 未显式设置时整个 cellHeight 键缺失(不是返回 null;前端按 32 渲染)
|
||||
```
|
||||
|
||||
## 数据高亮规则(条件填色,仅 Grid)
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
1. 未按"阶段 1"做人员解析 → `--users` 传入部门 ID 而非员工 userId,脚本虽内置回退但会浪费一次失败的接口调用
|
||||
2. 未按"阶段 0"判断报表类型 → 用户说"汇总"被理解成"明细",导致输出粒度错误
|
||||
3. 未按"列选择"判断是否传 `--column-keywords` → 用户要"迟到情况报表"被输出成全字段默认报表
|
||||
4. 未按"错误处理"规则处理 403 / `HSF_ILLEGALPARAMS` → 把环境错误当成业务错误反馈给用户
|
||||
4. 未按"错误处理"规则处理无权限错误(errorCode 6001「无权限操作」/ `AUTH_ERROR`)/ `HSF_ILLEGALPARAMS` → 把环境错误当成业务错误反馈给用户
|
||||
5. 未按"阶段 4"返回结果 → 把 Excel 内容贴在对话里,或者裸 userId 直接输出
|
||||
|
||||
**执行前自检(必须能在心中回答)**:
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
如果上述任何一项答不出,**回到本文档对应章节重新阅读**,禁止凭记忆/想象组装命令。
|
||||
|
||||
**前提**:当前用户必须是钉钉管理员,否则 report 系列接口返回 403 权限错误。
|
||||
**前提**:当前用户必须是钉钉管理员,否则 report 系列接口返回业务权限错误(`server_error_code` 为 `AUTH_ERROR`、errorCode `6001`「无权限操作」),**不是 HTTP 403**。提示需要管理员权限,不要重试。
|
||||
|
||||
## 核心原则
|
||||
|
||||
@@ -46,7 +46,7 @@ Agent 解析用户意图(报表类型、人员范围、时间范围、关注
|
||||
- 所有 `dws` 命令必须携带 `--format json`
|
||||
- 时间参数 `--start` / `--end` 格式必须为 `yyyy-MM-dd HH:mm:ss`
|
||||
- 字段 ID 与字段名的映射必须从 `report columns` 实时建立,禁止硬编码
|
||||
- 任何接口失败(含 403)必须向用户清晰报错,禁止静默吞掉
|
||||
- 任何接口失败(含无权限错误 6001/`AUTH_ERROR`)必须向用户清晰报错,禁止静默吞掉
|
||||
|
||||
## 涉及工具
|
||||
|
||||
@@ -246,7 +246,7 @@ python scripts/attendance_report_checkin.py \
|
||||
|
||||
- 脚本依赖 `openpyxl`,若未安装需先 `pip install openpyxl`
|
||||
- 脚本摘要输出到 stdout,进度日志输出到 stderr
|
||||
- 首次调试可加 `--inspect` 参数查看首条记录原始结构
|
||||
- 首次调试可加 `--inspect` 参数查看首条记录原始结构(`attendance_report_detail.py` / `attendance_report_monthly.py` / `attendance_report_daily.py` / `attendance_report_checkin.py` 支持;**`attendance_report_record.py` 无 `--inspect` 参数**)
|
||||
- 脚本执行失败(exit ≠ 0)时,stderr 中有具体错误信息
|
||||
|
||||
### 阶段 4: 返回结果给用户
|
||||
@@ -488,11 +488,11 @@ python scripts/attendance_report_checkin.py \
|
||||
|
||||
| 错误 | 原因 | 处理方式 |
|
||||
|------|------|---------|
|
||||
| 权限错误(403) | 当前账号非管理员 | 提示需要管理员权限,不要重试 |
|
||||
| 权限错误(errorCode 6001「无权限操作」/ `AUTH_ERROR`,非 HTTP 403) | 当前账号非管理员 | 提示需要管理员权限,不要重试 |
|
||||
| userId 无效 | 用户 ID 错误或已离职 | 脚本跳过并在摘要中标注 |
|
||||
| 时间区间超长 | 接口可能性能不佳 | 提示"超过 1 年的数据建议分阶段导出" |
|
||||
| openpyxl 未安装 | 环境缺包 | 输出 `pip install openpyxl` 安装提示 |
|
||||
| 脚本执行失败 | 接口异常/配置问题 | 将 stderr 错误信息转告用户,可加 `--inspect` 重试 |
|
||||
| 脚本执行失败 | 接口异常/配置问题 | 将 stderr 错误信息转告用户,可加 `--inspect` 重试(`attendance_report_record.py` 不支持 `--inspect`) |
|
||||
|
||||
## 使用示例
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 考勤 (attendance) 命令参考
|
||||
|
||||
> **【开源版命令可用性提示】** 当前开源 dws 已落地 P0 5 条命令:`attendance check result`、`attendance check record`、`attendance group search`、`attendance vacation balance`、`attendance vacation types`。文档中其余 P1 阶段命令(`class` / `overtime` / `adjustment` / `group settings` / `report` / `schedule` / `boss-check` 等共 28 条)暂未在当前开源二进制暴露,调用会返回 `unknown command`,将在后续批次落地。
|
||||
> **【命令可用性提示】** 当前 dws 已注册全部考勤子命令组(`record` / `check` / `approve` / `shift` / `schedule` / `class` / `adjustment` / `overtime` / `group` / `summary` / `rules` / `selfsetting` / `globalsetting` / `vacation` / `checkin` / `report` / `boss-check`)。查询与写操作大多可直接调用后端,不会再返回 `unknown command`,不要以"开源版不支持"为由拒答。个别命令返回受账号权限和组织数据影响:`report` 系列仅管理员可用;非管理员或数据为空时可能返回空列表或权限错误。执行前可用 `dws <cmd> --help` 或 `--dry-run` 验证参数。
|
||||
|
||||
> **【必读】日期范围严格计算规则 — 所有含 --start/--end 或 --from/--to 的命令均适用**
|
||||
>
|
||||
@@ -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:
|
||||
@@ -143,21 +156,20 @@ Usage:
|
||||
dws attendance schedule import [flags]
|
||||
Example:
|
||||
dws attendance schedule import --group-id 123456 \
|
||||
--schedules '[{"userId":"user001","classId":123,"workDate":"2026-04-22","checkBeginTime":"09:00","checkEndTime":"18:00"}]' \
|
||||
--schedules '[{"userId":"user001","classId":123,"workDate":"2026-04-22","isRest":"N"}]' \
|
||||
--yes
|
||||
Flags:
|
||||
--group-id string 考勤组(必填,传入考勤组ID)
|
||||
--schedules string 排班记录 JSON 数组(必填)
|
||||
--yes 跳过确认提示
|
||||
--group-id string 考勤组(必填,传入考勤组ID);--help 主名为 --groupId,--group-id 为别名
|
||||
--schedules string 排班记录 JSON 数组(必填);--help 主名为 --scheduleVOS,--schedules 为别名
|
||||
--yes 跳过确认提示;--help 主名为 --user-say-yes,--yes 为别名
|
||||
```
|
||||
|
||||
为排班制考勤组导入排班记录。`--schedules` 为 JSON 数组,每条记录包含:
|
||||
- `userId`: 员工ID
|
||||
- `classId`: 班次ID
|
||||
- `workDate`: 工作日期(YYYY-MM-DD),如 2026-04-22
|
||||
- `checkBeginTime`: 开始打卡时间
|
||||
- `checkEndTime`: 结束打卡时间
|
||||
- `isRest`: 是否休息日 Y/N(可选)
|
||||
- `userId`: 员工ID(必填)
|
||||
- `classId`: 班次ID(必填)
|
||||
- `workDate`: 工作日期(YYYY-MM-DD),如 2026-04-22(必填)
|
||||
- `isRest`: 是否休息日 Y/N(**必填**,服务端要求传入)
|
||||
- `checkBeginTime` / `checkEndTime`: 开始/结束打卡时间(可传,但当前不会进入后端 payload,实际打卡时段以 `classId` 对应班次为准)
|
||||
|
||||
#### AI 调用 `schedule import` 的二次确认流程
|
||||
|
||||
@@ -305,7 +317,7 @@ Flags:
|
||||
--adjustment-id int 补卡规则主键 ID (必填)
|
||||
```
|
||||
|
||||
根据补卡规则主键 ID 查询对应的补卡规则详情。主键 ID 可从 `adjustment search` 返回结果中提取,也有可能来源于用户手动输入。**注意:已被删除或被更新覆盖的补卡规则无法查询到。**
|
||||
**注意:本命令当前拿不到有效的补卡规则详情,不要依赖它。** 服务端对任意 `--adjustment-id`(含不存在的 ID)都返回同一个 `{"success":true,"有效期类型":"..."}`,不返回规则明细,也无法据此判断规则是否存在;且 `adjustment search` 返回的默认补卡规则 `entityVO.id` 为 `null`,`search → get` 取 id 的链路走不通。补卡规则内容请直接看 `adjustment search` 的返回结果。
|
||||
|
||||
### 分页查询加班规则,支持按名称搜素
|
||||
```
|
||||
@@ -580,12 +592,17 @@ Flags:
|
||||
Usage:
|
||||
dws attendance summary [flags]
|
||||
Example:
|
||||
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00"
|
||||
dws attendance summary --user USER_ID --date 2026-03-12 --stats-type week
|
||||
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
|
||||
Flags:
|
||||
--date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填)
|
||||
--user string 钉钉用户 ID (必填)
|
||||
--date string 查询日期, 格式 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss (必填)
|
||||
--stats-type string 统计类型: week 周统计 / month 月统计 (必填)
|
||||
--tag-name string 标签名称 (可选)
|
||||
--user string 钉钉用户 ID (必填)
|
||||
```
|
||||
|
||||
`summary` 必须同时传 `--user`、`--date`、`--stats-type`,缺一即报错(如 C0002)。`--stats-type` 只能是 `week`(周统计)或 `month`(月统计)。
|
||||
|
||||
### 查询考勤组与考勤规则
|
||||
```
|
||||
Usage:
|
||||
@@ -847,10 +864,10 @@ Example:
|
||||
dws attendance vacation balance --users userId1,userId2 --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890
|
||||
Flags:
|
||||
--users string 目标员工 ID 列表, 逗号分隔 (必填)
|
||||
--leave-code string 假期规则 code (选填,不传则查询所有假期规则余额)
|
||||
--leave-code string 假期规则 code (必填,服务端要求非空,不传返回 INVALID_PARAMS)
|
||||
```
|
||||
|
||||
调用 MCP 工具 get_leave_balance_quota 查询指定员工的假期余额。例如:查询某员工年假还剩多少、病假额度等。`--leave-code` 可通过 `vacation types` 获取;不传 `--leave-code` 时查询所有假期规则余额。认证信息(corpId、opUserId)由系统自动注入。
|
||||
调用 MCP 工具 get_leave_balance_quota 查询指定员工的假期余额。例如:查询某员工年假还剩多少、病假额度等。`--leave-code` 可通过 `vacation types` 获取。**注意:`--help` 虽标"选填",但服务端要求 `leaveCode` 非空,实际必填;不传会返回 `INVALID_PARAMS`(corpId、opUserId、leaveCode、targetUserIds 不能为空)。** 若要一次查所有假期规则余额,必须走 [attendance-vacation.md](./attendance-vacation.md) 工作流脚本逐个规则遍历。认证信息(corpId、opUserId)由系统自动注入。
|
||||
|
||||
如用户需要“所有假期规则余额 / 导出假期余额列表 / 所有假期规则余额 Excel / 按截图样式导出假期余额”,必须先读取 [attendance-vacation.md](./attendance-vacation.md),再按其中工作流调用脚本生成 Excel。
|
||||
|
||||
@@ -867,7 +884,7 @@ Flags:
|
||||
--end string 查询结束日期, 格式 YYYY-MM-DD (必填)
|
||||
```
|
||||
|
||||
调用 MCP 工具 get_leave_balance_records 查询指定员工的假期余额变更记录。例如:查询某员工年假变更历史、请假扣减记录等。`--leave-code` 可通过 `vacation types` 获取。认证信息(corpId、opUserId)由系统自动注入。
|
||||
调用 MCP 工具 get_leave_balance_records_v2 查询指定员工的假期余额变更记录。例如:查询某员工年假变更历史、请假扣减记录等。`--leave-code` 必填,可通过 `vacation types` 获取。认证信息(corpId、opUserId)由系统自动注入。
|
||||
|
||||
### 更新假期规则(写场景接口,必须走二次确认流程)
|
||||
|
||||
@@ -1002,7 +1019,7 @@ Usage:
|
||||
dws attendance checkin records [flags]
|
||||
Example:
|
||||
dws attendance checkin records \
|
||||
--operator-staff-id op001 --staff-ids user001,user002 --start "2026-04-01 00:00:00" --end "2026-04-07 00:00:00"
|
||||
--operator-corp-id corp001 --operator-staff-id op001 --staff-ids user001,user002 --start "2026-04-01 00:00:00" --end "2026-04-07 00:00:00"
|
||||
Flags:
|
||||
--end string 结束时间, 格式 yyyy-MM-dd HH:mm:ss(必填)
|
||||
--operator-corp-id string 操作者企业 ID(必填)
|
||||
@@ -1032,7 +1049,7 @@ Flags:
|
||||
用户说"班次详情/某个班次的具体信息" → `class search --name "..."`(search 直出,直接返回详情)。`class get` 仅在需要按已知 classId 精确查询时使用
|
||||
用户说"更新班次/修改班次/班次改名/修改上下班时间" → `class update`
|
||||
用户说"补卡规则/补卡设置" → `adjustment search`(返回结果已包含全量属性,无需再调 get)
|
||||
用户说"补卡规则详情/某条补卡规则的具体信息" → `adjustment search --name "..."`(search 直出)。`adjustment get` 仅在需要按已知 adjustmentId 精确查询时使用
|
||||
用户说"补卡规则详情/某条补卡规则的具体信息" → `adjustment search --name "..."`(search 直出,已含全量属性)。**不要用 `adjustment get`**:当前服务端对任意 id 都只返回"有效期类型"、拿不到规则明细
|
||||
用户说"加班规则/加班设置/加班计算" → `overtime search`(返回结果已包含全量属性,无需再调 get)
|
||||
用户说"加班规则详情/某条加班规则的具体信息" → `overtime search --name "..."`(search 直出)。如需查已删除/被覆盖的历史记录 → `overtime get`
|
||||
用户说"考勤组列表/有哪些考勤组" → `group search`
|
||||
@@ -1068,7 +1085,7 @@ Flags:
|
||||
```bash
|
||||
# 导入排班记录
|
||||
dws attendance schedule import --group-id 123456 \
|
||||
--schedules '[{"userId":"user001","classId":123,"workDate":"2026-04-22","checkBeginTime":"09:00","checkEndTime":"18:00"}]' \
|
||||
--schedules '[{"userId":"user001","classId":123,"workDate":"2026-04-22","isRest":"N"}]' \
|
||||
--yes --format json
|
||||
|
||||
# 获取排班记录 — 禁止直接调用,必须走 attendance-schedule.md 排班查询导出工作流
|
||||
@@ -1123,8 +1140,8 @@ dws attendance group update --group-id 123456 --group-vo '{"positions":[{"title"
|
||||
dws attendance group create --name "研发考勤组" --type FIXED --group-vo '{"defaultClassId":1170996821,"workDayClassList":[0,1170996821,0,0,0,0,0]}' --timeout 10 --format json
|
||||
dws attendance group create --name "自由工时分组" --type NONE --timeout 10 --format json
|
||||
|
||||
# 查看考勤统计摘要
|
||||
dws attendance summary --user <USER_ID> --date "2026-03-12 15:00:00" --format json
|
||||
# 查看考勤统计摘要(--stats-type 必填:week 周统计 / month 月统计)
|
||||
dws attendance summary --user <USER_ID> --date 2026-03-12 --stats-type week --format json
|
||||
|
||||
# 查看考勤组和规则
|
||||
dws attendance rules --date 2026-03-14 --format json
|
||||
@@ -1157,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 \
|
||||
@@ -1202,7 +1219,7 @@ dws attendance vacation save-balance --target user001 \
|
||||
--num 8 --reason "绩效奖励发放3天" --format json
|
||||
|
||||
# 查询签到记录
|
||||
dws attendance checkin records --operator-staff-id op001 --staff-ids user001,user002 \
|
||||
dws attendance checkin records --operator-corp-id corp001 --operator-staff-id op001 --staff-ids user001,user002 \
|
||||
--start "2026-04-01 00:00:00" --end "2026-04-07 00:00:00" --format json
|
||||
```
|
||||
|
||||
@@ -1233,7 +1250,7 @@ dws attendance checkin records --operator-staff-id op001 --staff-ids user001,use
|
||||
- `class get` 的 `--class-id` 必填,班次 ID 可从 `class search` 结果中提取
|
||||
- `class search` 返回结果已包含全量属性,无需再调用 `class get`;`class get` 仅在需要按已知 classId 精确查询时使用
|
||||
- `class update` 的 `--class-id` 必填,其余均可选,仅需对要修改的字段赋值,未传字段会自动从已有配置补充;由于保存班次耗时较久,建议加 `--timeout 10`
|
||||
- `adjustment search` 返回结果已包含全量属性,无需再调用 `adjustment get`;`adjustment get` 仅在需要按已知 adjustmentId 精确查询时使用
|
||||
- `adjustment search` 返回结果已包含全量属性;`adjustment get` 当前服务端对任意 id 都只返回"有效期类型"、无规则明细,不可用,补卡规则详情一律看 `adjustment search` 返回
|
||||
- `overtime search` 返回结果已包含全量属性,无需再调用 `overtime get`;`overtime get` 仅在需要按已知 overtimeId 查询时使用(包括已删除/被覆盖的历史记录)
|
||||
- `adjustment search` / `overtime search` 分页字段为 `--page` 和 `--limit`,不传时自动使用默认值 1 / 20
|
||||
- `group search` 的分页字段为 `--page` 和 `--limit`,不传时自动使用默认值 1 / 20
|
||||
@@ -1242,7 +1259,7 @@ dws attendance checkin records --operator-staff-id op001 --staff-ids user001,use
|
||||
- `group update` 的 --group-id 必填,其余均可选,至少需指定一个修改项;仅需对要修改的字段赋値,未传字段会从已有配置自动补充;修改打卡地址/wifi/蓝牙等复杂子对象时用 `--group-vo` 传入完整 JSON;`--group-vo` 与单字段 flag 同时传入时单字段 flag 优先级更高
|
||||
- `group create` 的 `--name` 和 `--type` 必填,`--type` 必须为 FIXED/TURN/NONE 之一;type=FIXED 时 `--group-vo` 必须包含 `workDayClassList`(非空)和 `defaultClassId`(非 null);由于保存考勤组耗时较久,建议加 `--timeout 10`
|
||||
- `group filtered-get` 的 `--group-id` 必填,`--member/--position/--wifi/--bles` 均可选,默认 false。**返回结果中如含成员 userId 列表,必须调用 `dws contact user get --ids <userId1>,<userId2>,...`(支持逗号分隔传多个 ID),将 userId 转换为员工姓名后再输出;不得直接输出裸 userId。**
|
||||
- `summary` 的 `--date` 格式: yyyy-MM-dd HH:mm:ss(如 `2026-03-12 15:00:00`)
|
||||
- `summary` 必须同时传 `--user`、`--date`、`--stats-type`(week 周统计 / month 月统计),三者缺一即报错;`--date` 支持 `YYYY-MM-DD` 或 `yyyy-MM-dd HH:mm:ss`;`--tag-name` 可选
|
||||
- `rules` 的 `--date` 支持 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss 两种格式
|
||||
- `selfsetting get/save` 的 `--setting-scene` 必须是 `checkRemind`、`fastCheck`、`checkResultNotify`、`lackRemind`、`personalAttendStatNotify`、`bossAttendStatNotify` 之一
|
||||
- `selfsetting get/save` 的 MCP 入参 `userId` 为必填;CLI 的 `--user` 也必填,必须显式传入目标用户 ID
|
||||
@@ -1255,8 +1272,8 @@ dws attendance checkin records --operator-staff-id op001 --staff-ids user001,use
|
||||
- 用户 ID 需从 `contact user get-self` 或 `aisearch person` 获取
|
||||
- 考勤组 ID 需从 `rules` 命令返回结果中获取
|
||||
- `vacation types` 无需任何参数,认证信息自动注入
|
||||
- `vacation balance` 的 `--users` 为目标员工 ID 列表,逗号分隔;`--leave-code` 选填,可通过 `vacation types` 获取
|
||||
- `vacation records` 的 `--start/--end` 使用 YYYY-MM-DD 格式,CLI 自动转换为毫秒时间戳;`--leave-code` 选填
|
||||
- `vacation balance` 的 `--users` 为目标员工 ID 列表,逗号分隔;`--leave-code` 服务端要求非空、实际必填(`--help` 标"选填"不准),不传返回 `INVALID_PARAMS`,可通过 `vacation types` 获取
|
||||
- `vacation records` 的 `--start/--end` 使用 YYYY-MM-DD 格式,CLI 自动转换为毫秒时间戳;`--leave-code` 必填(不传无法查询),底层 MCP 工具为 `get_leave_balance_records_v2`
|
||||
- `vacation balance` 和 `vacation records` 的认证参数(corpId、opUserId)由系统自动注入,无需手动传入
|
||||
- `vacation update-type` 的 `--leave-code` 必填;其他字段均为可选,但至少需传一个更新字段
|
||||
- `vacation update-type` 的 `--visibility-rules` 为 JSON 数组字符串,格式:`[{"type":"dept","visible":["1","2","3"]}]`,type 可取值 staff/label/dept
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user