Compare commits

..
Author SHA1 Message Date
chichuan 20d27f6db9 Merge latest main into codex/fix-macos-auth-test-scope
Resolve the macOS race budget conflict in favour of the focused scope.

c1f96241 on main extended the whole-package macOS race step from 10m to
12m and pinned that budget in the workflow contract. This branch removes
the whole-package run instead: ./internal/app is already covered by the
Ubuntu "race: app" shard, and macOS only needs the natively-gated tests.
With the focused scope the job drops from 10m47s to ~3m, so the 12m
budget is no longer needed and the two step timeouts (6m + 5m) fit inside
the 15m job budget with headroom.

The contract assertion c1f96241 added is superseded rather than dropped:
pinning both focused commands locks the per-step timeouts, and the
existing checks still block a whole-package regression and require
./internal/app to appear exactly once.
2026-08-04 20:14:58 +08:00
github-actions[bot] ca2b8adcb2 Merge pull request #853 from DingTalk-Real-AI/codex/sync-wukong-oa-approval
feat(oa): add approval form workflow commands
2026-08-04 20:05:58 +08:00
chichuan a3c7009f9b Merge latest main into codex/fix-macos-auth-test-scope 2026-08-04 18:17:33 +08:00
chichuan 2d3f820f91 ci: keep the darwin-gated upgrade self-heal test reachable on macOS
Narrowing the macOS internal/app -run pattern orphaned
TestValidateNewBinary_RecoversFromUnsignedDarwin: it is the only
runtime.GOOS != "darwin" gated test in the package, the Ubuntu race shard
skips it on Linux, and the platform coverage gate only runs
^(TestAllShortcuts|TestCrossPlatformCoverage). No CI job selected it any
more, so it could never run or fail again.

- Add the self-heal test back to the macOS -run pattern.
- Add the (CrossPlatformCoverage)? group the Windows pattern already has,
  which also recovers TestCrossPlatformCoverageAuthMigrateKeychainRemainingBranches.
- Attribute vacuous runs: the two skip paths now name the branch that went
  unverified, and DWS_REQUIRE_AMFI_SELF_HEAL=1 escalates such a run to a
  hard failure on a host that does enforce amfid. GitHub's hosted macOS
  runners do not reproduce the amfid kill, so this test has been silently
  skipping there all along.
- Restore step-timeout headroom: 10m + 5m exactly equalled the 15m job
  budget, leaving none for setup. The keychain/auth step drops to 6m
  (measured 2m43s).
- Add a contract test that couples the macOS -run pattern to the set of
  darwin-gated tests in internal/app, so the next narrowing fails loudly
  instead of silently orphaning one.
2026-08-04 17:59:59 +08:00
chichuan f5a1b64d7a test(oa): include approval cases in native coverage 2026-08-04 17:36:22 +08:00
chichuan c1f96241af ci: extend macOS race test budget 2026-08-04 17:23:04 +08:00
chichuan eb571e6e73 fix(oa): remove unreachable mode checks 2026-08-04 16:42:57 +08:00
chichuan 4d5a47ac93 Merge latest main into codex/sync-wukong-oa-approval 2026-08-04 16:16:21 +08:00
chichuan 6108f51c9d fix(oa): align approval mode schema contracts 2026-08-04 16:08:51 +08:00
github-actions[bot] 6376f294da Merge pull request #862 from DingTalk-Real-AI/codex/sync-wukong-wiki-feed
feat(wiki): add knowledge base feed query command
2026-08-04 07:48:31 +00:00
chichuan f48a707e04 Merge latest main into codex/sync-wukong-wiki-feed 2026-08-04 15:30:25 +08:00
chichuan 7cb0de1f29 test(wiki): register feed command in the interface baseline
Add wiki.feed and wiki.feed.list to the CLI interface baseline so the new
command enters the backwards-compatibility contract and a later change
cannot silently drop it. The wiki root entry gains feed in its command
list; the leaf records the reviewed flags including the hidden
cross-product aliases.

Only the wiki nodes are merged. `make update-interface-baseline` would
also fold in 90 unrelated nodes that main has accumulated for chat,
aitable, doc, drive, and sheet; catching those up belongs in a separate
maintenance change, not in this feature PR.
2026-08-04 15:28:50 +08:00
chichuan f8d1fb84c0 Merge branch 'main' into codex/sync-wukong-wiki-feed 2026-08-04 14:42:10 +08:00
chichuan d3692e7b6e docs(wiki): document knowledge base feed query
Mirror the dws-wukong Skill updates for `dws wiki feed list` across both
Skill layouts: command reference, intent routing, the feed workflow, and
the nextToken context-passing row. Also refresh the wiki capability
summaries so the feed query is discoverable from the product tables.
2026-08-04 11:42:27 +08:00
chichuan e2e855d12a feat(wiki): add knowledge base feed query command
Port the knowledge base activity feed capability from the internal
dws-wukong branch feat/pull_knowledge_base_dynamic-wiki (merged there as
58fd118c) to the open-source CLI as `dws wiki feed list`.

The command forwards to the native wiki MCP tool list_workspace_feeds,
mapping --workspace/--limit/--cursor/--exclude-file onto
workspaceId/maxResults/nextToken/excludeFile. Cross-product hidden
aliases come from RegisterCrossProductAliases rather than hand-written
flags, so --workspace-id/--page-token/--next-token/--page-size all
resolve.

Register the reviewed Schema inputs (MCP contract pinned from the live
wiki tools/list payload, CommandRegistry entry, safety and selection
hints, parameter bindings, surface completeness count) and regenerate the
Catalog and Agent metadata projections.
2026-08-04 11:42:15 +08:00
chichuan 31e3f6bcbc Merge remote-tracking branch 'origin/main' into codex/sync-wukong-oa-approval
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
2026-08-04 11:41:38 +08:00
chichuan 678f108adf docs(oa): complete approval workflow references 2026-08-04 11:39:55 +08:00
github-actions[bot] e40f5bc537 Merge pull request #854 from DingTalk-Real-AI/codex/fix-chat-download-url
fix(chat): restore download-media JSON contract
2026-08-04 10:50:25 +08:00
修雨 3210232876 Merge latest main into codex/fix-chat-download-url 2026-08-04 10:22:26 +08:00
github-actions[bot] 162a2eb0a7 chore: update formula for v1.0.56 [skip ci] 2026-08-04 02:16:23 +00:00
chichuan d3f62193e7 Merge pull request #859 from DingTalk-Real-AI/codex/changelog-v1.0.56
docs: seal v1.0.56 changelog
2026-08-04 10:05:35 +08:00
修雨 1a11c687ed Merge remote-tracking branch 'origin/main' into codex/fix-chat-download-url 2026-08-04 10:04:55 +08:00
修雨 dfed4ba37d Merge main into codex/fix-chat-download-url 2026-08-04 10:04:07 +08:00
chichuan f26df04679 docs: seal v1.0.56 changelog 2026-08-04 10:00:32 +08:00
github-actions[bot] d02b03436d chore: update beta formula for v1.0.56-beta.4 [skip ci] 2026-08-04 01:55:53 +00:00
chichuan bc7b96ba5f Merge pull request #858 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.4
docs: seal v1.0.56-beta.4 changelog
2026-08-04 09:46:33 +08:00
chichuan 9539c887f6 docs: seal v1.0.56-beta.4 changelog 2026-08-04 09:35:44 +08:00
github-actions[bot] 6607f44724 Merge pull request #852 from AlwaysLee/feat/center-protocol-transfer
feat: multipart download engine with checkpoint resume and credential refresh
2026-08-04 09:29:17 +08:00
半圭 837a96fe3d fix: show friendly message on Ctrl+C instead of internal error JSON
When user interrupts multipart download with Ctrl+C, display a helpful
message indicating checkpoint is saved and download can be resumed,
instead of returning an internal error with context.Canceled.
2026-08-04 08:58:17 +08:00
半圭 fdcd44f9e3 fix: handle Ctrl+C (SIGINT) gracefully during drive download
- Replace context.Background() with cmd.Context() in download and
  download-version commands so SIGINT propagates to download goroutines
- Enables graceful interruption of multipart downloads via Ctrl+C
2026-08-03 23:21:59 +08:00
半圭 34c0c86a59 feat: center protocol upload/download refactoring with multipart download
- Add multipart download engine (drive_transfer.go) with Range probe,
  resume support, and credential auto-refresh on 401/403
- Add --part-size, --parallel, --no-resume flags to drive download and
  download-version commands
- Replace httpGetFile with driveTransferDownload for chunked parallel
  downloads in download and download-version commands
- Replace uploadToDrive credential parsing with driveUploadPut
  (transparent header pass-through, retry on 401/403)
- Add typed httpStatusError for non-2xx HTTP responses in doc.go
- Add comprehensive unit tests (32 cases) for drive_transfer
- Update drive reference documentation with multipart download behavior
- Add E2E test for multipart download (auto-test/, gitignored)

CR: 28984991
2026-08-03 23:21:59 +08:00
Raph a240ad2b81 ci: focus macOS auth race tests 2026-08-03 23:09:58 +08:00
修雨 b1f4a5d62a test(chat): add download-media CLI integration coverage 2026-08-03 21:50:17 +08:00
修雨 bf33ab622f fix(chat): restore download media JSON result 2026-08-03 21:50:17 +08:00
chichuan f39a3f5417 Merge branch 'main' into codex/sync-wukong-oa-approval 2026-08-03 21:18:39 +08:00
github-actions[bot] b2cbca2762 chore: update beta formula for v1.0.56-beta.3 [skip ci] 2026-08-03 12:55:19 +00:00
chichuan 9ce95db08e Merge pull request #855 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.3
docs: seal v1.0.56-beta.3 changelog
2026-08-03 20:39:44 +08:00
chichuan e99c20a0a1 docs: seal v1.0.56-beta.3 changelog 2026-08-03 20:34:17 +08:00
chichuan e806e761ad test(oa): cover approval request branches 2026-08-03 19:48:26 +08:00
chichuan a6696fe9e9 fix(schema): map OA request wrappers 2026-08-03 19:26:22 +08:00
chichuan 06af21c7a1 fix(oa): complete approval instance options 2026-08-03 19:09:51 +08:00
chichuan eba2b692ec feat(oa): add approval form workflow commands 2026-08-03 19:02:39 +08:00
github-actions[bot] 96bfae079a Merge pull request #846 from wxianfeng/fix/event-unix-socket-tmpdir
fix(event): use secure Unix bus runtime directory
2026-08-03 16:04:41 +08:00
wxianfeng bb18cdba3b Merge upstream/main into fix/event-unix-socket-tmpdir 2026-08-03 15:45:38 +08:00
wxianfeng 015a1f85ca fix(event): satisfy platform coverage gate 2026-08-03 15:42:17 +08:00
github-actions[bot] 8854e0d1d4 Merge pull request #851 from abucraft/codex/aitable-workflow-docs
feat: add aitable workflow edit example command
2026-08-03 07:01:27 +00:00
镜玄 22862508b8 feat: add aitable workflow edit example command 2026-08-03 14:47:59 +08:00
wxianfeng 5459bcc524 fix(event): secure Unix bus runtime directory 2026-08-03 11:40:01 +08:00
wxianfeng 029c665029 fix(event): place Unix bus sockets in temp dir 2026-07-31 18:01:34 +08:00
127 changed files with 16976 additions and 32457 deletions
+5 -2
View File
@@ -693,8 +693,11 @@ jobs:
with:
go-version-file: go.mod
- name: Test macOS auth and Keychain paths with Race Detection
run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth ./internal/app
- name: Test macOS auth and Keychain packages with Race Detection
run: go test -v -race -count=1 -timeout=6m ./internal/keychain ./internal/auth
- name: Test macOS auth migration, Keychain diagnostics, and upgrade self-heal with Race Detection
run: go test -v -race -count=1 -timeout=5m ./internal/app -run '^(TestValidateNewBinary_RecoversFromUnsignedDarwin|Test(CrossPlatformCoverage)?Auth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch))'
test-windows:
name: Test (Windows)
+47
View File
@@ -6,6 +6,53 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Fixed
- **Chat media download JSON compatibility** — `dws chat message download-media --format json` once again returns a clean `{success, downloadUrl, output}` result after the file is saved, preserving the temporary URL and resolved local path without progress text corrupting JSON stdout.
## [1.0.56] - 2026-08-04
This stable release promotes the fully delivered `v1.0.56-beta.4` baseline.
It includes PR #852's resilient multipart Drive download implementation,
together with the v1.0.56 beta-line command, Schema, Skill, and runtime
improvements already validated through the prerelease channel.
### Added
- **Resilient multipart Drive downloads** (#852) — `drive download` and
`drive download-version` support parallel chunk transfer, Range probing,
fingerprint-validated checkpoint resume, automatic 401/403 credential
refresh, and graceful interruption with checkpoint preservation.
## [1.0.56-beta.4] - 2026-08-04
This beta adds PR #852 on top of v1.0.56-beta.3. It makes Drive downloads
resilient for large files through parallel transfer, validated resumable
checkpoints, and automatic credential refresh.
### Added
- **Multipart Drive downloads** (#852) — adds `--part-size`, `--parallel`, and
`--no-resume` to `drive download` and `drive download-version`. Files above
the part-size threshold use a Range probe and parallel chunks, resume from a
fingerprint-validated checkpoint, refresh credentials on 401/403, and keep
the checkpoint when Ctrl+C interrupts a transfer.
## [1.0.56-beta.3] - 2026-08-03
This beta adds PRs #846 and #851 on top of v1.0.56-beta.2. It adds a
service-provided Aitable workflow-editing reference command and makes local
event-bus IPC reliable on shared filesystems by placing Unix sockets in a
validated private runtime directory.
### Added
- **Aitable workflow editing reference** (#851) — adds `dws aitable workflow edit-example`, a parameter-free read command that returns the service-provided workflow editing documentation and `workflow-dsl/v1` examples through `aitable/edit_workflow_example`.
### Fixed
- **Event bus sockets on shared filesystems** (#846) — Unix event buses now place their local IPC socket in a private per-user runtime directory (`XDG_RUNTIME_DIR` when available, otherwise a `0700` per-UID directory under the system temporary directory) while retaining locks, metadata, logs, and subscription state in the configured Workdir. Listener and dial paths validate directory ownership and permissions before use. This prevents `dws event consume` from failing with `bind: errno 524` when `~/.dws` is hosted on NFS, CSI, FUSE, or another filesystem that does not support Unix Domain Sockets without exposing the socket directly in a shared `/tmp` root. When `XDG_RUNTIME_DIR` is unavailable, the per-UID directory name is deterministic: ownership validation prevents endpoint hijacking, but another local user can pre-create the directory to deny service; multi-user deployments should provide a private `XDG_RUNTIME_DIR`.
## [1.0.56-beta.2] - 2026-07-30
This beta adds PRs #831 and #835 on top of v1.0.56-beta.1. It separates
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.56-beta.2"
version "1.0.56-beta.4"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-darwin-arm64.tar.gz"
sha256 "19b52b5427dbf24acfeb1da16a93e6edfd0443389a778abbbfd381ff8f656139"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-arm64.tar.gz"
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-darwin-amd64.tar.gz"
sha256 "621da52d04f391234d160a0522d70c1fb2e36da59102ef201a9a3a11b706b3e8"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-linux-arm64.tar.gz"
sha256 "4ba956463f4b583c1727f58026a6bc1fb23d27537083e3bafd541a05ab15b48b"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-arm64.tar.gz"
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-linux-amd64.tar.gz"
sha256 "a8156ec5b89355faf8c08a65d9c088f89a7b411a418fb4b488f3d472efc79670"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-skills.zip"
sha256 "92c71fdeade88b3b76a00cb74ebd3223cc4110c7ee151d36d782537613129967"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
end
def install
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.55"
version "1.0.56"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-arm64.tar.gz"
sha256 "dd753bbd051e5dd007cf433b8aa211c4a221dd73dfcb0b3783fa924d09f12351"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-arm64.tar.gz"
sha256 "5c6003fe484aa36cc00820a574186652467b9d075f19c159cf807e57590256ba"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-darwin-amd64.tar.gz"
sha256 "f465eb7ac38a8a84eac4eb821fd15424bfc6f6245a60fa695ba97a639970dd77"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-amd64.tar.gz"
sha256 "969b005a10682c2a1a828fa112165b5b0cd8ceeed8d22110ef7f39402cc36804"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-arm64.tar.gz"
sha256 "5961be0fd551ec8e69b6fff2b1609f73486f7e6c3ffe8eb4bb99fa1ed691b401"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-arm64.tar.gz"
sha256 "530c5ea7ddc7de320d9c2471fbd33752a723d00c9665f49321c7580e8392c756"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-linux-amd64.tar.gz"
sha256 "051ba404a5f6a8fb15def0e0f5d9d273cf9d63f881df2fffe159f2c4ea3366e7"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-amd64.tar.gz"
sha256 "675fa42727ac9a549c6710b82e1980cd0f795363d71d5116a4e69771b7c5470e"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55/dws-skills.zip"
sha256 "bd35f674f184001f5a03c7b5fa6029ebcda54f0054e15cd608b5b5e213ce2d05"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-skills.zip"
sha256 "3d57794e4660a089209ce3962571d16ca0d46141e973c9993257a301cce0e097"
end
def install
+6 -3
View File
@@ -40,7 +40,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
- [`dws minutes` — AI Minutes](#dws-minutes) · 19 commands
- [`dws oa` — OA Approval](#dws-oa) · 9 commands
- [`dws oa` — OA Approval](#dws-oa) · 12 commands
- [`dws report` — Reports](#dws-report) · 7 commands
- [`dws todo` — Todo Tasks](#dws-todo) · 6 commands
@@ -277,14 +277,17 @@ _AI meeting notes: listing, summary, todos, transcription, recording control, mi
## `dws oa` — OA Approval
_OA approval workflows: list, approve, reject, revoke, records._
_OA approval workflows: inspect forms, forecast routes, create instances, approve, reject, revoke, and audit records._
**9 commands**
**12 commands**
| Command | Description | When to use |
|---|---|---|
| `dws oa approval approve` | Approve a pending approval process instance (task) as the current user. | When the agent acts on a pending approval the user has delegated it to handle. |
| `dws oa approval create-instance` | Create a real approval process instance from validated form values or a complete request payload. | After the agent has inspected the form Schema, forecast the route, resolved any selectable approvers, and obtained explicit user confirmation. |
| `dws oa approval detail` | Retrieve full details of an approval process instance, including form fields, attachments, and state. | When the agent needs to read the content of an approval ticket before deciding on it or summarizing it. |
| `dws oa approval form-schema` | Retrieve the form Schema for an approval template by processCode. | Before collecting or validating values for a new approval instance. |
| `dws oa approval forecast-process` | Forecast the approval route for a template and its proposed form values. | Before creating an instance, especially when the route contains user-selectable approver or notifier nodes. |
| `dws oa approval list-forms` | List approval process templates (forms) the current user is allowed to initiate. | When the agent needs to pick the right approval form before submitting a new request. |
| `dws oa approval list-initiated` | List approval process instances the current user has initiated. | When the agent reviews the status of approvals the user submitted. |
| `dws oa approval list-pending` | List approval process instances currently awaiting action from the current user. | When the agent surfaces "needs your approval" items in the user's inbox. |
@@ -1,301 +0,0 @@
# IM Chat Skill 精简与渐进加载优化方案
## 1. 背景
当前 `im-chat-skill-hint-align` 分支已经增强了 Chat Skill 的 Shortcut 路由、执行骨架、身份边界、查询与资源处理、低频原子回退和错误导航。与 Lark IM Skill 对比后,DWS 在 Agent 路由和执行约束上更直接,但根 Skill 仍存在以下问题:
- 高频执行骨架与核心意图表重复。
- Runtime Shortcut Catalog、leaf Schema 和 leaf Help 的读取规则在多处重复。
- Shortcut 错误处理与 Workflow 错误导航重复。
- 身份、ID 和三种置顶对象的边界分散在不同章节。
- 缺少简短、集中且可复用的核心对象与查询结果语义。
- Frontmatter 能力召回仍可扩展,但不能削弱 DING、邮件和班级群等产品边界。
本方案只调整根文件 `skills/multi/dingtalk-chat/SKILL.md` 的组织和必要语义,不把 API 手册、完整 Shortcut 清单或权限表重新放回根 Skill。
## 2. 修改目标
1. 提高高频 Chat 意图的直接命中率,减少不必要的 Catalog、Schema 和 `--help` 调用。
2. 提前建立渐进加载顺序,避免模型在高频任务中优先进入原子命令树。
3. 补齐身份、核心对象、ID 和查询结果的必要语义,降低错误重试和 ID 混用。
4. 合并重复 SOP,确保新增内容不会增加根 Skill 的总体 token。
5. 保持参数、安全和完整 API 事实由 leaf Schema、Help 和 references 按需提供。
## 3. 设计原则
### 3.1 根 Skill 只保留决策必需信息
根 Skill 应负责:
- Skill 触发范围和跨产品排除边界。
- 渐进加载与能力选择顺序。
- 高频意图到精确 Shortcut 的映射。
- 身份、ID、幂等、分页、部分失败等跨命令不变量。
- 低频能力的导航入口。
- 错误恢复和停止条件。
以下内容继续留在 Schema 或 references:
- 完整 Shortcut Catalog。
- API Resources 全量列表。
- 权限 scope 表。
- 叶级参数全集和接口字段格式。
- 只服务单个命令的实现细节。
### 3.2 渐进加载规则必须早于命令骨架
模型应在看到具体命令前,先知道何时直接执行、何时才加载额外上下文。但完整一级命令树不应提前,以免低频原子命令干扰高频 Shortcut 选择。
### 3.3 同一事实只保留一个权威位置
- 高频 Shortcut 只出现在一张核心意图表中。
- Catalog、Schema 和 Help 的读取顺序只定义一次。
- 错误恢复与 reference 导航只定义一次。
- 身份、对象和 ID 的公共边界集中定义,后续章节只引用,不重复解释。
## 4. 目标章节结构
```text
1. Frontmatter
2. Preconditions
3. 加载与路由顺序
4. 核心对象与 ID
5. 核心意图与执行骨架
6. 统一发送
7. 查询、资源与卡片
8. 低频原子路由
├── 一级命令树
├── branch references
└── 低频操作回退表
9. 错误恢复与按需 Reference
10. 跨产品协作
```
## 5. 具体修改
### 5.1 扩展 Frontmatter 产品能力
扩展 `description` 的正向能力召回,覆盖:
- 单聊、群聊、建群、群搜索和群成员管理。
- 消息发送、回复、转发、撤回、查询和聊天记录搜索。
- 图片、文件和消息资源下载。
- 表情回应、收藏、Pin、消息置顶和会话置顶。
- 应用机器人、Webhook 和互动卡片。
- 未读、红点、消息已读状态和会话分类。
同时保留明确排除:
- DING、短信和电话转到 `dingtalk-ding`。
- 邮件转到 `dingtalk-mail`。
- 班级群转到对应的低频产品 Skill。
- 找人本身由 `dingtalk-contact` 或 `dingtalk-aisearch` 负责,Chat 只消费真实人员 ID。
Frontmatter 只描述真实能力和路由边界,不加入参数、SOP 或 token 实现细节。
### 5.2 前移并合并渐进加载规则
将现有“Shortcut 发现”“Shortcut 执行契约”和“渐进加载与一级路由”的加载决策部分合并为紧随 Preconditions 的唯一章节:
```markdown
## 加载与路由顺序
1. 已知高频意图:直接使用“核心意图与执行骨架”,不查 Help。
2. 已有匹配 Shortcut:直接执行;参数、约束或安全不确定时才查 leaf Schema。
3. 仅 Cobra flags 不确定时查 leaf `--help`。
4. 现有路由无法定位低频能力时,才查 Runtime Shortcut Catalog。
5. 没有 Shortcut 时,按需读取对应 branch reference,进入原子命令。
```
同一章节保留以下公共规则:
- 路由优先级为 `exact recipe/runnable script > public Shortcut > atomic command`。
- 不猜测 `cli_path` 或参数名称。
- `confirmation=user_required` 时先确认,再添加 `--yes`。
- 来源冲突时采用更安全的解释并报告契约漂移。
- 命令已确定且参数清楚时直接执行,不为验证已知路径重复发现。
删除其他章节重复出现的 Catalog、Schema、Help 选择说明。
### 5.3 新增“核心对象与 ID”小表
增加不超过 8 行的表格,集中表达:
| 对象 | 核心标识与边界 |
|---|---|
| 人员 | 姓名必须先解析成唯一真实的 `userId` 或 `openDingTalkId`,名称不能作为 ID 传递 |
| 会话 | 使用真实 `openConversationId` / cid;群名只能用于 Shortcut 的目标解析 |
| 消息 | 使用真实 `openMessageId` / msgId,并保持与身份及会话一致 |
| 发送任务 | `openTaskId` 只用于查询发送状态,不能替代消息 ID |
| Thread | thread/topic ID 必须绑定真实会话,不跨会话复用 |
| 身份 | current-user、app-bot 和 Webhook 是不同操作者,不能自动互换 |
| 状态 | 收藏、消息置顶、消息 Pin 和会话置顶作用于不同对象 |
新增后删除后文对这些边界的重复说明。
### 5.4 合并高频骨架与核心意图表
删除独立的“高频直接执行骨架”,将其全部合入唯一的“核心意图与执行骨架”表。表格固定为三列:
| 用户意图 | 精确 Shortcut 骨架 | 必须保留的执行边界 |
|---|---|---|
至少覆盖以下高频场景:
- 姓名发单聊、群名发群消息。
- user、bot、webhook 三种身份发送。
- 建群、改群名、拉人和成员查询。
- 拉取会话消息、查询详情、撤回和发送状态。
- 关键词搜索、组合搜索和查询 @ 我的消息。
- 群邀请链接和群机器人。
- 会话置顶和收藏列表。
- 查和某人的聊天记录。
- 群消息翻页导出。
- 机器人多群广播。
表中直接给出正确参数骨架;命中后照抄参数名,不先调用 `--help`。同一个 Shortcut 不再在其他表中重复列出。
### 5.5 补充统一身份规则
在“统一发送”开头加入统一规则:
> 身份决定真实操作者、可见范围和可用能力;同一目标使用 user、bot 或 webhook 时,结果和权限可能不同,禁止自动切换身份重试。
继续保留:
- 发送前检查身份、目标、正文、标题、@、消息类型和附件路径。
- 重试复用相同 `--idempotency-key`。
- user、bot、webhook 的精确发送模板。
- @ 占位符、新行和文件能力边界。
不加入 Lark 的 access token 类型说明。
### 5.6 补充查询结果与增强失败语义
在“查询、资源与卡片”中增加:
- 发送者名称缺失时保留真实 ID,不猜姓名,也不自动扩大通讯录查询。
- 可选增强字段缺失不代表主查询失败;增强请求失败时保留主结果并写入 per-item ledger。
继续保留:
- `--page-all` 只在确需完整分页时使用。
- 部分失败保留已有结果,禁止把不完整结果声明为完整。
- 资源下载默认关闭,显式请求后才增加请求和本地输出。
- 子消息资源优先使用子 `messageId`。
- 输出路径、覆盖、HTTPS 和重定向安全限制。
### 5.7 拆分“渐进加载”与“一级命令树”
前移的只有加载决策。完整一级命令树及 branch references 改名为“低频原子路由”,保留在高频意图、统一发送和查询规则之后。
这样可以:
- 防止高频任务优先进入 atomic branch。
- 降低不必要的 Schema 和 Help 查询。
- 继续为没有 Shortcut 的能力提供确定导航。
低频原子回退表继续保留收藏、编辑、外部群升级、群昵称、分类、共同群、群公告、群身份、置顶、未读、已读、授权、退群和解散群等差异化入口。
### 5.8 合并错误恢复与 Workflow 导航
将现有“Shortcut 错误处理”和“Workflow 与错误导航”合并为:
```markdown
## 错误恢复与按需 Reference
```
只保留以下规则:
- 路径或参数错误时,按 Catalog、Schema、Help 的既定顺序校正一次。
- 始终从实际输出重新提取下游 ID。
- 复杂消息任务按需读取 `01-messaging.md`。
- Onboarding 按需读取对应 workflow。
- 命令错误按需读取 `chat-error-recovery.md`。
- 权限不足、歧义未消除、无结果或契约冲突时停止并报告。
删除其他位置重复的 `01-messaging.md` 和错误恢复入口。
### 5.9 保留跨产品协作边界
继续保留根 Skill 中不可由 Chat 自己完成的路由:
- 人名解析到 Contact / AISearch。
- DING、短信和电话到 Ding Skill。
- 邮件到 Mail Skill。
- 本地文件与已有 mediaId 的发送差异。
如果某项边界已经在 Frontmatter 或核心对象表中完整表达,正文只保留执行阶段真正需要的补充,不重复整段说明。
## 6. 删除与合并清单
| 当前内容 | 处理方式 |
|---|---|
| “Shortcut 发现(按需)” | 合入前置“加载与路由顺序” |
| “Shortcut 执行契约” | 公共规则合入前置章节 |
| “高频直接执行骨架” | 删除,内容合入核心意图表 |
| “渐进加载与一级路由”中的加载说明 | 前移并去重 |
| 完整一级命令树 | 保留,改放“低频原子路由” |
| “Shortcut 错误处理” | 合入统一错误章节 |
| “Workflow 与错误导航” | 合入统一错误章节 |
| 分散的身份、ID、置顶说明 | 合入核心对象表或统一身份规则 |
| 重复的 `01-messaging.md` 入口 | 只保留一处 |
## 7. 不纳入本次修改
- 不展开 97 个公开 Shortcut。
- 不复制 Lark 的完整 API Resources 和权限 scope 表。
- 不在根 Skill 中维护 leaf 参数全集。
- 不引入与当前 CLI 不一致的新命令或参数。
- 不改变 Schema、Help、Runtime Catalog 和 reference 的事实优先级。
- 不通过增加默认查询、自动通讯录查询或默认资源增强来换取结果丰富度。
## 8. 实施顺序
1. 更新 Frontmatter description,确认能力召回和排除边界。
2. 合并并前移“加载与路由顺序”。
3. 新增“核心对象与 ID”表,删除相应重复边界。
4. 合并高频骨架和核心意图表。
5. 补充统一身份规则。
6. 补充查询结果和增强失败语义。
7. 将一级命令树调整为后置的“低频原子路由”。
8. 合并错误恢复与 reference 导航。
9. 全文检查重复命令、重复 reference 和冲突参数。
10. 运行 Skill 格式及相关策略测试,并用高频场景做静态路由验证。
## 9. 验收标准
### 9.1 内容与结构
- 根 Skill 中只有一份 Catalog、Schema、Help 读取顺序。
- 根 Skill 中只有一张高频意图与 Shortcut 骨架表。
- 根 Skill 中只有一个错误恢复章节。
- `01-messaging.md` 的同类导航不重复。
- 核心对象与 ID 表不超过 8 行数据。
- 一级原子命令树位于高频路由之后。
- Frontmatter 同时覆盖主要产品能力和明确排除边界。
### 9.2 执行行为
- “发给某人”“发到某群”“查 @ 我”“改群名”等高频意图可直接选中已评审 Shortcut,不先查 Help。
- 低频未知意图才触发 Runtime Shortcut Catalog。
- 参数或安全不确定时读取 leaf Schema;只有 Cobra flags 不确定时读取 leaf Help。
- user、bot、webhook 不被自动互换。
- `openTaskId`、消息 ID、会话 ID 不混用。
- 发送者名称或 reaction 等增强缺失时,不把主查询误判为失败。
- 部分失败保留已有结果并明确报告 ledger/completeness。
### 9.3 Token 与维护成本
- 修改后的根 Skill 不超过当前 211 行,并以不丢失必要路由和边界为前提尽量低于 195 行。
- 文件单词数和字符数不高于修改前基线。
- 新增内容通过删除重复 SOP 抵消。
- 不新增完整 Shortcut、API 或权限清单。
## 10. 预期收益
- 减少高频任务中的 `--help` 和重复 Schema 查询。
- 降低因身份切换、ID 混用和发送者名称缺失导致的错误重试。
- 让 Shortcut、Schema、Help、Catalog 和 references 各自保持单一职责。
- 在不增加默认 token 和耗时的前提下,提高 Chat Skill 的选择准确率和执行成功率。
- 降低后续新增 Shortcut 时同时维护多张表和多处规则的漂移风险。
-162
View File
@@ -1,162 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
stderrors "errors"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func TestValidateChatWorkbookRawArgs(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
args []string
want string
}{
{
name: "members group flag",
args: []string{"chat", "group", "members", "list", "--group", "cid-demo", "--format", "json"},
want: "群成员列表命令路径或群参数不正确",
},
{
name: "rename group flag",
args: []string{"chat", "group", "rename", "--group=cid-demo", "--name", "新群名"},
want: "群重命名命令不支持 --group",
},
{
name: "image local path",
args: []string{"chat", "message", "send", "--group", "cid-demo", "--msg-type", "image", "--file-path", "/tmp/x.png"},
want: "image 消息不能直接使用 --file-path",
},
{
name: "unsupported message type",
args: []string{"chat", "message", "send", "--group", "cid-demo", "--msg-type=sticker"},
want: "不支持指定的 --msg-type:sticker",
},
{
name: "numeric group id required",
args: []string{"chat", "group", "get-by-group-id", "--group-id", "cid-demo"},
want: "--group-id 必须是数字群号",
},
{
name: "file media id conflict",
args: []string{"chat", "message", "send", "--group", "cid-demo", "--msg-type", "file", "--media-id", "media"},
want: "文件消息不能使用 --media-id",
},
{
name: "silent text media conflict",
args: []string{"chat", "message", "send", "--group", "cid-demo", "--media-id", "media", "--text", "file.pdf"},
want: "检测到 --media-id,但没有指定媒体消息类型",
},
{
name: "dismiss numeric group id",
args: []string{"chat", "group", "dismiss", "--group", "12345678"},
want: "解散群命令需要 openConversationId,不是数字群号",
},
} {
t.Run(tc.name, func(t *testing.T) {
err := validateChatWorkbookRawArgs(tc.args)
var typed *apperrors.Error
if !stderrors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Message != tc.want || len(typed.Actions) == 0 || len(typed.Examples) == 0 {
t.Fatalf("guidance = %#v", typed)
}
})
}
if err := validateChatWorkbookRawArgs([]string{"chat", "group", "rename", "--id", "cid-demo"}); err != nil {
t.Fatalf("canonical rename args rejected: %v", err)
}
}
func TestChatWorkbookHelpGuidanceCoverage(t *testing.T) {
t.Parallel()
for _, path := range []string{
"chat group members",
"chat group members add",
"chat group members remove",
"chat group members add-bot",
"chat group members remove-bot",
"chat group members list-by-ids",
"chat group create",
"chat group rename",
"chat message list",
"chat message search",
"chat message search-advanced",
"chat message list-all",
"chat message list-by-sender",
} {
guide, ok := chatWorkbookHelpGuidance[path]
if !ok || guide.reason == "" || guide.action == "" || guide.example == "" {
t.Fatalf("incomplete help guidance for %q: %#v", path, guide)
}
}
}
func TestRawArgsFlagValue(t *testing.T) {
t.Parallel()
if got := rawArgsFlagValue([]string{"--msg-type", "image"}, "msg-type"); got != "image" {
t.Fatalf("separate value = %q", got)
}
if got := rawArgsFlagValue([]string{"--msg-type=file"}, "msg-type"); got != "file" {
t.Fatalf("equals value = %q", got)
}
if got := rawArgsFlagValue([]string{"--text", "hello"}, "msg-type"); got != "" {
t.Fatalf("missing value = %q", got)
}
}
func TestRawArgsRequestJSON(t *testing.T) {
t.Parallel()
for _, args := range [][]string{
{"chat", "search", "--format", "json"},
{"chat", "search", "--format=json"},
{"chat", "search", "-f", "JSON"},
{"chat", "search", "-f=json"},
} {
if !rawArgsRequestJSON(args) {
t.Fatalf("rawArgsRequestJSON(%v) = false", args)
}
}
}
func TestSuppressJSONDeprecationPreamble(t *testing.T) {
t.Parallel()
root := NewRootCommand()
cmd := mustFindCommand(t, root, "chat", "media", "upload")
if cmd.Deprecated == "" {
t.Fatal("fixture command is not deprecated")
}
suppressJSONDeprecationPreamble(root, []string{"chat", "media", "upload", "--format", "json"})
if cmd.Deprecated != "" {
t.Fatalf("JSON execution kept deprecation preamble: %q", cmd.Deprecated)
}
plainRoot := NewRootCommand()
plain := mustFindCommand(t, plainRoot, "chat", "media", "upload")
suppressJSONDeprecationPreamble(plainRoot, []string{"chat", "media", "upload"})
if plain.Deprecated == "" {
t.Fatal("human execution unexpectedly removed deprecation metadata")
}
}
-462
View File
@@ -24,7 +24,6 @@ import (
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
@@ -113,16 +112,6 @@ func Execute() (exitCode int) {
root := rootNewRootCommandWithEngine(ctx, engine)
timing.Record("cmd_init", time.Since(initStart))
if err := validateChatWorkbookRawArgs(os.Args[1:]); err != nil {
if rawArgsRequestJSON(os.Args[1:]) {
_ = apperrors.PrintJSON(os.Stderr, err)
} else {
_ = apperrors.PrintHumanAt(os.Stderr, err, resolveVerbosity(root))
}
return apperrors.ExitCode(err)
}
suppressJSONDeprecationPreamble(root, os.Args[1:])
// Run PreParse handlers on raw argv before Cobra parses flags.
// This corrects model-generated errors like --userId → --user-id
// and --limit100 → --limit 100.
@@ -138,7 +127,6 @@ func Execute() (exitCode int) {
executed = root
}
err = rewordRequiredFlagError(err)
err = enrichChatWorkbookError(executed, err)
if isUnknownCommandError(err) {
executed.SetOut(os.Stderr)
_ = executed.Help()
@@ -153,453 +141,6 @@ func Execute() (exitCode int) {
return 0
}
func suppressJSONDeprecationPreamble(root *cobra.Command, args []string) {
if root == nil || !rawArgsRequestJSON(args) || len(args) < 3 {
return
}
if args[0] != "chat" || args[1] != "media" || args[2] != "upload" {
return
}
if cmd, _, err := root.Find([]string{"chat", "media", "upload"}); err == nil && cmd != nil {
cmd.Deprecated = ""
}
}
func validateChatWorkbookRawArgs(args []string) error {
path := strings.Join(args, " ")
switch {
case strings.HasPrefix(path, "chat message send ") && rawArgsFlagValue(args, "msg-type") == "file" &&
rawArgsContainFlag(args, "media-id"):
return apperrors.NewValidation(
"文件消息不能使用 --media-id",
apperrors.WithReason("PDF、DOCX、XLSX 和本地图片等文件通过 --file-path 上传发送;mediaId 仅用于已有媒体标识的 image 消息"),
apperrors.WithActions("移除 --media-id", "补充 --file-path 并保留 --msg-type file"),
apperrors.WithExamples(`dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json`),
)
case strings.HasPrefix(path, "chat message send ") && rawArgsContainFlag(args, "media-id") &&
rawArgsFlagValue(args, "msg-type") == "":
return apperrors.NewValidation(
"检测到 --media-id,但没有指定媒体消息类型",
apperrors.WithReason("未指定 --msg-type 时命令会进入文本分支,可能把文件名当成普通文字发送"),
apperrors.WithActions("已有图片 mediaId 时补充 --msg-type image", "发送 PDF/DOCX/XLSX 时移除 --media-id,改用 --msg-type file --file-path"),
apperrors.WithExamples(`dws chat message send --group <openConversationId> --msg-type image --media-id <mediaId> --format json`, `dws chat message send --group <openConversationId> --msg-type file --file-path ./thesis.pdf --format json`),
)
case strings.HasPrefix(path, "chat message send ") && rawArgsFlagValue(args, "msg-type") == "image" &&
rawArgsContainFlag(args, "file-path") && !rawArgsContainFlag(args, "media-id"):
filePath := rawArgsFlagValue(args, "file-path")
return apperrors.NewValidation(
"image 消息不能直接使用 --file-path",
apperrors.WithReason("msg-type=image 只接受已有 mediaId;本地图片路径不能自动转换为 mediaId"),
apperrors.WithActions("发送本地图片时改用 --msg-type file", "保留原路径并通过 --file-path 发送为文件附件"),
apperrors.WithExamples(fmt.Sprintf(`dws chat message send --group <openConversationId> --msg-type file --file-path %q --format json`, filePath)),
)
case strings.HasPrefix(path, "chat message send ") && rawArgsFlagValue(args, "msg-type") == "image" &&
!rawArgsContainFlag(args, "media-id"):
return apperrors.NewValidation(
"图片消息缺少 --media-id",
apperrors.WithReason("msg-type=image 只接受上游已经获得的有效 mediaId,不能把本地文件名当作 mediaId"),
apperrors.WithActions("已有 mediaId 时补充 --media-id", "发送本地图片时改用 --msg-type file --file-path"),
apperrors.WithExamples(`dws chat message send --group <openConversationId> --msg-type image --media-id <mediaId> --format json`, `dws chat message send --group <openConversationId> --msg-type file --file-path ./image.png --format json`),
)
case strings.HasPrefix(path, "chat message send "):
msgType := rawArgsFlagValue(args, "msg-type")
switch msgType {
case "", "text", "markdown", "image", "file", "audio", "video", "location", "profile":
default:
return apperrors.NewValidation(
"不支持指定的 --msg-type:"+msgType,
apperrors.WithReason("当前命令不支持 sticker/card 等消息类型;文本或 Markdown 消息无需传 --msg-type"),
apperrors.WithActions("文本消息移除 --msg-type 并使用 --text", "媒体消息使用 image、file、audio、video、location 或 profile"),
apperrors.WithExamples(`dws chat message send --group <openConversationId> --text "hi" --format json`),
)
}
case strings.HasPrefix(path, "chat group get-by-group-id "):
value := rawArgsFlagValue(args, "group-id")
if value != "" {
if _, err := strconv.ParseInt(value, 10, 64); err != nil {
return apperrors.NewValidation(
"--group-id 必须是数字群号",
apperrors.WithReason("cid 开头的值是 openConversationId,不是 get-by-group-id 所需的数字群号"),
apperrors.WithActions("如果已有 openConversationId,请改用接受 --group 的群查询命令", "只有拿到数字群号时才调用 get-by-group-id"),
apperrors.WithExamples(`dws chat group get-by-group-id --group-id 12345678 --format json`),
)
}
}
case strings.HasPrefix(path, "chat group dismiss ") && rawArgsContainFlag(args, "group"):
value := rawArgsFlagValue(args, "group")
if _, err := strconv.ParseInt(value, 10, 64); err == nil {
return apperrors.NewValidation(
"解散群命令需要 openConversationId,不是数字群号",
apperrors.WithReason("--group 应传 cid 开头或服务端返回的 openConversationId;数字群号只用于 get-by-group-id"),
apperrors.WithActions("先通过 chat search 获取 openConversationId", "确认目标群及不可逆影响后再执行解散"),
apperrors.WithExamples(`dws chat group dismiss --group <openConversationId> --format json`),
)
}
case strings.HasPrefix(path, "chat group members ") && rawArgsContainFlag(args, "group"):
return apperrors.NewValidation(
"群成员列表命令路径或群参数不正确",
apperrors.WithReason("群成员列表的可执行命令是 chat group members,群 ID 参数名为 --id;不存在 members list --group 这一组合"),
apperrors.WithActions("移除多余的 list 子命令", "将 --group 改为 --id"),
apperrors.WithExamples(`dws chat group members --id <openConversationId> --format json`),
)
case strings.HasPrefix(path, "chat group rename ") && rawArgsContainFlag(args, "group"):
return apperrors.NewValidation(
"群重命名命令不支持 --group",
apperrors.WithReason("chat group rename 使用 --id 接收群 openConversationId,而不是 --group"),
apperrors.WithActions("将 --group 改为 --id", "群 ID 不确定时先用 chat search 查询"),
apperrors.WithExamples(`dws chat group rename --id <openConversationId> --name "新群名" --format json`),
)
}
return nil
}
func rawArgsContainFlag(args []string, name string) bool {
prefix := "--" + name
for _, arg := range args {
if arg == prefix || strings.HasPrefix(arg, prefix+"=") {
return true
}
}
return false
}
func rawArgsFlagValue(args []string, name string) string {
prefix := "--" + name
for i, arg := range args {
if strings.HasPrefix(arg, prefix+"=") {
return strings.TrimPrefix(arg, prefix+"=")
}
if arg == prefix && i+1 < len(args) {
return args[i+1]
}
}
return ""
}
func rawArgsRequestJSON(args []string) bool {
for i, arg := range args {
if arg == "--format=json" || arg == "-f=json" {
return true
}
if (arg == "--format" || arg == "-f") && i+1 < len(args) && strings.EqualFold(args[i+1], "json") {
return true
}
}
return false
}
type chatWorkbookGuidance struct {
message string
reason string
actions []string
examples []string
}
var chatRequiredGuidance = map[string]chatWorkbookGuidance{
"chat message send-by-webhook": {
"Webhook 发送参数不完整",
"Webhook 消息必须同时提供机器人地址中的 access_token、标题和正文;不能降级为普通群消息",
[]string{"从自定义机器人 Webhook 地址提取 token", "同时补齐 --title 和 --text,并确保包含机器人安全关键词"},
[]string{`dws chat message send-by-webhook --token <access_token> --title "dws测试通知" --text "dws测试:评测结果已出" --format json`},
},
"chat group rename": {
"群重命名缺少群 ID 或新名称", "--id 必须是群 openConversationId,--name 是新的群名称",
[]string{"先用 chat search 获取群 openConversationId", "同时提供 --id 和 --name"},
[]string{`dws chat group rename --id <openConversationId> --name "新群名" --format json`},
},
"chat group dismiss": {
"解散群缺少目标群 ID", "解散群不可逆且需要群主权限,--group 必须是 openConversationId",
[]string{"先确认目标群和影响范围", "获取 openConversationId 后再执行,并按运行时要求确认"},
[]string{`dws chat group dismiss --group <openConversationId> --format json`},
},
"chat group quit": {
"退出群缺少目标群 ID", "quit 表示当前用户退出群聊,不会解散整个群;--group 必须是 openConversationId",
[]string{"确认你要退出而不是解散群", "先获取目标群 openConversationId"},
[]string{`dws chat group quit --group <openConversationId> --format json`},
},
"chat group set-admin": {
"设置群管理员参数不完整", "需要目标群以及一个或多个成员;默认设为管理员,--off 表示取消管理员",
[]string{"补充 --group", "通过 --user 或 --users 指定成员,取消管理员时增加 --off"},
[]string{`dws chat group set-admin --group <openConversationId> --users <userId1>,<userId2> --format json`},
},
"chat group transfer-owner": {
"转让群主参数不完整", "--group 指定群,--new-owner 使用 openDingTalkId,--user 使用 userId",
[]string{"补充群 openConversationId", "在 --new-owner 和 --user 中选择一个新群主标识"},
[]string{`dws chat group transfer-owner --group <openConversationId> --new-owner <openDingTalkId> --format json`},
},
"chat group update-nick": {
"修改本人群昵称参数不完整", "update-nick 只修改当前登录用户在指定群里的昵称,需要群 ID 和新昵称",
[]string{"补充 --group openConversationId", "补充新的昵称参数"},
[]string{`dws chat group update-nick --group <openConversationId> --nick "新昵称" --format json`},
},
"chat group update-icon": {
"更新群头像参数不完整", "需要群 openConversationId 和上游已经获得的有效图片 mediaId",
[]string{"补充 --group", "从上游媒体能力获取 mediaId 后传入 --icon-media-id"},
[]string{`dws chat group update-icon --group <openConversationId> --icon-media-id <mediaId> --format json`},
},
"chat group share-invite": {
"分享群邀请参数不完整", "--source 是被分享群,--target 是接收分享的会话,--receiver 是接收分享的单聊用户",
[]string{"补充 --source", "在 --target 和 --receiver 中选择一个接收目标"},
[]string{`dws chat group share-invite --source <源群ID> --target <目标会话ID> --format json`},
},
"chat message reply": {
"引用回复参数不完整", "会话 ID、原消息 ID、原发送者和回复正文必须来自或对应同一条原消息",
[]string{"先拉取目标消息", "补齐 conversation-id、ref-msg-id、ref-sender 和 text"},
[]string{`dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到" --format json`},
},
"chat message forward": {
"转发消息参数不完整", "消息 ID 必须属于源会话,并需要明确源会话和目标会话",
[]string{"先从源会话拉取真实消息 ID", "确认 src 和 dest 没有写反"},
[]string{`dws chat message forward --src-conversation-id <源会话ID> --msg-id <openMessageId> --dest-conversation-id <目标会话ID> --format json`},
},
"chat message recall": {
"撤回消息参数不完整", "用户消息撤回需要会话 ID 和本人发送的消息 ID;机器人消息应使用 recall-by-bot",
[]string{"确认消息由当前用户发送", "补齐 conversation-id 和 msg-id"},
[]string{`dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId> --format json`},
},
"chat message read-status": {
"查询消息已读状态参数不完整", "只能查询当前用户发出消息的已读状态,需要会话和消息标识",
[]string{"补齐会话和消息 ID", "人员筛选时区分 userId 与 openDingTalkId"},
[]string{`dws chat message read-status --conversation-id <openConversationId> --message-id <openMessageId> --format json`},
},
"chat message list-by-ids": {
"缺少消息 ID 列表", "--msg-ids 使用逗号分隔的真实 openMessageId,单次最多 50 条",
[]string{"先拉取真实消息 ID", "将不超过 50 条 ID 用逗号连接"},
[]string{`dws chat message list-by-ids --msg-ids <id1>,<id2> --format json`},
},
"chat message download-media": {
"媒体下载参数不完整", "type、resource-id、message-id、open-conversation-id 和 output 必须完整,且资源与消息来自同一条消息",
[]string{"先拉取目标媒体消息", "从同一条消息取得资源、消息和会话标识"},
[]string{`dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/ --format json`},
},
"chat message add-emoji": {
"添加表情回应参数不完整", "需要真实会话 ID、消息 ID 和 emoji 名称",
[]string{"先拉取目标消息", "补齐 conversation-id、msg-id 和 emoji"},
[]string{`dws chat message add-emoji --conversation-id <openConversationId> --msg-id <openMessageId> --emoji "赞" --format json`},
},
"chat message remove-emoji": {
"移除表情回应参数不完整", "只能移除当前用户已添加的同名回应,需要会话、消息和 emoji 名称完全匹配",
[]string{"确认当前用户添加过该回应", "补齐 conversation-id、msg-id 和 emoji"},
[]string{`dws chat message remove-emoji --conversation-id <openConversationId> --msg-id <openMessageId> --emoji "赞" --format json`},
},
"chat message list-by-sender": {
"按发送者查询参数不完整", "必须提供开始时间以及发送者 userId/openDingTalkId 二选一,可选 end 和 cursor",
[]string{"补充 --start", "在 sender-user-id 和 sender-open-dingtalk-id 中选择一个"},
[]string{`dws chat message list-by-sender --sender-user-id <userId> --start "2026-07-14T00:00:00+08:00" --format json`},
},
"chat message query-send-status": {
"缺少发送任务 ID", "--open-task-id 来自 message send 返回的 openTaskId,不是消息 ID",
[]string{"先执行 message send", "从发送结果读取 openTaskId"},
[]string{`dws chat message query-send-status --open-task-id <openTaskId> --format json`},
},
}
func enrichChatWorkbookError(cmd *cobra.Command, err error) error {
if cmd == nil || err == nil {
return err
}
path := cmd.CommandPath()
if fields := strings.Fields(path); len(fields) > 1 {
path = strings.Join(fields[1:], " ")
}
message := err.Error()
var guide chatWorkbookGuidance
switch {
case path == "chat message send" &&
(strings.Contains(message, "unknown flag: --at-user-ids") ||
strings.Contains(message, "unknown flag: --at-users") ||
strings.Contains(message, "unknown flag: --mention")):
guide = chatWorkbookGuidance{
"群消息 @成员参数不正确",
"当前用户身份发送群消息时使用 --at-open-dingtalk-ids,参数值必须是成员的 openDingTalkId;--at-user-ids、--at-users、--mention 均不是有效参数",
[]string{"先查询目标成员的 openDingTalkId", "改用 --at-open-dingtalk-ids,并在正文中写入 <@openDingTalkId>"},
[]string{`dws chat message send --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --text "<@openDingTalkId> 请关注" --format json`},
}
case path == "chat media upload":
guide = chatWorkbookGuidance{
"chat media upload 已下线",
"当前 CLI 不再通过该命令把本地文件转换为 mediaId,本地图片和文件统一由 message send 的 file 路径上传并发送",
[]string{"发送本地图片或文件时使用 --msg-type file --file-path", "只有上游已提供 mediaId 时才使用 --msg-type image --media-id"},
[]string{`dws chat message send --group <openConversationId> --msg-type file --file-path ./image.png --format json`},
}
case path == "chat group members" && strings.Contains(message, "unknown flag: --group"):
guide = chatWorkbookGuidance{
"群成员列表命令路径或群参数不正确",
"群成员列表的可执行命令是 chat group members,群 ID 参数名为 --id;不存在 members list --group 这一组合",
[]string{"移除多余的 list 子命令", "将 --group 改为 --id"},
[]string{`dws chat group members --id <openConversationId> --format json`},
}
case path == "chat group rename" && strings.Contains(message, "unknown flag: --group"):
guide = chatWorkbookGuidance{
"群重命名命令不支持 --group",
"chat group rename 使用 --id 接收群 openConversationId,而不是 --group",
[]string{"将 --group 改为 --id", "群 ID 不确定时先用 chat search 查询"},
[]string{`dws chat group rename --id <openConversationId> --name "新群名" --format json`},
}
case path == "chat group create" && strings.Contains(message, "unknown flag: --members"):
guide = chatWorkbookGuidance{
"建群命令不支持 --members",
"chat group create 使用 --users 接收逗号分隔的成员 userId;--members 是其他命令的参数名",
[]string{"将 --members 改为 --users", "成员标识不确定时先查询 userId"},
[]string{`dws chat group create --name "V2评审小组" --users 489149,550582 --format json`},
}
case path == "chat group bots" && strings.Contains(message, "unknown flag: --id"):
guide = chatWorkbookGuidance{
"群机器人列表命令不支持 --id",
"chat group bots 使用 --group 接收群 openConversationId;该参数名与 members、rename 命令不同",
[]string{"将 --id 改为 --group", "群 ID 不确定时先用 chat search 查询"},
[]string{`dws chat group bots --group <openConversationId> --format json`},
}
case path == "chat message list-mentions" && strings.Contains(message, "required flag"):
guide = chatWorkbookGuidance{
"缺少必填参数:--start、--end",
"查询 @我 消息必须同时提供 ISO-8601 格式的开始和结束时间;只提供分页参数不能确定查询范围",
[]string{"同时补充 --start 和 --end,不要逐个参数反复试错", "按本地时区设置明确的查询时间窗"},
[]string{`dws chat message list-mentions --start "2026-07-23T00:00:00+08:00" --end "2026-07-30T23:59:59+08:00" --limit 50 --format json`},
}
case path == "chat message send" && strings.Contains(message, "--group, --user or --open-dingtalk-id is required"):
guide = chatWorkbookGuidance{
"缺少消息接收目标",
"发送消息必须在 --group、--user、--open-dingtalk-id 中选择且只选择一个接收目标",
[]string{"发群消息时先查询并传入群 openConversationId", "发单聊时先查询并传入 userId 或 openDingTalkId"},
[]string{`dws chat message send --group <openConversationId> --text "评测消息" --format json`, `dws chat message send --open-dingtalk-id <openDingTalkId> --text "评测消息" --format json`},
}
case path == "chat search" && strings.Contains(message, "query"):
guide = chatWorkbookGuidance{
"缺少群聊搜索关键词:--query",
"群聊搜索需要关键词才能定位候选群,不能使用空查询",
[]string{"使用 --query 传入群名称或名称片段", "从结果中读取 openConversationId 供后续群命令使用"},
[]string{`dws chat search --query "项目群" --format json`},
}
case path == "chat message search-advanced":
guide = chatWorkbookGuidance{
"高级消息搜索至少需要一个搜索条件",
"空条件搜索无法限定目标消息,必须提供关键词、人员、@我状态或会话范围中的至少一种",
[]string{"按内容搜索时传入 --query", "也可通过 --user、--at-me 或 --conversation-ids 缩小范围"},
[]string{`dws chat message search-advanced --query "评审" --format json`},
}
case path == "chat message search" && strings.Contains(message, "required flag"):
guide = chatWorkbookGuidance{
"关键词消息搜索缺少完整查询条件",
"关键词消息搜索需要 --query、--start 和 --end;当前命令没有提供完整的关键词和时间范围",
[]string{"补充搜索关键词", "同时提供 ISO-8601 格式的开始和结束时间"},
[]string{`dws chat message search --query "评审" --start "2026-07-23T00:00:00+08:00" --end "2026-07-30T23:59:59+08:00" --format json`},
}
case path == "chat message list-all" && strings.Contains(message, "required flag"):
guide = chatWorkbookGuidance{
"跨会话消息查询缺少时间范围",
"拉取全部会话消息必须使用 --start 和 --end 限定范围,避免无边界查询历史消息",
[]string{"同时补充 --start 和 --end", "结果存在 hasMore 时使用 nextCursor 继续翻页"},
[]string{`dws chat message list-all --start "2026-07-23T00:00:00+08:00" --end "2026-07-30T23:59:59+08:00" --limit 50 --format json`},
}
case path == "chat message list-topic-replies" && strings.Contains(message, "topic-id"):
guide = chatWorkbookGuidance{
"缺少话题定位参数:--topic-id",
"topic-id 不能臆造,必须来自同一群聊消息列表中目标话题消息的 openConvThreadId",
[]string{"先执行 chat message list 拉取目标群消息", "从目标话题消息读取 openConvThreadId 并作为 --topic-id"},
[]string{`dws chat message list --group <openConversationId> --time "2026-07-30 23:59:59" --direction older --format json`, `dws chat message list-topic-replies --group <openConversationId> --topic-id <openConvThreadId> --limit 50 --format json`},
}
case path == "chat message send-by-bot" && strings.Contains(message, "required flag"):
guide = chatWorkbookGuidance{
"机器人发送消息缺少必填参数",
"机器人发送需要 robotCode、标题、正文以及群聊或单聊目标,当前参数不完整",
[]string{"补充 --robot-code 和 --title", "通过 --group 或用户参数指定接收目标"},
[]string{`dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "通知" --text "hello" --format json`},
}
case path == "chat message recall-by-bot" && strings.Contains(message, "required flag"):
guide = chatWorkbookGuidance{
"机器人撤回消息缺少 robotCode 或 processQueryKey",
"--keys 的 processQueryKey 来自机器人发送消息的返回结果,不能凭空构造",
[]string{"补充发送该消息的 --robot-code", "从发送结果读取 processQueryKey 并传给 --keys"},
[]string{`dws chat message recall-by-bot --robot-code <robotCode> --group <openConversationId> --keys <processQueryKey> --format json`},
}
case path == "chat message list" && strings.Contains(message, "required flag") && strings.Contains(message, "--time"):
guide = chatWorkbookGuidance{
"拉取会话消息缺少时间锚点:--time",
"消息列表按时间向前或向后拉取,必须提供一个明确的时间锚点",
[]string{"补充格式为 YYYY-MM-DD HH:mm:ss 的 --time", "使用 --direction older 或 newer 明确查询方向"},
[]string{`dws chat message list --group <openConversationId> --time "2026-07-30 10:00:00" --direction older --format json`},
}
case path == "chat message list" && strings.Contains(message, "--group, --user or --open-dingtalk-id is required"):
guide = chatWorkbookGuidance{
"拉取消息时缺少会话目标",
"必须在群聊 openConversationId、单聊 userId、单聊 openDingTalkId 中选择且只选择一个目标",
[]string{"群聊先用 chat search 获取 openConversationId", "单聊先查询人员标识,再传 --user 或 --open-dingtalk-id"},
[]string{`dws chat message list --group <openConversationId> --time "2026-07-15 10:00:00" --format json`},
}
case path == "chat message send" && strings.Contains(message, "media-id is required"):
guide = chatWorkbookGuidance{
"图片消息缺少 --media-id",
"msg-type=image 只接受上游已经获得的有效 mediaId,不能把本地文件名当作 mediaId",
[]string{"已有 mediaId 时补充 --media-id", "发送本地图片时改用 --msg-type file --file-path"},
[]string{`dws chat message send --group <openConversationId> --msg-type image --media-id <mediaId> --format json`, `dws chat message send --group <openConversationId> --msg-type file --file-path ./image.png --format json`},
}
case path == "chat message send" && strings.Contains(message, "readable local --file-path is required"):
guide = chatWorkbookGuidance{
"文件消息缺少可读的本地文件",
"file、audio、video 消息需要可读的 --file-path;旧版 dentry 参数则必须成组提供",
[]string{"优先传入当前机器上可读的 --file-path", "使用旧参数时同时提供 dentry-id、space-id 和 file-name"},
[]string{`dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json`},
}
case path == "chat message send" && strings.Contains(message, "--file-path must be a readable local file"):
guide = chatWorkbookGuidance{
"--file-path 指向的文件不可读",
"指定路径不存在、不是普通文件或当前进程没有读取权限,因此无法上传并发送",
[]string{"检查路径拼写并确认文件存在", "改用当前用户可读取的绝对路径或工作目录相对路径"},
[]string{`dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json`},
}
case path == "chat message send" && strings.Contains(message, "unsupported --msg-type"):
guide = chatWorkbookGuidance{
"不支持指定的 --msg-type",
"card 不是当前命令支持的消息类型;文本或 Markdown 消息无需传 --msg-type",
[]string{"文本消息移除 --msg-type 并使用 --text", "媒体消息仅使用 image、file、audio、video、location 或 profile"},
[]string{`dws chat message send --group <openConversationId> --text "消息正文" --format json`},
}
case path == "chat message send" && strings.Contains(message, "message content required"):
guide = chatWorkbookGuidance{
"群消息缺少正文内容",
"未提供 --text 或位置参数,同时也没有选择需要专用参数的媒体消息类型",
[]string{"发送文字时补充 --text", "发送文件时使用 --msg-type file --file-path"},
[]string{`dws chat message send --group <openConversationId> --text "消息正文" --format json`},
}
}
if guide.message == "" {
if required, ok := chatRequiredGuidance[path]; ok &&
(strings.Contains(message, "required") || strings.Contains(message, "缺少")) {
guide = required
} else if strings.HasPrefix(path, "chat ") &&
(strings.Contains(message, "required") ||
strings.Contains(message, "invalid") ||
strings.Contains(message, "unsupported") ||
strings.Contains(message, "unknown flag") ||
strings.Contains(message, "must be")) {
example := fmt.Sprintf("dws %s --help", path)
if meta, ok := cli.ResolveMeta(path); ok && len(meta.Selection.Examples) > 0 {
example = meta.Selection.Examples[0]
if !strings.Contains(example, "--format") {
example += " --format json"
}
}
guide = chatWorkbookGuidance{
"Chat 命令参数校验失败",
message,
[]string{"根据错误补齐或修正参数", fmt.Sprintf("运行 dws %s --help 核对当前命令参数", path)},
[]string{example},
}
} else {
return err
}
}
return apperrors.NewValidation(
guide.message,
apperrors.WithReason(guide.reason),
apperrors.WithHint(guide.actions[0]),
apperrors.WithActions(guide.actions...),
apperrors.WithExamples(guide.examples...),
apperrors.WithCause(err),
)
}
// newPreParseValidationError keeps pipeline handler identity in internal logs
// while exposing only the underlying parameter-domain error to CLI users.
func newPreParseValidationError(err error) error {
@@ -679,9 +220,6 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
)
}
if enriched := enrichChatWorkbookError(cmd, err); enriched != err {
return enriched
}
// Common flag aliases and suggestions
suggestions := map[string]string{
-141
View File
@@ -43,153 +43,12 @@ func configureRootHelp(root *cobra.Command) {
if cmd != root {
defaultHelpFunc(cmd, args)
cli.RenderSafetyAnnotation(cmd)
renderChatAgentSelectionHint(cmd)
return
}
renderRootHelp(root)
})
}
type chatHelpGuidance struct {
reason string
action string
example string
}
var chatWorkbookHelpGuidance = map[string]chatHelpGuidance{
"chat group members": {
"群成员列表固定使用 --id 传群 openConversationId,不使用消息命令的 --group。",
"先查群 ID,再直接执行 members;不要追加多余的 list 子命令。",
`dws chat group members --id <openConversationId> --format json`,
},
"chat group members add": {
"添加群成员固定使用 --id 指定群、--users 指定成员。",
"先查询群 ID 和成员 userId/openDingTalkId,再执行添加。",
`dws chat group members add --id <openConversationId> --users <userId1>,<userId2> --format json`,
},
"chat group members remove": {
"移除群成员使用 --id 和 --users,且不能移除群主。",
"先确认成员和不可逆影响,检查群主身份后再执行。",
`dws chat group members remove --id <openConversationId> --users <userId> --format json`,
},
"chat group members add-bot": {
"添加机器人属于群成员管理,群参数沿用 --id,并需要 robot-code。",
"确认机器人编码和目标群后执行。",
`dws chat group members add-bot --id <openConversationId> --robot-code <robotCode> --format json`,
},
"chat group members remove-bot": {
"移除机器人固定使用 --id 指定群、--bot-id 指定群内机器人。",
"先列出群机器人取得 openBotId,再执行移除。",
`dws chat group members remove-bot --id <openConversationId> --bot-id <openBotId> --format json`,
},
"chat group members list-by-ids": {
"批量查询成员详情使用 --id + --users,users 为成员标识列表。",
"确认目标群和成员 ID 后再查询。",
`dws chat group members list-by-ids --id <openConversationId> --users <openDingTalkId1>,<openDingTalkId2> --format json`,
},
"chat group create": {
"建群使用 --users;创建结果中的群 ID 可继续传给 members add 和 rename。",
"先准备成员 userId,创建后保存返回的 openConversationId。",
`dws chat group create --name "项目群" --users <userId1>,<userId2> --format json`,
},
"chat group rename": {
"群改名只使用 --id + --name,不能使用 --group。",
"先通过 chat search 获取 openConversationId。",
`dws chat group rename --id <openConversationId> --name "新群名" --format json`,
},
"chat message list": {
"message list 按会话和时间拉取消息,不执行服务端关键词搜索。",
"按关键词查找时改用 message search;拉历史时提供会话和 time。",
`dws chat message list --group <openConversationId> --time "2026-07-30 23:59:59" --direction older --format json`,
},
"chat message search": {
"关键词审计应使用服务端搜索,并同时提供 query、start、end。",
"不要用 message list 拉全量后人工筛选。",
`dws chat message search --query "评审" --start "2026-07-01T00:00:00+08:00" --end "2026-07-31T23:59:59+08:00" --format json`,
},
"chat message search-advanced": {
"简单关键词优先 message search;只有组合人员、@、会话等条件时才使用 search-advanced。",
"至少提供一个真实搜索条件,分页参数不算搜索条件。",
`dws chat message search-advanced --query "评审" --conversation-ids <openConversationId> --format json`,
},
"chat message list-all": {
"list-all 按时间跨会话拉取消息,不执行关键词匹配。",
"需要关键词时改用 message search,并始终限制时间范围。",
`dws chat message list-all --start "2026-07-01T00:00:00+08:00" --end "2026-07-31T23:59:59+08:00" --format json`,
},
"chat message list-by-sender": {
"list-by-sender 的核心条件是发送者;核心条件是关键词时应使用 message search。",
"提供发送者 ID 和开始时间,按 nextCursor 翻页。",
`dws chat message list-by-sender --sender-user-id <userId> --start "2026-07-01T00:00:00+08:00" --format json`,
},
}
func renderChatWorkbookHelpGuidance(cmd *cobra.Command) {
if cmd == nil {
return
}
path := strings.TrimSpace(strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" "))
guide, ok := chatWorkbookHelpGuidance[path]
if !ok {
meta, metaOK := cli.ResolveMeta(path)
if !metaOK || meta.Identity.ProductID != "chat" {
return
}
reason := meta.Selection.AgentSummary
if reason == "" {
reason = "执行前需要确认该 Chat 命令的适用场景、必填参数和安全边界。"
}
action := "根据帮助正文补齐必填参数,并在实际执行时增加 --format json。"
if len(meta.Selection.UseWhen) > 0 {
action = meta.Selection.UseWhen[0]
}
example := "dws " + path + " --format json"
if len(meta.Selection.Examples) > 0 {
example = meta.Selection.Examples[0]
if !strings.Contains(example, "--format") {
example += " --format json"
}
}
guide = chatHelpGuidance{reason: reason, action: action, example: example}
}
w := cmd.ErrOrStderr()
_, _ = fmt.Fprintln(w, "错误信息:当前为执行前 guidance,不是运行失败")
_, _ = fmt.Fprintln(w, "原因:"+guide.reason)
_, _ = fmt.Fprintln(w, "建议操作:")
_, _ = fmt.Fprintln(w, "1. "+guide.action)
_, _ = fmt.Fprintln(w, "示例:")
_, _ = fmt.Fprintln(w, "1. "+guide.example)
}
// renderChatAgentSelectionHint exposes the reviewed Chat selection contract in
// command help without reintroducing a second product-local guidance map.
// Selection prose remains authored in schema_hints/selection/chat.json and is
// consumed through the repository-wide ResolveMeta API.
func renderChatAgentSelectionHint(cmd *cobra.Command) {
cliPath := strings.TrimSpace(strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" "))
meta, ok := cli.ResolveMeta(cliPath)
if !ok || meta.Identity.ProductID != "chat" {
return
}
selection := meta.Selection
w := cmd.OutOrStdout()
_, _ = fmt.Fprintln(w, "Agent guidance:")
if selection.AgentSummary != "" {
_, _ = fmt.Fprintf(w, " Outcome: %s\n", selection.AgentSummary)
}
for _, scenario := range selection.UseWhen {
_, _ = fmt.Fprintf(w, " Use when: %s\n", scenario)
}
for _, scenario := range selection.AvoidWhen {
_, _ = fmt.Fprintf(w, " Avoid when: %s\n", scenario)
}
for _, example := range selection.Examples {
_, _ = fmt.Fprintf(w, " Example: %s\n", example)
}
_, _ = fmt.Fprintln(w, " Output: Agent execution should add --format json.")
}
func renderRootHelp(root *cobra.Command) {
services := visibleMCPRootCommands(root)
utilities := visibleUtilityRootCommands(root)
-21
View File
@@ -69,27 +69,6 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
}
}
func TestChatAgentGuidanceRendersOnlyOnStdout(t *testing.T) {
cmd := NewRootCommand()
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"chat", "clear-messages", "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat clear-messages --help: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
}
for _, want := range []string{"Agent guidance:", "Outcome:", "Use when:", "Avoid when:", "Example:", "Output:"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("chat help stdout missing %q:\n%s", want, stdout.String())
}
}
if got := strings.TrimSpace(stderr.String()); got != "" {
t.Fatalf("chat help wrote guidance or warnings to stderr:\n%s", got)
}
}
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
+2 -2
View File
@@ -742,7 +742,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint(apperrors.SuggestBusinessHint(callResult.Content)),
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
apperrors.WithServerDiag(diag),
)
// PAT scope error in business response: offer human-readable output and retry
@@ -767,7 +767,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("business_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint(apperrors.SuggestBusinessHint(callResult.Content)),
apperrors.WithHint("The API returned a business-level error. Check required parameters and values."),
apperrors.WithServerDiag(diag),
)
}
@@ -0,0 +1,76 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"strings"
"testing"
)
func TestOAApprovalDualModeConstraintsReachEmbeddedSchema(t *testing.T) {
tools := embeddedSchemaAllToolsForHelpFlagTest(t, NewRootCommand())
tests := []struct {
canonical string
optional []string
requireTogether []string
mutuallyExclusive []string
}{
{
canonical: "oa.forecast_process",
optional: []string{"request", "process-code", "dept-id", "form-values"},
requireTogether: []string{"process-code", "dept-id", "form-values"},
mutuallyExclusive: []string{"process-code", "dept-id", "form-values"},
},
{
canonical: "oa.start_process_instance",
optional: []string{"request", "process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"},
requireTogether: []string{"process-code", "form-values"},
mutuallyExclusive: []string{"process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"},
},
}
for _, test := range tests {
t.Run(test.canonical, func(t *testing.T) {
tool := tools[test.canonical]
parameters := schemaContractMap(tool["parameters"])
for _, name := range test.optional {
if got := parameters[name]["required"]; got != false {
t.Errorf("--%s required = %#v, want false for dual-mode command", name, got)
}
}
assertSchemaContractConstraintGroup(t, tool, "require_one_of", []string{"request", "process-code"})
assertSchemaContractConstraintGroup(t, tool, "require_together", test.requireTogether)
for _, name := range test.mutuallyExclusive {
assertSchemaContractConstraintGroup(t, tool, "mutually_exclusive", []string{"request", name})
}
constraints, _ := tool["constraints"].(map[string]any)
groups, _ := constraints["mutually_exclusive"].([]any)
if len(groups) != len(test.mutuallyExclusive) {
t.Errorf("mutually_exclusive group count = %d, want %d: %#v", len(groups), len(test.mutuallyExclusive), groups)
}
for _, rawGroup := range groups {
group, _ := rawGroup.([]any)
if len(group) != 2 {
t.Errorf("mutually_exclusive contains an over-broad group: %#v", group)
}
}
hasRequestOnlyExample := false
for _, example := range schemaContractStringSlice(tool["examples"]) {
if strings.Contains(example, " --request ") && !strings.Contains(example, " --process-code ") && !strings.Contains(example, " --form-values ") {
hasRequestOnlyExample = true
}
}
if !hasRequestOnlyExample {
t.Errorf("examples do not contain a request-only invocation: %#v", tool["examples"])
}
})
}
create := tools["oa.start_process_instance"]
if got := schemaContractString(create["confirmation"]); got != "user_required" {
t.Errorf("create-instance confirmation = %q, want user_required", got)
}
}
@@ -106,7 +106,7 @@ func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 159; got != want {
if got, want := int(product["count"].(float64)), 129; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
+19 -3
View File
@@ -543,6 +543,13 @@ func TestIsLikelyAMFIKill(t *testing.T) {
// validateNewBinary recovers via repairDarwinBinary (ad-hoc codesign) and
// successfully re-executes the binary. We use go itself as a stand-in for the
// new dws binary — it's a real signed Mach-O we can strip and re-sign.
//
// GitHub's hosted macOS runners do not reproduce the amfid kill, so in CI this
// test reports a skip that names the unverified path rather than implying the
// self-heal was exercised. Set DWS_REQUIRE_AMFI_SELF_HEAL=1 on a host that does
// enforce amfid to turn such a vacuous run into a hard failure.
const requireAMFISelfHealEnv = "DWS_REQUIRE_AMFI_SELF_HEAL"
func TestValidateNewBinary_RecoversFromUnsignedDarwin(t *testing.T) {
if runtime.GOOS != "darwin" {
@@ -573,15 +580,24 @@ func TestValidateNewBinary_RecoversFromUnsignedDarwin(t *testing.T) {
t.Fatalf("strip signature: %v\n%s", err, out)
}
// Sanity: confirm direct exec is killed.
// Sanity: confirm direct exec is killed. When it is not, repairDarwinBinary
// never runs and the rest of this test proves nothing — say so.
if _, err := tryExecVersion(bin); err == nil {
t.Skip("unsigned binary executed without amfid kill — likely Intel Mac or SIP disabled")
const unverified = "amfid did not kill the unsigned binary, so repairDarwinBinary was NOT exercised"
if os.Getenv(requireAMFISelfHealEnv) == "1" {
t.Fatalf("%s (%s=1)", unverified, requireAMFISelfHealEnv)
}
t.Skipf("%s — host does not enforce amfid (Intel Mac, SIP disabled, or hosted runner)", unverified)
}
// validateNewBinary should self-heal and succeed.
if err := validateNewBinary(bin, "dev"); err != nil {
if strings.Contains(err.Error(), "signal: killed") {
t.Skipf("host security policy still rejects the ad-hoc signed test binary: %v", err)
const unverified = "host security policy still rejects the ad-hoc signed binary, so the self-heal outcome was NOT verified"
if os.Getenv(requireAMFISelfHealEnv) == "1" {
t.Fatalf("%s (%s=1): %v", unverified, requireAMFISelfHealEnv, err)
}
t.Skipf("%s: %v", unverified, err)
}
t.Fatalf("validateNewBinary did not recover: %v", err)
}
+15 -5
View File
@@ -131,9 +131,12 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat +chat-bots",
Aliases: map[string]string{
"chat": "group",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat +chat-dismiss",
@@ -148,9 +151,12 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat +chat-invite-url",
Aliases: map[string]string{
"chat": "group",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat +chat-mute",
@@ -307,7 +313,11 @@ var generatedParamAliases = []ParamAliasEntry{
},
{
CLIPath: "chat +messages-mget",
Blocked: []string{"msg-id", "open-message-id", "ref-msg-id", "src-msg-id"},
Aliases: map[string]string{
"message-ids": "msg-ids",
"open-message-ids": "msg-ids",
},
Blocked: []string{"message-id", "msg-id", "open-message-id", "ref-msg-id", "src-msg-id"},
},
{
CLIPath: "chat +messages-read-status",
@@ -37075,6 +37075,265 @@
"用户明确要求停止某自动化工作流时"
]
},
"aitable workflow edit-example": {
"agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"agent_summary_source": "dws-agent-selection/aitable",
"availability": "available",
"avoid_when": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws aitable workflow edit-example"
],
"field_provenance": {
"agent_summary": {
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"candidates": [
{
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"avoid_when": {
"value": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"candidates": [
{
"value": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"effect": {
"value": "read",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "read",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"examples": {
"value": [
"dws aitable workflow edit-example"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"candidates": [
{
"value": [
"dws aitable workflow edit-example"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
}
]
},
"idempotency": {
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"interface_mode": {
"value": "composite",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "composite",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"interface_reason": {
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
}
]
},
"risk": {
"value": "low",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "low",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"use_when": {
"value": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"candidates": [
{
"value": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
}
]
}
},
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"risk": "low",
"source_refs": [
"cobra-help:dws aitable workflow edit-example --help",
"internal/cli/schema_command_registry.json#aitable.workflow_edit_example",
"internal/cli/schema_hints/metadata/aitable.json",
"internal/cli/schema_hints/selection/aitable.json",
"mcp-contract:aitable/edit_workflow_example",
"skills/mono/references/products/aitable/aitable-workflow.md"
],
"use_when": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
]
},
"aitable workflow enable": {
"agent_summary": "启用工作流。",
"agent_summary_source": "dws-agent-selection/aitable",
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -1,18 +1,18 @@
{
"version": 1,
"source_hash": "sha256:7111ced2e1cd0764338aacce792d95603734177a82deea4a4642626691940485",
"surface_hash": "sha256:4897d582a472d9d3596f88fb4546e7795d6b9bc334d020712f5d895ce894f7a7",
"source_hash": "sha256:9a6bc544f78424a85d7465cbb8ce20ee80c954de7ec3f7657eb42b05679d663d",
"surface_hash": "sha256:beeddac7cd934e409e47e5b4552dbc188840ad46205d8d48340569a97c51b59b",
"coverage": {
"surface_products": 26,
"products_with_metadata": 26,
"surface_tools": 875,
"tools_with_metadata": 875,
"tools_with_agent_summary": 875,
"tools_with_use_when": 875,
"tools_with_avoid_when": 875,
"tools_with_examples": 875,
"tools_with_interface_mode": 875,
"unmatched_skill_tools": 96,
"surface_tools": 850,
"tools_with_metadata": 850,
"tools_with_agent_summary": 850,
"tools_with_use_when": 850,
"tools_with_avoid_when": 850,
"tools_with_examples": 850,
"tools_with_interface_mode": 850,
"unmatched_skill_tools": 122,
"unreviewed_skill_tools": 11
},
"products": {
+801
View File
@@ -2077,6 +2077,290 @@
"已知 processInstanceId 与待办 taskId,用户明确要求同意该审批任务时"
]
},
"oa approval create-instance": {
"agent_summary": "发起新的审批实例",
"agent_summary_source": "dws-agent-selection/oa",
"availability": "available",
"avoid_when": [
"只需预测流程时使用 forecast-process;用户尚未确认或字段未按 Schema 核对时不要发起"
],
"confirmation": "user_required",
"effect": "write",
"effect_source": "agent-hint",
"examples": [
"dws oa approval create-instance --process-code \u003cprocessCode\u003e --form-values '{\"事由\":\"测试\"}'",
"dws oa approval create-instance --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[{\"name\":\"事由\",\"value\":\"测试\"}],\"targetSelectActioners\":[{\"actionerKey\":\"manual-node\",\"actionerStaffIds\":[\"user-id\"]}]}'"
],
"field_provenance": {
"agent_summary": {
"value": "发起新的审批实例",
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;示例覆盖简单与高级请求契约,真实执行仍须在用户确认后显式添加 --yes。",
"candidates": [
{
"value": "发起新的审批实例",
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;示例覆盖简单与高级请求契约,真实执行仍须在用户确认后显式添加 --yes。"
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;命令在调用前强制显式 --yes,未提供时直接拒绝,不进入交互确认。",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;命令在调用前强制显式 --yes,未提供时直接拒绝,不进入交互确认。"
},
{
"value": "available",
"source": "internal/cli/schema_mcp_metadata.json#tools.oa.start_process_instance.interface_ref",
"precedence": "mcp_fallback",
"selected": false
}
]
},
"avoid_when": {
"value": [
"只需预测流程时使用 forecast-process;用户尚未确认或字段未按 Schema 核对时不要发起"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;示例覆盖简单与高级请求契约,真实执行仍须在用户确认后显式添加 --yes。",
"candidates": [
{
"value": [
"只需预测流程时使用 forecast-process;用户尚未确认或字段未按 Schema 核对时不要发起"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;示例覆盖简单与高级请求契约,真实执行仍须在用户确认后显式添加 --yes。"
}
]
},
"confirmation": {
"value": "user_required",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;命令在调用前强制显式 --yes,未提供时直接拒绝,不进入交互确认。",
"candidates": [
{
"value": "user_required",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;命令在调用前强制显式 --yes,未提供时直接拒绝,不进入交互确认。"
},
{
"value": "not_required",
"source": "risk-default",
"precedence": "inference_or_default",
"selected": false
}
]
},
"effect": {
"value": "write",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;命令在调用前强制显式 --yes,未提供时直接拒绝,不进入交互确认。",
"candidates": [
{
"value": "write",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;命令在调用前强制显式 --yes,未提供时直接拒绝,不进入交互确认。"
},
{
"value": "write",
"source": "command-verb",
"precedence": "inference_or_default",
"selected": false
}
]
},
"examples": {
"value": [
"dws oa approval create-instance --process-code \u003cprocessCode\u003e --form-values '{\"事由\":\"测试\"}'",
"dws oa approval create-instance --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[{\"name\":\"事由\",\"value\":\"测试\"}],\"targetSelectActioners\":[{\"actionerKey\":\"manual-node\",\"actionerStaffIds\":[\"user-id\"]}]}'"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;示例覆盖简单与高级请求契约,真实执行仍须在用户确认后显式添加 --yes。",
"candidates": [
{
"value": [
"dws oa approval create-instance --process-code \u003cprocessCode\u003e --form-values '{\"事由\":\"测试\"}'",
"dws oa approval create-instance --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[{\"name\":\"事由\",\"value\":\"测试\"}],\"targetSelectActioners\":[{\"actionerKey\":\"manual-node\",\"actionerStaffIds\":[\"user-id\"]}]}'"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;示例覆盖简单与高级请求契约,真实执行仍须在用户确认后显式添加 --yes。"
}
]
},
"idempotency": {
"value": "non_idempotent",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;命令在调用前强制显式 --yes,未提供时直接拒绝,不进入交互确认。",
"candidates": [
{
"value": "non_idempotent",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;命令在调用前强制显式 --yes,未提供时直接拒绝,不进入交互确认。"
},
{
"value": "unknown",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": false
}
]
},
"interface_mode": {
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;命令在调用前强制显式 --yes,未提供时直接拒绝,不进入交互确认。",
"candidates": [
{
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;命令在调用前强制显式 --yes,未提供时直接拒绝,不进入交互确认。"
},
{
"value": "mcp",
"source": "internal/cli/schema_mcp_metadata.json#tools.oa.start_process_instance.interface_ref",
"precedence": "mcp_fallback",
"selected": false
}
]
},
"interface_ref": {
"value": "oa.start_process_instance",
"source": "internal/cli/schema_mcp_metadata.json#tools.oa.start_process_instance.interface_ref",
"precedence": "mcp_fallback",
"resolution": "highest_precedence",
"candidates": [
{
"value": "oa.start_process_instance",
"source": "internal/cli/schema_mcp_metadata.json#tools.oa.start_process_instance.interface_ref",
"precedence": "mcp_fallback",
"selected": true
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;命令在调用前强制显式 --yes,未提供时直接拒绝,不进入交互确认。",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;命令在调用前强制显式 --yes,未提供时直接拒绝,不进入交互确认。"
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;示例覆盖简单与高级请求契约,真实执行仍须在用户确认后显式添加 --yes。"
}
]
},
"risk": {
"value": "high",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;命令在调用前强制显式 --yes,未提供时直接拒绝,不进入交互确认。",
"candidates": [
{
"value": "high",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;命令在调用前强制显式 --yes,未提供时直接拒绝,不进入交互确认。"
},
{
"value": "medium",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": false
}
]
},
"use_when": {
"value": [
"用户确认要发起审批,且已查询表单 Schema、核对字段及审批路径后使用"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;示例覆盖简单与高级请求契约,真实执行仍须在用户确认后显式添加 --yes。",
"candidates": [
{
"value": [
"用户确认要发起审批,且已查询表单 Schema、核对字段及审批路径后使用"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;示例覆盖简单与高级请求契约,真实执行仍须在用户确认后显式添加 --yes。"
}
]
}
},
"idempotency": "non_idempotent",
"interface_mode": "mcp",
"interface_ref": {
"product_id": "oa",
"rpc_name": "start_process_instance"
},
"reviewed": true,
"risk": "high",
"source_refs": [
"CommandRegistry:canonical_path=oa.start_process_instance",
"cobra-help:dws oa approval create-instance",
"internal/cli/schema_hints/metadata/oa.json",
"internal/cli/schema_hints/selection/oa.json",
"internal/cli/schema_mcp_metadata.json#tools.oa.start_process_instance",
"live-mcp-tools-list:oa.start_process_instance",
"skills/mono/references/products/oa.md"
],
"use_when": [
"用户确认要发起审批,且已查询表单 Schema、核对字段及审批路径后使用"
]
},
"oa approval detail": {
"agent_summary": "获取指定审批实例的详情信息",
"agent_summary_source": "dws-agent-selection/oa",
@@ -2343,6 +2627,523 @@
"已知 processInstanceId,需要查看表单内容与当前状态详情时"
]
},
"oa approval forecast-process": {
"agent_summary": "预测审批流程与自选审批节点",
"agent_summary_source": "dws-agent-selection/oa",
"availability": "available",
"avoid_when": [
"需要真正创建审批单时改用 create-instance;未获得字段定义时先用 form-schema"
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws oa approval forecast-process --process-code \u003cprocessCode\u003e --dept-id -1 --form-values '{\"金额\":\"100\"}'",
"dws oa approval forecast-process --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[[{\"name\":\"金额\",\"value\":\"100\"}]]}'"
],
"field_provenance": {
"agent_summary": {
"value": "预测审批流程与自选审批节点",
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process;示例覆盖 Cobra 简单模式与完整请求高级模式。",
"candidates": [
{
"value": "预测审批流程与自选审批节点",
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process;示例覆盖 Cobra 简单模式与完整请求高级模式。"
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process,只预测流程而不创建审批实例。",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process,只预测流程而不创建审批实例。"
},
{
"value": "available",
"source": "internal/cli/schema_mcp_metadata.json#tools.oa.forecast_process.interface_ref",
"precedence": "mcp_fallback",
"selected": false
}
]
},
"avoid_when": {
"value": [
"需要真正创建审批单时改用 create-instance;未获得字段定义时先用 form-schema"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process;示例覆盖 Cobra 简单模式与完整请求高级模式。",
"candidates": [
{
"value": [
"需要真正创建审批单时改用 create-instance;未获得字段定义时先用 form-schema"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process;示例覆盖 Cobra 简单模式与完整请求高级模式。"
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process,只预测流程而不创建审批实例。",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process,只预测流程而不创建审批实例。"
}
]
},
"effect": {
"value": "read",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process,只预测流程而不创建审批实例。",
"candidates": [
{
"value": "read",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process,只预测流程而不创建审批实例。"
}
]
},
"examples": {
"value": [
"dws oa approval forecast-process --process-code \u003cprocessCode\u003e --dept-id -1 --form-values '{\"金额\":\"100\"}'",
"dws oa approval forecast-process --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[[{\"name\":\"金额\",\"value\":\"100\"}]]}'"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process;示例覆盖 Cobra 简单模式与完整请求高级模式。",
"candidates": [
{
"value": [
"dws oa approval forecast-process --process-code \u003cprocessCode\u003e --dept-id -1 --form-values '{\"金额\":\"100\"}'",
"dws oa approval forecast-process --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[[{\"name\":\"金额\",\"value\":\"100\"}]]}'"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process;示例覆盖 Cobra 简单模式与完整请求高级模式。"
}
]
},
"idempotency": {
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process,只预测流程而不创建审批实例。",
"candidates": [
{
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process,只预测流程而不创建审批实例。"
}
]
},
"interface_mode": {
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process,只预测流程而不创建审批实例。",
"candidates": [
{
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process,只预测流程而不创建审批实例。"
},
{
"value": "mcp",
"source": "internal/cli/schema_mcp_metadata.json#tools.oa.forecast_process.interface_ref",
"precedence": "mcp_fallback",
"selected": false
}
]
},
"interface_ref": {
"value": "oa.forecast_process",
"source": "internal/cli/schema_mcp_metadata.json#tools.oa.forecast_process.interface_ref",
"precedence": "mcp_fallback",
"resolution": "highest_precedence",
"candidates": [
{
"value": "oa.forecast_process",
"source": "internal/cli/schema_mcp_metadata.json#tools.oa.forecast_process.interface_ref",
"precedence": "mcp_fallback",
"selected": true
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process,只预测流程而不创建审批实例。",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process,只预测流程而不创建审批实例。"
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process;示例覆盖 Cobra 简单模式与完整请求高级模式。"
}
]
},
"risk": {
"value": "low",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process,只预测流程而不创建审批实例。",
"candidates": [
{
"value": "low",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process,只预测流程而不创建审批实例。"
}
]
},
"use_when": {
"value": [
"已知道 processCode 且已根据表单 Schema 组装字段,需要在发起前确认审批路径或自选节点时"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process;示例覆盖 Cobra 简单模式与完整请求高级模式。",
"candidates": [
{
"value": [
"已知道 processCode 且已根据表单 Schema 组装字段,需要在发起前确认审批路径或自选节点时"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process;示例覆盖 Cobra 简单模式与完整请求高级模式。"
}
]
}
},
"idempotency": "idempotent",
"interface_mode": "mcp",
"interface_ref": {
"product_id": "oa",
"rpc_name": "forecast_process"
},
"reviewed": true,
"risk": "low",
"source_refs": [
"CommandRegistry:canonical_path=oa.forecast_process",
"cobra-help:dws oa approval forecast-process",
"internal/cli/schema_hints/metadata/oa.json",
"internal/cli/schema_hints/selection/oa.json",
"internal/cli/schema_mcp_metadata.json#tools.oa.forecast_process",
"live-mcp-tools-list:oa.forecast_process",
"skills/mono/references/products/oa.md"
],
"use_when": [
"已知道 processCode 且已根据表单 Schema 组装字段,需要在发起前确认审批路径或自选节点时"
]
},
"oa approval form-schema": {
"agent_summary": "查询审批模板的表单 Schema",
"agent_summary_source": "dws-agent-selection/oa",
"availability": "available",
"avoid_when": [
"只需列出可用模板时使用 list-forms;不要把返回的 Schema 当作可直接提交的实例请求"
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws oa approval form-schema --process-code \u003cprocessCode\u003e"
],
"field_provenance": {
"agent_summary": {
"value": "查询审批模板的表单 Schema",
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,按 GitHub OA 命令树手写选型。",
"candidates": [
{
"value": "查询审批模板的表单 Schema",
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,按 GitHub OA 命令树手写选型。"
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,可按 processCode 只读查询表单结构。",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,可按 processCode 只读查询表单结构。"
},
{
"value": "available",
"source": "internal/cli/schema_mcp_metadata.json#tools.oa.get_process_schema.interface_ref",
"precedence": "mcp_fallback",
"selected": false
}
]
},
"avoid_when": {
"value": [
"只需列出可用模板时使用 list-forms;不要把返回的 Schema 当作可直接提交的实例请求"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,按 GitHub OA 命令树手写选型。",
"candidates": [
{
"value": [
"只需列出可用模板时使用 list-forms;不要把返回的 Schema 当作可直接提交的实例请求"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,按 GitHub OA 命令树手写选型。"
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,可按 processCode 只读查询表单结构。",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,可按 processCode 只读查询表单结构。"
}
]
},
"effect": {
"value": "read",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,可按 processCode 只读查询表单结构。",
"candidates": [
{
"value": "read",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,可按 processCode 只读查询表单结构。"
}
]
},
"examples": {
"value": [
"dws oa approval form-schema --process-code \u003cprocessCode\u003e"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,按 GitHub OA 命令树手写选型。",
"candidates": [
{
"value": [
"dws oa approval form-schema --process-code \u003cprocessCode\u003e"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,按 GitHub OA 命令树手写选型。"
}
]
},
"idempotency": {
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,可按 processCode 只读查询表单结构。",
"candidates": [
{
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,可按 processCode 只读查询表单结构。"
}
]
},
"interface_mode": {
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,可按 processCode 只读查询表单结构。",
"candidates": [
{
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,可按 processCode 只读查询表单结构。"
},
{
"value": "mcp",
"source": "internal/cli/schema_mcp_metadata.json#tools.oa.get_process_schema.interface_ref",
"precedence": "mcp_fallback",
"selected": false
}
]
},
"interface_ref": {
"value": "oa.get_process_schema",
"source": "internal/cli/schema_mcp_metadata.json#tools.oa.get_process_schema.interface_ref",
"precedence": "mcp_fallback",
"resolution": "highest_precedence",
"candidates": [
{
"value": "oa.get_process_schema",
"source": "internal/cli/schema_mcp_metadata.json#tools.oa.get_process_schema.interface_ref",
"precedence": "mcp_fallback",
"selected": true
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,可按 processCode 只读查询表单结构。",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,可按 processCode 只读查询表单结构。"
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,按 GitHub OA 命令树手写选型。"
}
]
},
"risk": {
"value": "low",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,可按 processCode 只读查询表单结构。",
"candidates": [
{
"value": "low",
"source": "internal/cli/schema_hints/metadata/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,可按 processCode 只读查询表单结构。"
}
]
},
"use_when": {
"value": [
"已从 list-forms 或 search-forms 获得 processCode,需要读取字段、选项和必填规则后再填写审批时"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,按 GitHub OA 命令树手写选型。",
"candidates": [
{
"value": [
"已从 list-forms 或 search-forms 获得 processCode,需要读取字段、选项和必填规则后再填写审批时"
],
"source": "internal/cli/schema_hints/selection/oa.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,按 GitHub OA 命令树手写选型。"
}
]
}
},
"idempotency": "idempotent",
"interface_mode": "mcp",
"interface_ref": {
"product_id": "oa",
"rpc_name": "get_process_schema"
},
"reviewed": true,
"risk": "low",
"source_refs": [
"CommandRegistry:canonical_path=oa.get_process_schema",
"cobra-help:dws oa approval form-schema",
"internal/cli/schema_hints/metadata/oa.json",
"internal/cli/schema_hints/selection/oa.json",
"internal/cli/schema_mcp_metadata.json#tools.oa.get_process_schema",
"live-mcp-tools-list:oa.get_process_schema",
"skills/mono/references/products/oa.md"
],
"use_when": [
"已从 list-forms 或 search-forms 获得 processCode,需要读取字段、选项和必填规则后再填写审批时"
]
},
"oa approval list-cc": {
"agent_summary": "获取抄送用户的列表",
"agent_summary_source": "dws-agent-selection/oa",
@@ -259,6 +259,265 @@
"当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。"
]
},
"wiki feed list": {
"agent_summary": "查询知识库的活动动态:谁在什么时间更新/上传/评论了哪些文档",
"agent_summary_source": "dws-agent-selection/wiki",
"availability": "available",
"avoid_when": [
"要看知识库当前有哪些节点用 node list;库内按关键词找文档用 node search",
"要读某篇文档正文用 doc read;跨库全局找文件用 drive search"
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "command-verb",
"examples": [
"dws wiki feed list --workspace \u003cworkspaceId\u003e --format json",
"dws wiki feed list --workspace \u003cworkspaceId\u003e --limit 10 --format json"
],
"field_provenance": {
"agent_summary": {
"value": "查询知识库的活动动态:谁在什么时间更新/上传/评论了哪些文档",
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。",
"candidates": [
{
"value": "查询知识库的活动动态:谁在什么时间更新/上传/评论了哪些文档",
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。"
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Live wiki MCP tools/list exposes list_workspace_feeds as a read-only knowledge base activity query. The reviewed CLI leaf forwards workspaceId/maxResults/nextToken/excludeFile to the native wiki server and adds no side effects, so it keeps the same read-only posture as the sibling wiki list commands.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Live wiki MCP tools/list exposes list_workspace_feeds as a read-only knowledge base activity query. The reviewed CLI leaf forwards workspaceId/maxResults/nextToken/excludeFile to the native wiki server and adds no side effects, so it keeps the same read-only posture as the sibling wiki list commands."
},
{
"value": "available",
"source": "internal/cli/schema_mcp_metadata.json#tools.wiki.list_workspace_feeds.interface_ref",
"precedence": "mcp_fallback",
"selected": false
}
]
},
"avoid_when": {
"value": [
"要看知识库当前有哪些节点用 node list;库内按关键词找文档用 node search",
"要读某篇文档正文用 doc read;跨库全局找文件用 drive search"
],
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。",
"candidates": [
{
"value": [
"要看知识库当前有哪些节点用 node list;库内按关键词找文档用 node search",
"要读某篇文档正文用 doc read;跨库全局找文件用 drive search"
],
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。"
}
]
},
"confirmation": {
"value": "not_required",
"source": "risk-default",
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"candidates": [
{
"value": "not_required",
"source": "risk-default",
"precedence": "inference_or_default",
"selected": true
}
]
},
"effect": {
"value": "read",
"source": "command-verb",
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"candidates": [
{
"value": "read",
"source": "command-verb",
"precedence": "inference_or_default",
"selected": true
}
]
},
"examples": {
"value": [
"dws wiki feed list --workspace \u003cworkspaceId\u003e --format json",
"dws wiki feed list --workspace \u003cworkspaceId\u003e --limit 10 --format json"
],
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。",
"candidates": [
{
"value": [
"dws wiki feed list --workspace \u003cworkspaceId\u003e --format json",
"dws wiki feed list --workspace \u003cworkspaceId\u003e --limit 10 --format json"
],
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。"
}
]
},
"idempotency": {
"value": "idempotent",
"source": "effect-default",
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"candidates": [
{
"value": "idempotent",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": true
}
]
},
"interface_mode": {
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Live wiki MCP tools/list exposes list_workspace_feeds as a read-only knowledge base activity query. The reviewed CLI leaf forwards workspaceId/maxResults/nextToken/excludeFile to the native wiki server and adds no side effects, so it keeps the same read-only posture as the sibling wiki list commands.",
"candidates": [
{
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Live wiki MCP tools/list exposes list_workspace_feeds as a read-only knowledge base activity query. The reviewed CLI leaf forwards workspaceId/maxResults/nextToken/excludeFile to the native wiki server and adds no side effects, so it keeps the same read-only posture as the sibling wiki list commands."
},
{
"value": "mcp",
"source": "internal/cli/schema_mcp_metadata.json#tools.wiki.list_workspace_feeds.interface_ref",
"precedence": "mcp_fallback",
"selected": false
}
]
},
"interface_ref": {
"value": "wiki.list_workspace_feeds",
"source": "internal/cli/schema_mcp_metadata.json#tools.wiki.list_workspace_feeds.interface_ref",
"precedence": "mcp_fallback",
"resolution": "highest_precedence",
"candidates": [
{
"value": "wiki.list_workspace_feeds",
"source": "internal/cli/schema_mcp_metadata.json#tools.wiki.list_workspace_feeds.interface_ref",
"precedence": "mcp_fallback",
"selected": true
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Live wiki MCP tools/list exposes list_workspace_feeds as a read-only knowledge base activity query. The reviewed CLI leaf forwards workspaceId/maxResults/nextToken/excludeFile to the native wiki server and adds no side effects, so it keeps the same read-only posture as the sibling wiki list commands.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/wiki.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "Live wiki MCP tools/list exposes list_workspace_feeds as a read-only knowledge base activity query. The reviewed CLI leaf forwards workspaceId/maxResults/nextToken/excludeFile to the native wiki server and adds no side effects, so it keeps the same read-only posture as the sibling wiki list commands."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。"
}
]
},
"risk": {
"value": "low",
"source": "effect-default",
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"candidates": [
{
"value": "low",
"source": "effect-default",
"precedence": "inference_or_default",
"selected": true
}
]
},
"use_when": {
"value": [
"用户问某个知识库最近有什么更新、谁改了什么、有哪些评论等协作动态时",
"需要巡检知识库变更并按时间线汇总成动态摘要时"
],
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。",
"candidates": [
{
"value": [
"用户问某个知识库最近有什么更新、谁改了什么、有哪些评论等协作动态时",
"需要巡检知识库变更并按时间线汇总成动态摘要时"
],
"source": "internal/cli/schema_hints/selection/wiki.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。"
}
]
}
},
"idempotency": "idempotent",
"interface_mode": "mcp",
"interface_ref": {
"product_id": "wiki",
"rpc_name": "list_workspace_feeds"
},
"reviewed": true,
"risk": "low",
"source_refs": [
"CommandRegistry:canonical_path=wiki.list_workspace_feeds",
"Skill:skills/mono/references/products/wiki.md",
"cobra-help:dws wiki feed list",
"internal/cli/schema_hints/metadata/wiki.json",
"internal/cli/schema_hints/selection/wiki.json",
"internal/cli/schema_mcp_metadata.json#tools.wiki.list_workspace_feeds",
"live-mcp-tools-list:wiki.list_workspace_feeds",
"skills/mono/references/products/wiki.md"
],
"use_when": [
"用户问某个知识库最近有什么更新、谁改了什么、有哪些评论等协作动态时",
"需要巡检知识库变更并按时间线汇总成动态摘要时"
]
},
"wiki member add": {
"agent_summary": "为指定知识库添加一个或多个成员,并授予指定角色",
"agent_summary_source": "dws-agent-selection/wiki",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -97777,6 +97777,326 @@
"用户明确要求停止某自动化工作流时"
]
},
"aitable.workflow_edit_example": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
"cobra-help:dws aitable workflow edit-example --help",
"internal/cli/schema_command_registry.json#aitable.workflow_edit_example",
"internal/cli/schema_hints/metadata/aitable.json",
"internal/cli/schema_hints/selection/aitable.json",
"mcp-contract:aitable/edit_workflow_example",
"skills/mono/references/products/aitable/aitable-workflow.md"
],
"agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"agent_summary_source": "dws-agent-selection/aitable",
"availability": "available",
"avoid_when": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"canonical_path": "aitable.workflow_edit_example",
"cli_name": "edit-example",
"cli_path": "aitable workflow edit-example",
"confirmation": "not_required",
"description": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。",
"display": "AI 表格操作",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws aitable workflow edit-example"
],
"field_provenance": {
"agent_summary": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"selected": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。"
},
"availability": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "available"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "available"
},
"avoid_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"selected": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
]
},
"canonical_path": {
"candidates": [
{
"precedence": "command_registry",
"selected": true,
"source": "reviewed_command_registry",
"source_ref": "aitable workflow edit-example",
"value": "aitable.workflow_edit_example"
}
],
"precedence": "command_registry",
"resolution": "registry_identity",
"source": "reviewed_command_registry",
"source_ref": "aitable workflow edit-example",
"value": "aitable.workflow_edit_example"
},
"confirmation": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "not_required"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "not_required"
},
"description": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。"
},
"effect": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "read"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "read"
},
"examples": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"selected": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"dws aitable workflow edit-example"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"dws aitable workflow edit-example"
]
},
"idempotency": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "idempotent"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "idempotent"
},
"interface_mode": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "composite"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "composite"
},
"interface_reason": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
},
"interface_ref": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": null
}
],
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": null
},
"reviewed": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": true
},
{
"precedence": "reviewed_explicit",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"selected": false,
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": true
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": true
},
"risk": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "low"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "low"
},
"title": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "获取工作流编辑文档与示例"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "获取工作流编辑文档与示例"
},
"use_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"selected": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
]
}
},
"group": "workflow",
"has_parameters": false,
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"is_alias": false,
"name": "workflow_edit_example",
"parameter_count": 0,
"parameters": {},
"path": "aitable.workflow_edit_example",
"primary_cli_path": "aitable workflow edit-example",
"product_id": "aitable",
"reviewed": true,
"risk": "low",
"source": "reviewed_command_registry",
"title": "获取工作流编辑文档与示例",
"use_when": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
]
},
"aitable.workflow_enable": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
File diff suppressed because it is too large Load Diff
+576 -2
View File
@@ -5312,8 +5312,99 @@
"is_alias": false,
"metadata_source": "embedded-mcp-metadata",
"name": "download_file",
"parameter_count": 3,
"parameter_count": 6,
"parameters": {
"no-resume": {
"description": "关闭断点续传 (可选)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "关闭断点续传 (可选)"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "关闭断点续传 (可选)"
},
"property": {
"candidates": [
{
"precedence": "reviewed_mapping_exclusion",
"review_reason": "CLI-only transfer-layer flag controlling download resume behaviour; not an MCP interface parameter.",
"selected": true,
"source": "reviewed_mapping_exclusion",
"value": ""
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "noResume"
}
],
"precedence": "reviewed_mapping_exclusion",
"resolution": "highest_precedence",
"review_reason": "CLI-only transfer-layer flag controlling download resume behaviour; not an MCP interface parameter.",
"source": "reviewed_mapping_exclusion",
"value": ""
},
"required": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": false
}
],
"precedence": "default",
"resolution": "fallback",
"source": "default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "boolean"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "boolean"
}
},
"required": false,
"type": "boolean"
},
"node": {
"description": "文件 ID (dentryUuid) (必填)",
"field_provenance": {
@@ -5520,6 +5611,202 @@
"required": true,
"type": "string"
},
"parallel": {
"default": "4",
"description": "分片下载并发数,范围 1-8 (可选)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "分片下载并发数,范围 1-8 (可选)"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "分片下载并发数,范围 1-8 (可选)"
},
"property": {
"candidates": [
{
"precedence": "reviewed_mapping_exclusion",
"review_reason": "CLI-only transfer-layer flag controlling parallel chunk downloads; not an MCP interface parameter.",
"selected": true,
"source": "reviewed_mapping_exclusion",
"value": ""
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "parallel"
}
],
"precedence": "reviewed_mapping_exclusion",
"resolution": "highest_precedence",
"review_reason": "CLI-only transfer-layer flag controlling parallel chunk downloads; not an MCP interface parameter.",
"source": "reviewed_mapping_exclusion",
"value": ""
},
"required": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_nonzero_default",
"value": false
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_nonzero_default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "integer"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "integer"
}
},
"required": false,
"type": "integer"
},
"part-size": {
"default": "16MB",
"description": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
},
"property": {
"candidates": [
{
"precedence": "reviewed_mapping_exclusion",
"review_reason": "CLI-only transfer-layer flag controlling download chunk size; not an MCP interface parameter.",
"selected": true,
"source": "reviewed_mapping_exclusion",
"value": ""
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "partSize"
}
],
"precedence": "reviewed_mapping_exclusion",
"resolution": "highest_precedence",
"review_reason": "CLI-only transfer-layer flag controlling download chunk size; not an MCP interface parameter.",
"source": "reviewed_mapping_exclusion",
"value": ""
},
"required": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_nonzero_default",
"value": false
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_nonzero_default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "string"
}
},
"required": false,
"type": "string"
},
"space-id": {
"description": "文件所属空间 ID (可选)",
"field_provenance": {
@@ -5937,8 +6224,99 @@
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"is_alias": false,
"name": "download_file_version",
"parameter_count": 3,
"parameter_count": 6,
"parameters": {
"no-resume": {
"description": "关闭断点续传 (可选)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "关闭断点续传 (可选)"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "关闭断点续传 (可选)"
},
"property": {
"candidates": [
{
"precedence": "reviewed_mapping_exclusion",
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI wrapper input and does not publish a direct interface property.",
"selected": true,
"source": "reviewed_mapping_exclusion",
"value": ""
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "noResume"
}
],
"precedence": "reviewed_mapping_exclusion",
"resolution": "highest_precedence",
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI wrapper input and does not publish a direct interface property.",
"source": "reviewed_mapping_exclusion",
"value": ""
},
"required": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": false
}
],
"precedence": "default",
"resolution": "fallback",
"source": "default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "boolean"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "boolean"
}
},
"required": false,
"type": "boolean"
},
"node": {
"description": "文件 ID (dentryUuid) 或 URL (必填)",
"field_provenance": {
@@ -6133,6 +6511,202 @@
"required": true,
"type": "string"
},
"parallel": {
"default": "4",
"description": "分片下载并发数,范围 1-8 (可选)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "分片下载并发数,范围 1-8 (可选)"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "分片下载并发数,范围 1-8 (可选)"
},
"property": {
"candidates": [
{
"precedence": "reviewed_mapping_exclusion",
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --parallel is a CLI wrapper input and does not publish a direct interface property.",
"selected": true,
"source": "reviewed_mapping_exclusion",
"value": ""
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "parallel"
}
],
"precedence": "reviewed_mapping_exclusion",
"resolution": "highest_precedence",
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --parallel is a CLI wrapper input and does not publish a direct interface property.",
"source": "reviewed_mapping_exclusion",
"value": ""
},
"required": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_nonzero_default",
"value": false
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_nonzero_default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "integer"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "integer"
}
},
"required": false,
"type": "integer"
},
"part-size": {
"default": "16MB",
"description": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
},
"property": {
"candidates": [
{
"precedence": "reviewed_mapping_exclusion",
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --part-size is a CLI wrapper input and does not publish a direct interface property.",
"selected": true,
"source": "reviewed_mapping_exclusion",
"value": ""
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "partSize"
}
],
"precedence": "reviewed_mapping_exclusion",
"resolution": "highest_precedence",
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --part-size is a CLI wrapper input and does not publish a direct interface property.",
"source": "reviewed_mapping_exclusion",
"value": ""
},
"required": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_nonzero_default",
"value": false
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_nonzero_default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "string"
}
},
"required": false,
"type": "string"
},
"version": {
"description": "历史版本号 (必填,正整数,从 drive list --versions 获取)",
"field_provenance": {
File diff suppressed because it is too large Load Diff
+803 -16
View File
@@ -28,7 +28,7 @@
"cli_path": "wiki member add",
"confirmation": "not_required",
"description": "为指定知识库添加一个或多个成员,并授予指定角色。\n\n通过 --users 传入逗号分隔的 userId 列表,多个用户将被授予同一角色。\n\n支持的角色 (--role)(必须大写):\n MANAGER 管理员,可读写、管理成员\n EDITOR 编辑者,可查看、编辑、上传内容\n DOWNLOADER 查看下载者,可查看并下载内容\n READER 仅可查看者,仅可查看,不可下载\n\n注意:\n- OWNER 角色不可通过此接口添加,知识库创建者默认为所有者。\n- 操作者需具备知识库的 OWNER 或 MANAGER 权限。\n- 单次请求最多 30 个成员,超出请分批调用。\n\n支持通过 --workspace 传入知识库 ID 或知识库 URL,系统自动识别。\n用户 uid 可通过「钉钉通讯录」相关命令检索,如:\n dws contact user search --keyword \"姓名\"",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "write",
"effect_source": "command-verb",
"examples": [
@@ -732,7 +732,7 @@
"cli_path": "wiki node create",
"confirmation": "not_required",
"description": "在指定知识库中创建文档、文件夹或其他类型的节点。\n\n通过 --type 指定节点类型(服务端支持以下值,asheet 不被支持):\n adoc 在线文档 (默认)\n axls 在线电子表格\n able 多维表\n appt 在线演示\n adraw 白板/画板\n amind 脑图\n folder 文件夹\n\n通过 --folder 指定父节点,不传则创建在知识库根目录。",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "write",
"effect_source": "command-verb",
"examples": [
@@ -1501,7 +1501,7 @@
"cli_path": "wiki space create",
"confirmation": "not_required",
"description": "创建一个新的钉钉文档知识库(WikiSpace)。\n\n创建成功后返回新知识库的 workspaceId,可用于后续在该知识库下创建文档或遍历文件。\n操作受权限控制,仅当调用者具备在当前组织内创建知识库的权限时可成功创建。",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "write",
"effect_source": "command-verb",
"examples": [
@@ -2149,7 +2149,7 @@
"cli_path": "wiki node delete",
"confirmation": "user_required",
"description": "将知识库中的节点移入回收站。\n\n注意: 这是一个危险操作。执行前需要确认,或传入 --yes 跳过确认。\n删除后节点会进入回收站,有保留期限可恢复。\n\n权限要求: 对节点有\"管理\"权限。",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "destructive",
"effect_source": "agent-hint",
"examples": [
@@ -2737,7 +2737,7 @@
"cli_path": "wiki space delete",
"confirmation": "user_required",
"description": "将指定知识库移入回收站。\n\n删除后知识库会进入回收站,可在回收站中恢复(有保留期限)。\n支持传入知识库 ID 或知识库 URL,系统自动识别。\n知识库 URL 格式:https://alidocs.dingtalk.com/i/spaces/{workspaceId}/overview\n\n注意:\n- 操作者必须具备知识库的 OWNER 角色。\n- 这是一个危险操作,执行前请确认。",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "destructive",
"effect_source": "agent-hint",
"examples": [
@@ -3219,7 +3219,7 @@
"cli_path": "wiki space get",
"confirmation": "not_required",
"description": "获取指定知识库的详细信息,包括名称、描述、创建者、创建时间、成员数量等。\n\n支持传入知识库 ID 或知识库 URL,系统自动识别。\n知识库 URL 格式:https://alidocs.dingtalk.com/i/spaces/{workspaceId}/overview",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "read",
"effect_source": "command-verb",
"examples": [
@@ -3676,7 +3676,7 @@
"cli_path": "wiki member list",
"confirmation": "not_required",
"description": "查询指定知识库的成员列表,返回每位成员的 userId、姓名、角色等信息。\n\n注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。\n若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围;\nORG 类型授权不会出现在查询结果中。",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "read",
"effect_source": "command-verb",
"examples": [
@@ -4379,7 +4379,7 @@
"cli_path": "wiki node list",
"confirmation": "not_required",
"description": "列出指定知识库下的直接子节点(文档、文件夹、表格等)。\n\n通过 --folder 指定父节点可列出子目录内容;不传 --folder 则列出知识库根目录。\n支持分页,通过 --cursor 传入上次返回的 pageToken 获取下一页。",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "read",
"effect_source": "command-verb",
"examples": [
@@ -5163,7 +5163,7 @@
"cli_path": "wiki space list",
"confirmation": "not_required",
"description": "获取当前用户有权访问的空间列表。统一管理两种空间类型。\n\n通过 --type 参数控制返回范围:\n orgWikiSpace — 组织知识库列表(默认,支持分页)\n myWikiSpace — 当前用户的「我的文档」个人空间(固定 1 条)\n orgSpace — 钉盘企业空间(团队文件)列表\n mySpace — 钉盘「我的文件」个人空间",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "read",
"effect_source": "command-verb",
"examples": [
@@ -5796,6 +5796,793 @@
"需要 workspaceId / rootFolderId 作为后续 node/drive 操作前置时"
]
},
"wiki.list_workspace_feeds": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
"CommandRegistry:canonical_path=wiki.list_workspace_feeds",
"Skill:skills/mono/references/products/wiki.md",
"cobra-help:dws wiki feed list",
"internal/cli/schema_hints/metadata/wiki.json",
"internal/cli/schema_hints/selection/wiki.json",
"internal/cli/schema_mcp_metadata.json#tools.wiki.list_workspace_feeds",
"live-mcp-tools-list:wiki.list_workspace_feeds",
"skills/mono/references/products/wiki.md"
],
"agent_summary": "查询知识库的活动动态:谁在什么时间更新/上传/评论了哪些文档",
"agent_summary_source": "dws-agent-selection/wiki",
"availability": "available",
"avoid_when": [
"要看知识库当前有哪些节点用 node list;库内按关键词找文档用 node search",
"要读某篇文档正文用 doc read;跨库全局找文件用 drive search"
],
"canonical_path": "wiki.list_workspace_feeds",
"cli_name": "list",
"cli_path": "wiki feed list",
"confirmation": "not_required",
"description": "查询指定知识库的动态列表,返回动态类型、时间、内容摘要等信息。\n\n通过 --workspace 指定知识库,支持传入知识库 ID 或知识库 URL。\n支持分页,通过 --cursor 传入上次返回的 nextToken 获取下一页。\n\n权限要求:调用者需具备知识库的成员权限,非成员会被拒绝访问。",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "read",
"effect_source": "command-verb",
"examples": [
"dws wiki feed list --workspace \u003cworkspaceId\u003e --format json",
"dws wiki feed list --workspace \u003cworkspaceId\u003e --limit 10 --format json"
],
"field_provenance": {
"agent_summary": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。",
"selected": true,
"source": "internal/cli/schema_hints/selection/wiki.json",
"value": "查询知识库的活动动态:谁在什么时间更新/上传/评论了哪些文档"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/wiki.json",
"value": "查询知识库的活动动态:谁在什么时间更新/上传/评论了哪些文档"
},
"availability": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Live wiki MCP tools/list exposes list_workspace_feeds as a read-only knowledge base activity query. The reviewed CLI leaf forwards workspaceId/maxResults/nextToken/excludeFile to the native wiki server and adds no side effects, so it keeps the same read-only posture as the sibling wiki list commands.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/wiki.json",
"value": "available"
},
{
"precedence": "mcp_fallback",
"selected": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.wiki.list_workspace_feeds.interface_ref",
"value": "available"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Live wiki MCP tools/list exposes list_workspace_feeds as a read-only knowledge base activity query. The reviewed CLI leaf forwards workspaceId/maxResults/nextToken/excludeFile to the native wiki server and adds no side effects, so it keeps the same read-only posture as the sibling wiki list commands.",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"value": "available"
},
"avoid_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。",
"selected": true,
"source": "internal/cli/schema_hints/selection/wiki.json",
"value": [
"要看知识库当前有哪些节点用 node list;库内按关键词找文档用 node search",
"要读某篇文档正文用 doc read;跨库全局找文件用 drive search"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/wiki.json",
"value": [
"要看知识库当前有哪些节点用 node list;库内按关键词找文档用 node search",
"要读某篇文档正文用 doc read;跨库全局找文件用 drive search"
]
},
"canonical_path": {
"candidates": [
{
"precedence": "command_registry",
"selected": true,
"source": "reviewed_command_registry",
"source_ref": "wiki feed list",
"value": "wiki.list_workspace_feeds"
}
],
"precedence": "command_registry",
"resolution": "registry_identity",
"source": "reviewed_command_registry",
"source_ref": "wiki feed list",
"value": "wiki.list_workspace_feeds"
},
"confirmation": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "risk-default",
"value": "not_required"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "risk-default",
"value": "not_required"
},
"description": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "查询指定知识库的动态列表,返回动态类型、时间、内容摘要等信息。\n\n通过 --workspace 指定知识库,支持传入知识库 ID 或知识库 URL。\n支持分页,通过 --cursor 传入上次返回的 nextToken 获取下一页。\n\n权限要求:调用者需具备知识库的成员权限,非成员会被拒绝访问。"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "功能概述:\r\n查询指定钉钉文档知识库的活动动态,包括谁在什么时间对哪些文档进行了更新、上传、评论等操作。通过传入 workspaceId(知识库 ID 或知识库 URL)定位目标知识库,返回动态列表,每条包含动态类型(type)、发生时间(time)和内容摘要(content)。支持游标分页(nextToken),可选排除文件相关动态(excludeFile)。\r\n\r\n适用场景:\r\n– 需要了解知识库最近有哪些文档被更新或上传了新文件。\r\n– 需要追踪知识库内的协作活动,如谁评论了哪篇文档。\r\n– 定期巡检知识库变更情况,生成动态摘要报告。\r\n\r\n注意事项:\r\n– 权限要求:操作者需具备知识库的成员权限,非成员会被拒绝访问。\r\n– 通过 workspaceId 定位知识库,支持知识库 ID(纯字符串)或知识库 URL(如 https://alidocs.dingtalk.com/i/spaces/{workspaceId}/overview),系统自动提取。\r\n– 每页默认返回 20 条动态,通过 maxResults 调整,最大 50 条。首页不传 nextToken,翻页时传入上次返回的 nextToken 值。\r\n– excludeFile 设为 true 可过滤掉文件相关的动态,默认 false 即返回全部动态类型。"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "查询指定知识库的动态列表,返回动态类型、时间、内容摘要等信息。\n\n通过 --workspace 指定知识库,支持传入知识库 ID 或知识库 URL。\n支持分页,通过 --cursor 传入上次返回的 nextToken 获取下一页。\n\n权限要求:调用者需具备知识库的成员权限,非成员会被拒绝访问。"
},
"effect": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "command-verb",
"value": "read"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "command-verb",
"value": "read"
},
"examples": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。",
"selected": true,
"source": "internal/cli/schema_hints/selection/wiki.json",
"value": [
"dws wiki feed list --workspace \u003cworkspaceId\u003e --format json",
"dws wiki feed list --workspace \u003cworkspaceId\u003e --limit 10 --format json"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/wiki.json",
"value": [
"dws wiki feed list --workspace \u003cworkspaceId\u003e --format json",
"dws wiki feed list --workspace \u003cworkspaceId\u003e --limit 10 --format json"
]
},
"idempotency": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "effect-default",
"value": "idempotent"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "effect-default",
"value": "idempotent"
},
"interface_mode": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Live wiki MCP tools/list exposes list_workspace_feeds as a read-only knowledge base activity query. The reviewed CLI leaf forwards workspaceId/maxResults/nextToken/excludeFile to the native wiki server and adds no side effects, so it keeps the same read-only posture as the sibling wiki list commands.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/wiki.json",
"value": "mcp"
},
{
"precedence": "mcp_fallback",
"selected": false,
"source": "internal/cli/schema_mcp_metadata.json#tools.wiki.list_workspace_feeds.interface_ref",
"value": "mcp"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Live wiki MCP tools/list exposes list_workspace_feeds as a read-only knowledge base activity query. The reviewed CLI leaf forwards workspaceId/maxResults/nextToken/excludeFile to the native wiki server and adds no side effects, so it keeps the same read-only posture as the sibling wiki list commands.",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"value": "mcp"
},
"interface_ref": {
"candidates": [
{
"precedence": "mcp_fallback",
"selected": true,
"source": "internal/cli/schema_mcp_metadata.json#tools.wiki.list_workspace_feeds.interface_ref",
"value": {
"product_id": "wiki",
"rpc_name": "list_workspace_feeds"
}
}
],
"precedence": "mcp_fallback",
"resolution": "highest_precedence",
"source": "internal/cli/schema_mcp_metadata.json#tools.wiki.list_workspace_feeds.interface_ref",
"value": {
"product_id": "wiki",
"rpc_name": "list_workspace_feeds"
}
},
"metadata_source": {
"candidates": [
{
"precedence": "derived_resolution",
"selected": true,
"source": "metadata_source_resolution",
"value": "embedded-mcp-metadata"
}
],
"precedence": "derived_resolution",
"resolution": "highest_precedence",
"source": "metadata_source_resolution",
"value": "embedded-mcp-metadata"
},
"reviewed": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "Live wiki MCP tools/list exposes list_workspace_feeds as a read-only knowledge base activity query. The reviewed CLI leaf forwards workspaceId/maxResults/nextToken/excludeFile to the native wiki server and adds no side effects, so it keeps the same read-only posture as the sibling wiki list commands.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/wiki.json",
"value": true
},
{
"precedence": "reviewed_explicit",
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。",
"selected": false,
"source": "internal/cli/schema_hints/selection/wiki.json",
"value": true
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Live wiki MCP tools/list exposes list_workspace_feeds as a read-only knowledge base activity query. The reviewed CLI leaf forwards workspaceId/maxResults/nextToken/excludeFile to the native wiki server and adds no side effects, so it keeps the same read-only posture as the sibling wiki list commands.",
"source": "internal/cli/schema_hints/metadata/wiki.json",
"value": true
},
"risk": {
"candidates": [
{
"precedence": "inference_or_default",
"selected": true,
"source": "effect-default",
"value": "low"
}
],
"precedence": "inference_or_default",
"resolution": "highest_precedence",
"source": "effect-default",
"value": "low"
},
"title": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "查询知识库动态列表"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "list_workspace_feeds"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "查询知识库动态列表"
},
"use_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。",
"selected": true,
"source": "internal/cli/schema_hints/selection/wiki.json",
"value": [
"用户问某个知识库最近有什么更新、谁改了什么、有哪些评论等协作动态时",
"需要巡检知识库变更并按时间线汇总成动态摘要时"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。",
"source": "internal/cli/schema_hints/selection/wiki.json",
"value": [
"用户问某个知识库最近有什么更新、谁改了什么、有哪些评论等协作动态时",
"需要巡检知识库变更并按时间线汇总成动态摘要时"
]
}
},
"group": "feed",
"has_parameters": true,
"idempotency": "idempotent",
"interface_mode": "mcp",
"interface_ref": {
"product_id": "wiki",
"rpc_name": "list_workspace_feeds"
},
"is_alias": false,
"metadata_source": "embedded-mcp-metadata",
"name": "list_workspace_feeds",
"parameter_count": 4,
"parameters": {
"cursor": {
"description": "分页游标 (首页留空)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "分页游标 (首页留空)"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "分页游标,首次查询不传,后续翻页时传入上次返回的 nextToken。"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "分页游标 (首页留空)"
},
"property": {
"candidates": [
{
"precedence": "versioned_binding",
"selected": true,
"source": "versioned_parameter_binding",
"value": "nextToken"
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "cursor"
}
],
"precedence": "versioned_binding",
"resolution": "highest_precedence",
"source": "versioned_parameter_binding",
"value": "nextToken"
},
"required": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": false
}
],
"precedence": "default",
"resolution": "fallback",
"source": "default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "string"
}
},
"interface_description": "分页游标,首次查询不传,后续翻页时传入上次返回的 nextToken。",
"property": "nextToken",
"required": false,
"type": "string"
},
"exclude-file": {
"description": "是否排除文件相关的动态 (默认 false)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "是否排除文件相关的动态 (默认 false)"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "是否排除文件相关的动态,选填,默认 false。"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "是否排除文件相关的动态 (默认 false)"
},
"property": {
"candidates": [
{
"precedence": "versioned_binding",
"selected": true,
"source": "versioned_parameter_binding",
"value": "excludeFile"
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "excludeFile"
}
],
"precedence": "versioned_binding",
"resolution": "highest_precedence",
"source": "versioned_parameter_binding",
"value": "excludeFile"
},
"required": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_nonzero_default",
"value": false
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_nonzero_default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "boolean"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "boolean"
}
},
"interface_description": "是否排除文件相关的动态,选填,默认 false。",
"property": "excludeFile",
"required": false,
"type": "boolean"
},
"limit": {
"description": "每页数量 (默认 20,最大 50)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "每页数量 (默认 20,最大 50)"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "期望返回的最大动态条数,默认 20,最大 50。"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "每页数量 (默认 20,最大 50)"
},
"interface_type": {
"candidates": [
{
"precedence": "mcp_metadata",
"selected": true,
"source": "mcp_metadata",
"value": "number"
},
{
"precedence": "fallback",
"selected": false,
"source": "cobra_flag_type",
"value": "integer"
}
],
"precedence": "mcp_metadata",
"resolution": "highest_precedence",
"source": "mcp_metadata",
"value": "number"
},
"property": {
"candidates": [
{
"precedence": "versioned_binding",
"selected": true,
"source": "versioned_parameter_binding",
"value": "maxResults"
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "limit"
}
],
"precedence": "versioned_binding",
"resolution": "highest_precedence",
"source": "versioned_parameter_binding",
"value": "maxResults"
},
"required": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_nonzero_default",
"value": false
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_nonzero_default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "integer"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "integer"
}
},
"interface_description": "期望返回的最大动态条数,默认 20,最大 50。",
"interface_type": "number",
"property": "maxResults",
"required": false,
"type": "integer"
},
"workspace": {
"description": "知识库 ID 或 URL (必填)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "知识库 ID 或 URL (必填)"
},
{
"precedence": "mcp_metadata",
"selected": false,
"source": "mcp_metadata",
"value": "目标知识库的标识,支持两种格式:1) 知识库 ID(纯字符串);2) 知识库 URL,如 https://alidocs.dingtalk.com/i/spaces/{workspaceId}/overview,系统自动提取其中的 workspaceId。"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "知识库 ID 或 URL (必填)"
},
"property": {
"candidates": [
{
"precedence": "versioned_binding",
"selected": true,
"source": "versioned_parameter_binding",
"value": "workspaceId"
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "workspace"
}
],
"precedence": "versioned_binding",
"resolution": "highest_precedence",
"source": "versioned_parameter_binding",
"value": "workspaceId"
},
"required": {
"candidates": [
{
"precedence": "mcp_metadata",
"selected": true,
"source": "mcp_metadata",
"value": true
},
{
"precedence": "inference",
"selected": false,
"source": "usage_required_inference",
"value": true
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "mcp_metadata",
"resolution": "highest_precedence",
"source": "mcp_metadata",
"value": true
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "string"
}
},
"interface_description": "目标知识库的标识,支持两种格式:1) 知识库 ID(纯字符串);2) 知识库 URL,如 https://alidocs.dingtalk.com/i/spaces/{workspaceId}/overview,系统自动提取其中的 workspaceId。",
"property": "workspaceId",
"required": true,
"type": "string"
}
},
"path": "wiki.list_workspace_feeds",
"primary_cli_path": "wiki feed list",
"product_id": "wiki",
"reviewed": true,
"risk": "low",
"source": "reviewed_command_registry",
"title": "查询知识库动态列表",
"use_when": [
"用户问某个知识库最近有什么更新、谁改了什么、有哪些评论等协作动态时",
"需要巡检知识库变更并按时间线汇总成动态摘要时"
]
},
"wiki.node_copy": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
@@ -5822,7 +6609,7 @@
"cli_path": "wiki node copy",
"confirmation": "not_required",
"description": "将知识库中的节点复制到指定位置。\n\n通过 --node 指定源节点,通过 --folder 指定目标文件夹。\n不传 --folder 时复制到 --workspace 指定知识库的根目录。",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "write",
"effect_source": "command-verb",
"examples": [
@@ -6488,7 +7275,7 @@
"cli_path": "wiki node move",
"confirmation": "not_required",
"description": "将知识库中的节点移动到指定位置。\n\n通过 --node 指定源节点,通过 --folder 指定目标文件夹。\n不传 --folder 时移动到 --workspace 指定知识库的根目录。\n\n注意:跨知识库移动需要同时具备源和目标的相应权限。",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "write",
"effect_source": "command-verb",
"examples": [
@@ -7153,7 +7940,7 @@
"cli_path": "wiki node search",
"confirmation": "not_required",
"description": "在指定知识库内搜索文档/文件夹/表格等节点。\n\n通过 --workspace 限定搜索范围到某个知识库,通过 --query 指定搜索关键词。\n支持按文件扩展名过滤(--extensions),如 adoc、asheet、pdf 等。",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "read",
"effect_source": "command-verb",
"examples": [
@@ -8051,7 +8838,7 @@
"cli_path": "wiki member remove",
"confirmation": "not_required",
"description": "从指定知识库中移除一个或多个成员(仅支持 USER 类型)。\n\n移除后相关用户将无法访问该知识库下的内容(除非通过节点级权限另行授权)。\n\n注意:\n- OWNER 角色不可通过此接口移除。\n- 操作者需具备知识库的 OWNER 或 MANAGER 权限。\n- 单次请求最多 30 个成员,超出请分批调用。",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "write",
"effect_source": "agent-hint",
"examples": [
@@ -8671,7 +9458,7 @@
]
},
"description": "根据关键词搜索当前用户有权限访问的知识库列表,匹配知识库名称和描述。",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "read",
"effect_source": "command-verb",
"examples": [
@@ -9370,7 +10157,7 @@
"cli_path": "wiki +space-search",
"confirmation": "not_required",
"description": "当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
@@ -9880,7 +10667,7 @@
"cli_path": "wiki member update",
"confirmation": "not_required",
"description": "更新指定知识库已有成员的角色。\n\n支持的角色 (--role)(必须大写):\n MANAGER 管理员\n EDITOR 编辑者\n DOWNLOADER 查看下载者\n READER 仅可查看者\n\n注意:\n- OWNER 角色不可通过此接口变更。\n- 同一成员在同一知识库只能拥有一个角色,变更后旧角色自动替换。\n- 操作者需具备知识库的 OWNER 或 MANAGER 权限。\n\n仅可更新已存在成员关系的成员,新增成员请使用 dws wiki member add。",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理",
"display": "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
"effect": "write",
"effect_source": "command-verb",
"examples": [
@@ -79,6 +79,36 @@
"calendar acl add",
"calendar acl delete",
"calendar book update",
"chat category add-conv",
"chat category create",
"chat category delete",
"chat category remove-conv",
"chat category rename",
"chat chmod",
"chat clear-all-red-point",
"chat clear-messages",
"chat clear-red-point",
"chat data-auth cross-org",
"chat group audit-join-validation",
"chat group list-all",
"chat group list-join-validations",
"chat group members list-by-ids",
"chat group notice create",
"chat group notice edit",
"chat group notice get",
"chat group notice list",
"chat group share-invite",
"chat group update-alias",
"chat hide",
"chat list-all-conversations",
"chat mark-read",
"chat mark-unread",
"chat message list-emotion-replies",
"chat message set-top-msg",
"chat message unset-top-msg",
"chat mute-at-all",
"chat mute-red-envelope",
"chat text translate",
"contact label get",
"contact label list",
"contact label list-members",
@@ -452,6 +452,10 @@
"canonical_path": "aitable.workflow_disable",
"cli_path": "aitable workflow disable"
},
{
"canonical_path": "aitable.workflow_edit_example",
"cli_path": "aitable workflow edit-example"
},
{
"canonical_path": "aitable.workflow_enable",
"cli_path": "aitable workflow enable"
@@ -1,378 +1,6 @@
{
"id": "chat",
"tools": [
{
"canonical_path": "chat.add_conv_to_categories",
"cli_path": "chat category add-conv"
},
{
"canonical_path": "chat.add_custom_group_role",
"cli_path": "chat group-role add"
},
{
"canonical_path": "chat.add_emoji_reaction",
"cli_path": "chat message add-emoji"
},
{
"canonical_path": "chat.add_group_member",
"cli_path": "chat group members add"
},
{
"canonical_path": "chat.add_message_favorite",
"cli_path": "chat message add-favorite"
},
{
"canonical_path": "chat.add_robot_to_group",
"cli_path": "chat group members add-bot"
},
{
"canonical_path": "chat.add_text_emotion",
"cli_path": "chat message add-text-emotion"
},
{
"canonical_path": "chat.audit_join_group",
"cli_path": "chat group audit-join-validation"
},
{
"canonical_path": "chat.batch_query_group_chat_settings",
"cli_path": "chat group user-settings query"
},
{
"canonical_path": "chat.batch_update_group_chat_settings",
"cli_path": "chat group user-settings set"
},
{
"canonical_path": "chat.clear_all_red_point",
"cli_path": "chat clear-all-red-point"
},
{
"canonical_path": "chat.clear_conversation_messages",
"cli_path": "chat clear-messages"
},
{
"canonical_path": "chat.clear_conversation_red_point",
"cli_path": "chat clear-red-point"
},
{
"canonical_path": "chat.combine_forward_messages",
"cli_path": "chat message combine-forward"
},
{
"canonical_path": "chat.create_and_send_card",
"cli_path": "chat message send-card"
},
{
"canonical_path": "chat.create_conv_category",
"cli_path": "chat category create"
},
{
"canonical_path": "chat.create_group_conversation",
"cli_path": "chat group create"
},
{
"canonical_path": "chat.create_group_notice",
"cli_path": "chat group notice create"
},
{
"canonical_path": "chat.create_smart_conv_category",
"cli_path": "chat category create-smart"
},
{
"canonical_path": "chat.create_text_emotion",
"cli_path": "chat message create-text-emotion"
},
{
"canonical_path": "chat.delete_conv_category",
"cli_path": "chat category delete"
},
{
"canonical_path": "chat.dismiss_group",
"cli_path": "chat group dismiss"
},
{
"canonical_path": "chat.download_media",
"cli_path": "chat message download-media"
},
{
"canonical_path": "chat.edit_group_notice",
"cli_path": "chat group notice edit"
},
{
"canonical_path": "chat.edit_message",
"cli_path": "chat message edit"
},
{
"canonical_path": "chat.forward_message",
"cli_path": "chat message forward"
},
{
"canonical_path": "chat.forward_topic",
"cli_path": "chat message forward-topic"
},
{
"canonical_path": "chat.get_conv_categories_info",
"cli_path": "chat category batch-info"
},
{
"canonical_path": "chat.get_conv_info_by_group_id",
"cli_path": "chat group get-by-group-id"
},
{
"canonical_path": "chat.get_conversation_info",
"cli_path": "chat conversation-info"
},
{
"canonical_path": "chat.get_group_invite_url",
"cli_path": "chat group invite-url"
},
{
"canonical_path": "chat.get_group_mute_config",
"cli_path": "chat group get-mute-config"
},
{
"canonical_path": "chat.get_group_notice",
"cli_path": "chat group notice get"
},
{
"canonical_path": "chat.grant_cross_org_data_access",
"cli_path": "chat data-auth cross-org"
},
{
"canonical_path": "chat.grant_permission",
"cli_path": "chat chmod"
},
{
"canonical_path": "chat.hide_conversation",
"cli_path": "chat hide"
},
{
"canonical_path": "chat.list_all_conversations",
"cli_path": "chat list-all-conversations"
},
{
"canonical_path": "chat.list_apply_join_group_records",
"cli_path": "chat group list-join-validations"
},
{
"canonical_path": "chat.list_conv_categories_by_conv",
"cli_path": "chat category list-by-conv"
},
{
"canonical_path": "chat.list_conversation_message_v2",
"cli_path": "chat message list"
},
{
"canonical_path": "chat.list_conversations_by_category",
"cli_path": "chat category list-conversations"
},
{
"canonical_path": "chat.list_custom_group_roles",
"cli_path": "chat group-role list"
},
{
"canonical_path": "chat.list_group_bots",
"cli_path": "chat group bots"
},
{
"canonical_path": "chat.list_group_member_by_ids",
"cli_path": "chat group members list-by-ids"
},
{
"canonical_path": "chat.list_group_notices",
"cli_path": "chat group notice list"
},
{
"canonical_path": "chat.list_individual_chat_message",
"cli_path": "chat message list-direct"
},
{
"canonical_path": "chat.list_message_emotion_replies",
"cli_path": "chat message list-emotion-replies"
},
{
"canonical_path": "chat.list_message_favorites",
"cli_path": "chat message list-favorites"
},
{
"canonical_path": "chat.list_messages_by_ids",
"cli_path": "chat message list-by-ids"
},
{
"canonical_path": "chat.list_my_groups_pagination",
"cli_path": "chat group list-all"
},
{
"canonical_path": "chat.list_owned_or_admin_groups",
"cli_path": "chat group list-my-groups"
},
{
"canonical_path": "chat.list_pin_messages",
"cli_path": "chat message list-pin-msg"
},
{
"canonical_path": "chat.list_special_focus_messages",
"cli_path": "chat message list-focused"
},
{
"canonical_path": "chat.list_top_conversations",
"cli_path": "chat list-top-conversations"
},
{
"canonical_path": "chat.list_topic_replies",
"cli_path": "chat message list-topic-replies"
},
{
"canonical_path": "chat.list_user_define_conv_categories",
"cli_path": "chat category list"
},
{
"canonical_path": "chat.mark_conversation_unread",
"cli_path": "chat mark-unread"
},
{
"canonical_path": "chat.mark_message_read",
"cli_path": "chat mark-read"
},
{
"canonical_path": "chat.query_custom_user_roles",
"cli_path": "chat group-role query-user"
},
{
"canonical_path": "chat.query_message_send_status",
"cli_path": "chat message query-send-status"
},
{
"canonical_path": "chat.query_msg_read_status",
"cli_path": "chat message read-status"
},
{
"canonical_path": "chat.quit_group",
"cli_path": "chat group quit"
},
{
"canonical_path": "chat.recall_message",
"cli_path": "chat message recall"
},
{
"canonical_path": "chat.recall_robot_message",
"cli_path": "chat message recall-by-bot"
},
{
"canonical_path": "chat.remove_conv_from_categories",
"cli_path": "chat category remove-conv"
},
{
"canonical_path": "chat.remove_custom_group_role",
"cli_path": "chat group-role remove"
},
{
"canonical_path": "chat.remove_custom_user_roles",
"cli_path": "chat group-role remove-user"
},
{
"canonical_path": "chat.remove_emoji_reaction",
"cli_path": "chat message remove-emoji"
},
{
"canonical_path": "chat.remove_group_member",
"cli_path": "chat group members remove"
},
{
"canonical_path": "chat.remove_message_favorite",
"cli_path": "chat message remove-favorite"
},
{
"canonical_path": "chat.remove_robot_in_group",
"cli_path": "chat group members remove-bot"
},
{
"canonical_path": "chat.remove_text_emotion",
"cli_path": "chat message remove-text-emotion"
},
{
"canonical_path": "chat.rename_conv_category",
"cli_path": "chat category rename"
},
{
"canonical_path": "chat.reply_personal_message",
"cli_path": "chat message reply"
},
{
"canonical_path": "chat.search_at_me_message",
"cli_path": "chat message list-mentions"
},
{
"canonical_path": "chat.search_bots",
"cli_path": "chat bot find"
},
{
"canonical_path": "chat.search_common_groups",
"cli_path": "chat search-common"
},
{
"canonical_path": "chat.search_groups",
"cli_path": "chat search"
},
{
"canonical_path": "chat.search_messages",
"cli_path": "chat message search-advanced"
},
{
"canonical_path": "chat.search_messages_by_keyword",
"cli_path": "chat message search"
},
{
"canonical_path": "chat.search_messages_by_sender",
"cli_path": "chat message list-by-sender"
},
{
"canonical_path": "chat.search_messages_by_time_range",
"cli_path": "chat message list-all"
},
{
"canonical_path": "chat.search_my_robots",
"cli_path": "chat bot search"
},
{
"canonical_path": "chat.send_message_by_custom_robot",
"cli_path": "chat message send-by-webhook"
},
{
"canonical_path": "chat.send_personal_message",
"cli_path": "chat message send"
},
{
"canonical_path": "chat.send_robot_message",
"cli_path": "chat message send-by-bot"
},
{
"canonical_path": "chat.set_custom_user_roles",
"cli_path": "chat group-role set-user"
},
{
"canonical_path": "chat.set_group_member_mute_list",
"cli_path": "chat group-mute-member"
},
{
"canonical_path": "chat.set_group_mute",
"cli_path": "chat group-mute"
},
{
"canonical_path": "chat.set_pin_message",
"cli_path": "chat message set-pin-msg"
},
{
"canonical_path": "chat.set_top_conversation",
"cli_path": "chat set-top"
},
{
"canonical_path": "chat.set_top_message",
"cli_path": "chat message set-top-msg"
},
{
"canonical_path": "chat.share_group_invite_url",
"cli_path": "chat group share-invite"
},
{
"canonical_path": "chat.shortcut_at_me",
"cli_path": "chat +at-me"
@@ -429,10 +57,6 @@
"canonical_path": "chat.shortcut_chat_list_mine",
"cli_path": "chat +chat-list-mine"
},
{
"canonical_path": "chat.shortcut_chat_messages",
"cli_path": "chat +chat-messages"
},
{
"canonical_path": "chat.shortcut_chat_mute",
"cli_path": "chat +chat-mute"
@@ -525,18 +149,10 @@
"canonical_path": "chat.shortcut_messages_read_status",
"cli_path": "chat +messages-read-status"
},
{
"canonical_path": "chat.shortcut_messages_send",
"cli_path": "chat +messages-send"
},
{
"canonical_path": "chat.shortcut_messages_send_by_webhook",
"cli_path": "chat +messages-send-by-webhook"
},
{
"canonical_path": "chat.shortcut_messages_send_card",
"cli_path": "chat +messages-send-card"
},
{
"canonical_path": "chat.shortcut_messages_update_card",
"cli_path": "chat +messages-update-card"
@@ -545,62 +161,114 @@
"canonical_path": "chat.shortcut_my_groups",
"cli_path": "chat +my-groups"
},
{
"canonical_path": "chat.shortcut_search_msg",
"cli_path": "chat +search-msg"
},
{
"canonical_path": "chat.shortcut_send_to_group",
"cli_path": "chat +send-to-group"
},
{
"canonical_path": "chat.shortcut_thread_replies",
"cli_path": "chat +thread-replies"
},
{
"canonical_path": "chat.shortcut_unread_chats",
"cli_path": "chat +unread-chats"
},
{
"canonical_path": "chat.transfer_group_owner",
"cli_path": "chat group transfer-owner"
"canonical_path": "chat.search_bots",
"cli_path": "chat bot find"
},
{
"canonical_path": "chat.translate",
"cli_path": "chat text translate"
"canonical_path": "chat.search_my_robots",
"cli_path": "chat bot search"
},
{
"canonical_path": "chat.unread_message_conversation_list",
"cli_path": "chat message list-unread-conversations"
"canonical_path": "chat.get_conv_categories_info",
"cli_path": "chat category batch-info"
},
{
"canonical_path": "chat.unset_pin_message",
"cli_path": "chat message unset-pin-msg"
"canonical_path": "chat.create_smart_conv_category",
"cli_path": "chat category create-smart"
},
{
"canonical_path": "chat.unset_top_message",
"cli_path": "chat message unset-top-msg"
"canonical_path": "chat.list_user_define_conv_categories",
"cli_path": "chat category list"
},
{
"canonical_path": "chat.update_at_all_notification_off",
"cli_path": "chat mute-at-all"
"canonical_path": "chat.list_conv_categories_by_conv",
"cli_path": "chat category list-by-conv"
},
{
"canonical_path": "chat.list_conversations_by_category",
"cli_path": "chat category list-conversations"
},
{
"canonical_path": "chat.get_conversation_info",
"cli_path": "chat conversation-info"
},
{
"canonical_path": "chat.list_group_bots",
"cli_path": "chat group bots"
},
{
"canonical_path": "chat.create_group_conversation",
"cli_path": "chat group create"
},
{
"canonical_path": "chat.dismiss_group",
"cli_path": "chat group dismiss"
},
{
"canonical_path": "chat.get_conv_info_by_group_id",
"cli_path": "chat group get-by-group-id"
},
{
"canonical_path": "chat.get_group_mute_config",
"cli_path": "chat group get-mute-config"
},
{
"canonical_path": "chat.get_group_invite_url",
"cli_path": "chat group invite-url"
},
{
"canonical_path": "chat.list_owned_or_admin_groups",
"cli_path": "chat group list-my-groups"
},
{
"canonical_path": "chat.add_group_member",
"cli_path": "chat group members add"
},
{
"canonical_path": "chat.add_robot_to_group",
"cli_path": "chat group members add-bot"
},
{
"canonical_path": "chat.remove_group_member",
"cli_path": "chat group members remove"
},
{
"canonical_path": "chat.remove_robot_in_group",
"cli_path": "chat group members remove-bot"
},
{
"canonical_path": "chat.quit_group",
"cli_path": "chat group quit"
},
{
"canonical_path": "chat.update_group_name",
"cli_path": "chat group rename"
},
{
"canonical_path": "chat.update_conv_member_roles",
"cli_path": "chat group set-admin"
},
{
"canonical_path": "chat.update_custom_group_role",
"cli_path": "chat group-role update"
"canonical_path": "chat.update_show_history_msg_option",
"cli_path": "chat group set-history"
},
{
"canonical_path": "chat.transfer_group_owner",
"cli_path": "chat group transfer-owner"
},
{
"canonical_path": "chat.update_group_icon",
"cli_path": "chat group update-icon"
},
{
"canonical_path": "chat.update_group_name",
"cli_path": "chat group rename"
},
{
"canonical_path": "chat.update_group_nick",
"cli_path": "chat group update-nick"
@@ -610,16 +278,200 @@
"cli_path": "chat group update-settings"
},
{
"canonical_path": "chat.update_notification_off",
"cli_path": "chat mute"
"canonical_path": "chat.upgrade_group_to_external",
"cli_path": "chat group upgrade-to-external"
},
{
"canonical_path": "chat.update_red_env_notification_off",
"cli_path": "chat mute-red-envelope"
"canonical_path": "chat.batch_query_group_chat_settings",
"cli_path": "chat group user-settings query"
},
{
"canonical_path": "chat.update_show_history_msg_option",
"cli_path": "chat group set-history"
"canonical_path": "chat.batch_update_group_chat_settings",
"cli_path": "chat group user-settings set"
},
{
"canonical_path": "chat.set_group_mute",
"cli_path": "chat group-mute"
},
{
"canonical_path": "chat.set_group_member_mute_list",
"cli_path": "chat group-mute-member"
},
{
"canonical_path": "chat.add_custom_group_role",
"cli_path": "chat group-role add"
},
{
"canonical_path": "chat.list_custom_group_roles",
"cli_path": "chat group-role list"
},
{
"canonical_path": "chat.query_custom_user_roles",
"cli_path": "chat group-role query-user"
},
{
"canonical_path": "chat.remove_custom_group_role",
"cli_path": "chat group-role remove"
},
{
"canonical_path": "chat.remove_custom_user_roles",
"cli_path": "chat group-role remove-user"
},
{
"canonical_path": "chat.set_custom_user_roles",
"cli_path": "chat group-role set-user"
},
{
"canonical_path": "chat.update_custom_group_role",
"cli_path": "chat group-role update"
},
{
"canonical_path": "chat.list_top_conversations",
"cli_path": "chat list-top-conversations"
},
{
"canonical_path": "chat.add_emoji_reaction",
"cli_path": "chat message add-emoji"
},
{
"canonical_path": "chat.add_message_favorite",
"cli_path": "chat message add-favorite"
},
{
"canonical_path": "chat.add_text_emotion",
"cli_path": "chat message add-text-emotion"
},
{
"canonical_path": "chat.combine_forward_messages",
"cli_path": "chat message combine-forward"
},
{
"canonical_path": "chat.create_text_emotion",
"cli_path": "chat message create-text-emotion"
},
{
"canonical_path": "chat.download_media",
"cli_path": "chat message download-media"
},
{
"canonical_path": "chat.edit_message",
"cli_path": "chat message edit"
},
{
"canonical_path": "chat.forward_message",
"cli_path": "chat message forward"
},
{
"canonical_path": "chat.forward_topic",
"cli_path": "chat message forward-topic"
},
{
"canonical_path": "chat.list_conversation_message_v2",
"cli_path": "chat message list"
},
{
"canonical_path": "chat.search_messages_by_time_range",
"cli_path": "chat message list-all"
},
{
"canonical_path": "chat.list_messages_by_ids",
"cli_path": "chat message list-by-ids"
},
{
"canonical_path": "chat.search_messages_by_sender",
"cli_path": "chat message list-by-sender"
},
{
"canonical_path": "chat.list_individual_chat_message",
"cli_path": "chat message list-direct"
},
{
"canonical_path": "chat.list_message_favorites",
"cli_path": "chat message list-favorites"
},
{
"canonical_path": "chat.list_special_focus_messages",
"cli_path": "chat message list-focused"
},
{
"canonical_path": "chat.search_at_me_message",
"cli_path": "chat message list-mentions"
},
{
"canonical_path": "chat.list_pin_messages",
"cli_path": "chat message list-pin-msg"
},
{
"canonical_path": "chat.list_topic_replies",
"cli_path": "chat message list-topic-replies"
},
{
"canonical_path": "chat.unread_message_conversation_list",
"cli_path": "chat message list-unread-conversations"
},
{
"canonical_path": "chat.query_message_send_status",
"cli_path": "chat message query-send-status"
},
{
"canonical_path": "chat.query_msg_read_status",
"cli_path": "chat message read-status"
},
{
"canonical_path": "chat.recall_message",
"cli_path": "chat message recall"
},
{
"canonical_path": "chat.recall_robot_message",
"cli_path": "chat message recall-by-bot"
},
{
"canonical_path": "chat.remove_emoji_reaction",
"cli_path": "chat message remove-emoji"
},
{
"canonical_path": "chat.remove_message_favorite",
"cli_path": "chat message remove-favorite"
},
{
"canonical_path": "chat.remove_text_emotion",
"cli_path": "chat message remove-text-emotion"
},
{
"canonical_path": "chat.reply_personal_message",
"cli_path": "chat message reply"
},
{
"canonical_path": "chat.search_messages_by_keyword",
"cli_path": "chat message search"
},
{
"canonical_path": "chat.search_messages",
"cli_path": "chat message search-advanced"
},
{
"canonical_path": "chat.send_personal_message",
"cli_path": "chat message send"
},
{
"canonical_path": "chat.send_robot_message",
"cli_path": "chat message send-by-bot"
},
{
"canonical_path": "chat.send_message_by_custom_robot",
"cli_path": "chat message send-by-webhook"
},
{
"canonical_path": "chat.create_and_send_card",
"cli_path": "chat message send-card"
},
{
"canonical_path": "chat.set_pin_message",
"cli_path": "chat message set-pin-msg"
},
{
"canonical_path": "chat.unset_pin_message",
"cli_path": "chat message unset-pin-msg"
},
{
"canonical_path": "chat.update_streaming_card",
@@ -630,12 +482,40 @@
"cli_path": "chat message update-text-emotion"
},
{
"canonical_path": "chat.update_user_group_alias",
"cli_path": "chat group update-alias"
"canonical_path": "chat.update_notification_off",
"cli_path": "chat mute"
},
{
"canonical_path": "chat.upgrade_group_to_external",
"cli_path": "chat group upgrade-to-external"
"canonical_path": "chat.search_groups",
"cli_path": "chat search"
},
{
"canonical_path": "chat.search_common_groups",
"cli_path": "chat search-common"
},
{
"canonical_path": "chat.set_top_conversation",
"cli_path": "chat set-top"
},
{
"canonical_path": "chat.shortcut_chat_messages",
"cli_path": "chat +chat-messages"
},
{
"canonical_path": "chat.shortcut_messages_send",
"cli_path": "chat +messages-send"
},
{
"canonical_path": "chat.shortcut_messages_send_card",
"cli_path": "chat +messages-send-card"
},
{
"canonical_path": "chat.shortcut_search_msg",
"cli_path": "chat +search-msg"
},
{
"canonical_path": "chat.shortcut_thread_replies",
"cli_path": "chat +thread-replies"
}
]
}
@@ -61,6 +61,18 @@
"canonical_path": "oa.list_pending_tasks",
"cli_path": "oa approval tasks"
},
{
"canonical_path": "oa.get_process_schema",
"cli_path": "oa approval form-schema"
},
{
"canonical_path": "oa.forecast_process",
"cli_path": "oa approval forecast-process"
},
{
"canonical_path": "oa.start_process_instance",
"cli_path": "oa approval create-instance"
},
{
"canonical_path": "oa.shortcut_list_pending",
"cli_path": "oa +list-pending"
@@ -1,6 +1,10 @@
{
"id": "wiki",
"tools": [
{
"canonical_path": "wiki.list_workspace_feeds",
"cli_path": "wiki feed list"
},
{
"canonical_path": "wiki.add_member",
"cli_path": "wiki member add"
@@ -999,6 +999,19 @@
"reviewed": true,
"runtime_gate": "confirm_delete"
},
"aitable.workflow_edit_example": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"cli_path": "aitable workflow edit-example",
"runtime_gate": "none"
},
"aitable.workflow_enable": {
"interface_ref": {
"product_id": "aitable-helper",
@@ -801,396 +801,6 @@
"cli_path": "chat group upgrade-to-external",
"runtime_gate": "confirm_dangerous"
},
"chat.add_conv_to_categories": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns validation and calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native category command with its real Cobra contract; runtime has no confirmation gate.",
"cli_path": "chat category add-conv",
"runtime_gate": "none"
},
"chat.create_conv_category": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns title validation and calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native category command and preserve the runtime 15-character title validation; runtime has no confirmation gate.",
"cli_path": "chat category create",
"runtime_gate": "none"
},
"chat.delete_conv_category": {
"effect": "destructive",
"risk": "high",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote category-delete helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native category deletion command with its explicit typed --yes confirmation gate.",
"cli_path": "chat category delete",
"runtime_gate": "typed_yes"
},
"chat.remove_conv_from_categories": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns validation and calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native category membership command with its real Cobra contract; runtime has no confirmation gate.",
"cli_path": "chat category remove-conv",
"runtime_gate": "none"
},
"chat.rename_conv_category": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns title validation and calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native category rename command and preserve the runtime 15-character title validation; runtime has no confirmation gate.",
"cli_path": "chat category rename",
"runtime_gate": "none"
},
"chat.grant_permission": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed permission adapter: the executable CLI normalizes grant scope and parameters before calling chat_permission_grant.",
"reviewed": true,
"review_reason": "Publish the documented chat permission grant surface without inventing a runtime confirmation gate.",
"cli_path": "chat chmod",
"runtime_gate": "none"
},
"chat.clear_all_red_point": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native red-point command; runtime has no confirmation gate.",
"cli_path": "chat clear-all-red-point",
"runtime_gate": "none"
},
"chat.clear_conversation_messages": {
"effect": "destructive",
"risk": "high",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote conversation-clear helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native clear-messages command with its explicit typed --yes confirmation gate.",
"cli_path": "chat clear-messages",
"runtime_gate": "typed_yes"
},
"chat.clear_conversation_red_point": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native red-point command; runtime has no confirmation gate.",
"cli_path": "chat clear-red-point",
"runtime_gate": "none"
},
"chat.grant_cross_org_data_access": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed cross-organization permission adapter: the executable CLI assembles scoped chat_permission_grant arguments.",
"reviewed": true,
"review_reason": "Publish the documented cross-organization data authorization command without inventing a runtime confirmation gate.",
"cli_path": "chat data-auth cross-org",
"runtime_gate": "none"
},
"chat.audit_join_group": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI validates supported audit statuses before calling a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented join-audit command and preserve the runtime AuditApprove/AuditDelete restriction.",
"cli_path": "chat group audit-join-validation",
"runtime_gate": "none"
},
"chat.list_my_groups_pagination": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native paginated group-list command.",
"cli_path": "chat group list-all",
"runtime_gate": "none"
},
"chat.list_apply_join_group_records": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native join-validation list command.",
"cli_path": "chat group list-join-validations",
"runtime_gate": "none"
},
"chat.list_group_member_by_ids": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote member lookup helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native batch group-member lookup command.",
"cli_path": "chat group members list-by-ids",
"runtime_gate": "none"
},
"chat.create_group_notice": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI assembles notice scheduling and notification arguments for a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native group notice creation command; runtime has no confirmation gate.",
"cli_path": "chat group notice create",
"runtime_gate": "none"
},
"chat.edit_group_notice": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI assembles notice update arguments for a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native group notice edit command; runtime has no confirmation gate.",
"cli_path": "chat group notice edit",
"runtime_gate": "none"
},
"chat.get_group_notice": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI validates notice IDs before calling a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native group notice detail command.",
"cli_path": "chat group notice get",
"runtime_gate": "none"
},
"chat.list_group_notices": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native group notice list command.",
"cli_path": "chat group notice list",
"runtime_gate": "none"
},
"chat.share_group_invite_url": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI enforces target/receiver exclusivity before calling a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native invite-sharing command; runtime has no confirmation gate.",
"cli_path": "chat group share-invite",
"runtime_gate": "none"
},
"chat.update_user_group_alias": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote personal-alias helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native personal group-alias command.",
"cli_path": "chat group update-alias",
"runtime_gate": "none"
},
"chat.hide_conversation": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote conversation visibility helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native hide-conversation command.",
"cli_path": "chat hide",
"runtime_gate": "none"
},
"chat.list_all_conversations": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native all-conversations list command.",
"cli_path": "chat list-all-conversations",
"runtime_gate": "none"
},
"chat.mark_message_read": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote read-state helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native mark-read command.",
"cli_path": "chat mark-read",
"runtime_gate": "none"
},
"chat.mark_conversation_unread": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote read-state helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native mark-unread command.",
"cli_path": "chat mark-unread",
"runtime_gate": "none"
},
"chat.list_message_emotion_replies": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote emotion-reply helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native batch emotion reply list command.",
"cli_path": "chat message list-emotion-replies",
"runtime_gate": "none"
},
"chat.set_top_message": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote message-top helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native set-top-message command.",
"cli_path": "chat message set-top-msg",
"runtime_gate": "none"
},
"chat.unset_top_message": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote message-top helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native unset-top-message command.",
"cli_path": "chat message unset-top-msg",
"runtime_gate": "none"
},
"chat.update_at_all_notification_off": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote notification preference helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native @all notification preference command.",
"cli_path": "chat mute-at-all",
"runtime_gate": "none"
},
"chat.update_red_env_notification_off": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote notification preference helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native red-envelope notification preference command.",
"cli_path": "chat mute-red-envelope",
"runtime_gate": "none"
},
"chat.translate": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote translation helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native chat text translation command.",
"cli_path": "chat text translate",
"runtime_gate": "none"
},
"chat.shortcut_bot_search": {
"effect": "read",
"risk": "low",
@@ -91,6 +91,21 @@
"reviewed": true,
"runtime_gate": "confirm_delete"
},
"oa.get_process_schema": {
"effect": "read", "risk": "low", "confirmation": "not_required", "idempotency": "idempotent",
"interface_mode": "mcp", "availability": "available", "reviewed": true, "runtime_gate": "none",
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,可按 processCode 只读查询表单结构。"
},
"oa.forecast_process": {
"effect": "read", "risk": "low", "confirmation": "not_required", "idempotency": "idempotent",
"interface_mode": "mcp", "availability": "available", "reviewed": true, "runtime_gate": "none",
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process,只预测流程而不创建审批实例。"
},
"oa.start_process_instance": {
"effect": "write", "risk": "high", "confirmation": "user_required", "idempotency": "non_idempotent",
"interface_mode": "mcp", "availability": "available", "reviewed": true, "runtime_gate": "typed_yes",
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;命令在调用前强制显式 --yes,未提供时直接拒绝,不进入交互确认。"
},
"oa.shortcut_list_pending": {
"effect": "read",
"risk": "low",
@@ -74,6 +74,12 @@
"interface_reason": "The CLI command routes by --type between wiki/list_wikiSpaces and drive/list_spaces, so the reviewed executable wrapper has no single direct MCP interface.",
"reviewed": true
},
"wiki.list_workspace_feeds": {
"interface_mode": "mcp",
"availability": "available",
"reviewed": true,
"review_reason": "Live wiki MCP tools/list exposes list_workspace_feeds as a read-only knowledge base activity query. The reviewed CLI leaf forwards workspaceId/maxResults/nextToken/excludeFile to the native wiki server and adds no side effects, so it keeps the same read-only posture as the sibling wiki list commands."
},
"wiki.node_copy": {
"interface_ref": {
"product_id": "doc",
@@ -7,7 +7,7 @@
"channel": "open-source"
},
"coverage": {
"source_tools": 875,
"source_tools": 850,
"matched_tools": 71
},
"tools": {
@@ -2250,6 +2250,26 @@
"dws-schema-live:none (helper/composite; Skill+Cobra)"
]
},
"aitable.workflow_edit_example": {
"agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"use_when": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
],
"avoid_when": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"examples": [
"dws aitable workflow edit-example"
],
"reviewed": true,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"source_refs": [
"internal/cli/schema_command_registry.json#aitable.workflow_edit_example",
"cobra-help:dws aitable workflow edit-example --help",
"skills/mono/references/products/aitable/aitable-workflow.md",
"mcp-contract:aitable/edit_workflow_example"
]
},
"aitable.workflow_enable": {
"agent_summary": "启用工作流。",
"use_when": [
+16 -615
View File
@@ -150,12 +150,12 @@
]
},
"chat.create_and_send_card": {
"agent_summary": "创建并向群聊或单聊发送通用流式卡片",
"agent_summary": "创建并向群聊或单聊发送互动卡片",
"use_when": [
"需要通用流式卡片交互且已准备接收会话或用户时"
"需要卡片式交互且已准备接收会话或用户时"
],
"avoid_when": [
"只发送普通文本时使用 send 或 send-by-bot;转发现有审批、日历、待办等原生产品卡片时应先取得真实 openMessageId,再使用 message forward"
"只发送普通文本时使用 send 或 send-by-bot"
],
"examples": [
"dws chat message send-card --group <openConversationId>"
@@ -263,7 +263,7 @@
"已知消息、会话和资源 ID,需要保存媒体文件时"
],
"avoid_when": [
"只查看文本消息内容时使用对应消息查询命令;本命令不是发送附件的前置步骤,本地文件发送直接使用 message send --msg-type file --file-path"
"只查看文本消息内容时使用对应消息查询命令"
],
"examples": [
"dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ."
@@ -280,10 +280,10 @@
"chat.forward_message": {
"agent_summary": "把一条已有消息转发到另一个会话",
"use_when": [
"已知同一源会话中的真实 openMessageId 与源、目标会话 ID,需要保留原消息或原生产品卡片时"
"已知源消息与源、目标会话 ID 时"
],
"avoid_when": [
"合并转发多条消息时使用 chat message combine-forward;OA 实例 ID、日历事件 ID、待办任务 ID 等产品对象 ID 不能代替消息 ID"
"合并转发多条消息时使用 chat message combine-forward"
],
"examples": [
"dws chat message forward --src-conversation-id <srcConversationId> --msg-id <openMessageId> --dest-conversation-id <destConversationId>"
@@ -340,7 +340,7 @@
"chat.get_conversation_info": {
"agent_summary": "获取群聊或单聊会话的详细信息",
"use_when": [
"已知群 ID 或单聊用户标识并需要解析会话详情时;--group、--user、--open-dingtalk-id 只能选择一个"
"已知群 ID 或用户标识并需要解析会话详情时"
],
"avoid_when": [
"按群名查找会话时使用 chat search"
@@ -383,7 +383,7 @@
"用户明确指定某个会话,并要读取消息或追溯引用回复中的原消息上下文时"
],
"avoid_when": [
"跨全部会话按时间查询时使用 chat message list-all;关键词搜索或审计应使用 message search,不要拉最近消息后在本地筛选"
"跨全部会话按时间查询时使用 chat message list-all"
],
"examples": [
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50"
@@ -663,7 +663,7 @@
"发送命令返回 openTaskId 后需要确认投递结果时"
],
"avoid_when": [
"没有 message send 真实返回的 openTaskId 时不要使用;openMessageId 和会话 ID 都不能代替 openTaskId"
"没有 openTaskId 或只需查消息内容时不要使用"
],
"examples": [
"dws chat message query-send-status --open-task-id <openTaskId>"
@@ -905,7 +905,7 @@
"chat.reply_personal_message": {
"agent_summary": "引用指定消息发送个人回复",
"use_when": [
"用户要针对某条已有消息进行引用回复,且 conversationId、openMessageId、senderOpenDingTalkId 来自同一条消息时"
"用户要针对某条已有消息进行引用回复时"
],
"avoid_when": [
"无需引用上下文的普通消息使用 chat message send"
@@ -1138,10 +1138,10 @@
"chat.send_personal_message": {
"agent_summary": "以当前用户身份发送群聊或单聊消息",
"use_when": [
"用户明确要以个人身份发送消息时;文本用 --text,图片仅在已有有效 mediaId 时用 --msg-type image --media-id,本地 PDF/DOCX/XLSX 等文件用 --msg-type file --file-path"
"用户明确要以个人身份发送文本或媒体消息时"
],
"avoid_when": [
"机器人身份或 Webhook 发送应使用对应命令;发送审批、日历或待办摘要不会创建对应产品对象"
"机器人身份或 Webhook 发送应使用对应命令"
],
"examples": [
"dws chat message send --group <openConversationId> \"项目已更新\""
@@ -1398,7 +1398,7 @@
"chat.update_group_name": {
"agent_summary": "修改指定群聊的名称",
"use_when": [
"需要把已有群聊改成用户指定的准确名称时;名称不得擅自加前缀、截断或改写,复合任务中重命名后继续完成后续步骤"
"需要给已有群聊重命名时"
],
"avoid_when": [
"只修改个人可见备注时不要使用群名称更新"
@@ -1478,10 +1478,10 @@
"chat.update_streaming_card": {
"agent_summary": "更新已发送流式卡片的内容和状态",
"use_when": [
"已有 message send-card 真实返回的 bizId,并需要追加内容或结束流式输出时;最后一次更新使用完成状态"
"已有 bizId 并需要追加内容或结束流式输出时"
],
"avoid_when": [
"创建新卡片时使用 chat message send-card;openMessageId、OA 实例 ID、日历事件 ID或待办任务 ID 不能代替 bizId"
"创建新卡片时使用 chat message send-card"
],
"examples": [
"dws chat message update-card --biz-id <bizId> --content \"处理完成\" --flow-status 2"
@@ -1596,604 +1596,6 @@
"live-dws-schema:chat.upgrade_group_to_external#FAILED"
]
},
"chat.add_conv_to_categories": {
"agent_summary": "把一个会话加入一个或多个现有自定义分组",
"use_when": [
"已从 category list 取得真实 categoryId,并要把当前账号可访问的会话归入这些分组时"
],
"avoid_when": [
"创建新分组使用 category create;移出分组使用 category remove-conv"
],
"examples": [
"dws chat category add-conv --group <openConversationId> --category-ids 123,456"
],
"reviewed": true,
"review_reason": "依据 nanrun 树形路由与当前 Cobra/多维表参考复核会话分组写入路径;要求使用真实 ID 并在复合任务中继续完成后续步骤。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.add_conv_to_categories",
"cobra-help:dws chat category add-conv",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.create_conv_category": {
"agent_summary": "创建用户自定义会话分组",
"use_when": [
"用户明确要新建手工管理的会话分组,且名称不超过 15 个字符时"
],
"avoid_when": [
"按关键词或成员自动归类应使用 category create-smart;不得静默截断、缩写或改写用户名称"
],
"examples": [
"dws chat category create --title \"项目群\""
],
"reviewed": true,
"review_reason": "迁移 nanrun 的分组标题和复合任务 guidance 到 reviewed selection;保持当前 runtime 的 15 字符校验。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.create_conv_category",
"cobra-help:dws chat category create",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.delete_conv_category": {
"agent_summary": "删除指定的用户自定义会话分组",
"use_when": [
"用户明确要删除已知 categoryId 的自定义分组时"
],
"avoid_when": [
"只想把会话移出分组时使用 category remove-conv;目标不明确时不要删除"
],
"examples": [
"dws chat category delete --category-id 123"
],
"reviewed": true,
"review_reason": "依据当前原生命令与 nanrun 树形 category 分支补齐删除能力的选择边界。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.delete_conv_category",
"cobra-help:dws chat category delete",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.remove_conv_from_categories": {
"agent_summary": "把一个会话从一个或多个自定义分组移出",
"use_when": [
"已知会话和真实 categoryId,需要解除现有分组归属时"
],
"avoid_when": [
"删除整个分组使用 category delete;加入分组使用 category add-conv"
],
"examples": [
"dws chat category remove-conv --group <openConversationId> --category-ids 123,456"
],
"reviewed": true,
"review_reason": "依据当前原生命令与 nanrun 树形 category 分支补齐会话移出分组的选择边界。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.remove_conv_from_categories",
"cobra-help:dws chat category remove-conv",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.rename_conv_category": {
"agent_summary": "重命名用户自定义会话分组",
"use_when": [
"已知 categoryId,且用户给出了不超过 15 个字符的新名称时"
],
"avoid_when": [
"不得为绕过长度限制而截断、缩写或改写;创建新分组使用 category create"
],
"examples": [
"dws chat category rename --category-id 123 --title \"新名称\""
],
"reviewed": true,
"review_reason": "迁移 nanrun 的分组标题和复合任务 guidance 到 reviewed selection;保持当前 runtime 的 15 字符校验。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.rename_conv_category",
"cobra-help:dws chat category rename",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.grant_permission": {
"agent_summary": "为指定 chat scope 和业务参数发起高风险操作授权",
"use_when": [
"目标 chat 操作因行为授权缺失而需要按 scope、目标和时效发起授权时"
],
"avoid_when": [
"不要把它当作实际发送、撤回或群管理命令;跨组织数据读取授权使用 data-auth cross-org"
],
"examples": [
"dws chat chmod chat.message:send --grant-type timed --ttl 24h --permParam openCid=<openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前授权 Cobra、dws-shared 安全规则和树形 data-auth/chmod 分支补齐 Agent 选路。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.grant_permission",
"cobra-help:dws chat chmod",
"skills/multi/dingtalk-chat/SKILL.md"
]
},
"chat.clear_all_red_point": {
"agent_summary": "清除当前用户所有会话的未读红点",
"use_when": [
"用户明确要求全部会话一键已读或清除所有红点时"
],
"avoid_when": [
"只处理一个会话时使用 clear-red-point;本命令不会删除聊天记录"
],
"examples": [
"dws chat clear-all-red-point"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 和 nanrun 会话状态树补齐全局红点清零能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.clear_all_red_point",
"cobra-help:dws chat clear-all-red-point",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.clear_conversation_messages": {
"agent_summary": "清空当前用户在指定会话中的聊天记录",
"use_when": [
"用户明确要求清空某个已知会话的本地聊天记录时"
],
"avoid_when": [
"仅清除未读红点使用 clear-red-point;未确认真实 openConversationId 时不要执行"
],
"examples": [
"dws chat clear-messages --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 和 nanrun 会话状态树补齐清空会话消息的严格目标边界。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.clear_conversation_messages",
"cobra-help:dws chat clear-messages",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.clear_conversation_red_point": {
"agent_summary": "清除指定会话的未读红点",
"use_when": [
"用户只要求把某个已知会话标为已读或清除红点时"
],
"avoid_when": [
"全部会话一键已读使用 clear-all-red-point;本命令不会删除消息"
],
"examples": [
"dws chat clear-red-point --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 和 nanrun 会话状态树补齐单会话红点清理能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.clear_conversation_red_point",
"cobra-help:dws chat clear-red-point",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.grant_cross_org_data_access": {
"agent_summary": "发起 chat 跨组织数据读取授权",
"use_when": [
"跨组织拉取聊天数据因 data scope 缺失,需要为目标组织或全部组织授权时"
],
"avoid_when": [
"发送、撤回和群管理授权使用 chat chmod;普通同组织查询不要预先授权"
],
"examples": [
"dws chat data-auth cross-org --target-org-id 439446171"
],
"reviewed": true,
"review_reason": "依据当前授权 Cobra、dws-shared 跨组织规则和 nanrun data-auth 分支补齐 Agent 选路。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.grant_cross_org_data_access",
"cobra-help:dws chat data-auth cross-org",
"skills/multi/dingtalk-chat/SKILL.md"
]
},
"chat.audit_join_group": {
"agent_summary": "审批一条群聊入群验证记录",
"use_when": [
"已从 list-join-validations 取得真实记录、申请人和邀请人 ID,需要执行 AuditApprove 或 AuditDelete 时"
],
"avoid_when": [
"列出申请使用 list-join-validations;服务端不支持 AuditIgnore、AuditRefuse 或 AuditBlock"
],
"examples": [
"dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <userId> --inviter <userId> --status AuditApprove"
],
"reviewed": true,
"review_reason": "依据 nanrun 的状态 hint 和当前 runtime 支持范围补齐入群审核选择语义。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.audit_join_group",
"cobra-help:dws chat group audit-join-validation",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_my_groups_pagination": {
"agent_summary": "分页拉取当前用户加入的全部群聊",
"use_when": [
"需要完整分页列出我加入的所有群,并沿用 nextCursor 继续读取时"
],
"avoid_when": [
"只查我创建或管理的群使用 group list-my-groups;按关键词找群使用 chat search"
],
"examples": [
"dws chat group list-all --limit 100"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun group 树补齐分页群列表能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_my_groups_pagination",
"cobra-help:dws chat group list-all",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_apply_join_group_records": {
"agent_summary": "分页拉取当前用户相关的入群验证记录",
"use_when": [
"需要查看待处理或历史入群申请,并取得后续审核所需记录 ID 时"
],
"avoid_when": [
"执行审批使用 audit-join-validation;普通群成员列表使用 group members"
],
"examples": [
"dws chat group list-join-validations --limit 20"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun group 树补齐入群验证列表能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_apply_join_group_records",
"cobra-help:dws chat group list-join-validations",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_group_member_by_ids": {
"agent_summary": "按成员 openDingTalkId 批量查询群成员详情",
"use_when": [
"已知群 openConversationId 和一组成员 openDingTalkId,需要批量取成员详情时"
],
"avoid_when": [
"列出整个群成员直接使用 group members --id;不要臆造 members list 子命令"
],
"examples": [
"dws chat group members list-by-ids --id <openConversationId> --users openDingTalkId1,openDingTalkId2"
],
"reviewed": true,
"review_reason": "迁移 nanrun 的群成员 flag guidance 到 reviewed selection,并固定 --id/--users 参数边界。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_group_member_by_ids",
"cobra-help:dws chat group members list-by-ids",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.create_group_notice": {
"agent_summary": "在指定群聊发布即时或定时群公告",
"use_when": [
"用户明确要发布群公告,并已给出群和 Markdown 正文时"
],
"avoid_when": [
"普通聊天消息使用 message send;修改已有公告使用 group notice edit"
],
"examples": [
"dws chat group notice create --group <openConversationId> --content \"今晚 22 点系统维护\""
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun notice 子树补齐群公告创建能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.create_group_notice",
"cobra-help:dws chat group notice create",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.edit_group_notice": {
"agent_summary": "整体替换指定群公告的正文与可选状态",
"use_when": [
"已从公告列表取得真实 dataId,需要修改现有群公告时"
],
"avoid_when": [
"发布新公告使用 group notice create;只查看内容使用 group notice get"
],
"examples": [
"dws chat group notice edit --group <openConversationId> --notice-id <dataId> --content \"更新后的公告\""
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun notice 子树补齐群公告编辑能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.edit_group_notice",
"cobra-help:dws chat group notice edit",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.get_group_notice": {
"agent_summary": "读取指定群公告的详情",
"use_when": [
"已知群和真实公告 dataId,需要查看正文、发布者或统计信息时"
],
"avoid_when": [
"不知道 dataId 时先用 group notice list;不要使用占位符公告 ID"
],
"examples": [
"dws chat group notice get --group <openConversationId> --notice-id <dataId>"
],
"reviewed": true,
"review_reason": "依据 nanrun 的真实 noticeId hint 和当前 Cobra 补齐公告详情选择语义。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.get_group_notice",
"cobra-help:dws chat group notice get",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_group_notices": {
"agent_summary": "分页读取指定群的已发布或定时公告",
"use_when": [
"需要列出群公告并取得后续详情或编辑使用的 dataId 时"
],
"avoid_when": [
"读取单条已知公告使用 group notice get;普通群消息使用 message list"
],
"examples": [
"dws chat group notice list --group <openConversationId> --limit 20"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun notice 子树补齐群公告列表能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_group_notices",
"cobra-help:dws chat group notice list",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.share_group_invite_url": {
"agent_summary": "把一个群的邀请链接分享到目标会话或单聊用户",
"use_when": [
"当前用户已加入源群,且要把邀请链接发给一个目标会话或一个接收人时"
],
"avoid_when": [
"只获取邀请链接使用 group invite-url;--target 与 --receiver 只能选择一个"
],
"examples": [
"dws chat group share-invite --source <sourceConversationId> --target <targetConversationId>"
],
"reviewed": true,
"review_reason": "迁移 nanrun 的 target/receiver 互斥 hint 与源群边界到 reviewed selection。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.share_group_invite_url",
"cobra-help:dws chat group share-invite",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.update_user_group_alias": {
"agent_summary": "设置仅当前用户可见的群备注",
"use_when": [
"用户要修改自己看到的群备注,而不是修改群的公开名称时"
],
"avoid_when": [
"修改所有成员看到的群名使用 group rename;修改自己群昵称使用 group update-nick"
],
"examples": [
"dws chat group update-alias --group <openConversationId> --alias-title \"客户项目群\""
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun group 树补齐群备注和群名/群昵称消歧。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.update_user_group_alias",
"cobra-help:dws chat group update-alias",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.hide_conversation": {
"agent_summary": "从当前用户的会话列表隐藏指定会话",
"use_when": [
"用户明确要隐藏一个已知会话,接受收到新消息后可能再次出现时"
],
"avoid_when": [
"免打扰使用 chat mute;退出群聊使用 group quit;本命令不会删除消息"
],
"examples": [
"dws chat hide --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话状态树补齐隐藏会话能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.hide_conversation",
"cobra-help:dws chat hide",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_all_conversations": {
"agent_summary": "分页获取当前用户的全部单聊和群聊会话",
"use_when": [
"需要完整枚举会话并沿用 nextCursor 翻页时"
],
"avoid_when": [
"只看置顶会话使用 list-top-conversations;只看群聊使用 group list-all"
],
"examples": [
"dws chat list-all-conversations --limit 50"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话树补齐全部会话列表能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_all_conversations",
"cobra-help:dws chat list-all-conversations",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.mark_message_read": {
"agent_summary": "把指定消息及之前的消息标记为已读",
"use_when": [
"已知同一会话中的真实 openMessageId,需要推进已读位置时"
],
"avoid_when": [
"只清除会话红点使用 clear-red-point;查询他人是否已读使用 message read-status"
],
"examples": [
"dws chat mark-read --conversation-id <openConversationId> --message-id <openMessageId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话状态树补齐消息已读写入能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.mark_message_read",
"cobra-help:dws chat mark-read",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.mark_conversation_unread": {
"agent_summary": "把指定会话标记为未读",
"use_when": [
"用户要稍后处理某个已知会话并将其重新标为未读时"
],
"avoid_when": [
"清除红点使用 clear-red-point;查询未读会话使用 message list-unread-conversations"
],
"examples": [
"dws chat mark-unread --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话状态树补齐标记未读能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.mark_conversation_unread",
"cobra-help:dws chat mark-unread",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_message_emotion_replies": {
"agent_summary": "批量读取多条消息的 emoji 与文字表情回应",
"use_when": [
"已从消息查询取得一组真实 openMessageId,需要汇总回应信息时"
],
"avoid_when": [
"添加或移除回应使用 add/remove-emoji 或 add/remove-text-emotion;不要传占位符 ID"
],
"examples": [
"dws chat message list-emotion-replies --msg-ids <openMessageId1>,<openMessageId2>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun message 树补齐批量消息回应读取能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_message_emotion_replies",
"cobra-help:dws chat message list-emotion-replies",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.set_top_message": {
"agent_summary": "把指定消息置顶到会话顶部",
"use_when": [
"已从目标会话取得真实且同源的 openMessageId,需要设置消息置顶时"
],
"avoid_when": [
"置顶整个会话使用 chat set-top;钉住消息使用 message set-pin-msg"
],
"examples": [
"dws chat message set-top-msg --open-conversation-id <openConversationId> --msg-id <openMessageId>"
],
"reviewed": true,
"review_reason": "迁移 nanrun 的真实消息 ID guidance 到 reviewed selection,并与会话置顶和 Pin 消歧。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.set_top_message",
"cobra-help:dws chat message set-top-msg",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.unset_top_message": {
"agent_summary": "取消指定会话中的消息置顶",
"use_when": [
"已知同源的会话和真实 openMessageId,需要取消消息置顶时"
],
"avoid_when": [
"取消会话置顶使用 chat set-top --off;取消 Pin 使用 message unset-pin-msg"
],
"examples": [
"dws chat message unset-top-msg --open-conversation-id <openConversationId> --msg-id <openMessageId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun message 树补齐取消消息置顶的选择边界。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.unset_top_message",
"cobra-help:dws chat message unset-top-msg",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.update_at_all_notification_off": {
"agent_summary": "关闭或恢复指定会话的 @所有人通知",
"use_when": [
"用户只想调整某个会话的 @所有人提醒偏好时"
],
"avoid_when": [
"关闭全部会话通知使用 chat mute;红包通知偏好使用 mute-red-envelope"
],
"examples": [
"dws chat mute-at-all --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话通知树补齐 @all 通知偏好能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.update_at_all_notification_off",
"cobra-help:dws chat mute-at-all",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.update_red_env_notification_off": {
"agent_summary": "关闭或恢复指定会话的红包通知",
"use_when": [
"用户只想调整某个会话的红包消息提醒偏好时"
],
"avoid_when": [
"关闭全部会话通知使用 chat mute;@所有人通知偏好使用 mute-at-all"
],
"examples": [
"dws chat mute-red-envelope --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话通知树补齐红包通知偏好能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.update_red_env_notification_off",
"cobra-help:dws chat mute-red-envelope",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.translate": {
"agent_summary": "把指定聊天文本翻译成目标语言",
"use_when": [
"用户给出文本并明确要求翻译为支持的语言代码时"
],
"avoid_when": [
"翻译文档或文件内容应使用对应产品能力;本命令只处理传入文本"
],
"examples": [
"dws chat text translate --query \"你好世界\" --to en_US"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun text 分支补齐聊天文本翻译能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.translate",
"cobra-help:dws chat text translate",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.shortcut_bot_search": {
"agent_summary": "搜索当前用户自己创建的机器人",
"use_when": [
@@ -3023,8 +2425,7 @@
"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget",
"按群名找群或解析群 openConversationId 时不要使用;改用 +chat-search"
"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"
],
"examples": [
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
@@ -32,6 +32,39 @@
"live-dws-schema:oa.approve_processInstance"
]
},
"oa.get_process_schema": {
"agent_summary": "查询审批模板的表单 Schema",
"use_when": ["已从 list-forms 或 search-forms 获得 processCode,需要读取字段、选项和必填规则后再填写审批时"],
"avoid_when": ["只需列出可用模板时使用 list-forms;不要把返回的 Schema 当作可直接提交的实例请求"],
"examples": ["dws oa approval form-schema --process-code <processCode>"],
"reviewed": true,
"review_reason": "公开 OA MCP tools/list 已验证 get_process_schema,按 GitHub OA 命令树手写选型。",
"source_refs": ["CommandRegistry:canonical_path=oa.get_process_schema", "cobra-help:dws oa approval form-schema", "live-mcp-tools-list:oa.get_process_schema"]
},
"oa.forecast_process": {
"agent_summary": "预测审批流程与自选审批节点",
"use_when": ["已知道 processCode 且已根据表单 Schema 组装字段,需要在发起前确认审批路径或自选节点时"],
"avoid_when": ["需要真正创建审批单时改用 create-instance;未获得字段定义时先用 form-schema"],
"examples": [
"dws oa approval forecast-process --process-code <processCode> --dept-id -1 --form-values '{\"金额\":\"100\"}'",
"dws oa approval forecast-process --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[[{\"name\":\"金额\",\"value\":\"100\"}]]}'"
],
"reviewed": true,
"review_reason": "公开 OA MCP tools/list 已验证 forecast_process;示例覆盖 Cobra 简单模式与完整请求高级模式。",
"source_refs": ["CommandRegistry:canonical_path=oa.forecast_process", "cobra-help:dws oa approval forecast-process", "live-mcp-tools-list:oa.forecast_process"]
},
"oa.start_process_instance": {
"agent_summary": "发起新的审批实例",
"use_when": ["用户确认要发起审批,且已查询表单 Schema、核对字段及审批路径后使用"],
"avoid_when": ["只需预测流程时使用 forecast-process;用户尚未确认或字段未按 Schema 核对时不要发起"],
"examples": [
"dws oa approval create-instance --process-code <processCode> --form-values '{\"事由\":\"测试\"}'",
"dws oa approval create-instance --request '{\"processCode\":\"PROC-xxx\",\"deptId\":-1,\"formComponentValues\":[{\"name\":\"事由\",\"value\":\"测试\"}],\"targetSelectActioners\":[{\"actionerKey\":\"manual-node\",\"actionerStaffIds\":[\"user-id\"]}]}'"
],
"reviewed": true,
"review_reason": "公开 OA MCP tools/list 已验证 start_process_instance;示例覆盖简单与高级请求契约,真实执行仍须在用户确认后显式添加 --yes。",
"source_refs": ["CommandRegistry:canonical_path=oa.start_process_instance", "cobra-help:dws oa approval create-instance", "live-mcp-tools-list:oa.start_process_instance"]
},
"oa.dingflow_comments": {
"agent_summary": "用户添加审批评论",
"use_when": [
@@ -220,6 +220,29 @@
"live-mcp:dws schema wiki.list_wikiSpaces"
]
},
"wiki.list_workspace_feeds": {
"agent_summary": "查询知识库的活动动态:谁在什么时间更新/上传/评论了哪些文档",
"use_when": [
"用户问某个知识库最近有什么更新、谁改了什么、有哪些评论等协作动态时",
"需要巡检知识库变更并按时间线汇总成动态摘要时"
],
"avoid_when": [
"要看知识库当前有哪些节点用 node list;库内按关键词找文档用 node search",
"要读某篇文档正文用 doc read;跨库全局找文件用 drive search"
],
"examples": [
"dws wiki feed list --workspace <workspaceId> --format json",
"dws wiki feed list --workspace <workspaceId> --limit 10 --format json"
],
"reviewed": true,
"review_reason": "人工审阅:依据 wiki MCP 实时 tools/list 的 list_workspace_feeds description/inputSchema 与 Cobra Long 手写选型文案,并与 node list / node search / doc read / drive search 划清路由边界;不改变命令身份、参数契约或接口绑定。",
"source_refs": [
"CommandRegistry:canonical_path=wiki.list_workspace_feeds",
"cobra-help:dws wiki feed list",
"Skill:skills/mono/references/products/wiki.md",
"live-mcp-tools-list:wiki.list_workspace_feeds"
]
},
"wiki.node_copy": {
"agent_summary": "将知识库中的节点复制到指定位置",
"use_when": [
+51
View File
@@ -17,6 +17,30 @@
"unmatched_tools": 85
},
"tools": {
"oa.forecast_process": {
"title": "forecast_process",
"description": "审批流程预测",
"parameters": {
"ProcessForecastPopRequest": {"type": "object", "description": "ProcessForecastPopRequest", "required": false}
},
"interface_ref": {"product_id": "oa", "rpc_name": "forecast_process"}
},
"oa.get_process_schema": {
"title": "get_process_schema",
"description": "根据表单的processCode,获取该表单的 schema 信息",
"parameters": {
"processCode": {"type": "string", "description": "需要查询schema信息的表单processCode", "required": true}
},
"interface_ref": {"product_id": "oa", "rpc_name": "get_process_schema"}
},
"oa.start_process_instance": {
"title": "审批发起表单实例",
"description": "根据用户输入审批表单内容、流程、抄送人等信息发起审批",
"parameters": {
"ProcessInstanceCreationPopRequest": {"type": "object", "description": "ProcessInstanceCreationPopRequest", "required": false}
},
"interface_ref": {"product_id": "oa", "rpc_name": "start_process_instance"}
},
"aisearch.enterprise_person_search": {
"title": "enterprise_person_search",
"description": "企业内找人搜索工具,支持按姓名、部门、职位、技能、工作职责等多维度搜索企业内人员。\\n\\n**适用场景示例**:\\n- 找一下负责智能化的人\\n- 帮我找产品经理\\n- 技术部有哪些人\\n- 张三的领导是谁\\n- 李四手下有哪些人\\n- 帮我找下王芳\\n- 谁在做大模型相关的工作\\n- 公司有哪些架构师\\n- 市场部的同事有谁\\n- 有没有会Java的同事\\n- 研发部的负责人是谁\\n- 帮我找一下跟智能客服有关的人\\n- 做设计的同事有谁\\n- 谁是技术总监\\n- 张三在哪个部门\\n\\n",
@@ -10666,6 +10690,33 @@
"rpc_name": "list_wikiSpaces"
}
},
"wiki.list_workspace_feeds": {
"title": "list_workspace_feeds",
"description": "功能概述:\r\n查询指定钉钉文档知识库的活动动态,包括谁在什么时间对哪些文档进行了更新、上传、评论等操作。通过传入 workspaceId(知识库 ID 或知识库 URL)定位目标知识库,返回动态列表,每条包含动态类型(type)、发生时间(time)和内容摘要(content)。支持游标分页(nextToken),可选排除文件相关动态(excludeFile)。\r\n\r\n适用场景:\r\n– 需要了解知识库最近有哪些文档被更新或上传了新文件。\r\n– 需要追踪知识库内的协作活动,如谁评论了哪篇文档。\r\n– 定期巡检知识库变更情况,生成动态摘要报告。\r\n\r\n注意事项:\r\n– 权限要求:操作者需具备知识库的成员权限,非成员会被拒绝访问。\r\n– 通过 workspaceId 定位知识库,支持知识库 ID(纯字符串)或知识库 URL(如 https://alidocs.dingtalk.com/i/spaces/{workspaceId}/overview),系统自动提取。\r\n– 每页默认返回 20 条动态,通过 maxResults 调整,最大 50 条。首页不传 nextToken,翻页时传入上次返回的 nextToken 值。\r\n– excludeFile 设为 true 可过滤掉文件相关的动态,默认 false 即返回全部动态类型。",
"parameters": {
"excludeFile": {
"type": "boolean",
"description": "是否排除文件相关的动态,选填,默认 false。"
},
"maxResults": {
"type": "number",
"description": "期望返回的最大动态条数,默认 20,最大 50。"
},
"nextToken": {
"type": "string",
"description": "分页游标,首次查询不传,后续翻页时传入上次返回的 nextToken。"
},
"workspaceId": {
"type": "string",
"description": "目标知识库的标识,支持两种格式:1) 知识库 ID(纯字符串);2) 知识库 URL,如 https://alidocs.dingtalk.com/i/spaces/{workspaceId}/overview,系统自动提取其中的 workspaceId。",
"required": true
}
},
"interface_ref": {
"product_id": "wiki",
"rpc_name": "list_workspace_feeds"
}
},
"wiki.node_copy": {
"title": "将指定节点复制到目标文件夹",
"description": "将指定节点复制到目标文件夹。\r\n\r\n支持的节点类型:知识库节点(文档、文件夹)、钉盘文件/文件夹。\r\nnodeId 支持文档 URL 或 dentryUuid(32 位字母数字字符串)。\r\ntargetFolderId 为目标文件夹的 dentryUuid;workspaceId 为目标知识库标识,不传 targetFolderId 时复制到该知识库根目录,如果不传 targetFolderId 和 workspaceId,默认到当前用户所在组织的「我的文档」下\r\n\r\n权限要求:\r\n- 对源节点有可查看下载权限\r\n- 对目标文件夹有写入权限\r\n\r\n注意:复制操作底层可能异步执行,异步时操作已提交但新节点 ID 无法立即返回,请稍后查看目标文件夹确认结果。",
+31 -2
View File
@@ -2,8 +2,8 @@
"version": 3,
"baseline": {
"manifest": "schema-parameter-bindings-v3",
"sha256": "sha256:ff0f76145dd27da5429434a348344c9cdf74c44eaf6f4ea5411d29d904a9066f",
"reason": "Reviewed v3 baseline after binding sheet.info --include to the include property while porting the wukong sheet info expansion flag.",
"sha256": "sha256:157aaf77922525f0cc010ce25432282c27c87f9b8ce2ef877a43724f2bc6d15b",
"reason": "Reviewed v3 baseline after combining the OA full-request wrapper bindings and exclusions with wiki.list_workspace_feeds workspaceId/maxResults/nextToken/excludeFile bindings.",
"reviewed": true
},
"removals": {
@@ -955,6 +955,9 @@
"limit": "pageSize",
"page": "pageNumber"
},
"oa.forecast_process": {
"request": "ProcessForecastPopRequest"
},
"oa.list_initiated_instances": {
"cursor": "nextToken",
"end": "endTime",
@@ -983,6 +986,9 @@
"oa.revoke_processInstance": {
"instance-id": "processInstanceId"
},
"oa.start_process_instance": {
"request": "ProcessInstanceCreationPopRequest"
},
"report.get_received_report_list": {
"end": "endTime",
"start": "startTime"
@@ -1324,6 +1330,12 @@
"limit": "pageSize",
"workspace": "workspaceId"
},
"wiki.list_workspace_feeds": {
"cursor": "nextToken",
"exclude-file": "excludeFile",
"limit": "maxResults",
"workspace": "workspaceId"
},
"wiki.node_copy": {
"folder": "targetFolderId",
"node": "nodeId",
@@ -1790,9 +1802,15 @@
"drive.apply_permission --reason": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --reason is a CLI wrapper input and does not publish a direct interface property.",
"drive.apply_permission --role": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --role is a CLI wrapper input and does not publish a direct interface property.",
"drive.apply_permission --users": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --users is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file --no-resume": "CLI-only transfer-layer flag controlling download resume behaviour; not an MCP interface parameter.",
"drive.download_file --output": "local output path",
"drive.download_file --parallel": "CLI-only transfer-layer flag controlling parallel chunk downloads; not an MCP interface parameter.",
"drive.download_file --part-size": "CLI-only transfer-layer flag controlling download chunk size; not an MCP interface parameter.",
"drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --parallel": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --parallel is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --part-size": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --part-size is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --version": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_cover --node": "Reviewed unpinned adapter: drive.get_cover has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_star_list --content-types": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --content-types is a CLI wrapper input and does not publish a direct interface property.",
@@ -1965,6 +1983,17 @@
"sheet.write_image --file": "local upload input used to obtain resourceId/resourceUrl",
"sheet.write_image --mime-type": "local upload metadata",
"sheet.write_image --name": "local upload metadata",
"oa.forecast_process --dept-id": "Conditional request wrapper: --dept-id is encoded inside ProcessForecastPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"oa.forecast_process --form-values": "Conditional request wrapper: --form-values is transformed into formComponentValues inside ProcessForecastPopRequest, so it has no independent top-level MCP property.",
"oa.forecast_process --process-code": "Conditional request wrapper: --process-code is encoded inside ProcessForecastPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"oa.start_process_instance --approvers": "Conditional request wrapper: --approvers is transformed into an approvers array inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --approvers-action-type": "Conditional request wrapper: --approvers-action-type only configures the generated approvers array inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --cc-list": "Conditional request wrapper: --cc-list is encoded inside ProcessInstanceCreationPopRequest only when supplied, so it has no independent top-level MCP property.",
"oa.start_process_instance --cc-position": "Conditional request wrapper: --cc-position only configures the generated ccList inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --dept-id": "Conditional request wrapper: --dept-id is encoded inside ProcessInstanceCreationPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"oa.start_process_instance --form-values": "Conditional request wrapper: --form-values is transformed into formComponentValues inside ProcessInstanceCreationPopRequest, so it has no independent top-level MCP property.",
"oa.start_process_instance --originator-user-id": "Conditional request wrapper: --originator-user-id is encoded inside ProcessInstanceCreationPopRequest only when supplied, so it has no independent top-level MCP property.",
"oa.start_process_instance --process-code": "Conditional request wrapper: --process-code is encoded inside ProcessInstanceCreationPopRequest together with the other simple-mode flags, so it has no independent top-level MCP property.",
"todo.add_todo_attachment --file-path": "local upload input used to construct attachmentList",
"todo.get_user_todos_in_current_org --query-all": "Local route selector: the default path calls get_user_todos_in_current_org, while --query-all switches to get_user_todos; it is not a property of the pinned default RPC.",
"todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.",
-264
View File
@@ -207,270 +207,6 @@ func TestCrossPlatformCoveragePATURLAndMutationCoverageEdges(t *testing.T) {
}
}
func TestCrossPlatformCoveragePATClassification(t *testing.T) {
patErr := &PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`}
if patErr.Error() != patErr.RawJSON || patErr.RawStderr() != patErr.RawJSON || patErr.ExitCode() != ExitCodePermission {
t.Fatalf("PATError contract changed: %#v", patErr)
}
if !IsPATError(patErr) || IsPATError(stderrors.New("plain")) {
t.Fatal("PAT error classification changed")
}
if !IsPATNoPermissionCode("PAT_NO_PERMISSION") || IsPATNoPermissionCode("UNKNOWN") {
t.Fatal("PAT permission code classification changed")
}
if code, ok := lookupCodeIn(map[string]any{
"code": 1,
"errorCode": "PAT_NO_PERMISSION",
}, patNoPermissionCodes); !ok || code != "PAT_NO_PERMISSION" {
t.Fatalf("lookupCodeIn fallback = %q, %v", code, ok)
}
if code, ok := lookupCodeIn(map[string]any{"code": "UNKNOWN"}, patNoPermissionCodes); ok || code != "" {
t.Fatalf("lookupCodeIn unknown = %q, %v", code, ok)
}
for _, tc := range []struct {
body map[string]any
want string
ok bool
}{
{body: map[string]any{"code": "PAT_NO_PERMISSION"}, want: "PAT_NO_PERMISSION", ok: true},
{body: map[string]any{"error_code": "PAT_SCOPE_AUTH_REQUIRED"}, want: "PAT_SCOPE_AUTH_REQUIRED", ok: true},
{body: map[string]any{"code": "UNKNOWN"}},
} {
code, ok := getPATErrorCode(tc.body)
if code != tc.want || ok != tc.ok {
t.Fatalf("getPATErrorCode(%v) = %q, %v", tc.body, code, ok)
}
}
if code, ok := getDWSGatewayErrorCode(map[string]any{"errorCode": "DWS_AUTH_SERVICE_FAILED"}); !ok || code != "DWS_AUTH_SERVICE_FAILED" {
t.Fatalf("gateway code = %q, %v", code, ok)
}
if !isNotLoggedInError(map[string]any{
"error": 1,
"message": "Missing service_id or access_key",
}) {
t.Fatal("missing-login response was not recognized")
}
if isNotLoggedInError(map[string]any{"message": "other"}) {
t.Fatal("ordinary response was recognized as missing login")
}
for _, body := range []map[string]any{
{"error": "failure"},
{"success": false},
{"success": "FALSE"},
} {
if !isBusinessError(body) {
t.Fatalf("business error was not recognized: %#v", body)
}
}
for _, body := range []map[string]any{
{},
{"success": true},
{"success": "true"},
} {
if isBusinessError(body) {
t.Fatalf("successful response was classified as a business error: %#v", body)
}
}
if err := ClassifyToolResultContent(map[string]any{"code": "DWS_SERVICE_UNAUTHORIZED"}); err == nil {
t.Fatal("gateway tool result was not classified")
}
if err := ClassifyToolResultContent(map[string]any{"code": "PAT_BATCH_AUTH_PENDING"}); !IsPATError(err) {
t.Fatalf("PAT tool result = %T %v", err, err)
}
if err := ClassifyToolResultContent(map[string]any{"success": true}); err != nil {
t.Fatalf("successful tool result = %v", err)
}
responseCases := []struct {
name string
text string
kind string
}{
{name: "invalid json", text: "not-json", kind: "nil"},
{name: "gateway", text: `{"code":"DWS_AUTH_SERVICE_FAILED"}`, kind: "error"},
{name: "not logged in", text: `{"message":"Missing service_id or access_key"}`, kind: "error"},
{name: "pat", text: `{"code":"PAT_NO_PERMISSION"}`, kind: "pat"},
{name: "business", text: `{"success":false,"message":"参数错误"}`, kind: "error"},
{name: "success", text: `{"success":true}`, kind: "nil"},
}
for _, tc := range responseCases {
t.Run(tc.name, func(t *testing.T) {
err := ClassifyMCPResponseText(tc.text)
switch tc.kind {
case "nil":
if err != nil {
t.Fatalf("ClassifyMCPResponseText() = %v", err)
}
case "pat":
if !IsPATError(err) {
t.Fatalf("ClassifyMCPResponseText() = %T %v", err, err)
}
default:
if err == nil {
t.Fatal("ClassifyMCPResponseText() returned nil")
}
}
})
}
if !strings.Contains(authExpiredHint(), "auth login") || !strings.Contains(notLoggedInHint(), "auth login") {
t.Fatal("authentication recovery hints lost the login command")
}
if got := ClassifyPatAuthCheck(map[string]any{"code": "PAT_LOW_RISK_NO_PERMISSION"}); got == nil {
t.Fatal("ClassifyPatAuthCheck() returned nil")
}
if got := ClassifyPatAuthCheck(map[string]any{"code": "UNKNOWN"}); got != nil {
t.Fatalf("ClassifyPatAuthCheck() = %#v", got)
}
wrapped := stderrors.Join(stderrors.New("outer"), patErr)
if got := AsPatAuthCheckError(wrapped); got != patErr {
t.Fatalf("AsPatAuthCheckError() = %#v", got)
}
if got := AsPatAuthCheckError(stderrors.New("plain")); got != nil {
t.Fatalf("AsPatAuthCheckError() = %#v", got)
}
}
func TestSuggestBusinessHintChatRecovery(t *testing.T) {
tests := []struct {
name string
body map[string]any
want string
}{
{
name: "missing group role context",
body: map[string]any{
"error": map[string]any{"code": "IM_ERROR", "message": "listRoles null"},
"summary": "context",
"code": "TOP_LEVEL",
},
want: "list-my-groups",
},
{name: "legacy open id spelling", body: map[string]any{"message": "OpendId is not in conversation"}, want: "实际加入"},
{name: "open id outside conversation", body: map[string]any{"message": "OpenId is not in conversation"}, want: "实际加入"},
{name: "operator outside source group", body: map[string]any{"message": "The operator is not in this group chat"}, want: "源群"},
{name: "missing invitation receiver", body: map[string]any{"message": "targetOpenConversationId和receiverUid不能同时为空"}, want: "--receiver"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := SuggestBusinessHint(tc.body); !strings.Contains(got, tc.want) {
t.Errorf("SuggestBusinessHint(%v) = %q, want containing %q", tc.body, got, tc.want)
}
})
}
}
func TestCrossPlatformCoveragePATSerializationAndPolicyEdges(t *testing.T) {
oldHost := hostControlProvider
oldBrowser := patBrowserProvider
t.Cleanup(func() {
SetHostControlProvider(oldHost)
SetPATOpenBrowserProvider(oldBrowser)
})
SetHostControlProvider(func() string { return "" })
if block := HostControlBlock(); block != nil {
t.Fatalf("empty host provider returned %#v", block)
}
SetHostControlProvider(func() string { return "codex" })
SetPATOpenBrowserProvider(func() bool { return false })
out := map[string]any{
"data": map[string]any{
"authUrl": " https://example.test/fe/old#%2FpersonalAuthorization%3FflowId%3Df%26userCode%3Du ",
"callbacks": map[string]any{"owner": "cli"},
},
}
ApplyHostMutations(out)
data := out["data"].(map[string]any)
if data["openBrowser"] != false || data["hostControl"] == nil || data["uri"] == "" {
t.Fatalf("host mutations = %#v", data)
}
if _, ok := data["authUrl"]; ok {
t.Fatalf("authUrl alias was not removed: %#v", data)
}
if _, ok := data["callbacks"]; ok {
t.Fatalf("legacy callbacks were not removed: %#v", data)
}
rawPolicy := cleanPATJSON(map[string]any{
"message": "organization denied",
"scope": "chat.read",
}, "PAT_ORG_POLICY_DENIED")
var policyPayload map[string]any
if err := json.Unmarshal([]byte(rawPolicy), &policyPayload); err != nil {
t.Fatalf("decode policy PAT JSON: %v", err)
}
policyData := policyPayload["data"].(map[string]any)
for key, want := range map[string]any{
"policy": "OPEN_SOURCE_ORG_SCOPE_FORBIDDEN",
"message": "organization denied",
"action": "contact_org_admin",
"openBrowser": false,
"retryable": false,
} {
if got := policyData[key]; got != want {
t.Fatalf("policy data %s = %#v, want %#v", key, got, want)
}
}
if !strings.Contains(policyData["hint"].(string), "organization denied") {
t.Fatalf("policy hint = %#v", policyData["hint"])
}
rawDefault := cleanPATJSON(map[string]any{}, "PAT_ORG_POLICY_DENIED")
var defaultPayload map[string]any
if err := json.Unmarshal([]byte(rawDefault), &defaultPayload); err != nil {
t.Fatalf("decode default policy PAT JSON: %v", err)
}
defaultData := defaultPayload["data"].(map[string]any)
if !strings.Contains(defaultData["hint"].(string), "组织策略") {
t.Fatalf("default policy hint = %#v", defaultData["hint"])
}
prepopulated := map[string]any{"data": map[string]any{
"policy": "CUSTOM",
"message": "existing",
"hint": "existing hint",
}}
applyOrgPolicyDeniedHint(prepopulated, map[string]any{"message": "ignored"})
prepopulatedData := prepopulated["data"].(map[string]any)
if prepopulatedData["policy"] != "CUSTOM" ||
prepopulatedData["message"] != "existing" ||
prepopulatedData["hint"] != "existing hint" {
t.Fatalf("prepopulated policy fields changed: %#v", prepopulatedData)
}
if got := stringValue(map[string]any{
"number": 1,
"blank": " ",
"value": " kept ",
}, "number", "blank", "value"); got != "kept" {
t.Fatalf("stringValue fallback = %q", got)
}
if got := stringValue(map[string]any{"blank": " "}, "blank", "missing"); got != "" {
t.Fatalf("stringValue empty = %q", got)
}
cleaned := stripClassFields(map[string]any{
"class": "top",
"items": []any{
map[string]any{"class": "nested", "keep": "yes"},
"scalar",
},
}).(map[string]any)
if _, ok := cleaned["class"]; ok {
t.Fatalf("top-level class was retained: %#v", cleaned)
}
items := cleaned["items"].([]any)
nested := items[0].(map[string]any)
if _, ok := nested["class"]; ok || nested["keep"] != "yes" || items[1] != "scalar" {
t.Fatalf("nested class cleanup = %#v", cleaned)
}
}
func mustParseURLForTest(t *testing.T, raw string) *url.URL {
t.Helper()
parsed, err := url.Parse(raw)
+14 -53
View File
@@ -42,24 +42,23 @@ const (
// Error is the structured repository-local error model for the Go rewrite.
type Error struct {
Category Category
Message string
Operation string
ServerKey string
Retryable bool
Category Category
Message string
Operation string
ServerKey string
Retryable bool
RetryableSet bool
RetryAfterSeconds *int64
NextRetryAt *time.Time
Reason string
Hint string
Actions []string
Examples []string
AvailableFlags []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
Reason string
Hint string
Actions []string
AvailableFlags []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
}
func (e *Error) Error() string {
@@ -170,22 +169,6 @@ func WithActions(actions ...string) Option {
}
}
// WithExamples records copyable command examples for recovery.
func WithExamples(examples ...string) Option {
return func(err *Error) {
out := make([]string, 0, len(examples))
for _, example := range examples {
if strings.TrimSpace(example) == "" {
continue
}
out = append(out, example)
}
if len(out) > 0 {
err.Examples = out
}
}
}
// WithAvailableFlags records visible local flag names for agent recovery.
func WithAvailableFlags(names ...string) Option {
return func(err *Error) {
@@ -326,12 +309,7 @@ func PrintJSON(w io.Writer, err error) error {
}
if len(typed.Actions) > 0 {
errorPayload["actions"] = typed.Actions
errorPayload["suggested_actions"] = typed.Actions
}
if len(typed.Examples) > 0 {
errorPayload["examples"] = typed.Examples
}
errorPayload["error_message"] = typed.Message
if len(typed.AvailableFlags) > 0 {
errorPayload["available_flags"] = typed.AvailableFlags
}
@@ -410,23 +388,6 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
return writeErr
}
if len(typed.Examples) > 0 {
lines := []string{
"错误信息:" + typed.Message,
"原因:" + typed.Reason,
"建议操作:",
}
for i, action := range typed.Actions {
lines = append(lines, fmt.Sprintf("%d. %s", i+1, action))
}
lines = append(lines, "示例:")
for i, example := range typed.Examples {
lines = append(lines, fmt.Sprintf("%d. %s", i+1, example))
}
_, writeErr := fmt.Fprintln(w, strings.Join(lines, "\n"))
return writeErr
}
// Line 1: Error summary
lines := []string{
fmt.Sprintf("%s %s", tui.StateMark("error"), tui.Danger(fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message))),
-40
View File
@@ -192,46 +192,6 @@ func TestPrintJSON_AvailableFlags(t *testing.T) {
}
}
func TestPrintJSON_FourPartGuidance(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewValidation(
"缺少参数",
WithReason("必须提供时间范围"),
WithActions("补充开始时间", "补充结束时间"),
WithExamples(`dws chat message list-all --start "..." --end "..." --format json`),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
for _, want := range []string{`"error_message"`, `"reason"`, `"suggested_actions"`, `"examples"`} {
if !strings.Contains(got, want) {
t.Fatalf("four-part JSON output missing %s: %q", want, got)
}
}
}
func TestPrintHuman_FourPartGuidance(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintHuman(&b, NewValidation(
"缺少参数",
WithReason("必须提供时间范围"),
WithActions("补充开始和结束时间"),
WithExamples(`dws chat message list-all --start "..." --end "..."`),
)); err != nil {
t.Fatalf("PrintHuman() error = %v", err)
}
got := b.String()
for _, want := range []string{"错误信息:", "原因:", "建议操作:", "示例:"} {
if !strings.Contains(got, want) {
t.Fatalf("four-part human output missing %q: %q", want, got)
}
}
}
func TestPrintHuman(t *testing.T) {
t.Parallel()
-28
View File
@@ -308,13 +308,6 @@ func notLoggedInHint() string {
return "请先登录:dws auth login"
}
// SuggestBusinessHint returns an actionable recovery hint for a parsed MCP
// business-error payload. Runtime callers share this entry point so product
// helpers and the generic runner do not drift.
func SuggestBusinessHint(body map[string]any) string {
return suggestForBusinessErrorText(body)
}
func suggestForBusinessErrorText(body map[string]any) string {
msg := ""
if v, ok := body["errorMsg"].(string); ok {
@@ -323,19 +316,6 @@ func suggestForBusinessErrorText(body map[string]any) string {
msg = v
} else if v, ok := body["error"].(string); ok {
msg = v
} else if nested, ok := body["error"].(map[string]any); ok {
if code, ok := nested["code"].(string); ok {
msg = code
}
if message, ok := nested["message"].(string); ok {
msg = strings.TrimSpace(msg + " " + message)
}
}
if summary, ok := body["summary"].(string); ok {
msg = strings.TrimSpace(msg + " " + summary)
}
if code, ok := body["code"].(string); ok {
msg = strings.TrimSpace(msg + " " + code)
}
switch {
case strings.Contains(msg, "搜索内容不能为空"):
@@ -346,14 +326,6 @@ func suggestForBusinessErrorText(body map[string]any) string {
return "API rate limit exceeded, wait a moment and retry"
case strings.Contains(msg, "参数错误") || strings.Contains(msg, "param error"):
return "Check input parameters. Use --help for available flags"
case strings.Contains(msg, "listRoles null"):
return "当前群的群身份或权限上下文不可用。请先用 dws chat group list-my-groups --format json 选择当前账号实际加入或管理的群,再核对群成员与权限。"
case strings.Contains(msg, "OpendId is not in conversation") || strings.Contains(msg, "OpenId is not in conversation"):
return "当前账号不在该会话中。请先用 dws chat group list-my-groups --format json 选择实际加入的群,并重新获取该会话中的真实 OpendId。"
case strings.Contains(msg, "The operator is not in this group chat"):
return "当前操作者不在源群中。请重新选择当前账号已加入的群,或先完成入群;不要只替换接收方后重复原命令。"
case strings.Contains(msg, "targetOpenConversationId和receiverUid不能同时为空"):
return "分享群邀请链接必须提供接收目标:群到群使用 --target,群到人使用 --receiver;同时确认 --source 是当前操作者已加入的源群。"
default:
return "MCP tool returned a business error; check parameters and refer to skill documentation."
}
+3 -1
View File
@@ -25,7 +25,9 @@ import (
)
// MetaFileName is the on-disk name of the bus metadata file. It lives
// alongside bus.lock and bus.sock inside the bus working directory.
// alongside bus.lock inside the bus working directory. Unix bus sockets live
// in a private per-user runtime directory so shared config filesystems do not
// need socket support.
const MetaFileName = "bus.meta"
// Meta is the JSON document written once at bus startup. Its primary
+3 -3
View File
@@ -30,9 +30,9 @@ import (
type SpawnFunc func(SpawnConfig) (pid int, err error)
// DiscoverConfig describes one discover attempt. WorkDir holds bus.lock and
// usually (on Unix) bus.sock — see dwsevent.IPCEndpoint for the short-path
// fallback when WorkDir is too deep; the caller must mkdir it with
// pkg/config.DirPerm beforehand.
// persistent bus metadata; Unix sockets live in a private per-user runtime
// directory so WorkDir may reside on a shared filesystem without socket
// support. The caller must mkdir WorkDir with pkg/config.DirPerm beforehand.
type DiscoverConfig struct {
WorkDir string
IPCEndpoint string
+2 -2
View File
@@ -69,8 +69,8 @@ type BusEntry struct {
// IPCEndpoint returns the IPC endpoint for this entry. Delegates to
// dwsevent.IPCEndpoint so status/stop dial exactly where consume and the
// bus daemon bound (including the short-path fallback when WorkDir is too
// deep for sun_path).
// bus daemon bound (a private per-user runtime path on Unix and a named pipe
// on Windows).
func (e BusEntry) IPCEndpoint() string {
hash := e.ClientIDHash
if e.IdentityHash != "" {
+36 -14
View File
@@ -17,8 +17,16 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
)
const eventRuntimeDirPrefix = "dws-event-"
func currentUserID() string {
return strconv.Itoa(os.Geteuid())
}
// MaxUnixSocketPath returns the longest Unix socket path accepted by
// bind/connect on this OS (Go rejects longer names with EINVAL before
// the syscall). sockaddr_un.sun_path is 104 bytes on darwin and the
@@ -35,17 +43,19 @@ func maxUnixSocketPath(goos string) int {
}
// IPCEndpoint returns the bus IPC endpoint for one identity: a Named Pipe
// name on Windows, otherwise bus.sock inside workDir.
// name on Windows, otherwise a deterministic Unix socket under a private
// per-user runtime directory.
//
// The canonical Unix location is <workDir>/bus.sock, but workDir derives
// from the config dir, which can be arbitrarily deep (e.g. dwssb sandboxes
// use ~/.dwssb/sandboxes/<name>/config/...). When the canonical path would
// exceed the OS sun_path limit, the socket falls back to a short
// deterministic path under os.TempDir keyed by a hash of workDir, so every
// process (consume parent, forked _bus child, status/stop tooling) that
// derives the endpoint from the same workDir agrees on the location.
// bus.lock / bus.meta / bus.log always stay in workDir — only the socket
// moves.
// Unix sockets must live on a local filesystem that supports bind(2).
// Config directories may reside on NFS, CSI, FUSE, or other shared mounts
// that reject Unix socket creation with ENOTSUPP. On Unix, the endpoint uses
// XDG_RUNTIME_DIR when it is absolute and short enough; otherwise it falls
// back to a per-UID directory under os.TempDir. The transport creates and
// validates that directory as owner-only before listening or dialing. The
// socket name is keyed by a hash of workDir so every process (consume parent,
// forked _bus child, status/stop tooling) that derives the endpoint from the
// same workDir agrees on the location. bus.lock / bus.meta / bus.log always
// stay in workDir.
//
// This is the single source of truth for endpoint derivation; the cobra
// layer and busctl must not re-implement the shape.
@@ -60,9 +70,21 @@ func ipcEndpointForOS(goos, workDir, editionName string, sourceKind SourceKind,
if goos == "windows" {
return `\\.\pipe\dws-event-` + editionName + "-" + string(sourceKind) + "-" + identityHash
}
sock := filepath.Join(workDir, "bus.sock")
if len(sock) <= maxUnixSocketPath(goos) {
return sock
return unixSocketEndpoint(goos, workDir, strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")), os.TempDir())
}
func unixSocketEndpoint(goos, workDir, runtimeDir, tempDir string) string {
socketName := "dws-evt-" + IdentityHash(workDir) + ".sock"
userDirName := eventRuntimeDirPrefix + currentUserID()
if filepath.IsAbs(runtimeDir) {
candidate := filepath.Join(runtimeDir, userDirName, socketName)
if len(candidate) <= maxUnixSocketPath(goos) {
return candidate
}
}
return filepath.Join(os.TempDir(), "dws-evt-"+IdentityHash(workDir)+".sock")
fallback := filepath.Join(tempDir, userDirName, socketName)
if len(fallback) <= maxUnixSocketPath(goos) {
return fallback
}
return filepath.Join("/tmp", userDirName, socketName)
}
+19 -1
View File
@@ -14,6 +14,7 @@
package event
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -33,10 +34,27 @@ func TestCrossPlatformCoverageEndpointPortableCoverageEdges(t *testing.T) {
if got := ipcEndpointForOS("windows", "ignored", "open", "", "hash"); got != `\\.\pipe\dws-event-open-app_stream-hash` {
t.Fatalf("Windows endpoint = %q", got)
}
if got := ipcEndpointForOS("darwin", "short", "open", SourceKindPersonalStream, "hash"); got != filepath.Join("short", "bus.sock") {
runtimeRoot := filepath.VolumeName(os.TempDir()) + string(filepath.Separator)
runtimeDir := filepath.Join(runtimeRoot, "dws-xdg-runtime")
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
workDir := "portable-xdg-workdir"
wantXDG := filepath.Join(runtimeDir, eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
if got := ipcEndpointForOS("linux", workDir, "open", SourceKindPersonalStream, "hash"); got != wantXDG {
t.Fatalf("XDG Unix endpoint = %q, want %q", got, wantXDG)
}
t.Setenv("XDG_RUNTIME_DIR", "")
if got := ipcEndpointForOS("darwin", "short", "open", SourceKindPersonalStream, "hash"); got != filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash("short")+".sock") {
t.Fatalf("short Unix endpoint = %q", got)
}
if got := ipcEndpointForOS("darwin", strings.Repeat("x", 200), "open", SourceKindAppStream, "hash"); !strings.Contains(got, "dws-evt-") {
t.Fatalf("long Unix endpoint = %q", got)
}
longTempDir := filepath.Join(string(filepath.Separator), strings.Repeat("long-temp-root", 20))
wantShortFallback := filepath.Join("/tmp", eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
if got := unixSocketEndpoint("darwin", workDir, "", longTempDir); got != wantShortFallback {
t.Fatalf("overlong temp endpoint = %q, want %q", got, wantShortFallback)
}
}
+57 -5
View File
@@ -23,6 +23,7 @@ import (
)
func TestEndpointPlatformVariants(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "")
if maxUnixSocketPath("linux") != 107 || maxUnixSocketPath("darwin") != 103 {
t.Fatal("Unix socket limits changed")
}
@@ -31,7 +32,7 @@ func TestEndpointPlatformVariants(t *testing.T) {
t.Fatalf("Windows pipe = %q", pipe)
}
short := ipcEndpointForOS("darwin", "/tmp/events", "open", SourceKindPersonalStream, "hash")
if short != filepath.Join("/tmp/events", "bus.sock") {
if short != filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash("/tmp/events")+".sock") {
t.Fatalf("short Unix endpoint = %q", short)
}
long := ipcEndpointForOS("darwin", "/"+strings.Repeat("deep/", 40), "open", SourceKindAppStream, "hash")
@@ -40,16 +41,40 @@ func TestEndpointPlatformVariants(t *testing.T) {
}
}
func TestIPCEndpointShortWorkDirUsesCanonicalPath(t *testing.T) {
workDir := "/tmp/dws/events/open/app_stream/aabbccdd00112233"
func TestIPCEndpointUsesXDGUserRuntimeDir(t *testing.T) {
tempRoot, err := filepath.EvalSymlinks("/tmp")
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
runtimeDir, err := os.MkdirTemp(tempRoot, "dws-xdg-")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(runtimeDir) })
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
workDir := "/shared/events/open/app_stream/aabbccdd00112233"
got := IPCEndpoint(workDir, "open", SourceKindAppStream, "aabbccdd00112233")
want := filepath.Join(workDir, "bus.sock")
want := filepath.Join(runtimeDir, eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
if got != want {
t.Fatalf("IPCEndpoint = %q, want %q", got, want)
}
}
func TestIPCEndpointLongWorkDirFallsBackUnderTempDir(t *testing.T) {
func TestIPCEndpointWithoutXDGUsesPerUserLocalTempDir(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "")
workDir := "/tmp/dws/events/open/app_stream/aabbccdd00112233"
got := IPCEndpoint(workDir, "open", SourceKindAppStream, "aabbccdd00112233")
want := filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
if got != want {
t.Fatalf("IPCEndpoint = %q, want %q", got, want)
}
if strings.HasPrefix(got, workDir) {
t.Fatalf("IPCEndpoint = %q, want endpoint outside workDir", got)
}
}
func TestIPCEndpointLongWorkDirUsesLocalTempDir(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "")
// Mirrors the dwssb sandbox layout that produced a 111-byte socket
// path — over macOS's 103-byte usable sun_path budget.
workDir := "/Users/zhengyubai/.dwssb/sandboxes/event-subscribe/config/events/open/personal_stream/3928ce0fb4860a52"
@@ -66,6 +91,7 @@ func TestIPCEndpointLongWorkDirFallsBackUnderTempDir(t *testing.T) {
}
func TestIPCEndpointFallbackIsDeterministicPerWorkDir(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "")
long := strings.Repeat("x", 120)
a := IPCEndpoint("/base/"+long+"/one", "open", SourceKindPersonalStream, "hash")
b := IPCEndpoint("/base/"+long+"/one", "open", SourceKindPersonalStream, "hash")
@@ -77,3 +103,29 @@ func TestIPCEndpointFallbackIsDeterministicPerWorkDir(t *testing.T) {
t.Fatalf("different workDirs collided on endpoint %q", a)
}
}
func TestIPCEndpointLongXDGPathFallsBackToTempDir(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "/"+strings.Repeat("runtime/", 30))
workDir := "/shared/events/open/personal_stream/aabbccdd00112233"
got := ipcEndpointForOS("linux", workDir, "open", SourceKindPersonalStream, "hash")
wantPrefix := filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID()) + string(filepath.Separator)
if !strings.HasPrefix(got, wantPrefix) {
t.Fatalf("IPCEndpoint = %q, want fallback under %q", got, wantPrefix)
}
if len(got) > maxUnixSocketPath("linux") {
t.Fatalf("fallback path still too long: %d > %d (%q)", len(got), maxUnixSocketPath("linux"), got)
}
}
func TestIPCEndpointLongTempDirUsesShortSystemFallback(t *testing.T) {
workDir := "/shared/events/open/personal_stream/aabbccdd00112233"
longTempDir := "/" + strings.Repeat("long-temp-root/", 20)
got := unixSocketEndpoint("linux", workDir, "", longTempDir)
want := filepath.Join("/tmp", eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
if got != want {
t.Fatalf("IPCEndpoint = %q, want short fallback %q", got, want)
}
if len(got) > maxUnixSocketPath("linux") {
t.Fatalf("short fallback path too long: %d > %d (%q)", len(got), maxUnixSocketPath("linux"), got)
}
}
@@ -73,6 +73,83 @@ func TestCrossPlatformCoverageUnixListenErrorCoverage(t *testing.T) {
}
}
func TestCrossPlatformCoverageUnixSocketDirectoryErrorCoverage(t *testing.T) {
oldLstat, oldRuntimeStat, oldMkdir := lstatSocketPath, statSocketRuntimeRoot, mkdirSocketDir
t.Cleanup(func() {
lstatSocketPath, statSocketRuntimeRoot, mkdirSocketDir = oldLstat, oldRuntimeStat, oldMkdir
})
wantErr := errors.New("synthetic socket directory failure")
if err := ensureSocketDir("relative/bus.sock", true); err == nil || !strings.Contains(err.Error(), "must be absolute") {
t.Fatalf("relative socket path error = %v", err)
}
root := shortSecureTempDir(t)
missingRootPath := filepath.Join(root, "missing-root", "dws-event-test", "bus.sock")
if err := ensureSocketDir(missingRootPath, false); err == nil || !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing runtime root error = %v", err)
}
rootInfo, err := oldLstat(root)
if err != nil {
t.Fatalf("lstat secure root: %v", err)
}
lstatSocketPath = func(path string) (os.FileInfo, error) {
if path == "/tmp" {
return fileInfoWithMode{FileInfo: rootInfo, mode: os.ModeSymlink | 0o777}, nil
}
return oldLstat(path)
}
statSocketRuntimeRoot = func(path string) (os.FileInfo, error) {
if path == "/tmp" {
return nil, wantErr
}
return oldRuntimeStat(path)
}
if err := ensureSocketDir("/tmp/dws-event-coverage/bus.sock", false); !errors.Is(err, wantErr) {
t.Fatalf("runtime root resolution error = %v", err)
}
lstatSocketPath, statSocketRuntimeRoot = oldLstat, oldRuntimeStat
rootFile := filepath.Join(root, "runtime-root-file")
if err := os.WriteFile(rootFile, []byte("not a directory"), 0o600); err != nil {
t.Fatalf("write runtime root file: %v", err)
}
if err := ensureSocketDir(filepath.Join(rootFile, "dws-event-test", "bus.sock"), false); err == nil || !strings.Contains(err.Error(), "runtime root is not a directory") {
t.Fatalf("non-directory runtime root error = %v", err)
}
mkdirSocketDir = func(string, os.FileMode) error { return wantErr }
if err := ensureSocketDir(filepath.Join(root, "mkdir-failure", "bus.sock"), true); !errors.Is(err, wantErr) {
t.Fatalf("socket directory creation error = %v", err)
}
mkdirSocketDir = oldMkdir
if err := ensureSocketDir(filepath.Join(root, "missing-socket-dir", "bus.sock"), false); err == nil || !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing socket directory error = %v", err)
}
withoutOwner := fileInfoWithoutOwner{FileInfo: rootInfo}
if err := validateSocketRuntimeRoot(root, withoutOwner, uint32(os.Geteuid())); err == nil || !strings.Contains(err.Error(), "owner") {
t.Fatalf("runtime root owner error = %v", err)
}
if err := validatePrivateSocketDir(root, withoutOwner, uint32(os.Geteuid())); err == nil || !strings.Contains(err.Error(), "owner") {
t.Fatalf("socket directory owner error = %v", err)
}
}
type fileInfoWithMode struct {
os.FileInfo
mode os.FileMode
}
func (f fileInfoWithMode) Mode() os.FileMode { return f.mode }
func (f fileInfoWithMode) IsDir() bool { return f.mode.IsDir() }
type fileInfoWithoutOwner struct{ os.FileInfo }
func (fileInfoWithoutOwner) Sys() any { return struct{}{} }
type stubNetListener struct {
close func() error
}
+98 -5
View File
@@ -16,9 +16,12 @@
package transport
import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"syscall"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
@@ -30,10 +33,13 @@ type unixListener struct {
}
var (
statSocket = os.Stat
removeSocket = os.Remove
listenUnix = net.Listen
chmodSocket = os.Chmod
statSocket = os.Stat
removeSocket = os.Remove
listenUnix = net.Listen
chmodSocket = os.Chmod
lstatSocketPath = os.Lstat
statSocketRuntimeRoot = os.Stat
mkdirSocketDir = os.Mkdir
)
func (u *unixListener) Accept() (net.Conn, error) { return u.l.Accept() }
@@ -56,11 +62,95 @@ func checkSocketPath(path string) error {
return nil
}
// ensureSocketDir makes the socket's immediate parent an owner-only
// directory and rejects unsafe pre-existing paths. The parent of that
// directory must itself either be private to the effective user (for
// XDG_RUNTIME_DIR and macOS temporary roots) or sticky (for Linux /tmp), so
// another user cannot rename the private directory out from under us.
func ensureSocketDir(path string, create bool) error {
if !filepath.IsAbs(path) {
return fmt.Errorf("transport: unix socket path must be absolute: %s", path)
}
dir := filepath.Dir(path)
root := filepath.Dir(dir)
rootInfo, err := lstatSocketPath(root)
if err != nil {
return fmt.Errorf("transport: inspect socket runtime root %s: %w", root, err)
}
// macOS exposes the system /tmp as a root-owned symlink to /private/tmp.
// Follow only that well-known alias, then apply the same ownership/sticky
// validation to its target. Arbitrary runtime-root symlinks remain rejected.
if rootInfo.Mode()&os.ModeSymlink != 0 && filepath.Clean(root) == "/tmp" {
rootInfo, err = statSocketRuntimeRoot(root)
if err != nil {
return fmt.Errorf("transport: resolve socket runtime root %s: %w", root, err)
}
}
if err := validateSocketRuntimeRoot(root, rootInfo, uint32(os.Geteuid())); err != nil {
return err
}
if create {
if err := mkdirSocketDir(dir, config.DirPerm); err != nil && !errors.Is(err, os.ErrExist) {
return fmt.Errorf("transport: create socket directory %s: %w", dir, err)
}
}
dirInfo, err := lstatSocketPath(dir)
if err != nil {
return fmt.Errorf("transport: inspect socket directory %s: %w", dir, err)
}
return validatePrivateSocketDir(dir, dirInfo, uint32(os.Geteuid()))
}
func validateSocketRuntimeRoot(path string, info os.FileInfo, effectiveUID uint32) error {
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("transport: socket runtime root is not a directory: %s", path)
}
owner, err := fileOwnerUID(info)
if err != nil {
return fmt.Errorf("transport: inspect socket runtime root owner %s: %w", path, err)
}
privateOwnerRoot := owner == effectiveUID && info.Mode().Perm()&0o022 == 0
stickyRoot := info.Mode()&os.ModeSticky != 0
if !privateOwnerRoot && !stickyRoot {
return fmt.Errorf("transport: socket runtime root is neither private nor sticky: %s", path)
}
return nil
}
func validatePrivateSocketDir(path string, info os.FileInfo, effectiveUID uint32) error {
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("transport: socket directory is not a directory: %s", path)
}
owner, err := fileOwnerUID(info)
if err != nil {
return fmt.Errorf("transport: inspect socket directory owner %s: %w", path, err)
}
if owner != effectiveUID {
return fmt.Errorf("transport: socket directory %s is owned by uid %d, want %d", path, owner, effectiveUID)
}
if perm := info.Mode().Perm(); perm != config.DirPerm {
return fmt.Errorf("transport: socket directory %s has permissions %04o, want %04o", path, perm, config.DirPerm)
}
return nil
}
func fileOwnerUID(info os.FileInfo) (uint32, error) {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, errors.New("stat result does not expose an owner uid")
}
return stat.Uid, nil
}
func listen(path string) (Listener, error) {
if err := checkSocketPath(path); err != nil {
return nil, err
}
// Stale socket cleanup. Caller holds bus.lock so this is race-safe.
if err := ensureSocketDir(path, true); err != nil {
return nil, err
}
// The private per-user parent excludes other users. The caller's bus.lock
// serializes stale-socket cleanup for processes using the same WorkDir.
if _, err := statSocket(path); err == nil {
if err := removeSocket(path); err != nil {
return nil, fmt.Errorf("transport: remove stale socket %s: %w", path, err)
@@ -82,5 +172,8 @@ func dial(path string) (net.Conn, error) {
if err := checkSocketPath(path); err != nil {
return nil, err
}
if err := ensureSocketDir(path, false); err != nil {
return nil, err
}
return net.Dial("unix", path)
}
+138 -5
View File
@@ -21,12 +21,32 @@ import (
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
)
func shortSecureTempDir(t *testing.T) string {
t.Helper()
tempRoot, err := filepath.EvalSymlinks("/tmp")
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
dir, err := os.MkdirTemp(tempRoot, "dws-et-")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
if err := os.Chmod(dir, 0o700); err != nil {
t.Fatalf("chmod temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
return dir
}
func TestListen_DialRoundtrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "bus.sock")
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
l, err := Listen(path)
if err != nil {
t.Fatalf("Listen: %v", err)
@@ -87,7 +107,7 @@ func TestListen_DialRoundtrip(t *testing.T) {
}
func TestListen_StaleSocketCleanup(t *testing.T) {
path := filepath.Join(t.TempDir(), "bus.sock")
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
// Pre-create a stale file at path (not a valid socket).
if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil {
t.Fatalf("pre-create: %v", err)
@@ -99,8 +119,121 @@ func TestListen_StaleSocketCleanup(t *testing.T) {
defer l.Close()
}
func TestCrossPlatformCoverageListenCreatesPrivateSocketDirectory(t *testing.T) {
dir := filepath.Join(shortSecureTempDir(t), "dws-event-test")
path := filepath.Join(dir, "bus.sock")
l, err := Listen(path)
if err != nil {
t.Fatalf("Listen: %v", err)
}
defer l.Close()
st, err := os.Stat(dir)
if err != nil {
t.Fatalf("stat socket directory: %v", err)
}
if mode := st.Mode().Perm(); mode != 0o700 {
t.Fatalf("socket directory mode = %04o, want 0700", mode)
}
}
func TestCrossPlatformCoverageListenRejectsWorldAccessibleSocketDirectory(t *testing.T) {
dir := filepath.Join(shortSecureTempDir(t), "dws-event-test")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatalf("chmod: %v", err)
}
if _, err := Listen(filepath.Join(dir, "bus.sock")); err == nil || !strings.Contains(err.Error(), "want 0700") {
t.Fatalf("Listen error = %v, want 0700 directory rejection", err)
}
}
func TestCrossPlatformCoverageDialRejectsWorldAccessibleSocketDirectory(t *testing.T) {
dir := filepath.Join(shortSecureTempDir(t), "dws-event-test")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatalf("chmod: %v", err)
}
if _, err := Dial(filepath.Join(dir, "bus.sock")); err == nil || !strings.Contains(err.Error(), "want 0700") {
t.Fatalf("Dial error = %v, want 0700 directory rejection", err)
}
}
func TestCrossPlatformCoverageListenRejectsSymlinkSocketDirectory(t *testing.T) {
root := shortSecureTempDir(t)
target := filepath.Join(root, "target")
if err := os.Mkdir(target, 0o700); err != nil {
t.Fatalf("mkdir target: %v", err)
}
link := filepath.Join(root, "dws-event-test")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("symlink: %v", err)
}
if _, err := Listen(filepath.Join(link, "bus.sock")); err == nil || !strings.Contains(err.Error(), "not a directory") {
t.Fatalf("Listen error = %v, want symlink directory rejection", err)
}
}
func TestCrossPlatformCoverageValidatePrivateSocketDirRejectsDifferentOwner(t *testing.T) {
dir := shortSecureTempDir(t)
st, err := os.Lstat(dir)
if err != nil {
t.Fatalf("lstat: %v", err)
}
otherUID := uint32(os.Geteuid() + 1)
if err := validatePrivateSocketDir(dir, st, otherUID); err == nil || !strings.Contains(err.Error(), "is owned by uid") {
t.Fatalf("validatePrivateSocketDir error = %v, want owner mismatch", err)
}
}
func TestCrossPlatformCoverageListenRejectsUntrustedRuntimeRoot(t *testing.T) {
root := filepath.Join(shortSecureTempDir(t), "untrusted")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatalf("mkdir root: %v", err)
}
if err := os.Chmod(root, 0o777); err != nil {
t.Fatalf("chmod root: %v", err)
}
dir := filepath.Join(root, "dws-event-test")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("mkdir socket dir: %v", err)
}
if _, err := Listen(filepath.Join(dir, "bus.sock")); err == nil || !strings.Contains(err.Error(), "neither private nor sticky") {
t.Fatalf("Listen error = %v, want untrusted runtime root rejection", err)
}
}
func TestCrossPlatformCoverageListenSharedWorkDirUsesLocalSecureRuntimeEndpoint(t *testing.T) {
root := shortSecureTempDir(t)
runtimeDir := filepath.Join(root, "runtime")
if err := os.Mkdir(runtimeDir, 0o700); err != nil {
t.Fatalf("mkdir runtime: %v", err)
}
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
sharedWorkDir := filepath.Join(root, "simulated-nfs", "events", "open", "personal_stream", "identity")
endpoint := dwsevent.IPCEndpoint(sharedWorkDir, "open", dwsevent.SourceKindPersonalStream, "identity")
if strings.HasPrefix(endpoint, sharedWorkDir) {
t.Fatalf("endpoint = %q, want socket outside shared WorkDir %q", endpoint, sharedWorkDir)
}
l, err := Listen(endpoint)
if err != nil {
t.Fatalf("Listen on local runtime endpoint: %v", err)
}
defer l.Close()
if mode, err := os.Stat(filepath.Dir(endpoint)); err != nil {
t.Fatalf("stat runtime socket directory: %v", err)
} else if mode.Mode().Perm() != 0o700 {
t.Fatalf("runtime socket directory mode = %04o, want 0700", mode.Mode().Perm())
}
}
func TestListen_CloseUnlinksSocket(t *testing.T) {
path := filepath.Join(t.TempDir(), "bus.sock")
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
l, err := Listen(path)
if err != nil {
t.Fatalf("Listen: %v", err)
@@ -114,7 +247,7 @@ func TestListen_CloseUnlinksSocket(t *testing.T) {
}
func TestDial_NoServerReturnsError(t *testing.T) {
path := filepath.Join(t.TempDir(), "nonexistent.sock")
path := filepath.Join(shortSecureTempDir(t), "nonexistent.sock")
if _, err := Dial(path); err == nil {
t.Fatal("Dial to nonexistent socket should error")
}
@@ -124,7 +257,7 @@ func TestDial_NoServerReturnsError(t *testing.T) {
// surfaces as io.EOF to the server's Reader — the EOF signal is what bus
// uses to unregister dead consumers (plan invariant #5).
func TestReader_HandlesPeerCloseEOF(t *testing.T) {
path := filepath.Join(t.TempDir(), "bus.sock")
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
l, err := Listen(path)
if err != nil {
t.Fatalf("Listen: %v", err)
+13 -1
View File
@@ -853,6 +853,7 @@ func newAitableCommand() *cobra.Command {
dws aitable form [list|delete|update] 表单管理
dws aitable form field [list|update|hide] 表单字段管理
dws aitable form share [get|update|notify] 表单分享管理
dws aitable workflow [edit-example|create|update|enable|disable|get|list] 自动化工作流管理
dws aitable dashboard [get|create|update|delete|config-example] 仪表盘管理
dws aitable chart [get|create|update|delete|widgets-example] 图表管理
dws aitable export data 数据导出
@@ -3260,6 +3261,17 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
},
}
workflowEditExampleCmd := &cobra.Command{
Use: "edit-example",
Short: "获取工作流编辑文档与示例",
Long: `返回服务端提供的 AI 表格工作流编辑文档与示例。
可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。`,
Example: ` dws aitable workflow edit-example`,
RunE: func(cmd *cobra.Command, args []string) error {
return callAitableTool("edit_workflow_example", map[string]any{})
},
}
workflowUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新并发布已有自动化工作流",
@@ -4856,7 +4868,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
workflowListCmd.Flags().Int("limit", 0, "分页大小 [1, 100],不传走服务端默认 20")
workflowListCmd.Flags().Int("offset", 0, "分页偏移量,>= 0,不传走服务端默认 0")
workflowCmd.AddCommand(
workflowCreateCmd, workflowUpdateCmd,
workflowEditExampleCmd, workflowCreateCmd, workflowUpdateCmd,
workflowEnableCmd, workflowDisableCmd,
workflowGetCmd, workflowListCmd,
)
@@ -94,6 +94,23 @@ func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
}
}
func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil, "edit-example")
if err != nil {
t.Fatalf("workflow edit-example returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
}
call := caller.calls[0]
if call.productID != "aitable" || call.toolName != "edit_workflow_example" {
t.Fatalf("tool call = %s/%s, want aitable/edit_workflow_example", call.productID, call.toolName)
}
if len(call.args) != 0 {
t.Fatalf("tool args = %#v, want empty arguments", call.args)
}
}
func TestAitableWorkflowUpdateReadsDSLFile(t *testing.T) {
path := t.TempDir() + "/workflow.json"
if err := os.WriteFile(path, []byte(`{"version":"workflow-dsl/v1","name":"updated"}`), 0o600); err != nil {
+27 -251
View File
@@ -56,20 +56,11 @@ const maxConversationCategoryTitleRunes = 15
func validatedConversationCategoryTitle(raw string) (string, error) {
title := strings.TrimSpace(raw)
if title == "" {
return "", apperrors.NewValidation(
"--title 不能为空",
apperrors.WithReason("invalid_category_title"),
apperrors.WithHint("请提供 1 到 15 个字符的分组标题,并保持用户指定原文。"),
apperrors.WithActions("补充非空 --title", "运行当前命令 --help 查看示例"),
)
return "", apperrors.NewValidation("--title 不能为空")
}
if count := utf8.RuneCountInString(title); count > maxConversationCategoryTitleRunes {
return "", apperrors.NewValidation(
fmt.Sprintf("--title 当前 %d 个字符,最多 %d 个字符", count, maxConversationCategoryTitleRunes),
apperrors.WithReason("category_title_too_long"),
apperrors.WithHint("不得静默截断、缩写或改写用户指定名称;请让用户提供合法标题后重试。"),
apperrors.WithActions("请用户将标题缩短到 15 个字符以内", "使用用户确认后的标题原文重试"),
)
if utf8.RuneCountInString(title) > maxConversationCategoryTitleRunes {
return "", apperrors.NewValidation(fmt.Sprintf(
"--title 最多 %d 个字符", maxConversationCategoryTitleRunes))
}
return title, nil
}
@@ -340,25 +331,6 @@ func containsMessageMention(text, placeholder string) bool {
}
}
func chatGuidanceError(message, reason string, actions, examples []string) error {
return apperrors.NewValidation(
message,
apperrors.WithReason(reason),
apperrors.WithActions(actions...),
apperrors.WithExamples(examples...),
)
}
func isLikelyPlaceholderID(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized == "" ||
normalized == "0" ||
strings.Contains(normalized, "placeholder") ||
strings.HasPrefix(normalized, "test_") ||
strings.HasPrefix(normalized, "test-") ||
strings.HasPrefix(normalized, "<")
}
func resolveOpenDingTalkID(ctx context.Context, value string) (string, error) {
ids, err := resolveOpenDingTalkIDs(ctx, []string{value})
if err != nil {
@@ -1709,27 +1681,7 @@ func newChatCommand() *cobra.Command {
if atAll && !strings.Contains(text, "<@all>") {
text = "<@all> " + text
}
var addedMentions []string
for _, rawID := range atOpenIds {
id := strings.TrimSpace(rawID)
if id == "" {
continue
}
wrapped := "<@" + id + ">"
if !containsMessageMention(text, wrapped) && !containsMessageMention(text, "@"+id) {
addedMentions = append(addedMentions, wrapped)
}
}
if len(addedMentions) > 0 {
text = strings.Join(addedMentions, " ") + " " + text
fmt.Fprintf(
os.Stderr,
"错误信息:检测到 --at-open-dingtalk-ids,但正文缺少对应 @ 占位符;CLI 已自动补齐\n原因:钉钉群消息只有正文包含 <@openDingTalkId> 时才会真正展示 @ 提醒\n建议操作:\n1. 后续命令请在 --text 中显式写入对应占位符\n示例:\n1. dws chat message send --group <openConversationId> --at-open-dingtalk-ids %s --text %q --format json\n",
atOpenIdsStr,
text,
)
}
// 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 保持不变
// 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 不变
text = normalizeAtPlaceholders(text, atOpenIds, true)
// 群聊统一走 openDingTalkId @ 人接口。
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
@@ -2039,12 +1991,7 @@ func newChatCommand() *cobra.Command {
return fmt.Errorf("--sender-user-id and --sender-open-dingtalk-id are mutually exclusive, specify exactly one")
}
if senderUserID == "" && senderOpenDingTalkID == "" {
return chatGuidanceError(
"缺少消息发送者标识",
"list-by-sender 查询的是指定对方发送的消息,必须提供对方的 userId 或 openDingTalkId",
[]string{"使用 --sender-user-id 传入对方 userId", "或使用 --sender-open-dingtalk-id 传入对方 openDingTalkId"},
[]string{`dws chat message list-by-sender --sender-user-id <对方userId> --start "2026-07-14T00:00:00+08:00" --format json`},
)
return fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required")
}
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
if err != nil {
@@ -2235,25 +2182,6 @@ func newChatCommand() *cobra.Command {
# 查询单聊会话 ID: dws chat conversation-info --user <userId>
# 查询人员: dws contact user search --keyword "姓名" --format json`,
RunE: func(cmd *cobra.Command, args []string) error {
hasCondition := false
for _, name := range []string{
"query", "keyword", "user", "users", "userId", "sender-ids", "senders", "sender",
"at-ids", "conversation-ids", "groups", "group", "message-type",
"conversation-type", "search-conv-type", "start", "end",
} {
if value, _ := cmd.Flags().GetString(name); strings.TrimSpace(value) != "" {
hasCondition = true
break
}
}
if !hasCondition {
atMe, _ := cmd.Flags().GetBool("at-me")
hasCondition = atMe || cmd.Flags().Changed("only-robot") || cmd.Flags().Changed("only-robot-messages")
}
if !hasCondition {
return apperrors.NewValidation("at least one search condition is required")
}
toolArgs := map[string]any{}
// The CLI primary is --query; the IM MCP field is still named "keyword".
@@ -3048,7 +2976,6 @@ func newChatCommand() *cobra.Command {
chatCategoryDeleteCmd := &cobra.Command{
Use: "delete",
Short: "删除用户自定义会话分组",
Long: "删除用户自定义会话分组。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。",
Example: ` dws chat category delete --category-id <分组ID>
# 分组ID 可通过 dws chat category list 获取`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -3056,14 +2983,6 @@ func newChatCommand() *cobra.Command {
if categoryId == 0 {
return fmt.Errorf("flag --category-id is required")
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"删除会话分组不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标分组及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标会话分组", "获得用户确认后使用 --yes 执行"),
)
}
return callMCPToolOnServer("im", "delete_conv_category", map[string]any{
"categoryId": categoryId,
})
@@ -3139,16 +3058,6 @@ func newChatCommand() *cobra.Command {
if err != nil {
return fmt.Errorf("--category-ids: %w", err)
}
for _, categoryID := range categoryIds {
if categoryID > 0 && categoryID < 1000 {
return chatGuidanceError(
"会话分组 ID 看起来仍是示例占位值",
"--category-ids 必须来自 chat category list 返回的真实分组 ID;123、456 等短示例值不能直接用于移出操作",
[]string{"先查询当前用户的会话分组", "从结果读取真实 categoryId 后再执行移出"},
[]string{`dws chat category list --format json`, `dws chat category remove-conv --group <openConversationId> --category-ids <categoryId> --format json`},
)
}
}
return callMCPToolOnServer("im", "remove_conv_from_categories", map[string]any{
"openConversationId": groupID,
"categoryIds": categoryIds,
@@ -3220,16 +3129,6 @@ func newChatCommand() *cobra.Command {
return err
}
msgIds := parseCSVValues(mustGetFlag(cmd, "msg-ids"))
for _, msgID := range msgIds {
if isLikelyPlaceholderID(msgID) {
return chatGuidanceError(
"消息 ID 仍是占位符,无法查询真实消息",
"--msg-ids 必须来自消息列表返回的真实 openMsgId,test_msg_id_placeholder 等示例值不会命中消息",
[]string{"先执行 chat message list 获取目标消息", "从结果读取 openMsgId 后替换占位符"},
[]string{`dws chat message list-by-ids --msg-ids <openMsgId> --format json`},
)
}
}
if len(msgIds) > 50 {
return fmt.Errorf("--msg-ids 最多支持 50 条,当前 %d 条", len(msgIds))
}
@@ -3253,14 +3152,6 @@ func newChatCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "msg-id", "emoji"); err != nil {
return err
}
if msgID := mustGetFlag(cmd, "msg-id"); isLikelyPlaceholderID(msgID) {
return chatGuidanceError(
"消息 ID 仍是占位符,无法添加表情回应",
"--msg-id 必须是目标消息真实的 openMsgId,不能使用测试占位符",
[]string{"先拉取目标会话消息", "读取目标消息的 openMsgId 后重试"},
[]string{`dws chat message add-emoji --conversation-id <openConversationId> --msg-id <openMsgId> --emoji "赞" --format json`},
)
}
return callMCPToolOnServer("im", "add_emoji_reaction", map[string]any{
"openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"),
"openMsgId": mustGetFlag(cmd, "msg-id"),
@@ -3543,14 +3434,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
conversationID := mustGetFlag(cmd, "open-conversation-id")
messageID := mustGetFlag(cmd, "message-id")
outputPath := mustGetFlag(cmd, "output")
if isLikelyPlaceholderID(resourceID) || isLikelyPlaceholderID(messageID) {
return chatGuidanceError(
"媒体资源参数仍包含占位符",
"--resource-id 和 --message-id 必须来自同一条真实消息,不能使用 test-media 或 test_msg_id_placeholder",
[]string{"先拉取包含媒体的目标消息", "从同一条消息读取 mediaId、openMessageId 和 openConversationId"},
[]string{`dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/ --format json`},
)
}
jsonMode := deps.Caller.Format() == "json"
switch resourceType {
case "mediaId":
@@ -3572,7 +3456,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
ctx := context.Background()
// Step 1: 获取下载 URL
deps.Out.PrintInfo("[1/2] 获取资源下载链接...")
if !jsonMode {
deps.Out.PrintInfo("[1/2] 获取资源下载链接...")
}
text, err := callMCPToolReturnTextOnServer(ctx, "im", "get_resource_download_url", map[string]any{
"resourceType": resourceType,
"resourceId": resourceID,
@@ -3605,11 +3491,20 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
}
// Step 2: HTTP GET 下载文件
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载资源到 %s ...", outputPath))
if !jsonMode {
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载资源到 %s ...", outputPath))
}
if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil {
return err
}
if jsonMode {
return deps.Out.PrintJSONUnescaped(map[string]any{
"success": true,
"downloadUrl": resourceURL,
"output": outputPath,
})
}
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
return nil
},
@@ -3663,12 +3558,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
newOwner = newOwnerUserID
}
if newOwner == "" {
return chatGuidanceError(
"缺少新群主标识",
"--new-owner 接收新群主 openDingTalkId,--user 接收新群主 userId,二者必须选择一个",
[]string{"先查询新群主的人员标识", "使用 --new-owner 或 --user 之一"},
[]string{`dws chat group transfer-owner --group <openConversationId> --new-owner <openDingTalkId> --format json`},
)
return fmt.Errorf("flag --new-owner or --user is required")
}
if !isOpenDingTalkID(newOwner) {
return callMCPToolOnServer("im", "transfer_group_owner", map[string]any{
@@ -3818,36 +3708,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
return fmt.Errorf("flag --status is required (0=关闭, 1=开启)")
}
status, _ := cmd.Flags().GetInt("status")
settingKey := mustGetFlag(cmd, "setting-key")
validSettingKeys := map[string]bool{
"authority": true, "joinValidation": true, "onlyAdminCanAtAll": true,
"searchable": true, "addFriendForbidden": true, "toolbarStatus": true,
"pluginCustomizeVerify": true, "onlyAdminCanDING": true,
"allMembersCanCreateMcsConf": true, "onlyAdminCanSetMsgTop": true,
"onlyAdminCanPinMsg": true, "onlyAdminCanSendFile": true,
"allMembersCanCreateCalendar": true, "groupEmailDisabled": true,
"groupRedEnvelopeSwitch": true, "groupLiveAuthority": true,
"groupBillAuthority": true,
}
if !validSettingKeys[settingKey] {
return chatGuidanceError(
"不支持的群设置项:"+settingKey,
"--setting-key 必须使用当前接口支持的精确枚举值,on 不是设置项名称",
[]string{"从 --help 列表选择合法 setting-key", "开启或关闭通过 --status 1/0 表达"},
[]string{`dws chat group update-settings --group <openConversationId> --setting-key searchable --status 1 --format json`},
)
}
if status != 0 && status != 1 {
return chatGuidanceError(
"群设置值只能是 0 或 1",
"--status 表示开关状态:0=关闭,1=开启;其他整数不会被服务端接受",
[]string{"关闭设置时传 --status 0", "开启设置时传 --status 1"},
[]string{`dws chat group update-settings --group <openConversationId> --setting-key searchable --status 1 --format json`},
)
}
return callMCPToolOnServer("im", "update_group_settings", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"settingKey": settingKey,
"settingKey": mustGetFlag(cmd, "setting-key"),
"status": status,
})
},
@@ -3936,14 +3799,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "src-conversation-id", "msg-id", "dest-conversation-id"); err != nil {
return err
}
if msgID := mustGetFlag(cmd, "msg-id"); isLikelyPlaceholderID(msgID) {
return chatGuidanceError(
"转发消息 ID 仍是占位符",
"--msg-id 必须是源会话中真实消息的 openMessageId",
[]string{"先拉取源会话消息", "确认消息属于 --src-conversation-id 后读取 openMessageId"},
[]string{`dws chat message forward --src-conversation-id <源会话ID> --msg-id <openMessageId> --dest-conversation-id <目标会话ID> --format json`},
)
}
toolArgs := map[string]any{
"srcOpenCid": mustGetFlag(cmd, "src-conversation-id"),
"srcOpenMessageId": mustGetFlag(cmd, "msg-id"),
@@ -4088,21 +3943,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if !off {
muteTime, _ := cmd.Flags().GetInt64("mute-time")
if muteTime <= 0 {
return chatGuidanceError(
"禁言时必须提供 --mute-time",
"--mute-time 的单位是毫秒,仅支持 5 分钟、1 小时、1 天、7 天或 30 天对应的固定值",
[]string{"选择支持的毫秒值之一", "取消禁言时改用 --off"},
[]string{`dws chat group-mute-member --group <openConversationId> --user <userId> --mute-time 300000 --format json`},
)
}
validMuteTimes := map[int64]bool{300000: true, 3600000: true, 86400000: true, 604800000: true, 2592000000: true}
if !validMuteTimes[muteTime] {
return chatGuidanceError(
"不支持的禁言时长",
"--mute-time 使用毫秒且只支持 300000、3600000、86400000、604800000、2592000000;300 表示的时间不在支持范围内",
[]string{"5 分钟使用 300000", "从支持的五档时长中选择"},
[]string{`dws chat group-mute-member --group <openConversationId> --user <userId> --mute-time 300000 --format json`},
)
return fmt.Errorf("--mute-time is required when muting (supported: 300000/3600000/86400000/604800000/2592000000)")
}
toolArgs["muteTime"] = muteTime
}
@@ -4269,14 +4110,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "group", "role-id", "name"); err != nil {
return err
}
if roleID := mustGetFlag(cmd, "role-id"); isLikelyPlaceholderID(roleID) {
return chatGuidanceError(
"群身份 ID 仍是占位符",
"--role-id 必须来自 chat group-role list 返回的真实 openRoleId,0 不是有效群身份 ID",
[]string{"先列出目标群的群身份", "从结果读取 openRoleId 后重试"},
[]string{`dws chat group-role list --group <openConversationId> --format json`, `dws chat group-role update --group <openConversationId> --role-id <openRoleId> --name "新名称" --format json`},
)
}
return callMCPToolOnServer("im", "update_custom_group_role", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"openRoleId": mustGetFlag(cmd, "role-id"),
@@ -4571,28 +4404,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "src-conversation-id", "msg-ids", "dest-conversation-id"); err != nil {
return err
}
msgIDs := parseCSVValues(mustGetFlag(cmd, "msg-ids"))
if len(msgIDs) < 2 {
return chatGuidanceError(
"合并转发至少需要两条消息",
"--msg-ids 只有一条消息时不构成合并转发;单条消息应使用 message forward",
[]string{"提供至少两个来自同一源会话的 openMessageId", "只有一条时改用 message forward"},
[]string{`dws chat message combine-forward --src-conversation-id <源会话ID> --msg-ids <id1>,<id2> --dest-conversation-id <目标会话ID> --format json`},
)
}
for _, msgID := range msgIDs {
if isLikelyPlaceholderID(msgID) {
return chatGuidanceError(
"合并转发消息列表包含占位符",
"--msg-ids 必须全部是源会话中的真实 openMessageId",
[]string{"先拉取源会话消息", "选择至少两个真实消息 ID"},
[]string{`dws chat message combine-forward --src-conversation-id <源会话ID> --msg-ids <id1>,<id2> --dest-conversation-id <目标会话ID> --format json`},
)
}
}
toolArgs := map[string]any{
"srcOpenCid": mustGetFlag(cmd, "src-conversation-id"),
"srcOpenMessageIds": msgIDs,
"srcOpenMessageIds": parseCSVValues(mustGetFlag(cmd, "msg-ids")),
"destOpenCid": mustGetFlag(cmd, "dest-conversation-id"),
}
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
@@ -4628,14 +4442,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "src-msg-id", "src-conversation-id", "src-thread-id", "dest-conversation-id"); err != nil {
return err
}
if msgID := mustGetFlag(cmd, "src-msg-id"); isLikelyPlaceholderID(msgID) {
return chatGuidanceError(
"话题转发的源消息 ID 仍是占位符",
"--src-msg-id 必须来自源话题中的真实 openMessageId,并与源会话和 thread-id 对应",
[]string{"先拉取源会话的话题消息", "从同一条消息读取 openMessageId 和 openConvThreadId"},
[]string{`dws chat message forward-topic --src-msg-id <openMessageId> --src-conversation-id <源会话ID> --src-thread-id <openConvThreadId> --dest-conversation-id <目标会话ID> --format json`},
)
}
toolArgs := map[string]any{
"srcOpenMessageId": mustGetFlag(cmd, "src-msg-id"),
"srcOpenConversationId": mustGetFlag(cmd, "src-conversation-id"),
@@ -4914,21 +4720,12 @@ status 可选值:
if err != nil {
return fmt.Errorf("--record-id must be a valid integer: %w", err)
}
status := mustGetFlag(cmd, "status")
if status != "AuditApprove" && status != "AuditDelete" {
return chatGuidanceError(
"不支持的入群审批状态:"+status,
"当前服务端仅接受大小写完全一致的 AuditApprove 或 AuditDelete;AuditRefuse 和 approve 均不可用",
[]string{"通过申请使用 AuditApprove", "拒绝或删除申请使用 AuditDelete,并可补充 --description"},
[]string{`dws chat group audit-join-validation --group <openConversationId> --record-id <recordId> --applicant <userId> --inviter <userId> --status AuditApprove --format json`},
)
}
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"applyRecordId": recordID,
"applicantUid": mustGetFlag(cmd, "applicant"),
"inviterUid": mustGetFlag(cmd, "inviter"),
"status": status,
"status": mustGetFlag(cmd, "status"),
}
if v, _ := cmd.Flags().GetString("description"); v != "" {
toolArgs["auditDescription"] = v
@@ -5054,7 +4851,7 @@ status 可选值:
chatClearMessagesCmd := &cobra.Command{
Use: "clear-messages",
Short: "清空当前用户指定会话的聊天记录",
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
- 群聊:dws chat search --query "群名"
@@ -5066,14 +4863,6 @@ status 可选值:
if convID == "" {
return fmt.Errorf("flag --conversation-id is required\n hint: dws chat clear-messages --conversation-id <openConversationId>")
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"清空会话聊天记录不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标会话及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标会话", "获得用户确认后使用 --yes 执行"),
)
}
return callMCPToolOnServer("im", "clear_conversation_messages", map[string]any{
"openConversationId": convID,
})
@@ -5416,14 +5205,6 @@ status 可选值:
if err := validateRequiredFlags(cmd, "group", "notice-id"); err != nil {
return err
}
if noticeID := mustGetFlag(cmd, "notice-id"); isLikelyPlaceholderID(noticeID) {
return chatGuidanceError(
"群公告 ID 仍是占位符",
"--notice-id 必须来自 chat group notice list 返回的真实 dataId,0 不是有效公告 ID",
[]string{"先查询目标群公告列表", "从结果读取 dataId 后重试;查询操作不需要 --dry-run"},
[]string{`dws chat group notice list --group <openConversationId> --format json`, `dws chat group notice get --group <openConversationId> --notice-id <dataId> --format json`},
)
}
return callMCPToolOnServer("im", "get_group_notice", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"dataId": mustGetFlag(cmd, "notice-id"),
@@ -5485,12 +5266,7 @@ status 可选值:
target, _ := cmd.Flags().GetString("target")
receiver, _ := cmd.Flags().GetString("receiver")
if target == "" && receiver == "" {
return chatGuidanceError(
"缺少群邀请链接的接收目标",
"--target 表示接收分享的目标会话,--receiver 表示接收分享的单聊用户,二者必须选择一个",
[]string{"分享到群或会话时使用 --target", "分享到个人时使用 --receiver openDingTalkId"},
[]string{`dws chat group share-invite --source <源群ID> --target <目标会话ID> --format json`},
)
return fmt.Errorf("--target or --receiver is required")
}
if target != "" && receiver != "" {
return fmt.Errorf("--target and --receiver are mutually exclusive")
@@ -114,20 +114,6 @@ func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) {
for _, wrap := range []bool{true, false} {
_ = normalizeAtPlaceholders("hello @u1 <@u2>", []string{"", "u1", "u2"}, wrap)
}
for _, tc := range []struct {
value string
want bool
}{
{value: "0", want: true},
{value: "test_msg_id_placeholder", want: true},
{value: "test-media", want: true},
{value: "<openMsgId>", want: true},
{value: "real-message-id", want: false},
} {
if got := isLikelyPlaceholderID(tc.value); got != tc.want {
t.Fatalf("isLikelyPlaceholderID(%q) = %v, want %v", tc.value, got, tc.want)
}
}
if got := NormalizeMessageMentions("hello @u1", []string{"u1"}, true, true); got != "<@all> hello <@u1>" {
t.Fatalf("current-user mention normalization = %q", got)
}
@@ -0,0 +1,95 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"bytes"
"context"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func TestChatDownloadMediaJSONPreservesLegacyResult(t *testing.T) {
previousDeps, previousArgs := deps, os.Args
previousHTTPGetFile := httpGetFile
os.Args = []string{"dws", "chat"}
t.Cleanup(func() {
deps = previousDeps
os.Args = previousArgs
httpGetFile = previousHTTPGetFile
})
const downloadURL = "https://download.example.test/photo.jpg?token=one&part=two"
caller := &scriptedToolCaller{
format: "json",
steps: []scriptedToolStep{{
text: `{"resourceUrl":"` + downloadURL + `"}`,
}},
}
InitDeps(caller)
var stdout bytes.Buffer
deps.Out = &Formatter{w: &stdout, errW: io.Discard}
outputDir := t.TempDir()
wantOutput := filepath.Join(outputDir, "photo.jpg")
httpGetFile = func(_ context.Context, gotURL string, _ map[string]string, gotOutput string) error {
if gotURL != downloadURL {
t.Fatalf("download URL = %q, want %q", gotURL, downloadURL)
}
if gotOutput != wantOutput {
t.Fatalf("download output = %q, want %q", gotOutput, wantOutput)
}
return nil
}
root := newChatCommand()
installExampleGlobalFlags(root)
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{
"message", "download-media",
"--type=mediaId",
"--resource-id=resource",
"--message-id=message",
"--open-conversation-id=conversation",
"--output=" + outputDir,
})
if err := root.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}
var got struct {
Success bool `json:"success"`
DownloadURL string `json:"downloadUrl"`
Output string `json:"output"`
}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout.String())
}
if !got.Success || got.DownloadURL != downloadURL || got.Output != wantOutput {
t.Fatalf("result = %#v, want success=true downloadUrl=%q output=%q", got, downloadURL, wantOutput)
}
if strings.Contains(stdout.String(), "[INFO]") {
t.Fatalf("JSON stdout contains progress text: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "?token=one&part=two") {
t.Fatalf("downloadUrl was escaped or changed: %s", stdout.String())
}
}
+2 -8
View File
@@ -63,14 +63,8 @@ func newChatMediaUploadCommand() *cobra.Command {
func chatMediaUploadDownlineError() error {
return apperrors.NewValidation(
"chat media upload 已下线,当前 CLI 不提供本地文件到 mediaId 的上传能力。"+
" 本地图片或文件请改用: "+chatMediaUploadReplacement+
"chat media upload 已下线,当前 CLI 不提供本地文件到 mediaId 的上传能力。" +
" 本地图片或文件请改用: " + chatMediaUploadReplacement +
";已有 mediaId 时可使用 dws chat message send --msg-type image --media-id <mediaId>。",
apperrors.WithReason("chat_media_upload_retired"),
apperrors.WithHint("本地图片、PDF、DOCX、XLSX 等统一通过 message send --msg-type file --file-path 发送;不要改用 drive upload。"),
apperrors.WithActions(
"本地文件:dws chat message send --msg-type file --file-path <本地路径>",
"已有图片 mediaId:dws chat message send --msg-type image --media-id <mediaId>",
),
)
}
-34
View File
@@ -16,40 +16,6 @@ import (
"github.com/spf13/cobra"
)
func TestChatConversationDestructiveCommandsRequireConfirmation(t *testing.T) {
for _, args := range [][]string{
{"category", "delete", "--category-id", "42"},
{"clear-messages", "--conversation-id", "cid-1"},
} {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand, args...)
if err == nil || !strings.Contains(err.Error(), "确认") {
t.Fatalf("chat %v error = %v, want confirmation error", args, err)
}
if len(caller.calls) != 0 {
t.Fatalf("chat %v made tool calls before confirmation: %#v", args, caller.calls)
}
}
}
func TestChatConversationDestructiveCommandsRunAfterConfirmation(t *testing.T) {
for _, tc := range []struct {
args []string
toolName string
}{
{[]string{"category", "delete", "--category-id", "42", "--yes"}, "delete_conv_category"},
{[]string{"clear-messages", "--conversation-id", "cid-1", "--yes"}, "clear_conversation_messages"},
} {
caller := &guardedMutationCaller{}
if err := executeGuardedMutationCommand(t, caller, newChatCommand, tc.args...); err != nil {
t.Fatalf("chat %v error = %v", tc.args, err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != tc.toolName {
t.Fatalf("chat %v calls = %#v, want one %s call", tc.args, caller.calls, tc.toolName)
}
}
}
type contractDefectCaller struct {
dryRun bool
calls []guardedMutationCall
+4 -2
View File
@@ -276,7 +276,8 @@ func defaultHTTPPutFile(ctx context.Context, url string, headers map[string]stri
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("OSS upload failed: HTTP %d: %s", resp.StatusCode, string(body))
// typed httpStatusError 供上层按 401/403 分支重取凭证
return fmt.Errorf("OSS upload failed: %w", &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)})
}
return nil
@@ -434,7 +435,8 @@ func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]stri
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
// typed httpStatusError 供上层按 401/403 分支重取凭证
return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
}
outFile, err := docCreateDestination(destPath)
+136 -14
View File
@@ -476,7 +476,8 @@ func newDriveCommand() *cobra.Command {
--output 指定本地保存路径,可以是文件路径或目录。
如果指定目录,文件名从下载 URL 中自动推断。`,
Example: ` dws drive download --node <dentryUuid> --output ./report.pdf
dws drive download --node <dentryUuid> --output ~/downloads/`,
dws drive download --node <dentryUuid> --output ~/downloads/
dws drive download --node <dentryUuid> --output ./big.zip --part-size 32MB --parallel 8`,
RunE: func(cmd *cobra.Command, args []string) error {
fileID := flagOrFallback(cmd, "node", "file-id")
if fileID == "" {
@@ -492,6 +493,15 @@ func newDriveCommand() *cobra.Command {
argsMap["spaceId"] = v
}
// fail-fast:分片下载参数校验
dlOpts, err := driveDownloadOptionsFromFlags(cmd)
if err != nil {
return err
}
dlOpts.logf = func(format string, a ...any) {
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "下载钉盘文件")
deps.Out.PrintKeyValue("文件ID", fileID)
@@ -499,7 +509,7 @@ func newDriveCommand() *cobra.Command {
return nil
}
ctx := context.Background()
ctx := cmd.Context()
// Step 1: 获取下载 URL 和签名请求头
deps.Out.PrintInfo("[1/2] 获取下载链接...")
@@ -508,7 +518,7 @@ func newDriveCommand() *cobra.Command {
return err
}
resourceURL, dlHeaders, err := parseDownloadInfo(text)
resourceURL, dlHeaders, err := parseDriveDownloadInfo(text)
if err != nil {
return err
}
@@ -523,9 +533,34 @@ func newDriveCommand() *cobra.Command {
outputPath = filepath.Join(outputPath, filename)
}
// Step 2: HTTP GET 下载文件
// Step 2: 分片下载(自动分派 + 401/403 凭证刷新重试)
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil {
dlOpts.knownSize = parseDownloadFileSize(text)
dlOpts.nodeID = fileID
dlOpts.version = parseDownloadFileVersion(text)
fetchCred := func(fctx context.Context) (string, map[string]string, int, error) {
t, ferr := callMCPToolReturnText(fctx, "download_file", argsMap)
if ferr != nil {
return "", nil, 0, ferr
}
u, h, perr := parseDriveDownloadInfo(t)
if perr != nil {
return "", nil, 0, perr
}
return u, h, parseDownloadFileVersion(t), nil
}
if err := driveTransferDownload(ctx, fetchCred, resourceURL, dlHeaders, outputPath, dlOpts); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
partSize, _ := cmd.Flags().GetString("part-size")
noResume, _ := cmd.Flags().GetBool("no-resume")
if partSize != "" && !noResume {
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断,已保存断点(可重新执行相同命令续传)\n")
} else {
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断\n")
}
cmd.SilenceErrors = true
return err
}
return err
}
@@ -564,6 +599,15 @@ func newDriveCommand() *cobra.Command {
return fmt.Errorf("flag --output is required")
}
// fail-fast:分片下载参数校验
dlOpts, err := driveDownloadOptionsFromFlags(cmd)
if err != nil {
return err
}
dlOpts.logf = func(format string, a ...any) {
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "下载文件历史版本")
deps.Out.PrintKeyValue("节点ID", fileID)
@@ -572,16 +616,17 @@ func newDriveCommand() *cobra.Command {
return nil
}
ctx := context.Background()
ctx := cmd.Context()
deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...")
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
dlArgsMap := map[string]any{
"nodeId": fileID,
"version": versionNum,
})
}
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", dlArgsMap)
if err != nil {
return err
}
resourceURL, dlHeaders, err := parseDownloadInfo(text)
resourceURL, dlHeaders, err := parseDriveDownloadInfo(text)
if err != nil {
return err
}
@@ -593,7 +638,32 @@ func newDriveCommand() *cobra.Command {
outputPath = filepath.Join(outputPath, filename)
}
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil {
dlOpts.knownSize = parseDownloadFileSize(text)
dlOpts.nodeID = fileID
dlOpts.version = versionNum
fetchCred := func(fctx context.Context) (string, map[string]string, int, error) {
t, ferr := callMCPToolReturnTextOnServer(fctx, "drive", "download_file_version", dlArgsMap)
if ferr != nil {
return "", nil, 0, ferr
}
u, h, perr := parseDriveDownloadInfo(t)
if perr != nil {
return "", nil, 0, perr
}
return u, h, parseDownloadFileVersion(t), nil
}
if err := driveTransferDownload(ctx, fetchCred, resourceURL, dlHeaders, outputPath, dlOpts); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
partSize, _ := cmd.Flags().GetString("part-size")
noResume, _ := cmd.Flags().GetBool("no-resume")
if partSize != "" && !noResume {
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断,已保存断点(可重新执行相同命令续传)\n")
} else {
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断\n")
}
cmd.SilenceErrors = true
return err
}
return err
}
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
@@ -709,10 +779,16 @@ func newDriveCommand() *cobra.Command {
driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
driveDownloadVersionCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
driveDownloadVersionCmd.Flags().Int("version", 0, "历史版本号 (必填,正整数,从 drive list --versions 获取)")
driveDownloadVersionCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
driveDownloadVersionCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
driveDownloadVersionCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
driveDownloadVersionCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
driveDownloadVersionCmd.Flags().String(alias, "", "")
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
@@ -2006,13 +2082,13 @@ func uploadToDrive(ctx context.Context, filePath, fileName string, fileSize int6
if err != nil {
return err
}
resourceURL, uploadID, headers, err := parseDriveUploadInfo(text)
// HTTP PUT 上传文件(OSS 与中心协议同路径;headers 透传,401/403 重取凭证重试一次)
uploadID, err := driveUploadPut(ctx, text, func(rctx context.Context) (string, error) {
return callMCPToolReturnTextOnServer(rctx, "drive", "get_upload_info", step1Args)
}, filePath, fileSize)
if err != nil {
return err
}
if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
return err
}
commitArgs := map[string]any{
"fileName": fileName,
@@ -2219,3 +2295,49 @@ func isPermissionCLIError(err error) bool {
var patErr *PATError
return errors.As(err, &patErr)
}
// parseDriveDownloadInfo 从 drive 的 download_file 返回里取下载 URL 与请求头。
// drive 返回形如 {"result":{"downloadUrl":"https://..."}};OSS 预签名 URL 自带签名参数、
// 无额外请求头;中心协议(httpToCenterWithToken)返回的 headers 含 dentry-token,
// 需原样透传。对历史字段名(resourceUrl / resourceUrls[].url)做 fallback。
func parseDriveDownloadInfo(text string) (string, map[string]string, error) {
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return "", nil, fmt.Errorf("解析 download_file 返回失败: %w", err)
}
if result, ok := data["result"].(map[string]any); ok {
data = result
}
headers := make(map[string]string)
if h, ok := data["headers"].(map[string]any); ok {
for k, v := range h {
if s, ok := v.(string); ok {
headers[k] = s
}
}
}
dlURL, _ := data["downloadUrl"].(string)
if dlURL == "" {
dlURL, _ = data["resourceUrl"].(string)
}
if dlURL == "" {
if arr, ok := data["resourceUrls"].([]any); ok && len(arr) > 0 {
if first, ok := arr[0].(map[string]any); ok {
dlURL, _ = first["url"].(string)
if h, ok := first["headers"].(map[string]any); ok {
for k, v := range h {
if s, ok := v.(string); ok {
headers[k] = s
}
}
}
}
}
}
if dlURL == "" {
return "", nil, fmt.Errorf("download_file 未返回下载链接(downloadUrl 为空)")
}
return dlURL, headers, nil
}
+847
View File
@@ -0,0 +1,847 @@
package helpers
// ──────────────────────────────────────────────────────────
// drive 传输增强:中心协议(token 化凭证)+ 分片下载(Range)
//
// 下载:文件大小 ≥ 2×part-size 时自动分片并发下载(对齐 aws s3 cp /
// ossutil 惯例),断点续传默认开启(<dest>.dwspart 临时文件 + checkpoint
// 元信息),服务端不支持 Range 时自动回退整流;401/403(凭证过期)自动
// 重新调用 MCP 取新凭证后续传。
// 上传:中心协议(uploadType=httpToCenterWithToken)与 OSS 走同一 PUT
// 路径(URL 服务端拼好、headers 透传、客户端零追加),401/403 重取凭证
// 重试一次;服务端超限错误补充可读提示。
// ──────────────────────────────────────────────────────────
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/spf13/cobra"
)
const (
driveDownloadDefaultPartSize = 16 * 1024 * 1024 // --part-size 默认 16MB
driveDownloadMinPartSize = 1 * 1024 * 1024
driveDownloadMaxPartSize = 1024 * 1024 * 1024
driveDownloadDefaultParallel = 4
driveDownloadMaxParallel = 8
// 单分片常规失败指数退避重试次数(不含 401/403 凭证刷新)。
driveDownloadPartRetries = 3
// 单分片 401/403 凭证刷新重试上限(防止无效凭证死循环)。
driveDownloadPartAuthRetries = 2
drivePartFileSuffix = ".dwspart"
drivePartMetaSuffix = ".dwspart.meta"
driveCheckpointVersion = 1
uploadTypeCenterToken = "httpToCenterWithToken"
driveTransferBodyErrCap = 2048 // 错误响应 body 截断长度
)
// driveRangeClient 分片下载/探测专用 HTTP 客户端。
// 整流路径仍走可注入的 httpGetFile,保持既有测试注入点不变。
var driveRangeClient = &http.Client{Timeout: 10 * time.Minute}
// errCredentialRefreshVersionUnknown 凭证刷新后无法验证文件版本一致性(双方之一
// version=0),激进策略要求清空已完成分片从头下载。
var errCredentialRefreshVersionUnknown = errors.New("凭证刷新后无法验证文件版本一致性,需从头下载")
// Testable OS operation hooks (package-level for coverage injection).
var (
driveJsonMarshal = json.Marshal
driveOsRename = os.Rename
driveFileTruncate = (*os.File).Truncate
driveFileSync = (*os.File).Sync
driveFileStat = (*os.File).Stat
)
// ──────────────────────────────────────────────────────────
// HTTP 状态错误
// ──────────────────────────────────────────────────────────
// httpStatusError 表示非 2xx 的 HTTP 响应,供上层按状态码分支(401/403 重取凭证等)。
type httpStatusError struct {
StatusCode int
Body string
}
func (e *httpStatusError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body)
}
// isAuthStatusError 判断错误是否为 401/403(凭证过期/无效)。
// 兼容 typed httpStatusError 与文本形态(测试注入或历史包装的 "HTTP 401/403" 错误)。
func isAuthStatusError(err error) bool {
if err == nil {
return false
}
var se *httpStatusError
if errors.As(err, &se) {
return se.StatusCode == http.StatusUnauthorized || se.StatusCode == http.StatusForbidden
}
msg := err.Error()
return strings.Contains(msg, "HTTP 401") || strings.Contains(msg, "HTTP 403")
}
// ──────────────────────────────────────────────────────────
// 参数解析
// ──────────────────────────────────────────────────────────
// parsePartSize 解析 --part-size 的人类可读值(16MB、512KB、1GB;纯数字按字节)。
func parsePartSize(s string) (int64, error) {
v := strings.TrimSpace(strings.ToUpper(s))
if v == "" {
return 0, fmt.Errorf("--part-size 不能为空(示例: 16MB、512KB、1GB)")
}
unit := int64(1)
switch {
case strings.HasSuffix(v, "GB"):
unit, v = 1<<30, strings.TrimSuffix(v, "GB")
case strings.HasSuffix(v, "MB"):
unit, v = 1<<20, strings.TrimSuffix(v, "MB")
case strings.HasSuffix(v, "KB"):
unit, v = 1<<10, strings.TrimSuffix(v, "KB")
case strings.HasSuffix(v, "G"):
unit, v = 1<<30, strings.TrimSuffix(v, "G")
case strings.HasSuffix(v, "M"):
unit, v = 1<<20, strings.TrimSuffix(v, "M")
case strings.HasSuffix(v, "K"):
unit, v = 1<<10, strings.TrimSuffix(v, "K")
case strings.HasSuffix(v, "B"):
v = strings.TrimSuffix(v, "B")
}
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil || n <= 0 {
return 0, fmt.Errorf("--part-size 格式非法: %q(示例: 16MB、512KB、1GB)", s)
}
size := n * unit
if size < driveDownloadMinPartSize || size > driveDownloadMaxPartSize {
return 0, fmt.Errorf("--part-size 取值范围 %s - %s,当前值: %s",
formatByteSize(driveDownloadMinPartSize), formatByteSize(driveDownloadMaxPartSize), s)
}
return size, nil
}
func formatByteSize(n int64) string {
switch {
case n >= 1<<30 && n%(1<<30) == 0:
return fmt.Sprintf("%dGB", n/(1<<30))
case n >= 1<<20 && n%(1<<20) == 0:
return fmt.Sprintf("%dMB", n/(1<<20))
case n >= 1<<10 && n%(1<<10) == 0:
return fmt.Sprintf("%dKB", n/(1<<10))
default:
return fmt.Sprintf("%dB", n)
}
}
// driveDownloadOptions 分片下载选项(由 drive download 的 flag 解析而来)。
type driveDownloadOptions struct {
partSize int64
parallel int
resume bool
knownSize int64 // MCP 返回的 fileSize;未知(0)或小于阈值时直接整流
nodeID string // 节点唯一标识(dentryUuid),用于生成 checkpoint 指纹
version int // 文件版本号;0 表示最新版
logf func(format string, args ...any)
}
// driveDownloadOptionsFromFlags 解析并校验 --part-size / --parallel / --no-resume。
func driveDownloadOptionsFromFlags(cmd *cobra.Command) (driveDownloadOptions, error) {
raw, _ := cmd.Flags().GetString("part-size")
partSize, err := parsePartSize(raw)
if err != nil {
return driveDownloadOptions{}, err
}
parallel, _ := cmd.Flags().GetInt("parallel")
if parallel < 1 || parallel > driveDownloadMaxParallel {
return driveDownloadOptions{}, fmt.Errorf("--parallel 取值范围 1-%d,当前值: %d", driveDownloadMaxParallel, parallel)
}
noResume, _ := cmd.Flags().GetBool("no-resume")
return driveDownloadOptions{partSize: partSize, parallel: parallel, resume: !noResume}, nil
}
// parseDownloadFileSize 从 download_file 返回中提取 fileSize(缺失/非法返回 0)。
func parseDownloadFileSize(text string) int64 {
var data map[string]any
if json.Unmarshal([]byte(text), &data) != nil {
return 0
}
if r, ok := data["result"].(map[string]any); ok {
data = r
}
switch v := data["fileSize"].(type) {
case float64:
return int64(v)
case string:
n, _ := strconv.ParseInt(v, 10, 64)
return n
}
return 0
}
// parseDownloadFileVersion 从 MCP download_file 响应中提取文件当前版本号。
// 返回 0 表示未获取到(兼容旧版 MCP 不返回 version 的场景)。
func parseDownloadFileVersion(text string) int {
var data map[string]any
if json.Unmarshal([]byte(text), &data) != nil {
return 0
}
if r, ok := data["result"].(map[string]any); ok {
data = r
}
switch v := data["version"].(type) {
case float64:
return int(v)
case string:
n, _ := strconv.Atoi(v)
return n
}
return 0
}
// ──────────────────────────────────────────────────────────
// 凭证状态(分片过程共享,401/403 时 single-flight 刷新)
// ──────────────────────────────────────────────────────────
// driveCredentialFetcher 重新调用 MCP 获取下载 URL + headers(含 dentry-token)。
type driveCredentialFetcher func(ctx context.Context) (url string, headers map[string]string, version int, err error)
type driveCredentialState struct {
mu sync.Mutex
fetch driveCredentialFetcher
url string
headers map[string]string
gen int
initialVersion int // 首次获取的文件版本号;0 表示未知(兼容旧 MCP)
}
func (cs *driveCredentialState) current() (string, map[string]string, int) {
cs.mu.Lock()
defer cs.mu.Unlock()
return cs.url, cs.headers, cs.gen
}
// refresh 重取凭证。仅当调用方持有的 generation 仍是最新时才真正重取
// (其他并发分片已刷新过则直接复用新凭证,避免重复 MCP 调用)。
func (cs *driveCredentialState) refresh(ctx context.Context, gen int) error {
cs.mu.Lock()
defer cs.mu.Unlock()
if cs.gen > gen {
return nil // 已被其他分片刷新
}
if cs.fetch == nil {
return fmt.Errorf("下载凭证已过期且无法自动刷新")
}
url, headers, version, err := cs.fetch(ctx)
if err != nil {
return err
}
// 版本校验
if cs.initialVersion > 0 && version > 0 {
if version != cs.initialVersion {
// 双方版本已知且不同 → 文件已被覆盖,终止下载防止数据不一致
return fmt.Errorf("下载凭证刷新后文件版本已变更(%d → %d),终止下载以防数据不一致", cs.initialVersion, version)
}
// 版本一致,正常继续
} else {
// 激进策略:版本不可验证(至少一方为 0),更新凭证但返回特殊错误
// 让上层清空已完成分片从头下载
cs.url, cs.headers = url, headers
cs.gen++
return errCredentialRefreshVersionUnknown
}
cs.url, cs.headers = url, headers
cs.gen++
return nil
}
// ──────────────────────────────────────────────────────────
// 下载入口:整流 / 分片自动分派
// ──────────────────────────────────────────────────────────
// driveTransferDownload 下载入口:按文件大小自动选择整流或分片下载。
// - 文件大小未知(MCP 未返回 fileSize)或 < 2×partSize → 直接整流
// (复用可注入的 httpGetFile,保持存量行为与测试注入边界不变,
// 401/403 时重取凭证重试一次);
// - 已知大小 ≥ 2×partSize → 首请求以 Range: bytes=0-0 探测:206 且解析出
// 总长 → 分片下载;服务端返回 200(不支持 Range)→ 直接消费该响应整流落盘。
func driveTransferDownload(ctx context.Context, fetch driveCredentialFetcher, rawURL string, headers map[string]string, destPath string, opts driveDownloadOptions) error {
if opts.partSize <= 0 {
opts.partSize = driveDownloadDefaultPartSize
}
if opts.parallel <= 0 {
opts.parallel = driveDownloadDefaultParallel
}
threshold := 2 * opts.partSize
if opts.knownSize < threshold {
// 含 knownSize==0(大小未知):不发起额外探测请求,保持存量整流行为
return downloadSingleWithAuthRetry(ctx, fetch, rawURL, headers, destPath)
}
creds := &driveCredentialState{fetch: fetch, url: rawURL, headers: headers, initialVersion: opts.version}
totalSize, fullResp, err := probeRangeSupport(ctx, creds)
if err != nil {
return err
}
if fullResp != nil {
// 服务端不支持 Range(忽略探测请求头返回 200 全量流):直接消费落盘
defer fullResp.Body.Close()
return writeStreamToFile(fullResp.Body, destPath)
}
curURL, curHeaders, _ := creds.current()
if totalSize <= 0 || totalSize < threshold {
// 总长未知(Content-Range 异常)或小于阈值:整流下载
return downloadSingleWithAuthRetry(ctx, func(fctx context.Context) (string, map[string]string, int, error) {
if fetch == nil {
return "", nil, 0, fmt.Errorf("下载凭证已过期且无法自动刷新")
}
return fetch(fctx)
}, curURL, curHeaders, destPath)
}
return downloadRangedParts(ctx, creds, destPath, totalSize, opts)
}
// downloadSingleWithAuthRetry 整流下载;401/403(凭证过期)时重新调用 MCP
// 获取新 URL+token 重试一次,仍失败走既有错误路径。
func downloadSingleWithAuthRetry(ctx context.Context, fetch driveCredentialFetcher, urlStr string, headers map[string]string, destPath string) error {
err := httpGetFile(ctx, urlStr, headers, destPath)
if err == nil || !isAuthStatusError(err) || fetch == nil {
return err
}
newURL, newHeaders, _, ferr := fetch(ctx)
if ferr != nil {
return err
}
return httpGetFile(ctx, newURL, newHeaders, destPath)
}
// probeRangeSupport 发送 Range: bytes=0-0 探测请求验证 206/Content-Range。
// 返回 (totalSize, nil, nil) 表示支持 Range 且已知总长;
// 返回 (0, resp, nil) 表示服务端忽略 Range 返回 200 全量响应(调用方直接消费);
// 401/403 时刷新凭证重试一次。
func probeRangeSupport(ctx context.Context, creds *driveCredentialState) (int64, *http.Response, error) {
for attempt := 0; ; attempt++ {
urlStr, headers, gen := creds.current()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return 0, nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
req.Header.Set("Range", "bytes=0-0")
resp, err := driveRangeClient.Do(req)
if err != nil {
return 0, nil, err
}
switch {
case resp.StatusCode == http.StatusPartialContent:
total, perr := parseContentRangeTotal(resp.Header.Get("Content-Range"))
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if perr != nil {
return 0, nil, nil // Content-Range 异常:总长未知,回退整流
}
return total, nil, nil
case resp.StatusCode == http.StatusOK:
return 0, resp, nil
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap))
resp.Body.Close()
if attempt > 0 {
return 0, nil, fmt.Errorf("下载凭证刷新后仍鉴权失败")
}
if rerr := creds.refresh(ctx, gen); rerr != nil && !errors.Is(rerr, errCredentialRefreshVersionUnknown) {
return 0, nil, fmt.Errorf("重新获取下载凭证失败: %w (原错误: %v)",
rerr, &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)})
}
// errCredentialRefreshVersionUnknown 在探测阶段无需处理(尚无已完成分片),
// 凭证已更新,循环继续用新凭证重试探测。
default:
body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap))
resp.Body.Close()
return 0, nil, &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
}
}
}
// parseContentRangeTotal 从 "bytes 0-0/12345" 解析总长;"*" 视为未知。
func parseContentRangeTotal(cr string) (int64, error) {
idx := strings.LastIndex(cr, "/")
if idx < 0 || idx == len(cr)-1 {
return 0, fmt.Errorf("非法 Content-Range: %q", cr)
}
totalStr := cr[idx+1:]
if totalStr == "*" {
return 0, fmt.Errorf("Content-Range 总长未知: %q", cr)
}
total, err := strconv.ParseInt(totalStr, 10, 64)
if err != nil || total <= 0 {
return 0, fmt.Errorf("非法 Content-Range 总长: %q", cr)
}
return total, nil
}
// parseContentRange 解析 "bytes <start>-<end>/<total>" 格式的 Content-Range 头。
// 返回值 start、end 为字节偏移(闭区间),total 为文件总长("*" 视为 -1)。
func parseContentRange(header string) (start, end, total int64, err error) {
if header == "" {
return 0, 0, 0, fmt.Errorf("Content-Range 为空")
}
// 去掉 "bytes " 前缀
const prefix = "bytes "
if !strings.HasPrefix(header, prefix) {
return 0, 0, 0, fmt.Errorf("非法 Content-Range 前缀: %q", header)
}
rest := header[len(prefix):] // e.g. "0-1048575/104857600"
// 按 "/" 分割范围与总长
slashIdx := strings.LastIndex(rest, "/")
if slashIdx < 0 || slashIdx == len(rest)-1 {
return 0, 0, 0, fmt.Errorf("非法 Content-Range 格式: %q", header)
}
rangePart := rest[:slashIdx] // "0-1048575"
totalPart := rest[slashIdx+1:] // "104857600" 或 "*"
// 解析 total
if totalPart == "*" {
total = -1
} else {
total, err = strconv.ParseInt(totalPart, 10, 64)
if err != nil || total <= 0 {
return 0, 0, 0, fmt.Errorf("非法 Content-Range 总长: %q", header)
}
}
// 按 "-" 分割 start 和 end
dashIdx := strings.Index(rangePart, "-")
if dashIdx < 0 {
return 0, 0, 0, fmt.Errorf("非法 Content-Range 区间: %q", header)
}
start, err = strconv.ParseInt(rangePart[:dashIdx], 10, 64)
if err != nil || start < 0 {
return 0, 0, 0, fmt.Errorf("非法 Content-Range start: %q", header)
}
end, err = strconv.ParseInt(rangePart[dashIdx+1:], 10, 64)
if err != nil || end < start {
return 0, 0, 0, fmt.Errorf("非法 Content-Range end: %q", header)
}
return start, end, total, nil
}
func writeStreamToFile(r io.Reader, destPath string) error {
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, r); err != nil {
return err
}
return nil
}
// ──────────────────────────────────────────────────────────
// 分片切分与 checkpoint
// ──────────────────────────────────────────────────────────
type driveDownloadPart struct {
index int
offset int64
length int64
}
// splitDownloadParts 按 partSize 等长切片,末片为余量。
func splitDownloadParts(totalSize, partSize int64) []driveDownloadPart {
if totalSize <= 0 || partSize <= 0 {
return nil
}
count := int((totalSize + partSize - 1) / partSize)
parts := make([]driveDownloadPart, 0, count)
for i := 0; i < count; i++ {
offset := int64(i) * partSize
length := partSize
if offset+length > totalSize {
length = totalSize - offset
}
parts = append(parts, driveDownloadPart{index: i, offset: offset, length: length})
}
return parts
}
// driveDownloadCheckpoint 断点续传元信息,随每个分片完成原子落盘。
type driveDownloadCheckpoint struct {
Version int `json:"version"`
Fingerprint string `json:"fingerprint"`
TotalSize int64 `json:"totalSize"`
PartSize int64 `json:"partSize"`
Completed []bool `json:"completed"`
}
// driveDownloadFingerprint 基于节点 ID + 版本号 + 文件总长 + 资源 URL 计算指纹。
// version>0 时只取 URL path(重签名不影响 checkpoint 复用);version==0(最新版)时
// 取完整 path+query——中心协议相同 path 可能对应不同实际版本,query 中的签名/token
// 标识了具体资源快照,防止错误复用旧 checkpoint。
// resourceURL 为空时不影响其他字段的指纹计算(安全降级)。
func driveDownloadFingerprint(nodeID string, version int, totalSize int64, resourceURL string) string {
urlComponent := ""
if resourceURL != "" {
if u, err := url.Parse(resourceURL); err == nil && u != nil {
if version == 0 {
// 最新版:含 query 以区分不同签名(不同实际版本)
urlComponent = u.RequestURI()
} else {
// 指定版本:只取 path,重签名不应废弃 checkpoint
urlComponent = u.Path
}
}
}
sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%d|%d|%s", nodeID, version, totalSize, urlComponent)))
return hex.EncodeToString(sum[:])
}
// loadDriveDownloadCheckpoint 读取并校验 checkpoint;任一字段不匹配返回 nil(从头下载)。
func loadDriveDownloadCheckpoint(metaPath, fingerprint string, totalSize, partSize int64, partCount int) *driveDownloadCheckpoint {
data, err := os.ReadFile(metaPath)
if err != nil {
return nil
}
var cp driveDownloadCheckpoint
if err := json.Unmarshal(data, &cp); err != nil {
return nil
}
if cp.Version != driveCheckpointVersion || cp.Fingerprint != fingerprint ||
cp.TotalSize != totalSize || cp.PartSize != partSize || len(cp.Completed) != partCount {
return nil
}
return &cp
}
// save 原子写入(临时文件 + rename),避免中断产生半截 checkpoint。
func (cp *driveDownloadCheckpoint) save(metaPath string) error {
data, err := driveJsonMarshal(cp)
if err != nil {
return err
}
tmp := metaPath + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return driveOsRename(tmp, metaPath)
}
// ──────────────────────────────────────────────────────────
// 分片下载引擎
// ──────────────────────────────────────────────────────────
// downloadRangedParts 并发分片下载到 <dest>.dwspart,全部完成后校验总长并
// 原子重命名为 destPath、清理 checkpoint;中途失败保留分片产物供断点续传。
func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destPath string, totalSize int64, opts driveDownloadOptions) error {
parts := splitDownloadParts(totalSize, opts.partSize)
partPath := destPath + drivePartFileSuffix
metaPath := destPath + drivePartMetaSuffix
// 取首次凭证 URL 用于指纹计算,防止同大小文件覆盖后 checkpoint 错误复用
initialURL, _, _ := creds.current()
fingerprint := driveDownloadFingerprint(opts.nodeID, opts.version, totalSize, initialURL)
var cp *driveDownloadCheckpoint
if opts.resume {
cp = loadDriveDownloadCheckpoint(metaPath, fingerprint, totalSize, opts.partSize, len(parts))
// 分片数据文件缺失或长度不符时 checkpoint 作废,从头下载
if cp != nil {
if fi, err := os.Stat(partPath); err != nil || fi.Size() != totalSize {
cp = nil
}
}
} else {
// --no-resume:清理历史断点产物,从头下载且不写 checkpoint
_ = os.Remove(partPath)
_ = os.Remove(metaPath)
}
if cp == nil {
cp = &driveDownloadCheckpoint{
Version: driveCheckpointVersion,
Fingerprint: fingerprint,
TotalSize: totalSize,
PartSize: opts.partSize,
Completed: make([]bool, len(parts)),
}
}
f, err := os.OpenFile(partPath, os.O_RDWR|os.O_CREATE, 0o644)
if err != nil {
return fmt.Errorf("创建分片临时文件失败: %w", err)
}
if err := driveFileTruncate(f, totalSize); err != nil {
f.Close()
return fmt.Errorf("预分配分片临时文件失败: %w", err)
}
remaining := 0
for _, p := range parts {
if !cp.Completed[p.index] {
remaining++
}
}
if opts.logf != nil {
if remaining < len(parts) {
opts.logf("断点续传: 共 %d 分片(%s/片,并发 %d),已完成 %d,续传 %d",
len(parts), formatByteSize(opts.partSize), opts.parallel, len(parts)-remaining, remaining)
} else {
opts.logf("分片下载: 共 %d 分片(%s/片,并发 %d)", len(parts), formatByteSize(opts.partSize), opts.parallel)
}
}
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
var (
mu sync.Mutex // 保护 cp 与 checkpoint 落盘
wg sync.WaitGroup
errOnce sync.Once
firstErr error
)
fail := func(err error) {
errOnce.Do(func() {
firstErr = err
cancel()
})
}
jobs := make(chan driveDownloadPart)
workers := opts.parallel
if workers > remaining {
workers = remaining
}
if workers < 1 {
workers = 1
}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for part := range jobs {
if runCtx.Err() != nil {
return
}
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
fail(fmt.Errorf("分片 %d/%d 下载失败: %w", part.index+1, len(parts), err))
return
}
mu.Lock()
cp.Completed[part.index] = true
var saveErr error
if opts.resume {
saveErr = cp.save(metaPath)
}
mu.Unlock()
if saveErr != nil {
fail(fmt.Errorf("写入下载断点信息失败: %w", saveErr))
return
}
}
}()
}
dispatch:
for _, part := range parts {
if cp.Completed[part.index] {
continue
}
select {
case jobs <- part:
case <-runCtx.Done():
break dispatch
}
}
close(jobs)
wg.Wait()
if firstErr != nil {
f.Close()
if errors.Is(firstErr, errCredentialRefreshVersionUnknown) {
// 激进策略:版本不可验证,清空 checkpoint 和分片文件防止错误续传;
// 用户重跑时将自然从头下载。
_ = os.Remove(metaPath)
_ = os.Remove(partPath)
}
return firstErr // 其他错误保留 .dwspart 与 checkpoint,重跑同一命令自动续传
}
if err := driveFileSync(f); err != nil {
f.Close()
return err
}
fi, statErr := driveFileStat(f)
f.Close()
if statErr != nil {
return statErr
}
if fi.Size() != totalSize {
return fmt.Errorf("下载完成但文件长度不符: got %d, want %d", fi.Size(), totalSize)
}
if err := driveOsRename(partPath, destPath); err != nil {
return fmt.Errorf("重命名下载文件失败: %w", err)
}
_ = os.Remove(metaPath)
return nil
}
// downloadOnePart 下载单个分片:常规失败指数退避重试 driveDownloadPartRetries 次;
// 401/403 触发凭证 single-flight 刷新(不计入常规重试,上限 driveDownloadPartAuthRetries),
// 刷新后用新凭证续传,不重下其他已完成分片。
func downloadOnePart(ctx context.Context, creds *driveCredentialState, f *os.File, part driveDownloadPart, totalSize int64) error {
attempt := 0
authRetries := 0
backoff := 500 * time.Millisecond
for {
urlStr, headers, gen := creds.current()
err := fetchRangeInto(ctx, urlStr, headers, f, part, totalSize)
if err == nil {
return nil
}
if ctx.Err() != nil {
return err
}
if isAuthStatusError(err) && authRetries < driveDownloadPartAuthRetries {
authRetries++
if rerr := creds.refresh(ctx, gen); rerr != nil {
return fmt.Errorf("重新获取下载凭证失败: %w (原错误: %v)", rerr, err)
}
continue // 凭证刷新不计常规重试、不退避
}
attempt++
if attempt > driveDownloadPartRetries {
return err
}
select {
case <-time.After(backoff):
case <-ctx.Done():
return err
}
backoff *= 2
}
}
// fetchRangeInto 拉取 [offset, offset+length) 区间并写入文件对应偏移。
func fetchRangeInto(ctx context.Context, urlStr string, headers map[string]string, f *os.File, part driveDownloadPart, expectedTotal int64) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return err
}
for k, v := range headers {
req.Header.Set(k, v)
}
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", part.offset, part.offset+part.length-1))
resp, err := driveRangeClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent {
body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap))
return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
}
// 校验 Content-Range 响应区间与请求分片一致,防止代理/服务端返回错位数据
cr := resp.Header.Get("Content-Range")
if cr == "" {
return fmt.Errorf("分片响应缺少 Content-Range 头,无法验证数据偏移一致性")
}
{
crStart, crEnd, crTotal, crErr := parseContentRange(cr)
if crErr != nil {
return fmt.Errorf("Content-Range 解析失败: %w", crErr)
}
wantStart := part.offset
wantEnd := part.offset + part.length - 1
if crStart != wantStart || crEnd != wantEnd {
return fmt.Errorf("Content-Range 区间不匹配: 响应 %d-%d, 期望 %d-%d",
crStart, crEnd, wantStart, wantEnd)
}
if crTotal > 0 && expectedTotal > 0 && crTotal != expectedTotal {
return fmt.Errorf("Content-Range 总长不匹配: 响应 %d, 期望 %d", crTotal, expectedTotal)
}
}
n, err := io.Copy(io.NewOffsetWriter(f, part.offset), io.LimitReader(resp.Body, part.length))
if err != nil {
return err
}
if n != part.length {
return fmt.Errorf("分片长度不符: got %d, want %d", n, part.length)
}
return nil
}
// ──────────────────────────────────────────────────────────
// 上传:中心协议识别 + 401/403 重试 + 超限可读提示
// ──────────────────────────────────────────────────────────
// parseDriveUploadType 提取 get_upload_info 返回中的 uploadType(可选字段)。
// "httpToCenterWithToken" 表示中心协议;存量 OSS 返回无该字段,返回空串。
func parseDriveUploadType(text string) string {
var data map[string]any
if json.Unmarshal([]byte(text), &data) != nil {
return ""
}
if r, ok := data["result"].(map[string]any); ok {
data = r
}
t, _ := data["uploadType"].(string)
return t
}
// decorateUploadSizeError 为服务端上传超限错误补充可读提示。
// 不做本地文件大小上限校验(上限为服务端动态权益值,本地硬编码会漂移)。
func decorateUploadSizeError(err error, uploadType string) error {
var se *httpStatusError
if !errors.As(err, &se) {
return err
}
if se.StatusCode == http.StatusRequestEntityTooLarge ||
(uploadType == uploadTypeCenterToken && likelySizeLimitBody(se.Body)) {
return fmt.Errorf("%w\n提示: 文件大小可能超出空间容量或上传上限,请确认文件大小、清理空间或联系管理员调整容量后重试", err)
}
return err
}
func likelySizeLimitBody(body string) bool {
b := strings.ToLower(body)
if strings.Contains(b, "超限") || strings.Contains(b, "超出") || strings.Contains(b, "容量") {
return true
}
return strings.Contains(b, "size") && (strings.Contains(b, "limit") || strings.Contains(b, "exceed") || strings.Contains(b, "over"))
}
// driveUploadPut 解析上传凭证并执行 HTTP PUT;401/403(token 过期)时通过 refetch
// 重新获取凭证重试一次。返回最终生效凭证的 uploadId(凭证刷新后以新值为准)。
// 中心协议(uploadType=httpToCenterWithToken)与 OSS 走同一路径:resourceUrl 为
// 服务端拼好的完整 PUT URL(客户端零追加),headers(含 dentry-token)原样透传。
func driveUploadPut(ctx context.Context, credText string, refetch func(context.Context) (string, error), filePath string, fileSize int64) (string, error) {
resourceURL, uploadID, headers, err := parseDriveUploadInfo(credText)
if err != nil {
return "", err
}
uploadType := parseDriveUploadType(credText)
putErr := httpPutFile(ctx, resourceURL, headers, filePath, fileSize)
if putErr != nil && isAuthStatusError(putErr) && refetch != nil {
text2, rerr := refetch(ctx)
if rerr == nil {
if url2, id2, headers2, perr := parseDriveUploadInfo(text2); perr == nil {
uploadID = id2
uploadType = parseDriveUploadType(text2)
putErr = httpPutFile(ctx, url2, headers2, filePath, fileSize)
}
}
}
if putErr != nil {
return "", decorateUploadSizeError(putErr, uploadType)
}
return uploadID, nil
}
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -14,7 +14,6 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -670,7 +669,15 @@ func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
// suggestForBusinessError returns a user-facing suggestion for known business
// error patterns in a parsed JSON body, or "" if no specific suggestion applies.
func suggestForBusinessError(body map[string]any) string {
return apperrors.SuggestBusinessHint(body)
msg := ""
if v, ok := body["errorMsg"].(string); ok {
msg = v
} else if v, ok := body["message"].(string); ok {
msg = v
} else if v, ok := body["error"].(string); ok {
msg = v
}
return suggestForBusinessErrorText(msg)
}
// confirmDelete is a convenience wrapper around cmdutil.ConfirmDelete that
+169 -1
View File
@@ -1,20 +1,54 @@
package helpers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/spf13/cobra"
)
func decodeOARequest(raw string) (map[string]any, error) {
dec := json.NewDecoder(bytes.NewBufferString(raw))
dec.UseNumber()
var request map[string]any
if err := dec.Decode(&request); err != nil || request == nil {
if err != nil {
return nil, err
}
return nil, fmt.Errorf("JSON 请求不能为 null")
}
if err := dec.Decode(new(any)); !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("JSON 请求包含多余内容")
}
return request, nil
}
func oaFormValues(raw string) ([]map[string]string, error) {
var values map[string]string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, err
}
result := make([]map[string]string, 0, len(values))
for name, value := range values {
result = append(result, map[string]string{"name": name, "value": value})
}
return result, nil
}
// ──────────────────────────────────────────────────────────
// dws oa — OA 审批
// MCP tools(tools/list): list_pending_approvals, get_processInstance_detail,
// approve_processInstance, reject_processInstance, revoke_processInstance,
// get_processInstance_records, list_initiated_instances, list_pending_tasks,
// list_user_visible_process, append_task, search_form, oa_ding_user, revert_task,
// get_inst_revert_activities
// get_inst_revert_activities, get_process_schema, forecast_process,
// start_process_instance
// ──────────────────────────────────────────────────────────
func newOaCommand() *cobra.Command {
@@ -463,6 +497,97 @@ func newOaCommand() *cobra.Command {
},
}
approvalFormSchemaCmd := &cobra.Command{
Use: "form-schema", Short: "查询审批模板的表单 Schema",
Example: "dws oa approval form-schema --process-code <processCode>",
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "process-code"); err != nil {
return err
}
return callMCPTool("get_process_schema", map[string]any{"processCode": mustGetFlag(cmd, "process-code")})
},
}
approvalForecastCmd := &cobra.Command{
Use: "forecast-process", Short: "根据表单值预测审批流程与自选节点",
Example: "dws oa approval forecast-process --process-code <processCode> --dept-id -1 --form-values '{\"金额\":\"100\"}'",
RunE: func(cmd *cobra.Command, args []string) error {
if raw, _ := cmd.Flags().GetString("request"); raw != "" {
request, err := decodeOARequest(raw)
if err != nil {
return fmt.Errorf("--request JSON 解析失败: %w", err)
}
return callMCPTool("forecast_process", map[string]any{"ProcessForecastPopRequest": request})
}
if err := validateRequiredFlags(cmd, "process-code", "dept-id", "form-values"); err != nil {
return err
}
deptID, err := strconv.ParseInt(mustGetFlag(cmd, "dept-id"), 10, 64)
if err != nil {
return fmt.Errorf("--dept-id 必须为整数: %w", err)
}
values, err := oaFormValues(mustGetFlag(cmd, "form-values"))
if err != nil {
return fmt.Errorf("--form-values JSON 解析失败: %w", err)
}
return callMCPTool("forecast_process", map[string]any{"ProcessForecastPopRequest": map[string]any{"processCode": mustGetFlag(cmd, "process-code"), "deptId": deptID, "formComponentValues": [][]map[string]string{values}}})
},
}
approvalCreateCmd := &cobra.Command{
Use: "create-instance", Short: "发起审批实例(需要 --yes 确认)",
Example: "dws oa approval create-instance --process-code <processCode> --form-values '{\"事由\":\"测试\"}' --yes",
RunE: func(cmd *cobra.Command, args []string) error {
if !commandDryRun(cmd) {
yes, _ := cmd.Flags().GetBool("yes")
if !yes {
return fmt.Errorf("发起审批实例会创建真实业务数据;请先核对参数,然后添加 --yes 确认执行")
}
}
var request map[string]any
if raw, _ := cmd.Flags().GetString("request"); raw != "" {
var err error
request, err = decodeOARequest(raw)
if err != nil {
return fmt.Errorf("--request JSON 解析失败: %w", err)
}
} else {
if err := validateRequiredFlags(cmd, "process-code", "form-values"); err != nil {
return err
}
values, err := oaFormValues(mustGetFlag(cmd, "form-values"))
if err != nil {
return fmt.Errorf("--form-values JSON 解析失败: %w", err)
}
request = map[string]any{"processCode": mustGetFlag(cmd, "process-code"), "formComponentValues": values}
if dept, _ := cmd.Flags().GetString("dept-id"); dept != "" {
value, err := strconv.ParseInt(dept, 10, 64)
if err != nil {
return fmt.Errorf("--dept-id 必须为整数: %w", err)
}
request["deptId"] = value
}
if userID, _ := cmd.Flags().GetString("originator-user-id"); userID != "" {
request["originatorUserId"] = userID
}
if rawApprovers, _ := cmd.Flags().GetString("approvers"); rawApprovers != "" {
action, _ := cmd.Flags().GetString("approvers-action-type")
if action != "AND" && action != "OR" && action != "NONE" {
return fmt.Errorf("--approvers-action-type 必须为 AND、OR 或 NONE")
}
request["approvers"] = []map[string]any{{"actionType": action, "userIds": strings.Split(rawApprovers, ",")}}
}
if rawCC, _ := cmd.Flags().GetString("cc-list"); rawCC != "" {
position, _ := cmd.Flags().GetString("cc-position")
if position != "START" && position != "FINISH" && position != "START_FINISH" {
return fmt.Errorf("--cc-position 必须为 START、FINISH 或 START_FINISH")
}
request["ccList"] = strings.Split(rawCC, ",")
request["ccPosition"] = position
}
}
return callMCPTool("start_process_instance", map[string]any{"ProcessInstanceCreationPopRequest": request})
},
}
approvalListPendingCmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填)")
approvalListPendingCmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填)")
approvalListPendingCmd.Flags().String("page", "", "分页页码 (可选)")
@@ -533,6 +658,46 @@ func newOaCommand() *cobra.Command {
approvalRevertTaskCmd.Flags().String("target-activity-id", "", "退回到的节点 ID(退回发起人固定传 sid-startevent)(必填)")
approvalRevertTaskCmd.Flags().String("action", "", "退回方式:REVERT_FOR_APPROVAL(退回到审批人)/ REVERT_FOR_RESUBMIT(退回到发起人)(必填)")
approvalRevertTaskCmd.Flags().String("remark", "", "退回说明 (可选)")
approvalFormSchemaCmd.Flags().String("process-code", "", "审批模板 processCode (必填)")
approvalForecastCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
approvalForecastCmd.Flags().String("dept-id", "", "发起人部门 ID(简单模式使用;与 --request 互斥)")
approvalForecastCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)")
approvalForecastCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)")
approvalForecastCmd.MarkFlagsOneRequired("request", "process-code")
approvalForecastCmd.MarkFlagsRequiredTogether("process-code", "dept-id", "form-values")
forecastMutuallyExclusive := make([][]string, 0, 3)
for _, name := range []string{"process-code", "dept-id", "form-values"} {
approvalForecastCmd.MarkFlagsMutuallyExclusive("request", name)
forecastMutuallyExclusive = append(forecastMutuallyExclusive, []string{"request", name})
}
cli.AnnotateRuntimeConstraints(approvalForecastCmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: forecastMutuallyExclusive,
RequireOneOf: [][]string{{"request", "process-code"}},
RequireTogether: [][]string{{"process-code", "dept-id", "form-values"}},
})
approvalCreateCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)")
approvalCreateCmd.Flags().String("dept-id", "-1", "发起人部门 ID")
approvalCreateCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)")
approvalCreateCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)")
approvalCreateCmd.Flags().String("originator-user-id", "", "审批发起人 userId")
approvalCreateCmd.Flags().String("approvers", "", "审批人 userId 列表,多个用逗号分隔")
approvalCreateCmd.Flags().String("approvers-action-type", "OR", "审批类型:AND、OR 或 NONE")
approvalCreateCmd.Flags().String("cc-list", "", "抄送人 userId 列表,多个用逗号分隔")
approvalCreateCmd.Flags().String("cc-position", "START", "抄送时点:START、FINISH 或 START_FINISH")
approvalCreateCmd.MarkFlagsOneRequired("request", "process-code")
approvalCreateCmd.MarkFlagsRequiredTogether("process-code", "form-values")
createSimpleFlags := []string{"process-code", "dept-id", "form-values", "originator-user-id", "approvers", "approvers-action-type", "cc-list", "cc-position"}
createMutuallyExclusive := make([][]string, 0, len(createSimpleFlags))
for _, name := range createSimpleFlags {
approvalCreateCmd.MarkFlagsMutuallyExclusive("request", name)
createMutuallyExclusive = append(createMutuallyExclusive, []string{"request", name})
}
cli.AnnotateRuntimeConstraints(approvalCreateCmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: createMutuallyExclusive,
RequireOneOf: [][]string{{"request", "process-code"}},
RequireTogether: [][]string{{"process-code", "form-values"}},
})
approvalCmd.AddCommand(
approvalListPendingCmd,
@@ -555,6 +720,9 @@ func newOaCommand() *cobra.Command {
approvalAppendTaskCmd,
approvalRevertActivitiesCmd,
approvalRevertTaskCmd,
approvalFormSchemaCmd,
approvalForecastCmd,
approvalCreateCmd,
)
root.AddCommand(approvalCmd)
+169 -1
View File
@@ -1,6 +1,34 @@
package helpers
import "testing"
import (
"io"
"os"
"strings"
"testing"
)
func executeOACommand(t *testing.T, caller *scriptedToolCaller, args ...string) error {
t.Helper()
previous := deps
previousArgs := os.Args
os.Args = []string{"dws", "oa"}
InitDeps(caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
t.Cleanup(func() {
deps = previous
os.Args = previousArgs
})
cmd := newOaCommand()
cmd.PersistentFlags().Bool("yes", false, "跳过确认")
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs(args)
return cmd.Execute()
}
func TestCrossPlatformCoverageOARemainingTimeAndRevertBranches(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{dry: true})
@@ -31,3 +59,143 @@ func TestCrossPlatformCoverageOARemainingTimeAndRevertBranches(t *testing.T) {
t.Fatalf("revert task: %v", err)
}
}
func TestCrossPlatformCoverageOAApprovalCreateInstanceMapsInternalSimpleOptions(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller,
"approval", "create-instance",
"--process-code", "PROC",
"--form-values", `{"事由":"测试"}`,
"--originator-user-id", "originator",
"--approvers", "approver-1,approver-2",
"--approvers-action-type", "AND",
"--cc-list", "cc-1,cc-2",
"--cc-position", "FINISH",
"--yes",
)
if err != nil {
t.Fatalf("create instance: %v", err)
}
if caller.server != "oa" || caller.tool != "start_process_instance" {
t.Fatalf("called %s/%s, want oa/start_process_instance", caller.server, caller.tool)
}
request, ok := caller.args["ProcessInstanceCreationPopRequest"].(map[string]any)
if !ok {
t.Fatalf("request payload = %#v", caller.args)
}
if got := request["originatorUserId"]; got != "originator" {
t.Fatalf("originatorUserId = %#v", got)
}
approvers, ok := request["approvers"].([]map[string]any)
if !ok || len(approvers) != 1 || approvers[0]["actionType"] != "AND" {
t.Fatalf("approvers = %#v", request["approvers"])
}
if got := approvers[0]["userIds"]; len(got.([]string)) != 2 || got.([]string)[0] != "approver-1" || got.([]string)[1] != "approver-2" {
t.Fatalf("approver userIds = %#v", got)
}
if got := request["ccList"]; len(got.([]string)) != 2 || got.([]string)[0] != "cc-1" || got.([]string)[1] != "cc-2" {
t.Fatalf("ccList = %#v", got)
}
if got := request["ccPosition"]; got != "FINISH" {
t.Fatalf("ccPosition = %#v", got)
}
}
func TestCrossPlatformCoverageOAApprovalCreateInstanceRejectsMixedRequestModes(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller,
"approval", "create-instance",
"--request", `{"processCode":"PROC"}`,
"--process-code", "PROC",
"--yes",
)
if err == nil {
t.Fatal("mixed request modes returned nil")
}
if caller.calls != 0 {
t.Fatalf("unexpected MCP call count: %d", caller.calls)
}
}
func TestCrossPlatformCoverageOAApprovalCreateInstanceRequiresExplicitYes(t *testing.T) {
caller := &scriptedToolCaller{}
err := executeOACommand(t, caller,
"approval", "create-instance",
"--request", `{"processCode":"PROC"}`,
)
if err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("create instance without --yes error = %v, want explicit --yes requirement", err)
}
if caller.calls != 0 {
t.Fatalf("create instance without --yes made %d MCP calls", caller.calls)
}
}
func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *testing.T) {
validCases := []struct {
name string
args []string
tool string
}{
{
name: "form schema",
args: []string{"approval", "form-schema", "--process-code", "PROC"},
tool: "get_process_schema",
},
{
name: "forecast simple mode",
args: []string{"approval", "forecast-process", "--process-code", "PROC", "--dept-id", "-1", "--form-values", `{"金额":"100"}`},
tool: "forecast_process",
},
{
name: "forecast request mode",
args: []string{"approval", "forecast-process", "--request", `{"processCode":"PROC"}`},
tool: "forecast_process",
},
{
name: "create request mode",
args: []string{"approval", "create-instance", "--request", `{"processCode":"PROC"}`, "--yes"},
tool: "start_process_instance",
},
}
for _, tc := range validCases {
t.Run(tc.name, func(t *testing.T) {
caller := &scriptedToolCaller{}
if err := executeOACommand(t, caller, tc.args...); err != nil {
t.Fatalf("execute %v: %v", tc.args, err)
}
if caller.tool != tc.tool || caller.calls != 1 {
t.Fatalf("called tool=%q calls=%d, want %q once", caller.tool, caller.calls, tc.tool)
}
})
}
invalidCases := [][]string{
{"approval", "form-schema"},
{"approval", "forecast-process"},
{"approval", "forecast-process", "--request", `{"processCode":"PROC"}`, "--process-code", "PROC"},
{"approval", "forecast-process", "--request", "{"},
{"approval", "forecast-process", "--request", "null"},
{"approval", "forecast-process", "--request", "{} {}"},
{"approval", "forecast-process", "--process-code", "PROC", "--dept-id", "bad", "--form-values", `{"金额":"100"}`},
{"approval", "forecast-process", "--process-code", "PROC", "--dept-id", "-1", "--form-values", "["},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`},
{"approval", "create-instance", "--yes"},
{"approval", "create-instance", "--request", "{", "--yes"},
{"approval", "create-instance", "--request", "null", "--yes"},
{"approval", "create-instance", "--request", "{} {}", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", "[", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--dept-id", "bad", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--approvers", "u", "--approvers-action-type", "bad", "--yes"},
{"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--cc-list", "u", "--cc-position", "bad", "--yes"},
}
for _, args := range invalidCases {
caller := &scriptedToolCaller{}
if err := executeOACommand(t, caller, args...); err == nil {
t.Fatalf("invalid args %v returned nil", args)
}
if caller.calls != 0 {
t.Fatalf("invalid args %v made %d MCP calls", args, caller.calls)
}
}
}
@@ -23,10 +23,16 @@ type scriptedToolCaller struct {
format string
dry bool
calls int
server string
tool string
args map[string]any
}
func (c *scriptedToolCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls++
c.server = serverID
c.tool = toolName
c.args = args
if len(c.steps) == 0 {
return &edition.ToolResult{}, nil
}
+56 -2
View File
@@ -143,8 +143,8 @@ func proxySubCmd(use, targetProduct, targetPath string, flagRenames map[string]s
func newWikiCommand() *cobra.Command {
root := &cobra.Command{
Use: "wiki",
Short: "知识库 / 空间管理 / 节点管理 / 成员管理",
Long: `管理钉钉文档知识库:空间管理(创建/查看/列出/搜索/删除)、节点管理(列出/创建/复制/移动/删除)、成员管理(添加/更新/列出/移除)。`,
Short: "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询",
Long: `管理钉钉文档知识库:空间管理(创建/查看/列出/搜索/删除)、节点管理(列出/创建/复制/移动/删除)、成员管理(添加/更新/列出/移除)、动态查询(知识库活动动态)。`,
RunE: groupRunE,
}
@@ -779,6 +779,60 @@ ORG 类型授权不会出现在查询结果中。`,
root.AddCommand(nodeCmd)
// ── feed (知识库动态查询) ─────────────────────────────────
feedCmd := &cobra.Command{
Use: "feed",
Short: "知识库动态查询",
Long: `查询知识库的动态:谁在什么时间更新/上传/评论了哪些文档。`,
RunE: groupRunE,
}
feedListCmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "查询知识库动态列表",
Long: `查询指定知识库的动态列表,返回动态类型、时间、内容摘要等信息。
通过 --workspace 指定知识库,支持传入知识库 ID 或知识库 URL。
支持分页,通过 --cursor 传入上次返回的 nextToken 获取下一页。
权限要求:调用者需具备知识库的成员权限,非成员会被拒绝访问。`,
Example: ` dws wiki feed list --workspace <workspaceId>
dws wiki feed list --workspace <workspaceId> --limit 10
dws wiki feed list --workspace <workspaceId> --cursor <nextToken>`,
RunE: func(cmd *cobra.Command, args []string) error {
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
if err != nil {
return err
}
toolArgs := map[string]any{
"workspaceId": workspaceID,
}
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
toolArgs["maxResults"] = v
}
if v := flagOrFallback(cmd, "cursor", "page-token"); v != "" {
toolArgs["nextToken"] = v
}
if cmd.Flags().Changed("exclude-file") {
v, _ := cmd.Flags().GetBool("exclude-file")
toolArgs["excludeFile"] = v
}
return callMCPTool("list_workspace_feeds", toolArgs)
},
}
feedListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
feedListCmd.Flags().Int("limit", 0, "每页数量 (默认 20,最大 50)")
feedListCmd.Flags().String("cursor", "", "分页游标 (首页留空)")
feedListCmd.Flags().Bool("exclude-file", false, "是否排除文件相关的动态 (默认 false)")
feedListCmd.Flags().String("workspace-id", "", "")
_ = feedListCmd.Flags().MarkHidden("workspace-id")
RegisterCrossProductAliases(feedListCmd)
feedCmd.AddCommand(feedListCmd)
root.AddCommand(feedCmd)
// ── [PROXY] wiki create/get/list/search → wiki space create/get/list/search ──
// Agent 常省略 "space" 直接输入 dws wiki list,透明转发到 wiki space 对应命令
root.AddCommand(
@@ -132,6 +132,9 @@ func TestCrossPlatformCoverageWikiRoutingAndValidationEdges(t *testing.T) {
{"space", "list", "--type", "orgSpace", "--limit", "3", "--cursor", "next"},
{"space", "list", "--type", "mySpace", "--limit", "not-a-number"},
{"space", "search", "--type", "myWikiSpace"},
{"feed", "list", "--workspace", "space"},
{"feed", "list", "--workspace", "space", "--limit", "3", "--cursor", "next", "--exclude-file"},
{"feed", "list", "--workspace-id", "space", "--page-token", "next"},
} {
if err := executeWikiEdge(t, args...); err != nil {
t.Fatalf("Execute(%v): %v", args, err)
@@ -142,6 +145,7 @@ func TestCrossPlatformCoverageWikiRoutingAndValidationEdges(t *testing.T) {
{"node", "create", "--workspace", "space", "--name", "name", "--folder", "123"},
{"node", "copy", "--workspace", "space", "--node", "node", "--folder", "123"},
{"node", "move", "--workspace", "space", "--node", "node", "--folder", "123"},
{"feed", "list"},
} {
if err := executeWikiEdge(t, args...); err == nil {
t.Fatalf("Execute(%v) returned nil", args)
+5 -17
View File
@@ -62,18 +62,13 @@ var ConversationSetTop = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
{Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId(最多 10 个)"},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat-ids", Type: shortcut.FlagStringSlice, Desc: "--conversation-ids 的兼容别名", Hidden: true},
{Name: "off", Type: shortcut.FlagBool, Desc: "取消置顶(不传则设置置顶)"},
{Name: "top", Type: shortcut.FlagBool, Default: "true", Desc: "置顶状态兼容参数", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"conversation-id", "conversation-ids", "open-conversation-id", "chat-id", "chat-ids"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"off", "top"}},
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"conversation-id", "conversation-ids"}},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"conversation-id", "conversation-ids", "open-conversation-id", "chat-id", "chat-ids"},
Flags: []string{"conversation-id", "conversation-ids"},
Description: "会话 ID 去重后必须为 1-10 个",
},
},
@@ -90,10 +85,6 @@ var ConversationSetTop = shortcut.Shortcut{
},
Execute: func(rt *shortcut.RuntimeContext) error {
ids := conversationSetTopIDs(rt)
top := !rt.Bool("off")
if rt.Changed("top") {
top = rt.Bool("top")
}
items := make([]shortcutBatchWrite, 0, len(ids))
for _, id := range ids {
items = append(items, shortcutBatchWrite{
@@ -101,7 +92,7 @@ var ConversationSetTop = shortcut.Shortcut{
arguments: map[string]any{
"openConversationId": id,
"cid": id,
"top": top,
"top": !rt.Bool("off"),
},
})
}
@@ -111,11 +102,8 @@ var ConversationSetTop = shortcut.Shortcut{
func conversationSetTopIDs(rt *shortcut.RuntimeContext) []string {
values := append([]string{}, rt.StrSlice("conversation-ids")...)
values = append(values, rt.StrSlice("chat-ids")...)
for _, name := range []string{"conversation-id", "open-conversation-id", "chat-id"} {
if value := rt.Str(name); value != "" {
values = append(values, value)
}
if value := rt.Str("conversation-id"); value != "" {
values = append(values, value)
}
return uniqueShortcutStrings(values)
}
+10 -37
View File
@@ -69,25 +69,15 @@ var ChatMembersGet = shortcut.Shortcut{
Intent: "当你已有若干成员的 openDingTalkId、需要批量获取他们在该群内的详情(群昵称、角色等)时使用;只读,需传群 openConversationId 和成员 openDingTalkId 列表。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"},
{Name: "group", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 openDingTalkId 列表(必填)"},
{Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "--users 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "group", "chat-id", "conversation-id", "open-conversation-id"}},
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"users", "open-dingtalk-ids"}},
{Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 openDingTalkId 列表", Required: true},
},
Tips: []string{`dws chat +chat-members-get --id <openConversationId> --users odid1,odid2`},
Execute: func(rt *shortcut.RuntimeContext) error {
conversationID := rt.StrFirst("id", "group", "chat-id", "conversation-id", "open-conversation-id")
return rt.CallMCP("list_group_member_by_ids", map[string]any{
"openConversationId": conversationID,
"cid": conversationID,
"memberOpenDingTalkIds": rt.StrSliceFirst("users", "open-dingtalk-ids"),
"openConversationId": rt.Str("id"),
"cid": rt.Str("id"),
"memberOpenDingTalkIds": rt.StrSlice("users"),
})
},
}
@@ -132,22 +122,14 @@ var ChatInviteURL = shortcut.Shortcut{
Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{Name: "expires-seconds", Type: shortcut.FlagInt, Desc: "链接有效期(秒),0 表示永久"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "id", "chat-id", "conversation-id", "open-conversation-id"}},
},
Tips: []string{`dws chat +chat-invite-url --group <openConversationId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
conversationID := rt.StrFirst("group", "id", "chat-id", "conversation-id", "open-conversation-id")
params := map[string]any{
"openConversationId": conversationID,
"cid": conversationID,
"openConversationId": rt.Str("group"),
"cid": rt.Str("group"),
}
if rt.Changed("expires-seconds") {
params["expiresSeconds"] = rt.Int("expires-seconds")
@@ -570,20 +552,11 @@ var ChatBots = shortcut.Shortcut{
Intent: "当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "id", "chat-id", "conversation-id", "open-conversation-id"}},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
},
Tips: []string{`dws chat +chat-bots --group <openConversationId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{
"openConversationId": rt.StrFirst("group", "id", "chat-id", "conversation-id", "open-conversation-id"),
})
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": rt.Str("group")})
if err != nil {
return err
}
+10 -31
View File
@@ -147,23 +147,14 @@ var MessagesRecall = shortcut.Shortcut{
Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息,需传会话 openConversationId 和消息 openMessageId。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId(必填)"},
{Name: "group", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId(必填)"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "--msg-id 的兼容别名", Hidden: true},
{Name: "open-message-id", Type: shortcut.FlagString, Desc: "--msg-id 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"conversation-id", "group", "chat-id", "open-conversation-id"}},
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"msg-id", "message-id", "open-message-id"}},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
},
Tips: []string{`dws chat +messages-recall --conversation-id <openConversationId> --msg-id <openMessageId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("recall_message", map[string]any{
"openConversationId": rt.StrFirst("conversation-id", "group", "chat-id", "open-conversation-id"),
"openMessageId": rt.StrFirst("msg-id", "message-id", "open-message-id"),
"openConversationId": rt.Str("conversation-id"),
"openMessageId": rt.Str("msg-id"),
})
},
}
@@ -538,30 +529,26 @@ var MessagesMget = shortcut.Shortcut{
Intent: "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: append([]shortcut.Flag{
{Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID(必填)"},
{Name: "message-id", Type: shortcut.FlagStringSlice, Desc: "--msg-ids 的兼容别名", Hidden: true},
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "--msg-ids 的兼容别名", Hidden: true},
{Name: "open-message-ids", Type: shortcut.FlagStringSlice, Desc: "--msg-ids 的兼容别名", Hidden: true},
{Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID", Required: true},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
}, MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"msg-ids", "message-id", "message-ids", "open-message-ids"}},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"msg-ids", "message-id", "message-ids", "open-message-ids"},
Flags: []string{"msg-ids"},
Description: "--msg-ids 去重后必须包含 1-50 条消息 ID",
},
}, MessageResourceDownloadConstraints()...),
Tips: []string{`dws chat +messages-mget --msg-ids msgId1,msgId2`},
Validate: func(rt *shortcut.RuntimeContext) error {
ids := messageMgetIDs(rt)
ids := uniqueShortcutStrings(rt.StrSlice("msg-ids"))
if len(ids) < 1 || len(ids) > 50 {
return fmt.Errorf("--msg-ids 去重后必须包含 1-50 条消息 ID,当前 %d 条", len(ids))
}
return ValidateMessageResourceDownload(rt)
},
Execute: func(rt *shortcut.RuntimeContext) error {
ids := messageMgetIDs(rt)
ids := uniqueShortcutStrings(rt.StrSlice("msg-ids"))
data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{"openMsgIds": ids})
if err != nil {
return err
@@ -594,10 +581,6 @@ var MessagesMget = shortcut.Shortcut{
},
}
func messageMgetIDs(rt *shortcut.RuntimeContext) []string {
return uniqueShortcutStrings(rt.StrSliceFirst("msg-ids", "message-id", "message-ids", "open-message-ids"))
}
// MessageResourceDownloadFlags returns the common opt-in resource workflow used
// by message list, search, mget, @me and thread-reading Shortcuts.
func MessageResourceDownloadFlags() []shortcut.Flag {
@@ -850,15 +833,11 @@ var MessagesQuerySendStatus = shortcut.Shortcut{
Intent: "当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "open-task-id", Type: shortcut.FlagString, Desc: "发送消息时返回的 openTaskId(必填)"},
{Name: "task-id", Type: shortcut.FlagString, Desc: "--open-task-id 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"open-task-id", "task-id"}},
{Name: "open-task-id", Type: shortcut.FlagString, Desc: "发送消息时返回的 openTaskId", Required: true},
},
Tips: []string{`dws chat +messages-query-send-status --open-task-id <openTaskId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.StrFirst("open-task-id", "task-id")})
return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.Str("open-task-id")})
},
}
@@ -129,72 +129,6 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
wantTool: "query_msg_read_status",
wantArgs: map[string]any{"openConversationId": "cid-1"},
},
{
name: "chat update id and title aliases",
argv: []string{"chat", "+chat-update", "--open-conversation-id", "cid-1", "--title", "新群名", "--yes"},
wantProduct: "chat",
wantTool: "update_group_name",
wantArgs: map[string]any{"openconversation_id": "cid-1", "group_name": "新群名"},
},
{
name: "member get id and users aliases",
argv: []string{"chat", "+chat-members-get", "--conversation-id", "cid-1", "--open-dingtalk-ids", "D1,D2"},
wantProduct: "im",
wantTool: "list_group_member_by_ids",
wantArgs: map[string]any{
"openConversationId": "cid-1",
"memberOpenDingTalkIds": []string{"D1", "D2"},
},
},
{
name: "invite url id alias",
argv: []string{"chat", "+chat-invite-url", "--chat-id", "cid-1"},
wantProduct: "im",
wantTool: "get_group_invite_url",
wantArgs: map[string]any{"openConversationId": "cid-1"},
},
{
name: "chat bots id alias",
argv: []string{"chat", "+chat-bots", "--id", "cid-1"},
wantProduct: "bot",
wantTool: "list_group_bots",
wantArgs: map[string]any{"openConversationId": "cid-1"},
},
{
name: "recall message aliases",
argv: []string{"chat", "+messages-recall", "--chat-id", "cid-1", "--message-id", "msg-1", "--yes"},
wantProduct: "im",
wantTool: "recall_message",
wantArgs: map[string]any{"openConversationId": "cid-1", "openMessageId": "msg-1"},
},
{
name: "message mget message id alias",
argv: []string{"chat", "+messages-mget", "--message-id", "msg-1,msg-2"},
wantProduct: "im",
wantTool: "list_messages_by_ids",
wantArgs: map[string]any{"openMsgIds": []string{"msg-1", "msg-2"}},
},
{
name: "send status task id alias",
argv: []string{"chat", "+messages-query-send-status", "--task-id", "task-1"},
wantProduct: "im",
wantTool: "query_message_send_status",
wantArgs: map[string]any{"openTaskId": "task-1"},
},
{
name: "conversation top aliases",
argv: []string{"chat", "+conversation-set-top", "--open-conversation-id", "cid-1", "--top=false", "--yes"},
wantProduct: "im",
wantTool: "set_top_conversation",
wantArgs: map[string]any{"openConversationId": "cid-1", "top": false},
},
{
name: "favorite list limit alias",
argv: []string{"chat", "+flag-list", "--limit", "7"},
wantProduct: "im",
wantTool: "list_message_favorites",
wantArgs: map[string]any{"size": "7"},
},
{
name: "at all mute matches live MCP schema",
argv: []string{"chat", "+conversation-mute-at-all", "--conversation-id", "cid-1", "--yes"},
@@ -256,7 +190,7 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
t.Fatalf("call = %s/%s, want %s/%s", fake.product, fake.tool, tc.wantProduct, tc.wantTool)
}
for key, want := range tc.wantArgs {
if got := fake.args[key]; !reflect.DeepEqual(got, want) {
if got := fake.args[key]; got != want {
t.Errorf("%s = %#v, want %#v", key, got, want)
}
}
+6 -18
View File
@@ -119,24 +119,14 @@ var ChatUpdate = shortcut.Shortcut{
Intent: "当你只需要修改群名称时使用;这是 lark-cli +chat-update 的诚实子集,只接受群 openConversationId 和新名称。修改群 description、个人备注、群昵称或其他群设置时不要使用。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "name", Type: shortcut.FlagString, Desc: "新的群名称(必填)"},
{Name: "title", Type: shortcut.FlagString, Desc: "--name 的兼容别名", Hidden: true},
{Name: "new-title", Type: shortcut.FlagString, Desc: "--name 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "id", "chat-id", "conversation-id", "open-conversation-id"}},
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"name", "title", "new-title"}},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "新的群名称", Required: true},
},
Tips: []string{`dws chat +chat-update --group <openConversationId> --name "新群名"`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("update_group_name", map[string]any{
"openconversation_id": rt.StrFirst("group", "id", "chat-id", "conversation-id", "open-conversation-id"),
"group_name": rt.StrFirst("name", "title", "new-title"),
"openconversation_id": rt.Str("group"),
"group_name": rt.Str("name"),
})
},
}
@@ -401,8 +391,6 @@ var FlagList = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "数字分页游标,首次传 0"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,范围 1-100"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "--size 的兼容别名", Hidden: true},
{Name: "max", Type: shortcut.FlagInt, Desc: "--size 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{{
Kind: shortcut.ConstraintCustom,
@@ -414,7 +402,7 @@ var FlagList = shortcut.Shortcut{
if rt.Int("cursor") < 0 {
return apperrors.NewValidation("--cursor 必须大于等于 0")
}
if size := rt.IntFirst("size", "limit", "max"); size < 1 || size > 100 {
if size := rt.Int("size"); size < 1 || size > 100 {
return apperrors.NewValidation("--size 必须在 1-100 之间")
}
return nil
@@ -422,7 +410,7 @@ var FlagList = shortcut.Shortcut{
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("list_message_favorites", map[string]any{
"cursor": rt.Int("cursor"),
"size": strconv.Itoa(rt.IntFirst("size", "limit", "max")),
"size": strconv.Itoa(rt.Int("size")),
})
},
}
+1 -30
View File
@@ -96,17 +96,6 @@ func (rt *RuntimeContext) StrSlice(name string) []string {
return v
}
// StrSliceFirst returns the first non-empty string-slice value across a
// primary flag and its compatibility aliases.
func (rt *RuntimeContext) StrSliceFirst(names ...string) []string {
for _, name := range names {
if v := rt.StrSlice(name); hasNonEmptyString(v) {
return v
}
}
return nil
}
// Changed reports whether the user explicitly set the flag on the command line.
func (rt *RuntimeContext) Changed(name string) bool {
f := rt.cmd.Flags().Lookup(name)
@@ -369,27 +358,9 @@ func shortcutLongHelp(s Shortcut) string {
if len(s.Constraints) == 0 {
return long
}
publicFlags := make(map[string]bool, len(s.Flags))
for _, flag := range s.Flags {
publicFlags[flag.Name] = !flag.Hidden
}
lines := make([]string, 0, len(s.Constraints))
for _, constraint := range s.Constraints {
visible := constraint
visible.Flags = make([]string, 0, len(constraint.Flags))
for _, flag := range constraint.Flags {
if publicFlags[flag] {
visible.Flags = append(visible.Flags, flag)
}
}
if len(visible.Flags) == 0 ||
(visible.Kind != ConstraintCustom && len(visible.Flags) < 2) {
continue
}
lines = append(lines, " - "+constraintHelp(visible))
}
if len(lines) == 0 {
return long
lines = append(lines, " - "+constraintHelp(constraint))
}
return long + "\n\n参数约束:\n" + strings.Join(lines, "\n")
}
-15
View File
@@ -16,7 +16,6 @@ package shortcut
import (
"bytes"
"os"
"reflect"
"strings"
"testing"
@@ -138,7 +137,6 @@ func TestCrossPlatformCoverageSchemaConstraintCollapsesHiddenAliases(t *testing.
cmd := mount(Shortcut{
Service: "chat",
Command: "+search",
Intent: "搜索消息",
Flags: []Flag{
{Name: "query", Type: FlagString},
{Name: "keyword", Type: FlagString, Hidden: true},
@@ -167,11 +165,6 @@ func TestCrossPlatformCoverageSchemaConstraintCollapsesHiddenAliases(t *testing.
if raw := cmd.Annotations["dws.schema.constraints"]; raw != "" {
t.Fatalf("hidden compatibility alias leaked into public Schema constraints: %q", raw)
}
for _, hidden := range []string{"--keyword", "--legacy-id"} {
if strings.Contains(cmd.Long, hidden) {
t.Fatalf("hidden compatibility alias leaked into long help: %q", cmd.Long)
}
}
}
func TestCrossPlatformCoverageAliasAndAIMessageTagHelpers(t *testing.T) {
@@ -186,8 +179,6 @@ func TestCrossPlatformCoverageAliasAndAIMessageTagHelpers(t *testing.T) {
Flags: []Flag{
{Name: "query", Type: FlagString},
{Name: "keyword", Type: FlagString},
{Name: "ids", Type: FlagStringSlice},
{Name: "legacy-ids", Type: FlagStringSlice},
{Name: "limit", Type: FlagInt, Default: "20"},
{Name: "size", Type: FlagInt},
aiTag,
@@ -202,12 +193,6 @@ func TestCrossPlatformCoverageAliasAndAIMessageTagHelpers(t *testing.T) {
if got := rt.StrFirst("query", "keyword"); got != "树莓派" {
t.Fatalf("StrFirst() = %q, want 树莓派", got)
}
if err := cmd.Flags().Set("legacy-ids", "a,b"); err != nil {
t.Fatal(err)
}
if got := rt.StrSliceFirst("ids", "legacy-ids"); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Fatalf("StrSliceFirst() = %#v, want [a b]", got)
}
if got := rt.IntFirst("limit", "size"); got != 20 {
t.Fatalf("IntFirst() default = %d, want 20", got)
}
@@ -218,20 +218,6 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
wantTool: "search_messages",
wantArgs: map[string]any{"openConversationIds": []string{"cid-1"}, "keyword": "树莓派"},
},
{
name: "dm name alias",
argv: []string{"chat", "+dm", "--name", "张三", "--text", "你好", "--yes"},
wantProduct: "chat",
wantTool: "send_personal_message",
wantArgs: map[string]any{"receiverOpenDingTalkId": "open1"},
},
{
name: "chat members list id alias",
argv: []string{"chat", "+chat-members-list", "--chat-id", "cid-1", "--member-types", "user"},
wantProduct: "chat",
wantTool: "get_group_members",
wantArgs: map[string]any{"openconversation_id": "cid-1"},
},
}
for _, tc := range tests {
+2 -7
View File
@@ -40,21 +40,16 @@ var DM = shortcut.Shortcut{
"内部先按姓名搜通讯录解析出唯一用户,并用其 openDingTalkId 发送,姓名匹配到多人时会列出候选让你区分。会真实发出消息。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "to", Type: shortcut.FlagString, Desc: "收件人姓名/花名(必填)"},
{Name: "name", Type: shortcut.FlagString, Desc: "--to 的兼容别名", Hidden: true},
{Name: "keyword", Type: shortcut.FlagString, Desc: "--to 的兼容别名", Hidden: true},
{Name: "to", Type: shortcut.FlagString, Desc: "收件人姓名/花名", Required: true},
{Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true},
shortcut.AIMessageTagFlag(),
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"to", "name", "keyword"}},
},
Tips: []string{`dws chat +dm --to 张三 --text "周报发我一下"`},
Execute: func(rt *shortcut.RuntimeContext) error {
text := rt.Str("text")
// Step 1 — resolve the recipient name to a unique userId.
user, err := resolveOpenDingTalkUser(rt, rt.StrFirst("to", "name", "keyword"))
user, err := resolveOpenDingTalkUser(rt, rt.Str("to"))
if err != nil {
return err
}
+2 -5
View File
@@ -109,20 +109,17 @@ var ChatMembersList = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群名称(与 --conversation-id 二选一)"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "群 openConversationId(与 --group 二选一)"},
{Name: "id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "member-types", Type: shortcut.FlagStringSlice, Desc: "成员类型:user,bot;不传则同时返回"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "chat-id", "open-conversation-id"}},
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id"}},
},
Tips: []string{
`dws chat +chat-members-list --group "项目冲刺"`,
`dws chat +chat-members-list --conversation-id <openConversationId> --member-types user,bot`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "id", "chat-id", "open-conversation-id"))
groupID := strings.TrimSpace(rt.Str("conversation-id"))
groupName := strings.TrimSpace(rt.Str("group"))
if groupID == "" {
resolved, err := resolveGroupName(rt, groupName)
+3 -32
View File
@@ -28,7 +28,7 @@ import (
// NewShortcutCommand builds the `dws shortcut` management command tree:
//
// dws shortcut list [--service x] [--compact] # list built-in shortcuts
// dws shortcut list [--service x] # list built-in shortcuts
// dws shortcut stats [--top N] # high-frequency usage aggregation
// dws shortcut stats --purge # clear the usage log
func NewShortcutCommand() *cobra.Command {
@@ -51,7 +51,6 @@ func newListCommand() *cobra.Command {
RunE: func(cmd *cobra.Command, _ []string) error {
svc, _ := cmd.Flags().GetString("service")
includeHidden, _ := cmd.Flags().GetBool("all")
compact, _ := cmd.Flags().GetBool("compact")
rows := make([]shortcutListRow, 0)
for _, s := range shortcut.All() {
if svc != "" && s.Service != svc {
@@ -62,37 +61,19 @@ func newListCommand() *cobra.Command {
}
rows = append(rows, newShortcutListRow(s))
}
payload := map[string]any{
return output.WriteCommandPayload(cmd, map[string]any{
"catalog": "shortcut",
"runtime_schema": true,
"count": len(rows),
"shortcuts": rows,
}
if compact {
compactRows := make([]shortcutCompactListRow, 0, len(rows))
for _, row := range rows {
compactRows = append(compactRows, row.compact())
}
payload["compact"] = true
payload["shortcuts"] = compactRows
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
}, output.FormatJSON)
},
}
cmd.Flags().String("service", "", "只列出指定服务的 shortcut")
cmd.Flags().Bool("all", false, "包含当前未进入公开 Catalog、但仍可直接调用的 shortcut")
cmd.Flags().Bool("compact", false, "仅返回发现和安全决策所需字段;选中后再查 leaf Schema 或完整 catalog")
return cmd
}
type shortcutCompactListRow struct {
CLIPath string `json:"cli_path"`
Risk string `json:"risk"`
Confirmation string `json:"confirmation"`
Description string `json:"description"`
Intent string `json:"intent,omitempty"`
}
type shortcutListRow struct {
Service string `json:"service"`
Command string `json:"command"`
@@ -113,16 +94,6 @@ type shortcutListRow struct {
Reviewed bool `json:"reviewed"`
}
func (r shortcutListRow) compact() shortcutCompactListRow {
return shortcutCompactListRow{
CLIPath: r.CLIPath,
Risk: r.Risk,
Confirmation: r.Confirmation,
Description: r.Description,
Intent: r.Intent,
}
}
func newShortcutListRow(s shortcut.Shortcut) shortcutListRow {
product := s.Product
if product == "" {
-51
View File
@@ -75,57 +75,6 @@ func TestCrossPlatformCoverageShortcutListFiltersHiddenAndService(t *testing.T)
}
}
func TestCrossPlatformCoverageShortcutListCompactPublishesDiscoveryContract(t *testing.T) {
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+messages-send",
Description: "发送一条消息",
Intent: "用户要求发送消息",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "text", Required: true},
},
Tips: []string{"dws chat +messages-send --text hello"},
})
cmd := newListCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{"--service", "chat", "--compact"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var payload struct {
Compact bool `json:"compact"`
Count int `json:"count"`
Shortcuts []map[string]any `json:"shortcuts"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if !payload.Compact || payload.Count != 1 || len(payload.Shortcuts) != 1 {
t.Fatalf("unexpected compact payload: %#v", payload)
}
row := payload.Shortcuts[0]
for key, want := range map[string]any{
"cli_path": "chat +messages-send",
"description": "发送一条消息",
"intent": "用户要求发送消息",
"risk": "write",
"confirmation": "user_required",
} {
if got := row[key]; got != want {
t.Fatalf("compact row %s = %#v, want %#v", key, got, want)
}
}
for _, omitted := range []string{"flags", "constraints", "examples", "primary", "reviewed"} {
if _, ok := row[omitted]; ok {
t.Fatalf("compact row unexpectedly contains %q: %#v", omitted, row)
}
}
}
func TestCrossPlatformCoverageShortcutListRowPublishesCompleteContract(t *testing.T) {
row := newShortcutListRow(shortcut.Shortcut{
Service: "chat",
+5 -5
View File
@@ -50,13 +50,13 @@ PRODUCT_START = "<!-- VISIBLE_SHORTCUTS_START -->"
PRODUCT_END = "<!-- VISIBLE_SHORTCUTS_END -->"
# Large, high-frequency product skills should route known intents directly and
# keep their full shortcut inventory in the Runtime Shortcut Catalog. Curated
# entries may additionally be queried through leaf Schema. Add services here
# only after verifying that the product skill has its own reviewed routing
# keep their full shortcut inventory in Runtime Catalog/Schema. Add services
# here only after verifying that the product skill has its own reviewed routing
# section and intent table; compacting a sparse skill without an alternative
# route would make its shortcuts harder to discover.
COMPACT_PRODUCT_SERVICES = {"chat"}
def md_escape(value: Any) -> str:
text = str(value or "")
return text.replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ")
@@ -138,9 +138,9 @@ def compact_product_section(service: str, rows: list[dict[str, Any]]) -> str:
return f"""{PRODUCT_START}
## Shortcut 发现(按需)
`{md_escape(service)}` 当前有 {len(rows)} 条公开 shortcut。完整清单保留在 Runtime Shortcut Catalog;已完成 Schema curation 的子集可通过 leaf Schema 查询。高频产品根 Skill 不重复展开完整清单。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
`{md_escape(service)}` 当前有 {len(rows)} 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service {md_escape(service)} --compact --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service {md_escape(service)} --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
{PRODUCT_END}"""
+1 -1
View File
@@ -21,7 +21,7 @@ shortcut_rows="$(
awk '
/<!-- VISIBLE_SHORTCUTS_START -->/ { in_block = 1; next }
/<!-- VISIBLE_SHORTCUTS_END -->/ { in_block = 0 }
in_block && /^\|[[:space:]]*`/ { count++ }
in_block && /^\| `dws chat \+/ { count++ }
END { print count + 0 }
' "$chat_skill"
)"
+5 -6
View File
@@ -1,8 +1,7 @@
---
name: dws
description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件、实时监听群成员加入、群成员退出、群改名和群解散时使用。
metadata:
cli_version: ">=1.0.15"
cli_version: ">=1.0.15"
---
# 钉钉全产品 Skill
@@ -22,15 +21,15 @@ metadata:
- 危险操作必须先向用户确认,用户同意后才加 `--yes` 执行
- 单次批量操作不超过 30 条记录
- 所有命令必须**严格遵循**对应产品参考文档里面规定的参数格式(如:如果有参数值,则参数和参数值之间至少用一个空格隔开)
- **CLI 路径必须独立可用**:不要假定用户环境安装了 Python。优先使用 `dws` 原生命令或 Shortcut;[scripts/](./scripts/) 仅在对应运行时已确认可用且能明显简化翻页、轮询或批量操作时作为可选加速项。脚本不可用时直接执行同场景的原生命令流程,不得把缺少 Python 当成能力阻塞
- **脚本优先**:[scripts/](./scripts/) 下的 `python scripts/<name>.py` 已封装翻页/轮询/批量逻辑,遇到对应场景(如 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等)**优先调用脚本**而非手写多步命令。脚本均支持 `--dry-run` 预览、`--format json` 输出,失败时回退到手动步骤
- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,必须走 `dws event consume ... --flatten` 长连接,不要写脚本轮询消息历史
## Shortcut 与原子命令的使用原则
`shortcut` 是对常用操作的高层封装,适合优先承担用户意图;产品参考文档和本 skill 负责判断意图、风险、跨产品流程和复杂参数,CLI 帮助负责声明当前版本真正可调用的命令。
- 先按产品参考、意图表和 recipe 路由。精确 recipe 或对应解释器(如 `python3`)已可用的专用脚本优先;解释器不可用时直接跳过脚本,不得阻塞任务。否则用户意图可由可见 shortcut 满足时,优先使用 `dws <service> +<verb> ... --format json`,不要手写等价的多步原子命令。
- 公开内建 shortcut 以 `dws shortcut list --service <service> --format json` 为动态 catalog;已进入 Runtime Schema 的 leaf 用 `dws schema --cli-path "<service> +<verb>" --format json` 读取 Agent 选择、参数、跨参数约束、risk/confirmation 与接口语义。若 leaf 暂未进入 Schema,则使用 catalog 中同一 `cli_path` 的完整契约。
- 先按产品参考、意图表和 recipe 路由。存在精确覆盖场景的专用脚本/recipe 时继续遵循“脚本优先”;否则用户意图可由可见 shortcut 满足时,优先使用 `dws <service> +<verb> ... --format json`,不要手写等价的多步原子命令。
- 公开内建 shortcut 同时进入 Runtime Schema。用 `dws schema --cli-path "<service> +<verb>" --format json` 读取 Agent 选择、参数、跨参数约束、risk/confirmation 与接口语义;`dws shortcut list --service <service> --format json` 只作为轻量批量发现入口。
- 真正组装参数前用叶子帮助 `dws <service> +<verb> --help` 核对当前 Cobra 接受的 flags。父级 `dws <service> --help` 只能发现子命令,不能替代叶子参数帮助。
- shortcut catalog 中 `confirmation=user_required` 时,必须先获得用户确认,确认后才加 `--yes`;`not_required` 不额外确认。
- 如果 shortcut 不在 help / list 中,改用产品参考里的原子命令、脚本或标准流程;不要猜测未展示的 `+` 命令。
@@ -94,7 +93,7 @@ metadata:
| `mail` | 邮箱:邮箱地址查询/邮件搜索(KQL)/邮件详情/发送邮件 | [mail.md](./references/products/mail.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) |
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) |
| `event` | 个人 IM 事件:监听消息接收、指定发送人、已读、撤回、表情回应,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
## 意图判断决策树
@@ -4,10 +4,10 @@
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| query-group-chat | 1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>" --direction newer --format json`<br>3. **翻页**:`hasMore=true` 时取本页最后一条消息的 `createTime` 作为下一次 `--time`,保持原 `--direction`,禁止自造 cursor/time;直到 `hasMore=false`<br>4. `--direction older` 拉给定时间**之前**的消息<br>5. 合并结果;用户要求导出时由 Agent 将结构化输出写入目标文件 |
| query-private-chat | 1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId` / `openDingTalkId`<br>2. `chat message list --user <userId> --time "<yyyy-MM-dd HH:mm:ss>" --direction newer --format json`;跨组织时可用 `--open-dingtalk-id`<br>3. **翻页**:同 query-group-chat<br>4. 合并结果后总结或按用户要求保存 |
| query-group-chat | **优先**:`chat_export_messages.py`(开源版未引入;可手动用 `dws chat message list` 翻页后写入文件)(自动搜群+翻页+导出)<br>备选:1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`<br>4. `--forward=false` 拉给定时间**之前**的消息<br>5. 合并全部消息后总结 |
| query-private-chat | **优先**:`chat_history_with_user.py`(开源版未引入;可手动用 `dws chat search` + `dws chat message list` 组合)(自动搜人+翻页+导出)<br>备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `chat message list --user <userId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:同 query-group-chat<br>4. 合并全部消息后总结 |
| escalate-ding | 三级升级:<br>1. `ding message send --robot-code <robotCode> --type app --users <userId> --content "<内容>"`(必填项见 [ding.md](../products/ding.md))<br>2. `chat message send --group <openConversationId> --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](../products/chat.md))<br>3. `todo task create --title "<标题>" --executors <userId> --priority 40` 建紧急待办<br>前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` |
| send-by-bot | 1. `chat bot search` → 取 `robotCode`<br>2. `chat search --query "<群名>"` → 取每个 `openConversationId`<br>3. 单群用 `chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "<标题>" --text "<内容>"`<br>4. 多群时先一次性确认全部目标与内容,再对每个已确认群逐一执行同一命令;汇总每群成功/失败结果 |
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat +chat-messages --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat +messages-send --as user --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--chat-id <openConversationId>` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
| send-by-bot | **多群批量优先**:`bot_broadcast.py`(开源版未引入;可手动用 `dws chat message send-by-bot` 多次调用)<br>单群:1. `chat bot search` → 取 `robotCode`<br>2. `chat search --query "<群名>"` → 取 `openConversationId`<br>3. `chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "<标题>" --text "<内容>"` |
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId> --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
| search-common-group | `chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0`(`--match-mode AND`=全在/`OR`=任一在,翻页:`hasMore=true` 时用 `nextCursor`)<br>用户说"我和XX的共同群" → nicks 包含"我"时,需先 `contact user get-self` 取自己昵称再拼接 |
| focus-messages | **零参数一行命令**:`chat message list-focused --limit 50`(拉特别关注人发的消息聚合)<br>触发 query:`"我特别关注的人最近发了什么消息"`、`"关注的人最近聊了啥"`、`"星标联系人最近的动态"`<br>**强消歧**:query 含动词【发/说/聊/讲】或名词【消息/聊天/动态】 → **必须**走本命令,**不要**先去拉 `contact relation list-my-followings`;仅当用户终点是"人员列表"(如"我关注了谁")才走 `relation list-my-followings`(详见 [contact.md](../products/contact.md#意图判断) 易混淆硬规则)<br>翻页:`hasMore=true` 时用 `nextCursor` 作为下次 `--cursor`<br>按人精控(可选):先 `contact relation list-my-followings` 取 `openDingTalkId`,再 `chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start <ISO> --end <ISO>` |
@@ -105,6 +105,7 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `workflow edit-example` | 获取编辑文档与 DSL 示例 | 无 | create/update 前优先调用,内容由服务端提供 |
| `workflow create` | 创建并发布工作流 | `--base-id` `--dsl` | `--dsl` 为完整 workflow-dsl/v1;非幂等,不自动重试 |
| `workflow update` | 更新并发布工作流 | `--base-id` `--workflow-id` `--dsl` | 全量替换,先 get 留底;检查 `data.valid/issues` |
| `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` |
@@ -7,6 +7,7 @@
| 命令 | 用途 |
|------|------|
| `workflow edit-example` | 获取工作流编辑文档与 workflow-dsl/v1 示例 |
| `workflow create` | 创建并发布自动化工作流 |
| `workflow update` | 更新并发布已有自动化工作流 |
| `workflow list` | 列出 Base 下所有工作流(含状态/创建人/最后修改时间),支持分页 |
@@ -14,11 +15,11 @@
| `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) |
| `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) |
> 所有子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
> `workflow edit-example` 无参数;其他子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
## DSL 入参格式与最小 Demo
`workflow create/update` 的 `--dsl` 接收完整的 `workflow-dsl/v1` JSON object,不是局部 patch。支持内联 JSON、`@文件路径` 或 `-` 从 stdin 读取。
先运行 `workflow edit-example` 获取服务端提供的最新编辑文档和示例。`workflow create/update` 的 `--dsl` 接收完整的 `workflow-dsl/v1` JSON object,不是局部 patch。支持内联 JSON、`@文件路径` 或 `-` 从 stdin 读取。
复杂工作流应先用 `table get` / `field get` / `view list` 确认真实 `sheetId`、`fieldId`、`viewId`,并检查所有 `next`、`loopEntry`、branch `to` 和 ref。下面是一个不依赖数据表字段的最小定时消息工作流:
@@ -53,6 +54,14 @@ create 和 update 都必须同时满足 `status=success`、`data.valid=true`、`
## 命令详情
### workflow edit-example — 获取编辑文档与示例
```bash
dws aitable workflow edit-example --format json
```
该命令无业务参数,调用 `aitable/edit_workflow_example` 返回服务端提供的工作流编辑文档和示例。创建或更新复杂工作流前优先调用它,避免依赖可能过期的本地 DSL 结构。
### workflow create — 创建并发布工作流
```bash
+14 -28
View File
@@ -4,26 +4,24 @@
## Shortcut 优先路由
精确脚本 / recipe 未覆盖时,常见 Agent 意图优先使用公开 `+` Shortcut;下面的原子命令章节保留给需要特定原始返回结构、兼容参数或 Shortcut 未覆盖字段的场景。先尝试 `dws shortcut list --service chat --compact --format json` 动态发现;当前 CLI 报 `unknown flag: --compact` 时,立即去掉 `--compact` 重试。选中后读取 `dws schema --cli-path "<cli_path>" --format json`;若 leaf 暂未进入 Schema,则以完整 Catalog 中同一 `cli_path` 的契约为准,最后用 `dws <cli_path> --help` 核对真实 flags。
常见 Agent 意图优先使用公开 `+` Shortcut;下面的原子命令章节保留给需要特定原始返回结构、兼容参数或 Shortcut 未覆盖字段的场景。执行前用 `dws schema --cli-path "chat +<shortcut>" --format json` 读取最终参数、约束和确认语义。
| 意图 | 首选 |
|---|---|
| 以 current-user / bot / webhook 身份发消息 | `dws chat +messages-send --as <identity> ...` |
| 拉取单个群聊或单聊的消息 | `dws chat +chat-messages ...` |
| 按关键词、发送者、@对象、会话、类型或时间组合搜索 | `dws chat +search-msg ...` |
| 查询 @ 我的消息 | `dws chat +at-me ...` |
| 查询 @我的消息 | `dws chat +at-me ...` |
| 根据消息 ID 批量取详情与 reaction | `dws chat +messages-mget ...` |
| 读取已知 thread/topic 的全部回复 | `dws chat +thread-replies ...` |
| 下载单个 mediaId/fileId | `dws chat +messages-resource-download ...` |
| 创建并按需立即更新流式卡片 | `dws chat +messages-send-card ...` |
- `+messages-send` 只暴露下层真实支持的身份能力:user 支持文本/Markdown、已有 mediaId 图片、本地文件和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。
- `+messages-send` 自动规范化并补齐对应身份的 @ 占位符。声明 `--at-*` / `--at-all` 即可,不要为统一 Shortcut 手工拼 `@10`。
- `+messages-send` 会自动规范化并补齐 @ 占位符。user 使用 `<@id>` / `<@all>`;bot/webhook 使用 `@id` / `@手机号` / `@all`。声明 `--at-*` / `--at-all` 即可,不要为统一 Shortcut 手工拼 `@10`。
- `+search-msg --page-all` 连续翻页并默认按消息 ID 批量富化;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger。
- `+at-me`、`+chat-messages`、`+messages-mget`、`+search-msg`、`+thread-replies` 可用 `--download-resources` 下载资源。引用、回复、合并转发中的资源使用结果 `resourceRefs` 自带的子消息 `messageId`;仅当子消息缺会话 ID 时继承父消息 `openConversationId`。
- 上述五个查询 Shortcut 与 `+messages-resource-download` 都是 `read/not_required`,不要添加 `--yes`。下载只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
- 下载器只接受经审查的钉钉与公网 OSS HTTPS 地址并逐跳校验重定向;跨主机时不会转发下层提供的请求头。新官方域名被拒绝时记录错误中的 host 供审查,不要放宽为任意 HTTPS。
- `+messages-send-card` 的目标为 `--group`、`--receiver`、`--receiver-open-dingtalk-id` 三选一;传 `--content` 时创建并立即更新,不传时返回 `bizId` 供 `message update-card` 使用。
- 上述五个查询 Shortcut 与 `+messages-resource-download` 都沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。下载只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
- 下载器仅接受经审查的钉钉与公网 OSS HTTPS 地址并逐跳校验重定向;跨主机时不会转发下层提供的请求头。新官方域名被拒绝时记录错误中的 host 供审查,不要放宽为任意 HTTPS。
### group (群组管理)
@@ -1171,10 +1169,6 @@ Flags:
#### 下载消息中的资源(图片/视频/语音等)到本地
读取消息时优先在 `+at-me`、`+chat-messages`、`+messages-mget`、`+search-msg`、`+thread-replies` 上加 `--download-resources --output-dir ./downloads`。只下载单个已知资源时优先用 `+messages-resource-download`:mediaId 必须同时传 `--message-id` 和 `--open-conversation-id`,fileId 不需要消息上下文。输出必须是工作目录内相对路径且不能包含 `..`;默认不覆盖,用户明确要求时才传 `--overwrite`。该 Shortcut 暂无 leaf Schema 时按完整 Catalog + 叶子 Help 核对。
以下原子命令保留给需要原始接口行为的场景。
下载聊天消息中的图片、视频、语音等资源到本地文件。流程:先获取下载 URL,再 HTTP GET 下载。
```
Usage:
@@ -1545,12 +1539,10 @@ Flags:
Usage:
dws chat category delete [flags]
Example:
# 先向用户确认,确认后执行
dws chat category delete --category-id <分组ID> --yes
dws chat category delete --category-id <分组ID>
# 分组ID 可通过 dws chat category list 获取
Flags:
--category-id int 会话分组 ID (必填)
--yes 跳过运行时确认;只能在用户明确确认后传入
```
#### 重命名会话分组
@@ -1779,18 +1771,15 @@ Flags:
Usage:
dws chat clear-messages [flags]
Example:
# 先向用户说明目标会话和影响范围,确认后执行
dws chat clear-messages --conversation-id <openConversationId> --yes
dws chat clear-messages --id <openConversationId> --yes
dws chat clear-messages --conversation-id <openConversationId>
dws chat clear-messages --id <openConversationId>
Flags:
--conversation-id string 会话 openConversationId (必填,支持群聊/单聊)
--id string --conversation-id 的别名
--chat string --conversation-id 的别名
--yes 跳过运行时确认;只能在用户明确确认后传入
注意:
- 仅清空当前用户视角的消息,不影响其他成员
- 仍属于高风险操作;未确认时不得传 --yes 或执行
- openConversationId 可通过 chat search(群聊)或 chat conversation-info(单聊)获取
```
@@ -2212,7 +2201,7 @@ dws chat message send --group <openConversationId> --msg-type image --media-id "
群聊传 --group,单聊传 --receiver,二者互斥。
**注意:本节原子 send-card 必须和 update-card 搭配使用。** 创建卡片时无需传入内容,后续通过 update-card 更新内容,最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。若使用 `+messages-send-card --content ...`,Shortcut 会在创建后立即完成一次更新;不传 `--content` 时仍返回 `bizId` 供本节 update-card 使用。
**注意:send-card 必须和 update-card 搭配使用。** 创建卡片时无需传入内容,后续通过 update-card 更新内容,最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。
flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。
```
Usage:
@@ -2331,15 +2320,12 @@ Flags:
- `chat group-mute-member` 指定群成员禁言,需传 --group、--user/--users(userId,逗号分隔)、--mute-time(毫秒,仅禁言时必填,支持 300000/3600000/86400000/604800000/2592000000),传 --off 解除禁言;CLI 会自动把 userId 解析成 openDingTalkId 再调用,直接传 userId 即可;禁言群主会被服务端拒绝
- `chat group set-admin` 设置/取消群管理员,需传 --group(openConversationId)、--user/--users(userId,逗号分隔),默认设为管理员,传 --off 取消
## 可选自动化脚本
## 自动化脚本
这些脚本仅用于已确认安装 Python 3 的环境,不是默认或唯一执行路径;无
Python 环境时直接使用上文的 `dws` Shortcut / 原子命令。
| 脚本 | 可选场景 | 用法 |
|------|----------|------|
| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python3 scripts/chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` |
| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` |
| 脚本 | 场景 | 用法 |
|------|------|------|
| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` |
| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` |
## 相关产品
+9
View File
@@ -202,14 +202,23 @@ Usage:
Example:
dws drive download --node <dentryUuid> --output ./report.pdf
dws drive download --node <dentryUuid> --output ~/downloads/
dws drive download --node <dentryUuid> --output ./big.zip --part-size 32MB --parallel 8
Flags:
--node string 文件 ID (dentryUuid) (必填)
--output string 本地保存路径 (必填),可以是文件路径或目录;如果指定目录,文件名从下载 URL 中自动推断
--space-id string 文件所属空间 ID (可选)
--part-size string 分片下载的分片大小,支持 KB/MB/GB 单位,范围 1MB-1GB (默认 16MB)
--parallel int 分片下载并发数,范围 1-8 (默认 4)
--no-resume 关闭断点续传,忽略历史下载进度从头下载 (默认开启续传)
```
> **注意**:`--output` 是必填参数,不传会报错。
> **大文件分片下载**:
> - 大文件自动分片并发下载,小文件整流下载,行为对用户透明,无需任何额外操作。
> - 断点续传默认开启:下载中断后重跑同一命令会自动跳过已完成部分继续下载(`<目标文件>.dwspart` 为临时进度文件,下载完成后自动清理);不需要续传时加 `--no-resume`。
> - 下载凭证过期会自动刷新并继续下载,已完成的部分不会重下;单个分片失败会自动重试,无需手动处理。
### 创建文件夹
```
+406
View File
@@ -117,6 +117,363 @@ Flags:
--query string 关键字,匹配 processCode 或表单名称 (必填)
```
### 按模板 processCode 查询表单 Schema 信息
> **说明:** 根据已知的 processCode 精确查询表单的完整 Schema,包括表单名称、状态、创建者、创建/修改时间以及表单组件 JSON(content 字段)。
```
Usage:
dws oa approval form-schema [flags]
Example:
dws oa approval form-schema --process-code PROC-594AE140-6AA5-4BA4-AF0C-9E6F66DB1E0B
Flags:
--process-code string 表单模板 processCode (必填)
```
返回值字段:
- `result.processName` — 表单名称
- `result.processCode` — 表单 processCode
- `result.processStatus` — 表单状态(如 `PUBLISHED`)
- `result.creator` — 创建者 userId
- `result.gmtCreate` / `result.gmtModified` — 创建/修改时间(毫秒时间戳)
- `result.processIconUrl` — 表单图标 URL
- `result.processDescription` — 表单描述
- `result.content` — 表单组件 JSON 字符串,包含表单项(items)和标题等配置
### 流程预测
```
Usage:
dws oa approval forecast-process [flags]
Example:
# 简单预测
dws oa approval forecast-process --process-code PROC-xxx --dept-id -1 --form-values '{"单行输入框":"测试内容"}'
# 指定部门预测
dws oa approval forecast-process --process-code PROC-xxx --dept-id 12345 --form-values '{"金额":"5000"}'
# 高级用法:传入完整 JSON
dws oa approval forecast-process --request '{"processCode":"PROC-xxx","deptId":-1,"formComponentValues":[[{"name":"单行输入框","value":"测试"}]]}'
Flags:
--process-code string 审批模板 processCode(简单模式必填)
--form-values string 表单值 JSON,格式 '{"控件名称":"值"}'(简单模式必填)
--dept-id string 发起人所在部门 ID,根部门填 -1(简单模式必填)
--request string 完整请求体 JSON(高级模式,与简单模式互斥)
```
> **注意:** forecast 接口的 `formComponentValues` 比 create-instance 多一层数组包裹(`[[{...}]]`),CLI 简单模式已自动处理,高级模式需自行包裹。`processCode`、`deptId`、`formComponentValues` 三个字段均为必填,`userId` 由系统从登录态自动填充。
#### 流程预测的作用
在 `create-instance` 之前调用 `forecast-process`,可以根据已填写的表单值预测审批流程走向,核心价值有两个:
1. **展示流程路径** — 告诉用户这个审批会经过哪些节点(审批人、抄送人、条件分支),让用户在提交前就知道流程走向。
2. **识别自选审批人节点** — 返回中 `targetSelect: true` 的节点需要用户手动选择审批人/抄送人,Agent 应提示用户选人,并将结果传入 `create-instance` 的 `targetSelectActioners`。
#### 返回值关键字段
| 字段 | 含义 |
|------|------|
| `result.forecastSuccess` | 预测是否成功 |
| `result.staticWorkflow` | 是否为静态流程(无条件分支) |
| `result.workflowForecastNodes` | 流程节点路径,每个节点包含 `activityId` 和 `outIds`(下一跳) |
| `result.workflowActivityRuleVOs` | **重点**:每个节点的详细规则,包含节点类型、审批人、是否自选等 |
#### `workflowActivityRuleVOs` 节点字段解读
| 字段 | 含义 |
|------|------|
| `activityId` | 节点 ID |
| `workflowActor.actorKey` | 自选节点的规则 key,即 `targetSelectActioners` 中 `actionerKey` 的值 |
| `activityName` | 节点名称(如"审批人"、"抄送人") |
| `activityType` | 节点类型:`target_approval`(已指定审批人)、`target_select`(需自选)、`target_notifier`(抄送) |
| `targetSelect` | **`true` 表示需要用户自选审批人/抄送人** |
| `activityActioners` | 已确定的处理人列表(含 `emplId`、`name`) |
| `workflowActor.actorType` | 角色类型:`approver`(审批人)、`notifier`(抄送人) |
| `workflowActor.approvalMethod` | 多人审批方式:`ONE_BY_ONE`(依次审批) |
| `workflowActor.actorSelectionType` | 选人范围:`allStaff`(全员可选)等 |
| `prevActivityId` | 上一节点 ID |
#### Agent 处理流程
```
1. 调用 forecast-process,传入 processCode + form-values
2. 遍历 workflowActivityRuleVOs:
a. 向用户展示每个节点的名称、类型、已指定处理人
b. 若 targetSelect == true:
- 提示用户"节点「{activityName}」需要您自选{actorType}人"
- 使用 dws aisearch person --keyword "<姓名>" --dimension name --format json 帮用户查找并选人
- 记录 activityId 和用户选择的 userIds
3. 将自选结果组装为 targetSelectActioners,传入 create-instance 高级模式 --request
```
#### 自选节点 → `targetSelectActioners` 组装示例
假设 forecast 返回两个自选节点:
```json
{
"targetSelectActioners": [
{
"actionerKey": "manual_33ff_89cb_da91_e3aa",
"actionerStaffIds": ["userId_选人A"]
},
{
"actionerKey": "manual_a29e_9633_f8b7_7291",
"actionerStaffIds": ["userId_选人B"]
}
]
}
```
此字段通过 `create-instance --request` 的高级模式传入。`actionerKey` 来自 forecast 返回的 `workflowActor.actorKey`。
### 发起审批实例
#### 执行摘要
- **如果用户未明确给出 `processCode`,必须固定走 `search-forms` → `form-schema` → 收集表单值 → `forecast-process` → 自选节点选人 → `create-instance`**,不要跳过 `form-schema` 直接拼请求。
- **如果用户明确给出 `processCode`,固定走 `form-schema` → 收集表单值 → `forecast-process` → 自选节点选人 → `create-instance`**,不要跳过 `form-schema` 直接拼请求。
- **`form-schema` 返回的 `content` 不是创建 payload 的原样模板。** 它主要用于识别控件 `label`(即 name)、`id`、控件类型(componentName)和选项值范围;真正的 `formComponentValues` 中 `value` 结构以本文的控件值格式表为准。
- **`forecast-process` 返回的自选节点必须在发起前让用户选人。** 若 `workflowActivityRuleVOs` 中有 `targetSelect: true` 的节点,必须提示用户选择处理人,并将结果通过 `targetSelectActioners` 传入 `create-instance`。
- **所有人员类参数使用 userId。** 若用户给的是姓名,先用 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 解析成 userId。**严禁把姓名直接写进** `approvers`、`ccList`、`directAppointedApprovers`、`targetSelectActioners` 或表单人员控件。
- **创建实例前一次性汇总确认。** `create-instance` 是写操作,执行前一次性展示模板、表单值、流程预测结果和审批人/抄送人供用户确认。
#### 严禁行为
- **严禁跳过 `form-schema`。** 未拿到表单 Schema 前,不得调用 `create-instance`。
- **严禁复用旧的 Schema 结果。** 每次发起实例前都必须重新调用 `form-schema`,模板可能已被修改。
- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如附件等),直接告知用户不支持通过 CLI 发起。
- **严禁把 `form-schema` 返回的 `content` 当成可直接提交的 payload 模板。**
- **严禁把姓名直接写进 `approvers`、`ccList`、`directAppointedApprovers`、`targetSelectActioners` 或表单人员控件。** 必须先通过 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 转成 userId。
- **严禁在未得到用户确认前直接执行真实提单。**
- **严禁猜测控件名称或选项值。** 必须从 `form-schema` 返回中提取。
- **严禁跳过 `forecast-process` 中的自选节点选人。** 若预测返回 `targetSelect: true` 的节点,必须让用户选人后再发起。
#### 最小判断表
| 你手上有什么 | 下一步 |
|---|---|
| 只有口语需求,比如"帮我发起请假审批" | 先 `search-forms --query 请假` |
| 已拿到 `processCode` | 直接 `form-schema --process-code <code>` |
| 已拿到 Schema | 向用户展示控件列表,收集表单值 |
| 已收集表单值 | `forecast-process` 预测流程走向 |
| 预测返回有 `targetSelect: true` 节点 | 让用户为自选节点选人(`dws aisearch person --keyword "<姓名>" --dimension name --format json` 解析姓名) |
| 预测完成,自选节点已选人 | 汇总确认后 `create-instance --yes` |
| 用户明确说"不走模板流程,直接指定审批人" | 使用 `directAppointedApprovers`(高级模式) |
#### 工作流
```
1. search-forms --query <关键词> → 拿到 processCode(若已有则跳过)
2. form-schema --process-code <code> → 拿到控件列表、类型、选项值
3. 检查 Schema 中是否有不支持的必填控件 → 若有则直接告知用户不支持发起
4. 收集表单值 → 向用户展示控件列表,收集用户填写的表单值
5. forecast-process → 根据表单值预测流程走向,识别自选节点
6. 自选节点选人 → 若预测返回 targetSelect=true 的节点,让用户选人(用 dws aisearch person --keyword "<姓名>" --dimension name --format json 解析姓名)
7. 汇总确认后 create-instance --yes → 展示完整信息(表单值 + 流程路径 + 审批人),用户确认后执行发起
```
> **IMPORTANT:每次发起实例前都必须重新调用 `form-schema` 查询模板。** 即使用户之前查询过同一个 processCode,模板可能已被修改(控件增减、选项变更、必填属性调整等),不得复用旧的 Schema 结果。
#### 交互优化原则
> **核心目标:流程清晰,步骤有序,避免重复询问。**
1. **先查 Schema 再收集表单值(步骤 2→4):** `form-schema` 后向用户展示需要填写的控件列表,然后一次性收集全部表单值。不要在未拿到 Schema 前就问用户填什么。
2. **流程预测后再选自选审批人(步骤 5→6):** `forecast-process` 返回流程路径和自选节点后:
- 先向用户展示完整的流程路径(经过哪些节点、各节点处理人)
- 对 `targetSelect: true` 的节点,提示用户"节点「{activityName}」需要您自选{actorType}人"
- 用 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 帮用户查找并选人
- 若有多个自选节点,一次性收集所有自选节点的选人结果
3. **单次汇总确认(步骤 7):** 发起前一次性展示完整信息供用户确认:
- 审批模板名称
- 表单各控件值
- 流程预测结果(审批路径)
- 各节点审批人/抄送人(含自选节点选人结果)
**反例(禁止):**
- 未查 Schema 就直接问用户填什么表单值
- 流程预测后逐个节点分别询问选人,而非一次性收集
- 用户确认前直接执行发起
```
Usage:
dws oa approval create-instance [flags]
Example:
# 简单发起(Agent 在汇总确认后需加 --yes)
dws oa approval create-instance --process-code PROC-xxx --form-values '{"单行输入框":"测试内容"}' --yes
# 指定审批人(OR=或签,AND=会签,NONE=单人)
dws oa approval create-instance --process-code PROC-xxx --form-values '{"单行输入框":"测试"}' --approvers "userId1,userId2" --approvers-action-type OR --yes
# 指定抄送人
dws oa approval create-instance --process-code PROC-xxx --form-values '{"单行输入框":"测试"}' --cc-list "userId1" --cc-position START --yes
# 高级用法:传入完整 JSON(支持 directAppointedApprovers、targetSelectActioners 等全部字段)
dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1,"formComponentValues":[{"name":"单行输入框","value":"测试"}]}' --yes
Flags:
--process-code string 审批模板 processCode(简单模式必填)
--form-values string 表单值 JSON,格式 '{"控件名称":"值"}'(简单模式必填)
--dept-id string 发起人所在部门 ID,根部门填 -1(可选,默认 -1)
--originator-user-id string 审批发起人 userId(可选,MCP 工具可从登录态获取)
--approvers string 审批人 userId 列表,多个用逗号分隔(可选)
--approvers-action-type string 审批类型:AND(会签)、OR(或签)、NONE(单人)(可选,默认 OR)
--cc-list string 抄送人 userId 列表,多个用逗号分隔(可选)
--cc-position string 抄送时间点:START/FINISH/START_FINISH(可选,默认 START)
--request string 完整请求体 JSON(高级模式,与简单模式互斥)
--yes 显式确认并发起审批;未提供时命令直接拒绝,不进入交互确认(Agent 必须先汇总并获得用户确认)
```
#### 两种模式
- **简单模式:** 通过 `--process-code` + `--form-values` + 可选 flags 发起,适合大多数场景
- **高级模式:** 通过 `--request` 传入完整 JSON 请求体,支持 `directAppointedApprovers`、`targetSelectActioners` 等复杂字段
#### 组装 form-values
`form-values` 是简单模式下的核心入参;传入时必须是一个 JSON 对象字符串,key 为控件 label,value 为该控件的提交值。组装原则:
- 先用 `form-schema` 识别有哪些控件、每个控件的 `label`(name)、`componentName`(type)、选项值范围以及明细子控件结构。
- **`form-schema` 返回的 `content` 不是可直接提交的原样模板。** 它提供控件定义,`value` 结构须按下方控件值格式表组装。
- 提交时必须保证每个控件的 `name`(即 label)与 Schema 中的 `props.label` **完全一致**。
- 如果用户提供的是人员信息,先用 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 转成 userId 后再写入对应控件。
- 单选/多选控件提交的是选项文本(option value),该值从 `form-schema` 返回的选项定义中取得。
- `InnerContactField`、`DepartmentField`、`TableField`、`DDDateRangeField`、`DDAttachment` 等控件的 `value` 结构各不相同,必须按下方格式表单独组装,不要套用文本控件的写法。
- `TextNote`(文字说明)不收集数据,**不要**出现在 `formComponentValues` 中。
#### 表单控件值格式速查
> **重要:** `formComponentValues` 中每条记录的 `name` 必须与审批模板中控件的 `label`(即 `form-schema` 返回的 `content.items[].props.label`)**完全一致**。`value` 为字符串类型,最大 65535 字符。
>
> **详细参考:** 每种控件的完整属性、约束和示例见 [oa-form-components.md](oa/oa-form-components.md)。组装前**必须先阅读该文档**。
| 控件类型 | componentName | value 格式 | 示例 | 备注 |
|---------|---------------|-----------|------|-------------------------------------------------------|
| 单行输入框 | `TextField` | 纯文本 | `"测试内容"` | |
| 多行输入框 | `TextareaField` | 纯文本 | `"第一行\n第二行"` | |
| 数字输入框 | `NumberField` | 数字字符串 | `"100"` | |
| 单选框 | `DDSelectField` | 选项文本 | `"同意"` | 必须与模板 options 中的 value 完全匹配 |
| 多选框 | `DDMultiSelectField` | JSON 数组字符串 | `'["选项A","选项B"]'` | 每个选项须与模板 options 匹配; |
| 日期控件 | `DDDateField` | `yyyy-MM-dd` | `"2026-07-27"` | |
| 时间区间 | `DDDateRangeField` | JSON 数组字符串 | `'["2026-07-27","2026-07-30"]'` | label 为数组 `["开始","结束"]`,用开始时间 label 作 name |
| 金额控件 | `MoneyField` | 数字字符串 | `"1500.50"` | 自动显示大写金额 |
| 电话控件 | `PhoneField` | 手机号字符串 | `"13800138000"` | |
| 联系人控件 | `InnerContactField` | userId | `"user123"` | 多人时传 JSON 数组 `'["user1","user2"]'`;choice="0"单选/"1"多 |
| 部门控件 | `DepartmentField` | 部门 ID | `"12345"` | 多部门传 JSON 数组;multiple=true 时支持多选 |
| 省市区控件 | `AddressField` | JSON 数组字符串 | `'["浙江省","杭州市","西湖区"]'` | 三级联动;needDetail=true 时末尾加详细地址 |
| 图片控件 | `DDPhotoField` | URL 数组转义字符串 | `"[\"http://example.com/img1.jpg\"]"` | 支持 URL 直接提交;**不支持本地文件上传** |
| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **当前不支持通过 CLI 提交**,需钉盘上传接口获取 fileId 等字段 |
| 评分控件 | `StarRatingField` | 数字字符串 | `"4"` | limit 控制最大星数(默认 5) |
| 关联审批单 | `RelateField` | 审批实例 ID | `"q-xxx"` | 须为当前组织下已存在的实例 |
| 明细控件 | `TableField` | JSON 数组字符串 | `'[{"子控件名":"值1"},{"子控件名":"值2"}]'` | 不可嵌套 TableField;不可含 DDMultiSelectField/DDPhotoField;最大 100 行 |
| 身份证控件 | `IdCardField` | 身份证号 | `"330102199001011234"` | 内置格式校验 |
| 文字说明 | `TextNote` | — | — | **不收集数据**,不会出现在 formComponentValues 中 |
#### API 不支持的控件
以下控件**不支持**通过创建实例 API 提交:
- `TextNote`(文字说明)— 纯展示,不收集数据
- `CalculateField`(计算公式)— 由系统自动计算
- `SeqNumberField`(流水号)— 由系统自动生成
- `OcrTextField` / `OcrIdCardField`(OCR 识别)— 需要客户端交互
- **`DDAttachment`(附件控件)— 当前不支持通过 CLI 提交**,value 需要 spaceId、fileName、fileSize、fileType、fileId 字段,须通过钉盘上传附件接口获取
- **套件类控件(暂不支持)** — `InvoiceField`(发票)、`RecipientAccountField`(收款账户)等业务套件控件当前暂不支持通过 CLI 发起,包含这些控件的审批模板请直接在钉钉客户端操作
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**(见上方速查表),仅不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。
如果目标审批模板包含上述控件,不要硬拼 `form-values`;应告知用户这些字段无需填写或需要在钉钉客户端补充。
> **必填不支持控件判断规则:** 检查 `form-schema` 返回的控件列表,若存在上述不支持控件且其 `props.required` 为 `true`(必填项),则**直接告知用户该审批模板不支持通过 CLI 发起**,请在钉钉客户端操作。只有不支持控件为非必填时,才可跳过该控件继续发起。
#### 高级模式请求体字段(`--request` JSON 完整结构)
| 字段 | 类型 | 必填 | 说明 |
|-----|------|------|------|
| `processCode` | String | 是 | 审批模板唯一码 |
| `originatorUserId` | String | 是 | 发起人 userId(MCP 工具可从登录态自动获取) |
| `deptId` | Long | 否 | 发起人部门 ID,根部门填 -1;approvers 已传时可不填 |
| `formComponentValues` | Array | 是 | 表单控件值列表,最大 150 条 |
| `approvers` | Array | 否 | 指定审批人列表(覆盖模板流程),最大 20 条 |
| `approvers[].actionType` | String | 否 | `AND`(会签)/ `OR`(或签)/ `NONE`(单人) |
| `approvers[].userIds` | Array | 否 | 审批人 userId 列表 |
| `ccList` | Array | 否 | 抄送人 userId 列表,最大 50 |
| `ccPosition` | String | 否 | `START` / `FINISH` / `START_FINISH` |
| `directAppointedApprovers` | Array | 否 | 指定审批人组(覆盖模板流程),结构见下方 |
| `targetSelectActioners` | Array | 否 | 自选审批人(模板中有自选节点时必填),最大 20 条 |
#### 节点参数组装
> **详细参考:** 流程节点类型、审批模式、条件分支和 10 种审批人选择规则的完整说明见 [oa-process-nodes.md](oa/oa-process-nodes.md)。
**directAppointedApprovers(指定审批人覆盖模板流程):**
当用户明确说"不走模板默认流程"或"直接指定 XX 审批"时使用。
```json
[
{
"staffIds": ["userId1", "userId2"],
"taskActionType": "NONE",
"staffId": ""
}
]
```
- `staffIds`:审批人 userId 列表(必须通过 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 获取,严禁填姓名)
- `taskActionType`:`NONE`(单人审批)/ `AND`(会签)/ `OR`(或签)
**targetSelectActioners(模板有自选审批节点时使用):**
当 `form-schema` 返回的模板流程中存在自选审批节点(`target_select` 类型)时必填。
```json
[
{
"actionerKey": "manual_nodeId_xxxx_yyyy",
"actionerStaffIds": ["userId1"]
}
]
```
- `actionerKey`:自选节点的规则 key,可通过获取审批单流程节点信息接口获取 `actorKey`
- `actionerStaffIds`:操作人 userId 列表
**审批类型(approvers actionType)说明:**
| 值 | 含义 | 说明 |
|----|------|------|
| `AND` | 会签 | 所有审批人都必须审批通过 |
| `OR` | 或签 | 任一审批人审批即可 |
| `NONE` | 单人审批 | 只有一个审批人 |
**抄送时间点(ccPosition)说明:**
| 值 | 含义 |
|----|------|
| `START` | 审批发起时抄送 |
| `FINISH` | 审批完成时抄送 |
| `START_FINISH` | 发起和完成时都抄送 |
#### 表单控件约束
- 单个表单最多 200 个控件
- 控件 label(name)和 placeholder 最大 50 字符
- `DDSelectField` / `DDMultiSelectField` 的选项 value 必须与模板中配置的选项文本完全一致
- `TableField`(明细)内不可嵌套 `TableField`,不可包含 `DDMultiSelectField` 和 `DDPhotoField`
- `TextNote`(文字说明)不收集数据,无需在 `formComponentValues` 中传入
- `InnerContactField` 的 userId 应为当前组织下在职成员
- `DepartmentField` 应传入当前组织下存在的部门 ID
- `RelateField` 传入的审批实例 ID 应为当前组织下已存在的实例
#### 返回结果
创建成功后,返回的 `result` 字段即为新审批实例的 `processInstanceId`。建议向用户展示:
```
审批已创建成功:
- 审批模板: <processName>(来自 form-schema)
- 审批实例 ID: <processInstanceId>(来自 create-instance 返回的 result)
```
后续可用该 processInstanceId 执行 `detail`、`tasks`、`records`、`revoke` 等操作。
### 获取审批任务的被催办人 userId
> **催办必须两步串联:** ① `ding-info` 获取被催办人 `userId` → ② `ding message send` 发送催办消息。禁止跳过第一步直接猜测 userId。
@@ -316,6 +673,18 @@ Flags:
用户说"审批记录/操作历史" → `approval records`
用户说"我发起的审批" → `approval list-initiated`(需 --process-code,可从 list-forms / search-forms / detail 获取)
用户说"有哪些审批表单/可见表单" → `approval list-forms`
用户说"搜索审批表单/查找xx审批表单/有没有xx表单" → `approval search-forms`(需 --query)
用户说"查表单schema/查表单结构/表单模板信息/查表单组件/查表单定义/表单有哪些字段/表单的字段信息" → `approval form-schema`(需 --process-code,可从 list-forms / search-forms / detail 获取)
用户说"预测审批流程/流程预测/审批走向/这个审批走哪些人/审批流程预览" → `approval forecast-process`(需 --process-code、--dept-id、--form-values)
- 在 `form-schema` 之后、`create-instance` 之前调用
- 返回的 `workflowActivityRuleVOs` 中 `targetSelect: true` 的节点需要用户自选审批人
- 自选结果组装为 `targetSelectActioners` 传入 `create-instance`
用户说"发起审批/提交审批/帮我发起XX审批/新建审批单/提一个XX审批/帮我提XX申请" → 五步流程:① `search-forms --query XX` 获取 processCode → ② `form-schema --process-code <code>` 获取表单字段定义 → ③ 阅读 [oa-form-components.md](oa/oa-form-components.md) 和 [oa-process-nodes.md](oa/oa-process-nodes.md) 后组装表单值 → ④ `forecast-process` 预测流程走向并识别自选节点 → ⑤ 若有自选节点让用户选人,确认后 `create-instance --yes` 发起
- 如果用户已知 processCode,可跳过第①步
- `--form-values` 的 key 必须与 `form-schema` 返回的控件 label 一致
- `forecast-process` 返回自选节点时必须让用户选人,不得跳过
- 执行前**必须向用户确认**表单内容、流程预测结果、审批人和抄送人
- 示例:"帮我发起一个AI审批单" → ① `search-forms --query AI` → ② `form-schema --process-code <code>` → ③ 组装表单值 → ④ `forecast-process` → ⑤ 向用户确认流程走向和自选审批人后 `create-instance --yes`
用户说"我有哪些待审的任务" → `approval tasks`
用户说"我发起的审批单" -> `approval list-submitted`
用户说"我审批/处理过的审批单" -> `approval list-executed`
@@ -351,6 +720,12 @@ dws oa approval records --instance-id <processInstanceId> --format json
# 7. 获取可见审批表单(得到 processCode)
dws oa approval list-forms --cursor 0 --limit 100 --format json
# 7b. 按关键字模糊搜索表单(快速定位 processCode)
dws oa approval search-forms --query AI --format json
# 7c. 按 processCode 查询表单 Schema(获取表单结构、组件定义)
dws oa approval form-schema --process-code <code> --format json
# 8. 查看自己发起的审批列表(--process-code 来自 list-forms / search-forms / detail)
dws oa approval list-initiated --process-code <code> \
--start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" \
@@ -373,6 +748,22 @@ dws oa approval oa-comments --instance-id <processInstanceId> --content "同意
# 14. 对审批实例进行抄送(processInstanceId 来自 list-pending 或 detail)
dws oa approval oa-cc-noticer --instance-id <processInstanceId> --users "68674200835816" --format json
dws oa approval oa-cc-noticer --instance-id <processInstanceId> --users "userId1,userId2" --format json
# 18. 发起审批(完整流程:搜表单 → 查 Schema → 收集表单值 → 流程预测 → 自选节点选人 → 发起)
# 18a. 模糊搜索表单获取 processCode
dws oa approval search-forms --query AI --format json
# 18b. 查询表单 Schema 获取字段定义
dws oa approval form-schema --process-code <code> --format json
# 18c. 收集表单值(向用户展示控件列表,用户填写后组装 form-values)
# 18d. 流程预测(根据表单值预测审批走向,识别自选审批人节点;processCode/deptId/formValues 必填,userId 由登录态自动填充)
dws oa approval forecast-process --process-code <code> --dept-id -1 --form-values '{"单行输入框":"测试内容"}' --format json
# 18e. 若 forecast 返回 targetSelect=true 的节点,用 dws aisearch person --keyword "<姓名>" --dimension name --format json 帮用户选人
# 18f. 发起审批实例(form-values 的 key 须与 Schema 中控件 label 一致)
dws oa approval create-instance --process-code <code> --form-values '{"单行输入框":"测试内容"}' --yes --format json
# 18g. 发起并指定审批人和抄送人
dws oa approval create-instance --process-code <code> --form-values '{"单行输入框":"测试"}' --approvers "userId1,userId2" --approvers-action-type OR --cc-list "userId3" --cc-position START --yes --format json
# 18h. 发起并使用 forecast 自选审批人结果(高级模式)
dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1,"formComponentValues":[{"name":"单行输入框","value":"测试"}],"targetSelectActioners":[{"actionerKey":"manual_33ff_89cb_da91_e3aa","actionerStaffIds":["userId_选人A"]}]}' --yes --format json
```
## 上下文传递表
@@ -384,6 +775,11 @@ dws oa approval oa-cc-noticer --instance-id <processInstanceId> --users "userId1
| `detail` | `processCode` | list-initiated 的 --process-code |
| `list-forms` | `processCode` | list-initiated 的 --process-code |
| `search-forms` | `processCode` | list-initiated 的 --process-code |
| `form-schema` | `processCode`, `processName`, `content` | 查看表单结构定义;`content` 字段包含表单组件 JSON,可解析获取字段列表;**控件 label 作为 create-instance --form-values 的 key** |
| `search-forms` → `form-schema` | `processCode` → 表单字段定义 | forecast-process / create-instance 的 --process-code 和 --form-values 填写依据 |
| `forecast-process` | `workflowActivityRuleVOs`(`activityId`, `targetSelect`, `activityActioners`, `workflowActor`) | ① 向用户展示流程走向和各节点处理人;② `targetSelect: true` 的节点需用户自选审批人,`workflowActor.actorKey` 作为 `targetSelectActioners` 的 `actionerKey` 传入 create-instance |
| `search-forms` → `form-schema` → `forecast-process` | `processCode` → 字段定义 → 流程走向 + 自选节点 | create-instance 的完整上下文:表单值 + 流程路径 + targetSelectActioners |
| `create-instance` | `result`(processInstanceId) | detail / tasks / records / revoke 等的 --instance-id,可跟踪已发起的审批 |
## 注意事项
@@ -395,6 +791,16 @@ dws oa approval oa-cc-noticer --instance-id <processInstanceId> --users "userId1
- `list-initiated` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取。当 `list-forms` 返回 `processCodeList` 为空(`totalCount -1`)时,用 `search-forms --query <表单名>`(如 `--query 报销`)按名称精准拿 `processCode` 更稳
- `list-initiated` 的 `--start` / `--end` 区间有后端上限(约 120 天)。超过上限会返回误导性的 `business_error: 时间戳无效`(实为区间过长,不是时间格式问题)。跨度大时请拆成多段短区间分别查询
- `form-schema` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取;返回的 `content` 字段为 JSON 字符串,需解析后查看表单组件(items)定义。
- `create-instance` 发起前**必须先阅读** [oa-form-components.md](oa/oa-form-components.md)(控件值格式)和 [oa-process-nodes.md](oa/oa-process-nodes.md)(流程节点规则),再调用 `form-schema` 获取表单字段定义,确保 `--form-values` 中的 key 与控件 label 完全一致。
- `create-instance` 发起前**应先调用 `forecast-process`** 预测流程走向,识别自选审批人节点(`targetSelect: true`),让用户选人后再提交。
- `create-instance` 的 `--form-values` 接受 JSON 格式 `'{"控件名称":"值"}'`,代码会自动转为 `[{"name":"控件名称","value":"值"}]`。
- `create-instance` 简单模式适合常见场景;如需 `directAppointedApprovers`(指定审批人覆盖模板流程)或 `targetSelectActioners`(自选审批节点)等高级字段,使用 `--request` 传完整 JSON。`--request` 与简单模式 flags 互斥。
- `create-instance` 会创建真实审批数据;Agent 只有在用户确认模板、表单值、流程路径和人员后才能传入 `--yes`。
- `create-instance` 返回的 processInstanceId 可用于 `detail`、`tasks`、`records`、`revoke` 等后续操作。
- `forecast-process` 的 `processCode`、`deptId`、`formComponentValues` 三个字段均为必填(`userId` 由系统自动填充);`formComponentValues` 比 `create-instance` 多一层数组包裹(`[[{...}]]`),CLI 简单模式已自动处理。
- `forecast-process` 返回 `workflowActivityRuleVOs` 中 `targetSelect: true` 的节点,其 `workflowActor.actorKey` 必须作为 `targetSelectActioners` 的 `actionerKey` 传入 `create-instance`。
## 自动化脚本
| 脚本 | 场景 | 用法 |
@@ -0,0 +1,346 @@
# OA 审批表单控件参考
本文档详细描述钉钉 OA 审批中每种表单控件(componentName)在**发起审批实例**时 `formComponentValues` 的 `value` 格式、约束和注意事项。
> **核心原则:** `formComponentValues[].name` 必须与审批模板中控件的 `props.label` **完全一致**,`value` 为字符串类型(最大 65535 字符)。
---
## 通用约束
| 约束 | 说明 |
|------|------|
| 单表单最大控件数 | 200 |
| label / placeholder 最大长度 | 50 字符 |
| value 最大长度 | 65535 字符 |
| ID / bizAlias 唯一性 | 同一表单内不可重复 |
| TextNote | 不收集数据,不出现在 formComponentValues 中 |
---
## 基础控件
### TextField(单行输入框)
| 属性 | 说明 |
|------|------|
| `componentName` | `TextField` |
| value 格式 | 纯文本字符串 |
| 示例 | `"测试内容"` |
| 约束 | 无特殊约束 |
```json
{ "name": "单行输入框", "value": "测试内容" }
```
### TextareaField(多行输入框)
| 属性 | 说明 |
|------|------|
| `componentName` | `TextareaField` |
| value 格式 | 纯文本字符串,支持换行 |
| 示例 | `"第一行\n第二行"` |
| 约束 | 无 `ratio` 属性 |
```json
{ "name": "多行输入框", "value": "第一行\n第二行\n第三行" }
```
### NumberField(数字输入框)
| 属性 | 说明 |
|------|------|
| `componentName` | `NumberField` |
| value 格式 | 数字字符串 |
| 示例 | `"100"` |
| 约束 | 适合数量、天数等纯数字场景 |
```json
{ "name": "加班天数", "value": "3" }
```
### DDSelectField(单选框)
| 属性 | 说明 |
|------|------|
| `componentName` | `DDSelectField` |
| value 格式 | 选项文本字符串 |
| 示例 | `"同意"` |
| 约束 | **必须与模板 `options[].value` 完全匹配**,不可自行编造选项 |
模板中的选项结构(从 `form-schema` 获取):
```json
"options": [
{ "key": "option_0", "value": "同意" },
{ "key": "option_1", "value": "不同意" }
]
```
提交时传选项的 `value` 文本:
```json
{ "name": "审批意见", "value": "同意" }
```
### DDMultiSelectField(多选框)
| 属性 | 说明 |
|------|------|
| `componentName` | `DDMultiSelectField` |
| value 格式 | JSON 数组字符串,每个元素为选项文本 |
| 示例 | `'["选项A","选项B"]'` |
| 约束 | 每个选项须与模板 `options[].value` 匹配;|
```json
{ "name": "兴趣爱好", "value": "[\"阅读\",\"运动\"]" }
```
### DDDateField(日期控件)
| 属性 | 说明 |
|------|------|
| `componentName` | `DDDateField` |
| value 格式 | `yyyy-MM-dd` 格式字符串 |
| 示例 | `"2026-07-27"` |
| 约束 | 格式固定,不可传其他日期格式 |
```json
{ "name": "请假日期", "value": "2026-07-27" }
```
### DDDateRangeField(时间区间控件)
| 属性 | 说明 |
|------|------|
| `componentName` | `DDDateRangeField` |
| value 格式 | JSON 数组字符串 `[开始日期, 结束日期]` |
| 示例 | `'["2026-07-27","2026-07-30"]'` |
| 约束 | `props.label` 为数组 `["开始时间","结束时间"]`;提交时 `name` 使用**开始时间的 label** |
模板中的 label 结构(从 `form-schema` 获取):
```json
"props": { "label": ["开始时间", "结束时间"] }
```
提交时用**开始时间 label** 作为 name:
```json
{ "name": "开始时间", "value": "[\"2026-07-27\",\"2026-07-30\"]" }
```
### PhoneField(电话控件)
| 属性 | 说明 |
|------|------|
| `componentName` | `PhoneField` |
| value 格式 | 手机号字符串 |
| 示例 | `"13800138000"` |
| 约束 | `mode: "phone"` 为手机号 |
```json
{ "name": "联系电话", "value": "13800138000" }
```
### IdCardField(身份证控件)
| 属性 | 说明 |
|------|------|
| `componentName` | `IdCardField` |
| value 格式 | 身份证号字符串 |
| 示例 | `"330102199001011234"` |
| 约束 | 内置格式校验,须传合法身份证号 |
```json
{ "name": "身份证号", "value": "330102199001011234" }
```
### TextNote(文字说明)
| 属性 | 说明 |
|------|------|
| `componentName` | `TextNote` |
| value 格式 | — |
| 约束 | **不收集数据**,不出现在 formComponentValues 中 |
> 遇到 TextNote 控件时直接跳过,不要尝试为它填写值。
---
## 增强控件
### MoneyField(金额控件)
| 属性 | 说明 |
|------|------|
| `componentName` | `MoneyField` |
| value 格式 | 数字字符串 |
| 示例 | `"1500.50"` |
| 约束 | 系统自动显示大写金额(`notUpper: "0"` 时显示) |
```json
{ "name": "报销金额", "value": "1500.50" }
```
### InnerContactField(联系人控件)
| 属性 | 说明 |
|------|----------------------------------------------------|
| `componentName` | `InnerContactField` |
| value 格式 | userId 字符串,多人时为 JSON 数组字符串 |
| 示例(单选) | `"user123"` |
| 示例(多选) | `'["userId1","userId2"]'` |
| 约束 | `choice: "0"` 单选 / `"1"` 多选;userId 须为**当前组织下在职成员** |
```json
{ "name": "项目负责人", "value": "[\"userId1\",\"userId2\"]" }
```
> **严禁直接写姓名。** 必须先通过 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 查询获取 userId;多结果时须让用户消歧确认。
### DepartmentField(部门控件)
| 属性 | 说明 |
|------|------|
| `componentName` | `DepartmentField` |
| value 格式 | 部门 ID 字符串,多部门时为 JSON 数组字符串 |
| 示例(单选) | `"12345"` |
| 示例(多选) | `'["12345","67890"]'` |
| 约束 | `multiple: boolean` 控制单选/多选;部门 ID 须为**当前组织下存在的部门** |
```json
{ "name": "所属部门", "value": "12345" }
```
### AddressField(省市区控件)
| 属性 | 说明 |
|------|------|
| `componentName` | `AddressField` |
| value 格式 | JSON 数组字符串 `["省","市","区"]` |
| 示例 | `'["浙江省","杭州市","西湖区"]'` |
| 约束 | 三级联动选择器;`needDetail: true` 时末尾追加详细地址文本 |
```json
{ "name": "办公地点", "value": "[\"浙江省\",\"杭州市\",\"西湖区\"]" }
```
### DDPhotoField(图片控件)
> **支持通过图片 URL 提交,不支持本地文件上传。** 如果用户已有图片 URL(如公网可访问的图片链接),可直接填入 value 提交。CLI 尚未封装本地文件上传到钉盘 CDN 的流程,若用户只有本地文件而非 URL,需告知用户在钉钉客户端补充。
| 属性 | 说明 |
|------|------|
| `componentName` | `DDPhotoField` |
| value 格式 | URL 数组转义字符串,即使只有一个 URL 也需数组形式 |
| 示例 | `"[\"http://example.com/img1.jpg\",\"http://example.com/img2.jpg\"]"` |
| 约束 | 支持 URL 直接提交;**不支持本地文件上传**(CLI 未封装钉盘上传流程); |
```json
{ "name": "图片", "value": "[\"http://example.com/photo.jpg\"]" }
```
### DDAttachment(附件控件)
> **[注意] 当前暂不支持通过 CLI 提交附件控件。** 附件控件的 value 需要包含 spaceId、fileName、fileSize、fileType 和 fileId 字段,这些字段需要通过调用钉盘的上传附件接口获取,CLI 尚未封装此流程。包含附件控件的审批模板请在钉钉客户端操作。
| 属性 | 说明 |
|------|------|
| `componentName` | `DDAttachment` |
| value 格式 | JSON 数组转义字符串,每个元素包含 spaceId、fileName、fileSize、fileType、fileId |
| 示例(参考) | `"[{\"spaceId\":\"163xxx\",\"fileName\":\"2644.JPG\",\"fileSize\":\"333\",\"fileType\":\"jpg\",\"fileId\":\"643xxx\"}]"` |
| 约束 | **当前不支持通过 CLI 提交**;各字段需通过钉盘上传附件接口获取 |
### StarRatingField(评分控件)
| 属性 | 说明 |
|------|------|
| `componentName` | `StarRatingField` |
| value 格式 | 数字字符串 |
| 示例 | `"4"` |
| 约束 | `limit` 控制最大星数(默认 5) |
```json
{ "name": "满意度评分", "value": "4" }
```
### RelateField(关联审批单)
| 属性 | 说明 |
|------|------|
| `componentName` | `RelateField` |
| value 格式 | 审批实例 ID 字符串 |
| 示例 | `"q-ZZ1sQaTIuYFpKI9aNC1g"` |
| 约束 | 须为**当前组织下已存在的审批实例 ID** |
```json
{ "name": "关联审批单", "value": "q-ZZ1sQaTIuYFpKI9aNC1g" }
```
### SignatureField(签名控件)
| 属性 | 说明 |
|------|------|
| `componentName` | `SignatureField` |
| value 格式 | 签名图片 mediaId |
| 约束 | 需要客户端交互签名,通常不支持 API 直接提交 |
---
## 复合控件
### TableField(明细控件)
| 属性 | 说明 |
|------|------|
| `componentName` | `TableField` |
| value 格式 | JSON 数组字符串,每个元素为一行数据的键值对 |
| 示例 | `'[{"商品名":"笔记本","数量":"2"},{"商品名":"钢笔","数量":"1"}]'` |
| 约束 | **不可嵌套 TableField**;**不可包含 DDMultiSelectField 和 DDPhotoField**;最大 100 行;总长度不超过 65535 字符 |
模板结构(从 `form-schema` 获取):
```json
{
"componentName": "TableField",
"props": { "label": "采购明细" },
"children": [
{ "componentName": "TextField", "props": { "label": "商品名", "id": "TextField_XXX" } },
{ "componentName": "NumberField", "props": { "label": "数量", "id": "NumberField_YYY" } }
]
}
```
提交时每行用子控件 label 作 key:
```json
{
"name": "采购明细",
"value": "[{\"商品名\":\"笔记本\",\"数量\":\"2\"},{\"商品名\":\"钢笔\",\"数量\":\"1\"}]"
}
```
---
## API 不支持的控件
以下控件**不支持**通过创建实例 API 提交,遇到时应告知用户需在钉钉客户端补充:
| 控件 | componentName | 原因 |
|------|---------------|------|
| 文字说明 | `TextNote` | 纯展示,不收集数据 |
| 计算公式 | `CalculateField` | 由系统自动计算,不可手动填写 |
| 流水号 | `SeqNumberField` | 由系统自动生成 |
| OCR 文本识别 | `OcrTextField` | 需要客户端 OCR 交互 |
| OCR 身份证识别 | `OcrIdCardField` | 需要客户端 OCR 交互 |
| 附件控件 | `DDAttachment` | value 需要 spaceId、fileName、fileSize、fileType、fileId,须通过钉盘上传接口获取,CLI 尚未封装 |
> **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**,但不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。详见本文 [DDPhotoField](#ddphotofield图片控件) 章节。
> **套件类控件(暂不支持)** — `InvoiceField`(发票)、`RecipientAccountField`(收款账户)等业务套件控件当前暂不支持通过 CLI 发起,包含这些控件的审批模板请直接在钉钉客户端操作。
---
## 组装优先级
1. **每次发起前都重新调用 `form-schema`**,不得复用旧结果(模板可能已被修改)
2. 先读 `form-schema` 返回的 `content`,识别所有控件的 `label`、`componentName`、`options`、`props.required`
3. **检查是否存在不支持控件且为必填项(`props.required: true`)**,若有则直接告知用户该模板不支持通过 CLI 发起,请在钉钉客户端操作
4. 按本文档中每种控件的 value 格式组装 `formComponentValues`
5. **不要把 `form-schema` 的 `content` 当成可直接提交的模板**
6. 遇到 API 不支持的控件(非必填),跳过并告知用户
@@ -0,0 +1,374 @@
# OA 审批流程节点与审批人规则参考
本文档描述钉钉 OA 审批的流程节点类型、审批模式、条件分支和审批人选择规则,用于理解审批模板结构和正确填写 `create-instance` 的节点参数。
---
## 流程结构概览
审批流程是一个嵌套树结构:
- **根节点**:发起人节点(`type: "start"`,`nodeId: "sid-startevent"`),固定不可删除
- **后续节点**:通过 `childNode` 链接形成链式结构
- **分支节点**:条件分支(`route` + `condition`)或并行分支(`parallel`)
- 当没有后续节点时,`childNode` 字段**必须省略**(不可设为 `null`)
---
## 7 种节点类型
### 1. 发起人节点(start)
| 属性 | 值 |
|------|-----|
| `type` | `start` |
| `nodeId` | `sid-startevent`(固定) |
| `properties` | `{}`(空对象) |
唯一、不可删除。是流程的起点。
### 2. 审批人节点(approver)
核心决策节点,有审批/拒绝权限。
| 属性 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `actionerRules` | Array | 是 | 审批人选择规则,至少一条 |
| `activateType` | String | 是 | 多人审批模式(见下方) |
| `approvalType` | String | 是 | 固定 `"MANUAL"` |
| `agreeAll` | Boolean | 是 | `true` 全部通过 / `false` 任一通过 |
| `noneActionerAction` | String | 否 | 如 `"admin"`(找不到审批人时转管理员) |
支持全部 10 种 actionerRules 类型。
### 3. 办理人节点(handler)
执行工作,无审批决策权。
| 属性 | 类型 | 必填 |
|------|------|------|
| `actionerRules` | Array | 是 |
| `activateType` | String | 是 |
支持 9 种 actionerRules(不支持 `target_matrix_approval`)。
### 4. 抄送人节点(notifier)
仅接收通知,无决策权。
| 属性 | 类型 | 必填 |
|------|------|------|
| `actionerRules` | Array | 是 |
支持多条 actionerRules 组合在一个节点中,实现同时抄送多类人员。
### 5. 条件分支(route + condition)
条件路由节点,包含多个条件分支。
**route 节点:**
- `type: "route"`
- `conditionNodes[]`:分支数组,按优先级排序,**默认分支必须在最后**
- `properties: {}`
**condition 节点(conditionNodes 的每个元素):**
- `type: "condition"`
- `isdefault: true`:标记默认分支
- `properties.conditions`:二维条件数组
- 外层数组:多个条件组,**OR 关系**
- 内层数组:多个条件对象,**AND 关系**
- 默认分支:`[[]]`(一个空组)
### 6. 并行分支(parallel)
多个分支同时执行,全部完成后才继续。
| 属性 | 说明 |
|------|------|
| `branches[]` | 分支数组 |
| `branches[].name` | 分支名称 |
| `branches[].childNode` | 该分支的第一个节点 |
### 7. 付款人节点(payer)
财务付款节点。
| 属性 | 说明 |
|------|------|
| `actionerRules` | 审批人规则 |
| `paymentConfig.amountField` | 金额控件 ID |
| `paymentConfig.accountField` | 收款账户控件 ID |
---
## 多人审批模式
| 模式 | `activateType` | `agreeAll` | 说明 |
|------|---------------|-----------|------|
| 会签 | `"ALL"` | `true` | 所有审批人都必须审批通过 |
| 或签 | `"ALL"` | `false` | 任一审批人审批即可 |
| 依次审批 | `"ONE_BY_ONE"` | `true` | 按顺序逐级审批 |
---
## 10 种审批人选择规则(actionerRules)
### 1. 指定成员(target_approval)
明确指定具体人员。
```json
{
"type": "target_approval",
"approvals": [
{ "userName": "张三", "workNo": "manager123" }
],
"isEmpty": false
}
```
- `workNo` 必须通过 `dws aisearch person --keyword "<工号>" --dimension jobNumber --format json` 获取,**严禁编造**
- 在 `create-instance` 中对应 `directAppointedApprovers` 的 `staffIds`
### 2. 直属主管(target_formula / reportLineManager)
按汇报线找到直属主管。
```json
{
"type": "target_formula",
"subType": "reportLineManager",
"formula": "ReportLineManager(corpId,originator,1)",
"isEmpty": false
}
```
- `formula` 中最后的数字 N 表示第 N 级主管
- **重要区分:** 用户说"直属主管/直属领导/汇报线主管"才用此规则;用户说"主管审批/leader审批"(模糊)时默认用 `target_management`(部门主管)
### 3. 发起人自己(target_originator)
发起人自行审批。
```json
{
"type": "target_originator",
"isEmpty": false
}
```
最简单的规则,只有 `type` 和 `isEmpty`。
### 4. 部门主管(target_management)
从发起人所在部门层级找主管。
```json
{
"type": "target_management",
"level": 1,
"autoUp": true,
"isEmpty": false
}
```
- `level: 1`:直接部门主管
- `autoUp: true`:找不到时向上级部门搜索
- **这是"主管审批/leader审批"模糊场景的默认选择**
### 5. 表单部门主管(target_formula / managerOfDept)
根据表单中部门控件选择的主管。
```json
{
"type": "target_formula",
"subType": "managerOfDept",
"formula": "ManagerOfDept(corpId,$('DepartmentField_XXX'),1)",
"isEmpty": false
}
```
- `formula` 中引用表单中的 `DepartmentField` 控件 ID
### 6. 发起人自选(target_select)
发起人在提单时自行选择审批人。
```json
{
"type": "target_select",
"select": ["allStaff"],
"range": {},
"key": "manual_nodeId_xxxx_yyyy",
"multi": 1,
"isEmpty": false
}
```
- `select: ["allStaff"]`:可选全组织人员
- `multi: 1`:单选
- `key`:格式 `manual_{nodeId}_{hex}_{hex}`
- 在 `create-instance` 中对应 `targetSelectActioners` 的 `actionerKey`
### 7. 角色标签主管(target_managers_labels)
按角色标签找多级主管。
```json
{
"type": "target_managers_labels",
"labelNames": ["项目经理"],
"labels": ["labelId123"],
"levels": [1],
"isEmpty": false
}
```
- `labels` 中的 ID 必须通过 `dws contact label get --names "<角色名>" --format json` 获取;已知角色名时直接查询,否则先 `dws contact label list --format json` 获取全部角色列表后匹配
### 8. 表单联系人(target_formcomponent_approval)
从表单中的联系人控件读取审批人。
```json
{
"type": "target_formcomponent_approval",
"paramKey": "InnerContactField_XXX",
"label": "项目负责人",
"isEmpty": false
}
```
- `paramKey` 指向表单中的 `InnerContactField` 控件 ID
- 该控件中填写的人即为审批人
### 9. 角色标签(target_label)
按角色标签找人(如"财务"、"HR")。
```json
{
"type": "target_label",
"labelNames": "财务",
"labels": "459272424",
"isEmpty": false
}
```
- `labels`:角色标签 ID(字符串),必须通过 `dws contact label get --names "<角色名>" --format json` 获取;未知角色名时先 `dws contact label list --format json`
- `labelNames`:角色显示名称
- **严禁编造 label ID**
### 10. 审批矩阵(target_matrix_approval)
按审批矩阵规则确定审批人。
```json
{
"type": "target_matrix_approval",
"matrixId": "xxx",
"roleColumnId": "yyy",
"expression": {
"subFilters": [...],
"operator": "AND"
}
}
```
- 仅适用于审批人节点
- 目前尚在完善中
---
## 条件分支详解
### 条件类型
| `type` | 依据 | 关键字段 |
|--------|------|---------|
| `dingtalk_actioner_dept_condition` | 发起人部门/人员/角色 | `paramKey: "dingtalk_origin_dept"`, `conds[]` |
| `dingtalk_actioner_dept_component_condition` | 表单部门控件 | `paramKey: 控件ID`, `conds[]` |
| `dingtalk_actioner_range_condition` | 数值/金额/时长范围 | `lowerBound`(>=) / `lowerBoundNotEqual`(>) / `upperBoundEqual`(<=) / `upperBound`(<) / `boundEqual`(=) |
| `dingtalk_actioner_value_condition` | 单选匹配 | `paramKey: 控件ID`, `paramValues[]`(选项 key) |
| `dingtalk_multi_value_condition` | 多选匹配 | `paramKey: 控件ID`, `paramValues[]`, `matchType`(1=精确/2=全选/3=任一) |
| `dingtalk_actioner_cascade_component_condition` | 级联控件 | `paramValues[]`, `displayValues[]` |
| `dingtalk_actioner_boolean_condition` | 布尔值 | `boundEqual: true/false` |
| `dingtalk_rule_template` | 节假日判断 | `template`, `outVars` |
| `dingtalk_formula` | 公式 | `formula`, `formulaDisplay` |
| `dingtalk_biz_var_condition` | 业务变量 | `dsKey`, `conds[]` |
| `dingtalk_table_condition` | 明细内字段 | `parentFieldId`, `componentName`, `paramValue` |
### 范围条件操作符
| 字段 | 含义 |
|------|------|
| `lowerBound` | >= (大于等于) |
| `lowerBoundNotEqual` | > (大于) |
| `upperBoundEqual` | <= (小于等于) |
| `upperBound` | < (小于) |
| `boundEqual` | = (等于) |
### 默认分支
- `isdefault: true`
- `conditions: [[]]`(一个空的条件组)
- **必须放在 `conditionNodes[]` 的最后**
---
## create-instance 中的节点参数映射
### directAppointedApprovers(指定审批人覆盖模板流程)
当需要**不使用模板默认流程、直接指定审批人**时使用。
```json
{
"directAppointedApprovers": [
{
"staffIds": ["userId1", "userId2"],
"taskActionType": "NONE",
"staffId": ""
}
]
}
```
| 字段 | 说明 |
|------|------|
| `staffIds` | 审批人 userId 列表(通过 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 获取;多结果须消歧) |
| `taskActionType` | `NONE`(单人)/ `AND`(会签)/ `OR`(或签) |
| `staffId` | 留空字符串 |
### targetSelectActioners(自选审批人)
当模板流程中存在**自选审批节点**(`target_select` 类型)时必填。
```json
{
"targetSelectActioners": [
{
"actionerKey": "manual_nodeId_xxxx_yyyy",
"actionerStaffIds": ["userId1"]
}
]
}
```
| 字段 | 说明 |
|------|------|
| `actionerKey` | 自选节点的规则 key,从审批流程节点信息接口获取 `actorKey` |
| `actionerStaffIds` | 操作人 userId 列表 |
---
## 组装优先级
1. 先用 `forecast-process` 获取模板的流程节点结构(`workflowActivityRuleVOs`)
2. 根据节点中的 `activityType` 和 `targetSelect` 判断是否需要传入 `directAppointedApprovers` 或 `targetSelectActioners`
3. 如果预测返回 `targetSelect: true` 的自选节点,`targetSelectActioners` 必填
4. 如果用户要求覆盖默认流程,使用 `directAppointedApprovers`
5. **所有 userId 必须通过 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 获取,严禁填姓名;多结果须消歧**
> **交互优化:** 若用户在 `forecast-process` 前已指定审批人/抄送人姓名,`forecast-process` 返回自选节点后应自动映射,仅对未覆盖的自选节点追问,不要重复询问。详见 [oa.md](../oa.md) 交互优化原则。
+41
View File
@@ -181,6 +181,30 @@ Flags:
> ⚠️ **返回字段限制**:`member list` 每条只返回 `name` / `role` / `type` 三个字段,**不含 userId**(服务端不返回)。因此**无法**从 `member list` 拿到 userId 再去串联 `member update` / `member remove`。要对某人改角色 / 移除,需另行拿到其 userId(例如用 `dws contact user search --query "<姓名>"` 按姓名反查)。
### 查询知识库动态
```
Usage:
dws wiki feed list [flags]
Aliases:
list, ls
Example:
dws wiki feed list --workspace <workspaceId> --format json
dws wiki feed list --workspace <workspaceId> --limit 10 --format json
dws wiki feed list --workspace <workspaceId> --exclude-file --format json
dws wiki feed list --workspace <workspaceId> --limit 10 --cursor <nextToken> --format json
Flags:
--workspace string 知识库 ID 或 URL (必填)
--limit int 每页数量 (默认 20,最大 50)
--cursor string 分页游标 (首页留空)
--exclude-file 是否排除文件相关的动态 (默认 false)
```
查询指定知识库的动态,返回谁在什么时间进行了更新、上传、评论等操作。
支持传入知识库 ID 或知识库 URL,系统自动识别。
支持分页,通过 `--cursor` 传入上次返回的 nextToken 获取下一页;出参 `hasMore` 指示是否还有下一页。
> **权限要求**:调用者需具备知识库的成员权限,非成员会被拒绝访问。
### 列出知识库节点
```
Usage:
@@ -296,6 +320,8 @@ Flags:
- 用户说"修改某人在知识库的权限/调整成员角色" → `member update`
- 用户说"移除知识库成员/把某人从知识库移除/删除知识库成员" → `member remove`(需 `--workspace` + `--users`)
- 用户说"知识库有哪些成员/查看知识库成员" → `member list`
- 用户说"知识库动态/最近有什么更新/谁改了什么/知识库活动" → `feed list`(需 `--workspace`)
- 用户说"知识库最近的评论/更新记录/操作日志" → `feed list`(需 `--workspace`)
- 用户说"删除知识库/移除知识库/把知识库删了" → `space delete`(需 `--workspace`)
> **跨产品路由说明**:知识库节点的**内容操作**(读取/编辑/块级操作)仍由 `dws doc` 承担:
@@ -382,6 +408,20 @@ dws wiki node move --workspace <workspaceId> --node <nodeId> --folder <targetFol
# 删除节点(会要求确认)
dws wiki node delete --workspace <workspaceId> --node <nodeId>
# ── 工作流: 查询知识库动态 ──
# 1. 获取知识库 ID
dws wiki space list --format json
# 2. 查询知识库动态
dws wiki feed list --workspace <workspaceId> --format json
# 3. 排除文件动态,只看文档操作
dws wiki feed list --workspace <workspaceId> --exclude-file --format json
# 4. 翻页(cursor 取上一页返回的 nextToken)
dws wiki feed list --workspace <workspaceId> --cursor <nextToken> --format json
# ── 工作流: 给知识库加成员 ──
# 1. 先确认知识库 ID(避免授权到「我的文档」)
@@ -424,6 +464,7 @@ dws wiki space delete --workspace <workspaceId> --format json
| `node list` | `nodeId` | node copy/move/delete 的 --node / `dws doc read` 的 --node |
| `node search` | `nodeId` | node copy/move/delete 的 --node / `dws doc read` 的 --node |
| `node create` | `nodeId` | node copy/move/delete 的 --node / `dws doc read` 的 --node |
| `feed list` | `nextToken` | feed list 的 --cursor(翻页,`hasMore` 为 true 时继续)|
| `member list` | `name` / `role` / `type`(**不含 userId**)| 仅用于查看成员名单;**无法**从这里取 userId 去串联 member update/remove,需另行按姓名反查 userId(如 `dws contact user search --query "<姓名>"`)|
## 相关产品
+10 -15
View File
@@ -3,19 +3,19 @@
用机器人向多个群批量发送相同消息(如日报提醒)
用法:
python3 scripts/bot_broadcast.py \
python bot_broadcast.py \
--robot-code <ROBOT_CODE> \
--chats "conv_id1,conv_id2,conv_id3" \
--title "日报提醒" \
--text "请大家今天下班前提交日报"
python3 scripts/bot_broadcast.py \
python bot_broadcast.py \
--robot-code <ROBOT_CODE> \
--chats-file groups.txt \
--title "周会通知" \
--text "明天下午3点周会"
python3 scripts/bot_broadcast.py --dry-run ...
python bot_broadcast.py --dry-run ...
"""
import sys
@@ -40,19 +40,14 @@ def run_dws(
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
data = json.loads(result.stdout)
if isinstance(data, dict) and data.get('success') is False:
detail = data.get('errorMsg') or data.get('message') or '未知错误'
print(f" ✗ 业务调用失败:{detail}")
return None
return data
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def run(argv: Optional[List[str]] = None) -> int:
def main():
parser = argparse.ArgumentParser(
description='向多个群批量发送机器人消息'
)
@@ -69,7 +64,7 @@ def run(argv: Optional[List[str]] = None) -> int:
'--text', required=True, help='消息内容 Markdown'
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args(argv)
args = parser.parse_args()
chat_ids: List[str] = []
if args.chats:
@@ -79,13 +74,13 @@ def run(argv: Optional[List[str]] = None) -> int:
p = Path(args.chats_file)
if not p.exists():
print(f"错误:文件不存在: {p}")
return 1
sys.exit(1)
chat_ids = [line.strip() for line in
p.read_text(encoding='utf-8').splitlines()
if line.strip() and not line.startswith('#')]
if not chat_ids:
print('错误:需要 --chats 或 --chats-file')
return 1
sys.exit(1)
print(f"📢 批量发送消息到 {len(chat_ids)} 个群")
print(f" 标题: {args.title}")
@@ -110,8 +105,8 @@ def run(argv: Optional[List[str]] = None) -> int:
fail += 1
print(f"\n完成: 成功 {success}, 失败 {fail}")
return 0 if fail == 0 else 1
sys.exit(0 if fail == 0 else 1)
if __name__ == '__main__':
sys.exit(run())
main()
+104 -215
View File
@@ -3,12 +3,12 @@
导出群聊消息到 JSON 文件(从指定时间点拉取)
用法:
python3 scripts/chat_export_messages.py \
python chat_export_messages.py \
--group <openconversation_id> \
--time "2026-03-10 00:00:00" \
--output messages.json
python3 scripts/chat_export_messages.py \
python chat_export_messages.py \
--query "项目冲刺" \
--time "2026-03-10 00:00:00" \
--no-forward --limit 100
@@ -16,51 +16,11 @@
import sys
import json
import datetime
import subprocess
import argparse
from typing import List, Any, Optional
class ScriptError(RuntimeError):
"""可预期的脚本执行错误。"""
exit_code = 1
class AmbiguousTargetError(ScriptError):
"""搜索结果不唯一,需要调用方消歧。"""
exit_code = 2
def normalize_boundary_time(value: Any) -> str:
"""将消息时间边界规范为 CLI 接受的 yyyy-MM-dd HH:mm:ss 或原字符串。"""
if value is None or isinstance(value, bool):
return ''
if isinstance(value, (int, float)):
timestamp = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return ''
try:
timestamp = float(text)
except ValueError:
return text
else:
return str(value).strip()
if timestamp > 10_000_000_000:
timestamp /= 1000
try:
dt = datetime.datetime.fromtimestamp(
timestamp, tz=datetime.timezone(datetime.timedelta(hours=8))
)
except (OSError, OverflowError, ValueError) as exc:
raise ScriptError(f'无效的消息时间边界:{value}') from exc
return dt.strftime("%Y-%m-%d %H:%M:%S")
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
@@ -72,19 +32,14 @@ def run_dws(
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
)
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
raise ScriptError(f'执行 dws 失败:{exc}') from exc
if result.returncode != 0:
detail = result.stderr.strip() or f'退出码 {result.returncode}'
raise ScriptError(f'dws 命令失败:{detail}')
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ScriptError(f'dws 返回的不是合法 JSON:{exc}') from exc
if isinstance(data, dict) and data.get('success') is False:
detail = data.get('errorMsg') or data.get('message') or '未知错误'
raise ScriptError(f'dws 业务调用失败:{detail}')
return data
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def search_group(
@@ -96,17 +51,14 @@ def search_group(
], dry_run=dry_run)
if dry_run:
return '<CONV_ID>'
if not data:
return None
if isinstance(data, list):
groups = data
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
groups = (
inner.get('value')
or inner.get('groups')
or inner.get('items')
or []
)
groups = inner.get('items', inner.get('groups', []))
elif isinstance(inner, list):
groups = inner
else:
@@ -114,64 +66,16 @@ def search_group(
else:
groups = []
if not groups:
raise ScriptError(f'未找到群聊:{query}')
exact = [
item for item in groups
if isinstance(item, dict)
and str(item.get('title') or item.get('name') or '').strip().casefold()
== query.strip().casefold()
]
candidates = exact if exact else groups
if len(candidates) != 1:
rendered = []
for item in candidates:
if not isinstance(item, dict):
continue
name = item.get('title') or item.get('name') or '未知'
conv_id = item.get('openConversationId') or item.get('id') or '无ID'
rendered.append(f'{name} ({conv_id})')
detail = ';'.join(rendered) or f'{len(candidates)} 个候选'
raise AmbiguousTargetError(
f'群名“{query}”匹配到多个候选,请指定 --group:{detail}'
)
g = candidates[0]
print(f"未找到群聊: {query}")
return None
g = groups[0]
name = g.get('title') or g.get('name', '未知')
conv_id = g.get('openConversationId') or g.get('id')
if not conv_id:
raise ScriptError(f'群聊“{name}”缺少 openConversationId')
print(f" 找到群聊: {name} ({conv_id})")
return conv_id
def parse_message_page(data: Any) -> tuple[List[Any], bool]:
"""提取消息页和 hasMore。"""
if isinstance(data, list):
return data, False
if not isinstance(data, dict):
return [], False
inner = data.get('result', data)
if isinstance(inner, dict):
messages = inner.get('messages', [])
return messages if isinstance(messages, list) else [], bool(
inner.get('hasMore', False)
)
if isinstance(inner, list):
return inner, False
return [], False
def message_identity(message: Any) -> Optional[str]:
if not isinstance(message, dict):
return None
value = (
message.get('openMessageId')
or message.get('openMsgId')
or message.get('msgId')
)
return str(value) if value else None
def run(argv: Optional[List[str]] = None) -> int:
def main():
parser = argparse.ArgumentParser(
description='导出群聊消息到 JSON'
)
@@ -191,115 +95,100 @@ def run(argv: Optional[List[str]] = None) -> int:
)
parser.add_argument('--output', default='', help='输出文件')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args(argv)
args = parser.parse_args()
try:
conv_id = args.group
if not conv_id:
if not args.query:
raise ScriptError('需要 --group 或 --query 参数')
print(f'🔍 搜索群聊: {args.query}')
conv_id = search_group(args.query, args.dry_run)
conv_id = args.group
if not conv_id:
if not args.query:
print('错误:需要 --group 或 --query 参数')
sys.exit(1)
print(f'🔍 搜索群聊: {args.query}')
conv_id = search_group(args.query, args.dry_run)
if not conv_id and not args.dry_run:
sys.exit(1)
print(f'📥 拉取消息 (起始: {args.time})...')
all_messages: List[Any] = []
seen_ids = set()
current_time = args.time
direction = 'older' if args.no_forward else 'newer'
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
has_more = False
print(f'📥 拉取消息 (起始: {args.time})...')
all_messages: List[Any] = []
current_time = args.time
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list',
'--group', conv_id or '<CONV_ID>',
'--time', current_time,
'--direction', direction,
'--format', 'json',
]
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list',
'--group', conv_id or '<CONV_ID>',
'--time', current_time,
'--format', 'json',
]
if args.no_forward:
cmd_args.append('--forward=false')
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 使用末条消息 createTime')
return 0
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time')
return
page_msgs, has_more = parse_message_page(data)
if not page_msgs:
if has_more:
raise ScriptError('服务端返回 hasMore=true,但本页没有消息')
break
if not data:
break
for message in page_msgs:
if not isinstance(message, dict):
raise ScriptError('消息列表包含非对象条目,无法安全导出')
identity = message_identity(message)
if identity and identity in seen_ids:
continue
if identity:
seen_ids.add(identity)
all_messages.append(message)
remaining -= 1
if remaining <= 0:
break
page += 1
if not has_more or remaining <= 0:
break
last_msg = page_msgs[-1]
if not isinstance(last_msg, dict):
raise ScriptError('末条消息不是对象,无法取得翻页时间边界')
boundary_time = normalize_boundary_time(
last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time')
)
if not boundary_time:
raise ScriptError('hasMore=true,但末条消息缺少 createTime')
if boundary_time == current_time:
raise ScriptError('分页边界没有推进,已停止以避免重复循环')
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if has_more and page >= max_pages and remaining > 0:
raise ScriptError(f'达到最大分页数 {max_pages},结果不完整')
if not all_messages:
print('未拉取到消息')
return 0
if args.output:
with open(args.output, 'w', encoding='utf-8') as file:
json.dump(all_messages, file, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
# 兼容两种结构: 顶层 messages / {result: {messages, hasMore}}
if isinstance(data, list):
page_msgs = data
has_more = False
else:
for message in all_messages:
sender = (
message.get('sender')
or message.get('senderNick')
or '未知'
)
text = message.get('content') or message.get('text', '')
time_str = (
message.get('createTime')
or message.get('createAt')
or message.get('time', '')
)
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
return 0
except ScriptError as exc:
print(f'错误:{exc}', file=sys.stderr)
return exc.exit_code
except OSError as exc:
print(f'错误:无法写入输出文件:{exc}', file=sys.stderr)
return 1
container = data
inner = data.get('result')
if isinstance(inner, dict):
container = inner
page_msgs = container.get('messages')
if page_msgs is None and isinstance(inner, list):
page_msgs = inner
if not isinstance(page_msgs, list):
page_msgs = []
has_more = bool(container.get('hasMore', False))
if not page_msgs:
break
all_messages.extend(page_msgs)
remaining -= len(page_msgs)
page += 1
if not has_more:
break
last_msg = page_msgs[-1]
boundary_time = (last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time', ''))
if not boundary_time or boundary_time == current_time:
break
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if not all_messages:
print('未拉取到消息')
return
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(all_messages, f, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for m in all_messages:
# 实际字段: sender/createTime/content (兼容旧字段名)
sender = (m.get('sender') or m.get('senderNick')
or m.get('senderOpenDingTalkId', '未知'))
text = m.get('content') or m.get('text', '')
time_str = (m.get('createTime') or m.get('createAt')
or m.get('time', ''))
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
if __name__ == '__main__':
sys.exit(run())
main()
+108 -216
View File
@@ -3,63 +3,23 @@
查询与某人的单聊聊天记录
用法:
python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"
python3 scripts/chat_history_with_user.py --user <userId> --time "2026-03-10 00:00:00" --limit 50
python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-01 00:00:00" --output history.json
python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"
python chat_history_with_user.py --user <userId> --time "2026-03-10 00:00:00" --limit 50
python chat_history_with_user.py --name "张三" --time "2026-03-01 00:00:00" --output history.json
工作流:
1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user)
2. 调用 chat message list-direct --user <userId> 拉取单聊消息
2. 调用 chat message list --user <userId> 拉取单聊消息
3. 输出到终端或导出为 JSON 文件
"""
import sys
import json
import datetime
import subprocess
import argparse
from typing import List, Any, Optional
class ScriptError(RuntimeError):
"""可预期的脚本执行错误。"""
exit_code = 1
class AmbiguousTargetError(ScriptError):
"""搜索结果不唯一,需要调用方消歧。"""
exit_code = 2
def normalize_boundary_time(value: Any) -> str:
"""将消息时间边界规范为 CLI 接受的 yyyy-MM-dd HH:mm:ss 或原字符串。"""
if value is None or isinstance(value, bool):
return ''
if isinstance(value, (int, float)):
timestamp = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return ''
try:
timestamp = float(text)
except ValueError:
return text
else:
return str(value).strip()
if timestamp > 10_000_000_000:
timestamp /= 1000
try:
dt = datetime.datetime.fromtimestamp(
timestamp, tz=datetime.timezone(datetime.timedelta(hours=8))
)
except (OSError, OverflowError, ValueError) as exc:
raise ScriptError(f'无效的消息时间边界:{value}') from exc
return dt.strftime("%Y-%m-%d %H:%M:%S")
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
@@ -72,19 +32,14 @@ def run_dws(
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
)
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
raise ScriptError(f'执行 dws 失败:{exc}') from exc
if result.returncode != 0:
detail = result.stderr.strip() or f'退出码 {result.returncode}'
raise ScriptError(f'dws 命令失败:{detail}')
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ScriptError(f'dws 返回的不是合法 JSON:{exc}') from exc
if isinstance(data, dict) and data.get('success') is False:
detail = data.get('errorMsg') or data.get('message') or '未知错误'
raise ScriptError(f'dws 业务调用失败:{detail}')
return data
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def search_user(
@@ -97,80 +52,30 @@ def search_user(
], dry_run=dry_run)
if dry_run:
return '<USER_ID>'
if not data:
return None
# 解析返回结构
users = data
if isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
users = (
inner.get('value')
or inner.get('users')
or inner.get('list')
or inner.get('items')
or []
)
users = (inner.get('users', [])
or inner.get('list', []))
elif isinstance(inner, list):
users = inner
else:
users = []
if not users or not isinstance(users, list):
raise ScriptError(f'未找到用户:{name}')
exact = [
item for item in users
if isinstance(item, dict)
and str(item.get('name') or item.get('nick') or '').strip().casefold()
== name.strip().casefold()
]
candidates = exact if exact else users
if len(candidates) != 1:
rendered = []
for item in candidates:
if not isinstance(item, dict):
continue
user_name = item.get('name') or item.get('nick') or '未知'
user_id = item.get('userId') or item.get('userid') or '无ID'
rendered.append(f'{user_name} ({user_id})')
detail = ';'.join(rendered) or f'{len(candidates)} 个候选'
raise AmbiguousTargetError(
f'姓名“{name}”匹配到多个候选,请指定 --user:{detail}'
)
u = candidates[0]
print(f"未找到用户: {name}")
return None
u = users[0]
user_name = u.get('name') or u.get('nick', '未知')
user_id = u.get('userId') or u.get('userid', '')
if not user_id:
raise ScriptError(f'用户“{user_name}”缺少 userId')
print(f" 找到用户: {user_name} ({user_id})")
return user_id
def parse_message_page(data: Any) -> tuple[List[Any], bool]:
"""提取消息页和 hasMore。"""
if isinstance(data, list):
return data, False
if not isinstance(data, dict):
return [], False
inner = data.get('result', data)
if isinstance(inner, dict):
messages = inner.get('messages', [])
return messages if isinstance(messages, list) else [], bool(
inner.get('hasMore', False)
)
if isinstance(inner, list):
return inner, False
return [], False
def message_identity(message: Any) -> Optional[str]:
if not isinstance(message, dict):
return None
value = (
message.get('openMessageId')
or message.get('openMsgId')
or message.get('msgId')
)
return str(value) if value else None
def run(argv: Optional[List[str]] = None) -> int:
def main():
parser = argparse.ArgumentParser(
description='查询与某人的单聊聊天记录'
)
@@ -191,113 +96,100 @@ def run(argv: Optional[List[str]] = None) -> int:
)
parser.add_argument('--output', default='', help='导出到 JSON 文件')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args(argv)
args = parser.parse_args()
try:
user_id = args.user
if not user_id:
print(f'🔍 搜索用户: {args.name}')
user_id = search_user(args.name, args.dry_run)
# 1. 获取 userId
user_id = args.user
if not user_id:
print(f'🔍 搜索用户: {args.name}')
user_id = search_user(args.name, args.dry_run)
if not user_id and not args.dry_run:
sys.exit(1)
print(f'📥 拉取与 {user_id} 的聊天记录 (起始: {args.time})...')
all_messages: List[Any] = []
seen_ids = set()
current_time = args.time
direction = 'older' if args.no_forward else 'newer'
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
has_more = False
# 2. 拉取单聊消息(自动翻页)
print(f'📥 拉取与 {user_id} 的聊天记录 (起始: {args.time})...')
all_messages: List[Any] = []
current_time = args.time
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list-direct',
'--user', user_id or '<USER_ID>',
'--time', current_time,
'--direction', direction,
'--format', 'json',
]
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list',
'--user', user_id or '<USER_ID>',
'--time', current_time,
'--format', 'json',
]
if args.no_forward:
cmd_args.append('--forward=false')
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 使用末条消息 createTime')
return 0
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time')
return
page_msgs, has_more = parse_message_page(data)
if not page_msgs:
if has_more:
raise ScriptError('服务端返回 hasMore=true,但本页没有消息')
break
if not data:
break
for message in page_msgs:
if not isinstance(message, dict):
raise ScriptError('消息列表包含非对象条目,无法安全导出')
identity = message_identity(message)
if identity and identity in seen_ids:
continue
if identity:
seen_ids.add(identity)
all_messages.append(message)
remaining -= 1
if remaining <= 0:
break
page += 1
if not has_more or remaining <= 0:
break
last_msg = page_msgs[-1]
if not isinstance(last_msg, dict):
raise ScriptError('末条消息不是对象,无法取得翻页时间边界')
boundary_time = normalize_boundary_time(
last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time')
)
if not boundary_time:
raise ScriptError('hasMore=true,但末条消息缺少 createTime')
if boundary_time == current_time:
raise ScriptError('分页边界没有推进,已停止以避免重复循环')
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if has_more and page >= max_pages and remaining > 0:
raise ScriptError(f'达到最大分页数 {max_pages},结果不完整')
if not all_messages:
print('未拉取到消息')
return 0
if args.output:
with open(args.output, 'w', encoding='utf-8') as file:
json.dump(all_messages, file, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
# 兼容两种结构: 顶层 messages / {result: {messages, hasMore}}
if isinstance(data, list):
page_msgs = data
has_more = False
else:
for message in all_messages:
sender = (
message.get('sender')
or message.get('senderNick')
or '未知'
)
text = message.get('content') or message.get('text', '')
time_str = (
message.get('createTime')
or message.get('createAt')
or message.get('time', '')
)
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
return 0
except ScriptError as exc:
print(f'错误:{exc}', file=sys.stderr)
return exc.exit_code
except OSError as exc:
print(f'错误:无法写入输出文件:{exc}', file=sys.stderr)
return 1
container = data
inner = data.get('result')
if isinstance(inner, dict):
container = inner
page_msgs = container.get('messages')
if page_msgs is None and isinstance(inner, list):
page_msgs = inner
if not isinstance(page_msgs, list):
page_msgs = []
has_more = bool(container.get('hasMore', False))
if not page_msgs:
break
all_messages.extend(page_msgs)
remaining -= len(page_msgs)
page += 1
if not has_more:
break
last_msg = page_msgs[-1]
boundary_time = (last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time', ''))
if not boundary_time or boundary_time == current_time:
break
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if not all_messages:
print('未拉取到消息')
return
# 3. 输出结果
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(all_messages, f, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for m in all_messages:
# 实际字段: sender/createTime/content (兼容旧字段名)
sender = (m.get('sender') or m.get('senderNick')
or m.get('senderOpenDingTalkId', '未知'))
text = m.get('content') or m.get('text', '')
time_str = (m.get('createTime') or m.get('createAt')
or m.get('time', ''))
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
if __name__ == '__main__':
sys.exit(run())
main()

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