Compare commits

..
Author SHA1 Message Date
Dennis 5f5d7ee21e fix(localio): harden local download publication 2026-08-06 12:26:50 +08:00
Dennis 2f3797c1f6 fix(localio): enforce secure download client 2026-08-06 11:31:01 +08:00
Dennis 990c85d36b fix(localio): strip headers on cross-origin redirects 2026-08-06 11:00:44 +08:00
Dennis 1f77ba31f3 fix(localio): disable proxies for secure downloads 2026-08-06 10:26:04 +08:00
dxy704330469 eb0bd69b82 feat(doc): add reviewed document shortcuts
- add 45 public document shortcuts and 2 reviewed expert-only paths
- preserve six historical command and Schema identities alongside canonical leaves
- add safe local download primitives and document access/share orchestration
- keep comment create/reply confirmation backward-compatible
- ensure grant-and-share upgrades insufficient roles before messaging
- return non-zero partial/failure message ledgers and structured partial-write recovery metadata
- enumerate every selection candidate and use rune-safe Unicode keyword contexts
- assert zero-call confirmation boundaries for destructive shortcuts

Validation:
- full Go test suite and repository policy
- real DingTalk E2E for 34 canonical shortcuts, all 8 compatibility-affected entries, READER-to-EDITOR grant-and-share upgrade, and same-block selection ambiguity with zero comment writes
- command compatibility across 1,221 historical nodes and complete Schema compatibility
- 1,152 Agent examples including 62 real Cobra dry-runs
- 100% changed-code coverage across 1,260 executable statements
2026-08-06 09:56:58 +08:00
github-actions[bot] 4bcf71fb9e Merge pull request #881 from Anonymity-0/feat/chat-reply-mentions
feat(chat): support mentions in message replies
2026-08-05 23:10:42 +08:00
前津 545ee17316 fix(chat): add missing reply mention placeholders 2026-08-05 21:23:12 +08:00
前津 0c62938f74 feat(chat): support mentions in message replies 2026-08-05 21:23:12 +08:00
github-actions[bot] 45b43e52bb chore: update beta formula for v1.0.57-beta.2 [skip ci] 2026-08-05 11:49:09 +00:00
chichuan 95a5cc42ce Merge pull request #879 from DingTalk-Real-AI/codex/changelog-v1.0.57-beta.2
docs: seal v1.0.57-beta.2 changelog
2026-08-05 19:36:25 +08:00
chichuan fec750b09e docs: remove duplicate beta.2 changelog entry 2026-08-05 19:26:27 +08:00
chichuan ddd5f15b91 docs: seal v1.0.57-beta.2 changelog 2026-08-05 19:19:49 +08:00
github-actions[bot] db50be868b Merge pull request #876 from DingTalk-Real-AI/codex/restore-chat-im-compat
fix(chat): restore stable send and history compatibility
2026-08-05 19:13:50 +08:00
Dennis a6220d7d8b fix(chat): preserve migration hints with legacy flags 2026-08-05 18:19:16 +08:00
Dennis 81bf0d2a6b test(coverage): stabilize drive worker cancellation branch 2026-08-05 18:05:54 +08:00
Dennis f3a95d34a3 fix(chat): restore stable send and history compatibility 2026-08-05 17:32:12 +08:00
chichuan a37e6e6847 Merge pull request #875 from DingTalk-Real-AI/codex/changelog-v1.0.57-beta.1
docs: seal v1.0.57-beta.1 changelog
2026-08-05 16:51:42 +08:00
chichuan 0ceb96c745 docs: seal v1.0.57-beta.1 changelog 2026-08-05 16:47:10 +08:00
github-actions[bot] 114503d52f Merge pull request #872 from lifeihong/feat/addUpdateUserOwnessV2A84934011
feat(contact): add update-ownness command for user personal status
2026-08-05 08:36:50 +00:00
chichuan 9de1c9c304 Merge branch 'main' into feat/addUpdateUserOwnessV2A84934011 2026-08-05 16:25:02 +08:00
github-actions[bot] 840e1d665f Merge pull request #861 from DingTalk-Real-AI/codex/sync-wukong-whiteboard
feat: add document whiteboard workflows
2026-08-05 16:17:14 +08:00
昕卉 f362c8c2a4 Merge remote-tracking branch 'upstream/main' into feat/addUpdateUserOwnessV2A84934011 2026-08-05 16:06:52 +08:00
chichuan e73a1556ce Merge latest main into codex/sync-wukong-whiteboard
冲突仅在 skills/mono/SKILL.md 的意图路由表,两侧改动正交,均保留:
- 本分支新增的 whiteboard 路由行
- main 把 event 行拆成 `event +listen-im` / `event consume` 的新表述
(下文「优先由一个 dws event +listen-im 进程表达目标」已是 main 版本,
保留旧 event 行会自相矛盾)
2026-08-05 15:59:17 +08:00
昕卉 186f2fa474 test(contact): add update-ownness tests and align confirmation with framework gate 2026-08-05 15:39:22 +08:00
github-actions[bot] d91a93c43b Merge pull request #860 from DingTalk-Real-AI/codex/multi-im-optimization
feat(im): harden Multi IM and publish complete Chat Schema
2026-08-05 15:28:29 +08:00
chichuan 08254e2a36 fix(whiteboard): fail closed when insert verification query fails
回查循环原先吞掉全部 queryErr,鉴权失败、MCP 错误与 JSONML 解析失败都
退化成 soft success 返回 whiteboardId: null,Agent 会把硬失败误判成最终
一致性并带着空 partId 继续调用 whiteboard query/update。

- 引入 errWhiteboardBlockPending sentinel,只有「块暂不可见」允许重试;
  其余错误立即返回,并把已插入的 blockId 带进错误消息供复原
- queryWhiteboardCardNode 严格校验 blocks 字段(缺失 / 非数组均为协议
  错误),避免畸形响应伪装成「块暂不可见」
- --ref-block 与 --parent-block、--where 与 --parent-block 显式互斥,
  锚点组装改用 else if 让单一定位分支在代码上自证
- 补全 mono / multi 两份 recipes.md 被截断的开篇句
- doc 根命令的命令结构清单补上 whiteboard insert 与 media upload/download
- 新增 3 个回归测试覆盖 fail-closed、soft success 与锚点互斥
2026-08-05 15:14:27 +08:00
昕卉 fdd9e189d6 add update ownness 2026-08-05 13:32:23 +08:00
chichuan eebdf52da9 fix: classify local whiteboard example precondition 2026-08-05 12:28:46 +08:00
chichuan 9eaee76a51 Merge origin/main into codex/sync-wukong-whiteboard 2026-08-05 11:51:35 +08:00
chichuan d7c28bcfef fix: complete whiteboard skill examples 2026-08-04 21:19:45 +08:00
chichuan 287b079c18 Merge origin/main into codex/sync-wukong-whiteboard 2026-08-04 20:12:37 +08:00
chichuan 50f8ade1d7 Merge origin/main into codex/sync-wukong-whiteboard 2026-08-04 17:36:59 +08:00
chichuan b87cad1eb5 docs: fix whiteboard protocol table 2026-08-04 17:14:11 +08:00
chichuan 0f2eec145e docs: add OpenNodes V1 whiteboard protocol 2026-08-04 17:10:15 +08:00
chichuan 64c2e8544c test: complete whiteboard branch coverage 2026-08-04 14:11:29 +08:00
chichuan fc31fddd73 test: cover whiteboard error paths 2026-08-04 13:51:37 +08:00
chichuan 4298d0833b test: refresh CLI interface baseline 2026-08-04 11:45:05 +08:00
chichuan 7e0957d9e8 feat: add document whiteboard workflows 2026-08-04 11:25:31 +08:00
88 changed files with 12431 additions and 264 deletions
+65
View File
@@ -6,8 +6,73 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.57-beta.2] - 2026-08-05
### Fixed
- **Stable Chat command compatibility** (#876) — restores the hidden migration
entries for `chat send`, `chat history`, and their `im` aliases, preserving
the v1.0.56 command surface while directing callers to the supported
`chat message send/list` commands. Legacy flags now reach the same migration
hints instead of failing during flag parsing.
- **Drive download cancellation-test stability** (#876) — replaces a
timing-sensitive worker-cancellation coverage test with a deterministic seam,
reducing flaky CI without changing download behavior.
## [1.0.57-beta.1] - 2026-08-05
This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified
command-contract and runtime Schema architecture, complete Multi IM Chat
coverage, document whiteboard and OA approval workflows, Wiki activity feeds,
and compatibility and CI reliability fixes.
### Added
- **Contact personal-status updates** (#872) — adds `contact user update-ownness`
(alias `set-ownness`) for updating a user's personal status text. The write
operation maps reviewed `userId` and `ownnessText` parameters to the service
contract and requires confirmation unless `--yes` is explicitly supplied.
- **Document whiteboard workflows** (#861) — adds `doc whiteboard insert`,
`whiteboard query/update`, and `doc media upload`. These commands support
confirmed document-embedded whiteboard creation and updates, structured
OpenNodes reads, and preparation of node-bound Vector/SVG resources.
- **Complete Multi IM Chat coverage** (#860) — hardens deterministic group and
stable-ID resolution, sending, querying, downloading, pagination, and JSON
export. The remaining reviewed Chat Shortcuts enter Schema coverage, with
destructive delete and clear operations aligned to confirmation gates.
- **OA approval form workflows** (#853) — adds OA form-schema lookup,
process forecast, and confirmed approval-instance creation, supporting both
simple flags and complete `--request` payloads.
- **Wiki activity-feed queries** (#862) — adds `wiki feed list` to retrieve
workspace document activity, with cursor paging and optional file exclusion.
### Changed
- **Unified command and Schema contract framework** (#830) — Leaf commands and
Shortcuts now use the shared typed `corecmd` base for flags, constraints,
confirmation, Help, and runtime Schema projection. Schema delivery assembles
from leaf Contract declarations at runtime; the retired hint overlays,
pinned MCP metadata, and committed Catalog artifacts are no longer delivery
authorities.
- **Faster macOS CI without reducing native coverage** (#857) — narrows the
macOS race suite to Keychain, codesign, and Darwin-only tests while adding a
reachability contract that prevents native-only tests from being silently
excluded.
### Fixed
- **Chat media-download JSON compatibility** (#854) — restores parseable
`success`, `downloadUrl`, and `output` fields for
`chat message download-media --format json` after a successful download,
without progress output corrupting JSON stdout.
### Added
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
### Changed
- **Chat reply mentions** — `dws chat message reply` can @ specified group members with `--at-open-dingtalk-ids` or @ everyone with `--at-all`, forwarding the existing `send_personal_message` mention fields and automatically adding missing current-user `<@id>` / `<@all>` placeholders.
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
- **MCP service review retired** — deletes `schema_mcp_service_review.json` and removes its policy jq / outputguard / test disposition gate (`notify` → `out_of_surface`, snapshot hash pin). No replacement ledger.
- **Hints retired; ContractDecl is the leaf Schema source** (#830) — `schema_hints/`, Manual/Schema hint overlays, and `schema_agent_metadata/` delivery are removed. Selection, safety, parameters, and interface facts declare on ProductDecl / leaf `Contract` (`corecmd.ContractDecl` + `contract.ParamDecl` / `Safety`). Authoring renamed `SchemaDecl` → `ContractDecl`; nested fields reuse `contract.*` directly.
+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.4"
version "1.0.57-beta.2"
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.4/dws-darwin-arm64.tar.gz"
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-darwin-arm64.tar.gz"
sha256 "2119754d4c6f6be2b4856ab559ad44ac582a3b3abc76ff907927f62c7a4a3d29"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-darwin-amd64.tar.gz"
sha256 "a453341d6df1a78b7d74bd624842503d857a41f73fa1ac36394e4594e4961e8d"
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.4/dws-linux-arm64.tar.gz"
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-linux-arm64.tar.gz"
sha256 "734df2c7f34ca36aa48151fda2b18e1c2c90fe812fb5ab13e8c00e074cca43af"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-linux-amd64.tar.gz"
sha256 "f602a63ab6afd2e24db7b7dabfddb0cdcf3a7bd55b0cc60a99013bac5cacc56f"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-skills.zip"
sha256 "486f5ef30a88a293c14df1ff0768760284179993c51f898fa2bee2c9391d8607"
end
def install
+386 -45
View File
@@ -1,6 +1,6 @@
{
"generated_at": "2026-07-29T00:06:19.285348",
"count": 265,
"generated_at": "2026-08-05T22:43:52.497190",
"count": 294,
"results": [
{
"suite": "read",
@@ -485,7 +485,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。",
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性;可选对资源去重后安全落盘并返回逐项失败 ledger。",
"availability": "available"
},
{
@@ -658,6 +658,16 @@
"semantic_delta": "群邀请链接是一对一读取;Shortcut 未增加生命周期或分享编排。",
"availability": "available"
},
{
"suite": "semantic",
"service": "chat",
"command": "+chat-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "对齐 lark-cli +chat-list:默认仅群聊,支持 --types group/p2p、--exclude-muted、page-size/page-token 别名,并投影 openConversationId/name/conversationType;不宣称 sort 或 bot 身份 p2p 剥离。",
"availability": "available"
},
{
"suite": "semantic",
"service": "chat",
@@ -715,7 +725,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。",
"semantic_delta": "统一群聊与两类单聊目标;省略时间时自动以当前时间向前读取最近消息,并输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文;可选对列表内资源去重后安全落盘并返回逐项失败 ledger。",
"availability": "available"
},
{
@@ -1215,7 +1225,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 复用 HTTPS、相对路径、无覆盖和原子落盘防护,逐资源返回下载失败 ledger。",
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 统一下载 mediaId 与 fileId,复用受信任下载域、相对路径、无覆盖和原子落盘防护,对重复资源去重并逐资源返回下载失败 ledger;安全本地下载沿用 read/not_required 契约,不产生非交互确认盲区。",
"availability": "available"
},
{
@@ -1295,7 +1305,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。",
"semantic_delta": "统一承接消息 mediaId 与钉盘 fileId:分别复用 IM 临时资源 URL 和 drive.download_file,只允许钉钉/OSS HTTPS 下载域且重定向复验并隔离跨域凭据,再通过工作目录内安全路径、默认不覆盖、临时文件下载和原子发布输出结构化结果。",
"availability": "available"
},
{
@@ -1315,7 +1325,7 @@
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 文本/Markdown 发送;按身份校验目标与凭据,幂等键只在真实支持的 user 分支开放,媒体上传仍诚实留在 native leaf。",
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 发送;current-user 支持文本、Markdown、mediaId 图片、安全相对路径本地文件上传、userId 姓名解析与幂等键,bot/webhook 仍只暴露下层真实支持的文本/Markdown 能力。",
"availability": "available"
},
{
@@ -1344,8 +1354,8 @@
"command": "+messages-send-card",
"risk": "write",
"status": "reviewed_available",
"disposition": "schema_leaf",
"semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。",
"disposition": "semantic_adapter",
"semantic_delta": "既可只创建流式卡片,也可在一次调用中创建、提取 bizId、写入内容并设置流式状态;dry-run 输出两步执行计划,更新失败时保留已创建的 bizId。",
"availability": "available"
},
{
@@ -1415,7 +1425,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。",
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;展开下层按会话分组的 conversationMessagesList,支持精确时间窗、page-all、50 条一组 mget 富化,可选安全下载命中消息资源,并以 failure ledger 显式报告截断、富化或下载失败。",
"availability": "available"
},
{
@@ -1435,7 +1445,7 @@
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"availability": "available"
},
{
@@ -1708,123 +1718,454 @@
"status": "real-ok"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+access-change",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "读取当前权限后再变更角色,避免把不存在的协作者当作成功更新。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+access-grant",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "在第一次写入前解析全部接收人,再批量授予文档权限并输出逐项 ledger。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+access-revoke",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "预检目标协作者权限后移除并输出逐项结果。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+background-delete",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以 clear 语义移除文档背景色。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+background-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "校验并设置 #RRGGBB 文档背景纯色。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+checkpoint-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "写入前保存版本快照,更新后读回验证并输出逐步 ledger。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+comment-create",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "无 selection 创建全文评论,有 selection 时定位文本并创建划词评论。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+comment-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "永久删除指定评论,并由静态安全契约强制确认。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+comment-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一评论类型、解决状态与分页过滤。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+comment-reply",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一评论回复、表情回复和 mention 参数。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+comment-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "更新指定评论正文与 mention。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+copy",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "旧 Doc 复制入口,仅为兼容保留;新的文件复制应使用 Drive 命令。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+create",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 Markdown/JSONML 内容输入、目标位置与创建后保真写入。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+create-from-template",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "支持 templateId 直达或按名称搜索消歧后创建文档。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+doc-append",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+export",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+export-get",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+export-submit",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "导出中断恢复所需的专家入口;常规场景使用一体化 +export。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+fetch",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 simple/with-ids/full 细节层级与 full/outline/range/section/keyword/tags 局部读取。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+find-doc",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+grant-and-share",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先确保目标角色,再发送链接;消息失败保留逐人 ledger,并以非零退出报告 failed/partial_success。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+history-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一历史版本分页参数并返回可用于回滚的版本列表。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+history-revert",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "先验证目标版本存在,再执行回滚并读回当前文档状态。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+history-save",
"risk": "write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+import",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+inspect",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "聚合文档元信息,并按需读取样式、权限、历史、媒体和评论。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+media-download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "解析附件临时链接并通过受控相对路径、no-clobber、原子发布安全下载。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+media-insert",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "组合本地文件校验、上传凭证、OSS PUT、插块和验证。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+media-list",
"risk": "read",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "从文档块中提取图片、附件及其 block/resource 标识。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+media-preview",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "将正文媒体下载到受控临时目录并返回本地预览 artifact。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+move",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+resource-delete",
"risk": "high-risk-write",
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "以幂等 clear 语义移除当前文档封面。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+resource-download",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "读取当前文档封面配置并安全下载资源到本地。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+resource-update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "支持本地图片或 HTTPS 图片转存后设置文档封面。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+review",
"risk": "read",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "聚合未解决评论、划词引用和确定性上下文,不调用模型生成总结。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一关键词、最近访问、过滤、分页和稳定精简投影,作为文档定位的 canonical 入口。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+share",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "按姓名解析唯一用户后发送文档链接,不改变文档权限。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+share-doc",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+template-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "统一 MY/PUBLIC 模板浏览和分页参数。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+template-search",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "semantic_adapter",
"semantic_delta": "按名称检索模板并返回可继续创建的 templateId。",
"availability": "available"
},
{
"suite": "read",
"suite": "semantic",
"service": "doc",
"command": "+update",
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。",
"availability": "available"
},
{
"suite": "semantic",
"service": "doc",
"command": "+version-list",
"risk": "read",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+version-revert",
"risk": "high-risk-write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。",
"availability": "available"
},
{
"suite": "write",
"suite": "semantic",
"service": "doc",
"command": "+version-save",
"risk": "write",
"status": "real-ok"
"status": "reviewed_available",
"disposition": "alias_internal",
"semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。",
"availability": "available"
},
{
"suite": "write",
+33
View File
@@ -72,6 +72,39 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
for _, path := range []string{
"chat send",
"chat history",
"im send",
"im history",
} {
command, remaining, err := root.Find(strings.Fields(path))
if err != nil {
t.Fatalf("find %s: %v", path, err)
}
if len(remaining) != 0 || !command.Hidden || !command.Runnable() {
t.Fatalf("%s compatibility contract: remaining=%v hidden=%v runnable=%v", path, remaining, command.Hidden, command.Runnable())
}
}
for _, tc := range []struct {
args []string
hint string
}{
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
} {
command := NewRootCommand()
command.SilenceErrors = true
command.SilenceUsage = true
command.SetArgs(tc.args)
err := command.Execute()
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
}
}
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
if listDirect.Flags().Lookup(flag) == nil {
+22 -14
View File
@@ -16,12 +16,12 @@ import (
)
const (
publicShortcutCount = 266
publicShortcutCount = 294
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
// including hidden leaves such as minutes.shortcut_minutes_search.
schemaPublishedShortcutCount = 267
schemaPublishedShortcutCount = 295
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
publiclyDeliveredShortcutCount = 266
publiclyDeliveredShortcutCount = 294
)
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
@@ -194,17 +194,9 @@ func assertDeliveryShortcutSafetyAndInterface(
canonical string,
) {
t.Helper()
risk := declared.Risk
if risk == "" {
risk = shortcut.RiskRead
}
wantEffect, wantRisk, wantConfirmation, wantIdempotency := "read", "low", "not_required", "idempotent"
switch risk {
case shortcut.RiskWrite:
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "write", "medium", "user_required", "unknown"
case shortcut.RiskHighWrite:
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "destructive", "high", "user_required", "unknown"
}
safety := shortcut.EffectiveSafety(declared)
wantEffect, wantRisk := safety.Effect, safety.Risk
wantConfirmation, wantIdempotency := safety.Confirmation, safety.Idempotency
for field, want := range map[string]string{
"effect": wantEffect,
"risk": wantRisk,
@@ -234,6 +226,15 @@ func assertDeliveryShortcutParameters(
for _, flag := range declared.Flags {
if !flag.Hidden {
publicFlags = append(publicFlags, flag)
if flag.AliasesVisible {
for _, alias := range flag.Aliases {
aliasFlag := flag
aliasFlag.Name = alias
aliasFlag.Default = ""
aliasFlag.Aliases = nil
publicFlags = append(publicFlags, aliasFlag)
}
}
}
}
if got, want := len(parameters), len(publicFlags); got != want {
@@ -299,6 +300,13 @@ func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
if flag.Name == flagName && flag.Required {
return true
}
if flag.Required && flag.AliasesVisible {
for _, alias := range flag.Aliases {
if alias == flagName {
return true
}
}
}
}
public := make(map[string]bool, len(declared.Flags))
for _, flag := range declared.Flags {
+9 -18
View File
@@ -30,38 +30,29 @@ import (
// remains a precise reviewed exception for such a capability whose runtime
// preconditions cannot be exercised safely and deterministically in the
// isolated test process.
type AgentExampleMode string
type AgentExampleMode = contract.ExampleDispositionMode
const (
AgentExampleModeContract AgentExampleMode = "contract"
AgentExampleModeDryRun AgentExampleMode = "dry_run"
AgentExampleModeContractOnly AgentExampleMode = "contract_only"
AgentExampleModeContract = contract.ExampleDispositionModeContract
AgentExampleModeDryRun = contract.ExampleDispositionModeDryRun
AgentExampleModeContractOnly = contract.ExampleDispositionModeContractOnly
)
// AgentExampleReasonCode is a closed taxonomy for reviewed contract-only
// exceptions to an explicit dry-run capability.
type AgentExampleReasonCode string
type AgentExampleReasonCode = contract.ExampleDispositionReasonCode
const (
AgentExampleReasonLocalState AgentExampleReasonCode = "local_state"
AgentExampleReasonStatefulPreflight AgentExampleReasonCode = "stateful_preflight"
AgentExampleReasonLocalState = contract.ExampleDispositionReasonLocalState
AgentExampleReasonStatefulPreflight = contract.ExampleDispositionReasonStatefulPreflight
)
// AgentExampleDisposition narrows one exact example with an explicit
// typed dry-run capability to contract-only. Index is a pointer so a missing
// field cannot silently select example zero.
//
// Dispositions are authored as an in-test / future ContractFinal extension
// surface; production ContractFinal Selection currently does not declare them,
// so the delivery plan treats every example as default-typed (contract or
// dry_run from ToolSpec.DryRun).
type AgentExampleDisposition struct {
Index *int `json:"index"`
Mode AgentExampleMode `json:"mode"`
ReasonCode AgentExampleReasonCode `json:"reason_code"`
Reason string `json:"reason"`
Reviewed bool `json:"reviewed"`
}
// Dispositions are authored on the owning ContractFinal Selection.
type AgentExampleDisposition = contract.ExampleDisposition
// AgentExampleExecution is one resolved example and its effective test mode.
type AgentExampleExecution struct {
+1
View File
@@ -178,6 +178,7 @@ func contractFinalToolSelection(command *cobra.Command) AgentToolSelection {
out.UseWhen = selection.UseWhen
out.AvoidWhen = selection.AvoidWhen
out.Examples = selection.Examples
out.ExampleDispositions = selection.ExampleDispositions
return out
}
@@ -123,16 +123,11 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
t.Run("disposition narrows dry_run capability", func(t *testing.T) {
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
payload.DryRun = &contract.DryRunSpec{PreviewKind: "plan"}
})
t.Cleanup(restoreSelection)
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
selection := contractFinalToolSelection(cmd)
selection.ExampleDispositions = []AgentExampleDisposition{{
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
Reason: "cannot dry-run safely", ReasonCode: AgentExampleReasonStatefulPreflight,
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
Reason: "cannot dry-run safely", ReasonCode: contract.ExampleDispositionReasonStatefulPreflight,
}}
return selection
}
})
plan, err := BuildAgentExampleExecutionPlan(bound, registry)
if err != nil {
t.Fatalf("plan error = %v", err)
@@ -146,16 +141,12 @@ func TestCrossPlatformCoverageAgentExampleRemainingBranches(t *testing.T) {
})
t.Run("disposition without dry_run capability fails", func(t *testing.T) {
bound, registry := crossPlatformAgentExampleFixture(t, nil)
t.Cleanup(restoreSelection)
agentExampleSelectionFn = func(cmd *cobra.Command) AgentToolSelection {
selection := contractFinalToolSelection(cmd)
selection.ExampleDispositions = []AgentExampleDisposition{{
Index: idx(0), Mode: AgentExampleModeContractOnly, Reviewed: true,
Reason: "no dry run", ReasonCode: AgentExampleReasonLocalState,
bound, registry := crossPlatformAgentExampleFixture(t, func(_ *cobra.Command, payload *contract.ContractFinalPayload) {
payload.Selection.ExampleDispositions = []contract.ExampleDisposition{{
Index: idx(0), Mode: contract.ExampleDispositionModeContractOnly, Reviewed: true,
Reason: "no dry run", ReasonCode: contract.ExampleDispositionReasonLocalState,
}}
return selection
}
})
_, err := BuildAgentExampleExecutionPlan(bound, registry)
if err == nil || !strings.Contains(err.Error(), "narrows no explicit dry_run") {
t.Fatalf("error = %v", err)
+4
View File
@@ -332,6 +332,10 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
reviewed := true
selection.Reviewed = &reviewed
}
// Example dispositions control only the policy gate's execution eligibility.
// They remain on ContractFinal for BuildAgentExampleExecutionPlan and are not
// part of the public ToolSpec / Schema wire contract.
selection.ExampleDispositions = nil
provenance := contractFinalProvenance(identity, title, description, titleProv, descriptionProv, safety, interfaceSpec, selection, final.DryRun)
+48
View File
@@ -200,6 +200,10 @@ type SelectionSpec struct {
Tips []string
WorkflowRefs []string
Examples []string
// ExampleDispositions narrows an exact example with a reviewed local or
// stateful precondition from dry-run execution to contract validation.
// It does not change the command's declared DryRun capability.
ExampleDispositions []ExampleDisposition
// Reviewed is a legacy-path (hints/registry) marker only. The Contract
// declaration path must not set it: declared selection is final by
// construction, and assembly rejects a declared payload carrying it.
@@ -219,10 +223,54 @@ func (s SelectionSpec) Normalized() SelectionSpec {
out.Tips = stableUniqueStrings(s.Tips)
out.WorkflowRefs = stableUniqueStrings(s.WorkflowRefs)
out.Examples = stableUniqueStrings(s.Examples)
out.ExampleDispositions = cloneExampleDispositions(s.ExampleDispositions)
out.SourceRefs = sortedUniqueStrings(s.SourceRefs)
return out
}
// ExampleDispositionMode controls how an already contract-validated example
// is exercised by the Agent example gate.
type ExampleDispositionMode string
const (
ExampleDispositionModeContract ExampleDispositionMode = "contract"
ExampleDispositionModeDryRun ExampleDispositionMode = "dry_run"
ExampleDispositionModeContractOnly ExampleDispositionMode = "contract_only"
)
// ExampleDispositionReasonCode is the closed taxonomy for reviewed
// contract-only exceptions to an explicit dry-run capability.
type ExampleDispositionReasonCode string
const (
ExampleDispositionReasonLocalState ExampleDispositionReasonCode = "local_state"
ExampleDispositionReasonStatefulPreflight ExampleDispositionReasonCode = "stateful_preflight"
)
// ExampleDisposition narrows one exact example to contract-only validation.
// Index is a pointer so a missing index cannot silently select example zero.
type ExampleDisposition struct {
Index *int `json:"index"`
Mode ExampleDispositionMode `json:"mode"`
ReasonCode ExampleDispositionReasonCode `json:"reason_code"`
Reason string `json:"reason"`
Reviewed bool `json:"reviewed"`
}
func cloneExampleDispositions(in []ExampleDisposition) []ExampleDisposition {
if len(in) == 0 {
return nil
}
out := append([]ExampleDisposition(nil), in...)
for i := range out {
if out[i].Index != nil {
index := *out[i].Index
out[i].Index = &index
}
}
return out
}
// ParamDecl is one parameter-level Schema fact declared on a command. It is
// stored at DeclareLeafMetadata time and applied as annotations at assembly
// time, when all flags are guaranteed to exist on the fully-built command tree.
@@ -75,9 +75,14 @@ func TestCrossPlatformCoverageInterfaceSpecAgentExecutableAndValidate(t *testing
}
func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *testing.T) {
exampleIndex := 0
normalized := (SelectionSpec{
UseWhen: []string{" one ", "one", ""},
AvoidWhen: []string{"avoid"},
UseWhen: []string{" one ", "one", ""},
AvoidWhen: []string{"avoid"},
ExampleDispositions: []ExampleDisposition{{
Index: &exampleIndex, Mode: ExampleDispositionModeContractOnly,
ReasonCode: ExampleDispositionReasonLocalState, Reason: "local file", Reviewed: true,
}},
SourceRefs: []string{"b", "a", "b"},
}).Normalized()
if len(normalized.UseWhen) != 1 || normalized.UseWhen[0] != "one" {
@@ -86,6 +91,16 @@ func TestCrossPlatformCoverageSelectionSpecNormalizedAndProvenanceHelpers(t *tes
if normalized.SourceRefs[0] != "a" || normalized.SourceRefs[1] != "b" {
t.Fatalf("SourceRefs = %#v", normalized.SourceRefs)
}
if len(normalized.ExampleDispositions) != 1 || normalized.ExampleDispositions[0].Index == nil || *normalized.ExampleDispositions[0].Index != 0 {
t.Fatalf("ExampleDispositions = %#v", normalized.ExampleDispositions)
}
exampleIndex = 1
if *normalized.ExampleDispositions[0].Index != 0 {
t.Fatal("ExampleDispositions index was not cloned")
}
if got := cloneExampleDispositions(nil); got != nil {
t.Fatalf("cloneExampleDispositions(nil) = %#v", got)
}
if got := stableUniqueStrings(nil); got != nil {
t.Fatalf("stableUniqueStrings(nil) = %#v", got)
}
+85 -27
View File
@@ -54,6 +54,14 @@ func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error
}
}
func chatCompatibilityHintSubCmd(use, hint string) *cobra.Command {
command := hintSubCmd(use, hint)
// Legacy callers may still pass the old command's flags. Let the migration
// command consume them so Cobra reaches RunE and returns the replacement path.
command.DisableFlagParsing = true
return command
}
type nativeChatTargetReader struct{}
func (nativeChatTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
@@ -396,6 +404,50 @@ func NormalizeMessageMentions(text string, ids []string, atAll, wrapAngle bool)
return text
}
// applyCurrentUserGroupMentions keeps the body placeholders and
// send_personal_message mention arguments aligned for send and reply.
func applyCurrentUserGroupMentions(params map[string]any, text, rawOpenIDs string, atAll bool) string {
var atOpenIDs []string
if rawOpenIDs != "" {
atOpenIDs = strings.Split(rawOpenIDs, ",")
}
if atAll && !strings.Contains(text, "<@all>") {
text = "<@all> " + text
}
text = normalizeAtPlaceholders(text, atOpenIDs, true)
if atAll {
params["atAll"] = true
}
if len(atOpenIDs) > 0 {
params["atOpenDingTalkIds"] = atOpenIDs
}
return text
}
func addMissingCurrentUserMentionPlaceholders(text, rawOpenIDs string) string {
if rawOpenIDs == "" {
return text
}
missing := make([]string, 0)
probeText := text
for _, id := range parseCSVValues(rawOpenIDs) {
placeholder := "<@" + id + ">"
if strings.Contains(probeText, placeholder) {
continue
}
missing = append(missing, placeholder)
probeText += placeholder
}
if len(missing) == 0 {
return text
}
prefix := strings.Join(missing, " ")
if strings.HasPrefix(text, "<@all> ") {
return "<@all> " + prefix + " " + strings.TrimPrefix(text, "<@all> ")
}
return prefix + " " + text
}
func containsMessageMention(text, placeholder string) bool {
if strings.HasPrefix(placeholder, "<") {
return strings.Contains(text, placeholder)
@@ -2029,29 +2081,15 @@ func newChatCommand() *cobra.Command {
if groupID != "" {
atAll, _ := cmd.Flags().GetBool("at-all")
atOpenIdsStr, _ := cmd.Flags().GetString("at-open-dingtalk-ids")
var atOpenIds []string
if atOpenIdsStr != "" {
atOpenIds = strings.Split(atOpenIdsStr, ",")
}
if atAll && !strings.Contains(text, "<@all>") {
text = "<@all> " + text
}
// 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 不变
text = normalizeAtPlaceholders(text, atOpenIds, true)
// 群聊统一走 openDingTalkId @ 人接口。
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
newParams := map[string]any{
"openConversationId": groupID,
"msgType": "markdown",
"content": string(contentJSON),
"clawType": clawType,
}
if atAll {
newParams["atAll"] = true
}
if len(atOpenIds) > 0 {
newParams["atOpenDingTalkIds"] = atOpenIds
}
text = applyCurrentUserGroupMentions(newParams, text, atOpenIdsStr, atAll)
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
newParams["content"] = string(contentJSON)
if msgUuid != "" {
newParams["uuid"] = msgUuid
}
@@ -5368,13 +5406,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
chatMessageReplyCmd := &cobra.Command{
Use: "reply",
Short: "引用回复消息(支持单聊/群聊)",
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。群聊回复可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人;正文中的裸 @openDingTalkId 会自动规范化为 <@openDingTalkId>,缺少对应成员或 <@all> 占位符时会自动补齐。
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
- 群聊:dws chat search --query "群名"
- 单聊:dws chat conversation-info --open-dingtalk-id <openDingTalkId>
(人员信息可通过 dws contact user search --keyword "姓名" --format json 获取)`,
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"`,
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
RunE: func(cmd *cobra.Command, args []string) error {
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
return err
@@ -5387,13 +5426,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
}
refSender = resolved
}
replyContent := map[string]string{
"referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"),
"srcMsgSendOpenDingTalkId": refSender,
"replyMsgType": "text",
"content": mustGetFlag(cmd, "text"),
}
contentJSON, _ := marshalJSONRaw(replyContent)
clawType := ""
aiTag, _ := cmd.Flags().GetBool("ai-tag")
if aiTag {
@@ -5402,9 +5434,25 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "conversation-id"),
"msgType": "reply",
"content": string(contentJSON),
"clawType": clawType,
}
atAll, _ := cmd.Flags().GetBool("at-all")
atOpenIDs := mustGetFlag(cmd, "at-open-dingtalk-ids")
replyText := applyCurrentUserGroupMentions(
toolArgs,
mustGetFlag(cmd, "text"),
atOpenIDs,
atAll,
)
replyText = addMissingCurrentUserMentionPlaceholders(replyText, atOpenIDs)
replyContent := map[string]string{
"referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"),
"srcMsgSendOpenDingTalkId": refSender,
"replyMsgType": "text",
"content": replyText,
}
contentJSON, _ := marshalJSONRaw(replyContent)
toolArgs["content"] = string(contentJSON)
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
toolArgs["uuid"] = v
}
@@ -5438,6 +5486,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
},
Parameters: []contract.ParamDecl{
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
{Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"},
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"},
{Name: "conversation-id", Property: "openConversationId"},
},
},
@@ -5452,6 +5502,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
_ = chatMessageReplyCmd.MarkFlagRequired("text")
chatMessageReplyCmd.Flags().String("uuid", "", "幂等键(可选)")
chatMessageReplyCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)")
chatMessageReplyCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐)")
chatMessageReplyCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效;正文缺少对应 <@id> 时自动补齐,裸 @id 自动规范化)")
cli.AttachRuntimeSchema(chatMessageReplyCmd, "chat", "reply_personal_message", "hardcoded:chat")
// ── message forward: 转发单条消息 ────────────────────────
@@ -8166,5 +8218,11 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
// Keep the v1.0.56 command surface recognizable while directing callers to
// the supported nested commands. The chat root's "im" alias makes these
// compatibility hints available through both chat and im.
root.AddCommand(chatCompatibilityHintSubCmd("send", "use: dws chat message send"))
root.AddCommand(chatCompatibilityHintSubCmd("history", "use: dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"))
return root
}
+21 -12
View File
@@ -81,24 +81,33 @@ func TestCrossPlatformCoverageEvaluationRegressionChatSearchSpellingsAndNaturalB
})
}
func TestCrossPlatformCoverageChatMisroutedPathsRemainUnknownSubcommands(t *testing.T) {
func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *testing.T) {
root := newChatCommand()
if len(root.Aliases) != 1 || root.Aliases[0] != "im" {
t.Fatalf("chat aliases = %v, want [im]", root.Aliases)
}
for _, tc := range []struct {
path string
flag string
args []string
hint string
}{
{path: "send", flag: "--group"},
{path: "history", flag: "--group"},
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
} {
caller := &productExampleCaller{}
err := runChatCoverageCommand(t, caller, tc.path, tc.flag, "cid")
if err == nil || !strings.Contains(err.Error(), "unknown command") || !strings.Contains(err.Error(), tc.path) {
t.Fatalf("chat %s error = %v, want unknown command", tc.path, err)
command, remaining, err := root.Find([]string{tc.path})
if err != nil {
t.Fatalf("find chat %s: %v", tc.path, err)
}
if strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("chat %s was misreported as a flag error: %v", tc.path, err)
if len(remaining) != 0 || command.Name() != tc.path {
t.Fatalf("find chat %s = command %q, remaining %v", tc.path, command.Name(), remaining)
}
if caller.calls != 0 {
t.Fatalf("chat %s tool calls = %d, want 0", tc.path, caller.calls)
if !command.Hidden || !command.Runnable() {
t.Fatalf("chat %s compatibility contract: hidden=%v runnable=%v", tc.path, command.Hidden, command.Runnable())
}
root.SetArgs(tc.args)
err = root.ExecuteContext(context.Background())
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint)
}
}
}
@@ -15,6 +15,7 @@ package helpers
import (
"context"
"encoding/json"
"io"
"os"
"reflect"
@@ -285,6 +286,150 @@ func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
}
}
func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T) {
tests := []struct {
name string
args []string
contentField string
wantContent string
wantAtAll bool
wantOpenIDs []string
}{
{
name: "send",
args: []string{
"message", "send", "--group", "cid",
"--text", "收到 @D-target 和 <@D-second>",
"--at-open-dingtalk-ids", "D-target,D-second",
"--at-all",
},
contentField: "text",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
},
{
name: "send keeps missing member placeholders unchanged",
args: []string{
"message", "send", "--group", "cid",
"--text", "DWS 发消息自测",
"--at-open-dingtalk-ids", "D-target",
},
contentField: "text",
wantContent: "DWS 发消息自测",
wantOpenIDs: []string{"D-target"},
},
{
name: "reply",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "收到 @D-target 和 <@D-second>",
"--at-open-dingtalk-ids", "D-target,D-second",
"--at-all",
},
contentField: "content",
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
wantAtAll: true,
wantOpenIDs: []string{"D-target", "D-second"},
},
{
name: "reply adds missing member placeholders",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "DWS 回复艾特前津(非主用)自测",
"--at-open-dingtalk-ids", "D-target,D-second,D-target",
},
contentField: "content",
wantContent: "<@D-target> <@D-second> DWS 回复艾特前津(非主用)自测",
wantOpenIDs: []string{"D-target", "D-second", "D-target"},
},
{
name: "reply adds missing member placeholders after at-all",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "请大家确认",
"--at-open-dingtalk-ids", "D-target",
"--at-all",
},
contentField: "content",
wantContent: "<@all> <@D-target> 请大家确认",
wantAtAll: true,
wantOpenIDs: []string{"D-target"},
},
{
name: "reply at-all preserves alliance word",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "联系 @alliance",
"--at-all",
},
contentField: "content",
wantContent: "<@all> 联系 @alliance",
wantAtAll: true,
},
{
name: "reply without at flags preserves alliance word",
args: []string{
"message", "reply",
"--conversation-id", "cid",
"--ref-msg-id", "mid",
"--ref-sender", "D-sender",
"--text", "联系 @alliance",
},
contentField: "content",
wantContent: "联系 @alliance",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &chatChangedContractCaller{}
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
t.Fatalf("calls = %#v", caller.calls)
}
args := caller.calls[0].args
gotAtAll, hasAtAll := args["atAll"]
if tc.wantAtAll {
if !hasAtAll || gotAtAll != true {
t.Fatalf("atAll = %#v, present = %v; want true", gotAtAll, hasAtAll)
}
} else if hasAtAll {
t.Fatalf("atAll = %#v; want absent", gotAtAll)
}
gotOpenIDs, hasOpenIDs := args["atOpenDingTalkIds"]
if len(tc.wantOpenIDs) > 0 {
if !hasOpenIDs || !reflect.DeepEqual(gotOpenIDs, tc.wantOpenIDs) {
t.Fatalf("atOpenDingTalkIds = %#v, present = %v; want %#v", gotOpenIDs, hasOpenIDs, tc.wantOpenIDs)
}
} else if hasOpenIDs {
t.Fatalf("atOpenDingTalkIds = %#v; want absent", gotOpenIDs)
}
var content map[string]string
if err := json.Unmarshal([]byte(args["content"].(string)), &content); err != nil {
t.Fatal(err)
}
if got := content[tc.contentField]; got != tc.wantContent {
t.Fatalf("content[%q] = %q; want %q", tc.contentField, got, tc.wantContent)
}
})
}
}
func TestCrossPlatformCoverageChatSendFailsClosedWhenUserCannotResolve(t *testing.T) {
caller := &chatChangedContractCaller{}
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
+80 -6
View File
@@ -310,6 +310,44 @@ func newContactUserUpdateSelfCommand() *cobra.Command {
return cmd
}
func newContactUserUpdateOwnnessCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update-ownness",
Aliases: []string{"set-ownness"},
Short: "更新用户个人状态",
Long: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)。执行前需要确认,自动化场景在用户明确授权后传 --yes。",
Example: ` dws contact user update-ownness --user-id user001 --ownness-text "居家办公中"`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := validateRequiredFlagWithAliases(cmd, "user-id", "id", "userid", "userId"); err != nil {
return err
}
userID := strings.TrimSpace(flagOrFallback(cmd, "user-id", "id", "userid", "userId"))
if userID == "" {
return fmt.Errorf("--user-id 不能为空")
}
if err := validateRequiredFlagWithAliases(cmd, "ownness-text", "ownnessText"); err != nil {
return err
}
ownnessText := strings.TrimSpace(flagOrFallback(cmd, "ownness-text", "ownnessText"))
if ownnessText == "" {
return fmt.Errorf("--ownness-text 不能为空")
}
return callMCPTool("user_ownness_update", map[string]any{
"userId": userID,
"ownnessText": ownnessText,
})
},
}
cmd.Flags().String("user-id", "", "要更新个人状态的用户 userId (必填)")
cmd.Flags().String("id", "", "--user-id 的别名")
cmd.Flags().String("userid", "", "--user-id 的别名")
_ = cmd.Flags().MarkHidden("id")
_ = cmd.Flags().MarkHidden("userid")
cmd.Flags().String("ownness-text", "", "个人状态文本 (必填),如 \"居家办公中\"")
cli.AnnotateRuntimeRequiredFlags(cmd, "user-id", "ownness-text")
return cmd
}
func newContactAccountUpdateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "update",
@@ -391,7 +429,7 @@ func newContactCommand() *cobra.Command {
通讯录功能:
- contact user get-self/search/search-mobile/get: 通讯录用户查询
- contact user invite/update/update-self: 邀请与更新员工
- contact user invite/update/update-self/update-ownness: 邀请与更新员工
- contact dept search/get-info/list-children/list-members/create/update: 部门查询与管理
- contact relation list-my-followings: 特别关注人查询
@@ -414,6 +452,7 @@ func newContactCommand() *cobra.Command {
- 查询用户的部门、主管、管理员权限 → contact user get
- 修改员工信息(姓名 / 部门 / 直属主管) → contact user update
- 更新当前用户自己的 profile(昵称 / 头像) → contact user update-self
- 更新用户个人状态(如「居家办公中」) → contact user update-ownness
- 邀请员工加入企业 → contact user invite
- 查询用户的学历、家庭、银行卡、合同等档案 → contact user profile get
- 查询离职员工列表 → contact user dismission search`,
@@ -1355,6 +1394,40 @@ contact user profile fields 获取可用字段列表。
},
},
})
contactUserUpdateOwnnessCmd := newContactUserUpdateOwnnessCommand()
DeclareLeafMetadata(contactUserUpdateOwnnessCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "contact",
Name: "user_ownness_update",
CanonicalPath: "contact.user_ownness_update",
CLIPath: "contact user update-ownness",
PrimaryCLIPath: "contact user update-ownness",
},
Description: "更新指定用户的个人状态文本(展示在个人资料与聊天会话中,如「居家办公中」)",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed unpinned remote adapter: the executable CLI maps personal-status update flags to contact/user_ownness_update, which is absent from the pinned MCP metadata snapshot.",
},
Selection: contract.SelectionSpec{
AgentSummary: "更新指定用户的个人状态文本(如「居家办公中」)",
UseWhen: []string{"用户明确要求设置或修改自己/指定用户的个人状态文本,且已确认目标 userId 和状态内容"},
AvoidWhen: []string{"修改员工组织信息(姓名 / 部门 / 主管)应使用 contact user update;修改当前用户昵称或头像应使用 contact user update-self"},
Examples: []string{"dws contact user update-ownness --user-id user001 --ownness-text \"居家办公中\""},
},
Parameters: []contract.ParamDecl{
{Name: "id", Property: "userId", Required: boolPtr(false)},
{Name: "ownness-text", Property: "ownnessText", Required: boolPtr(true)},
{Name: "user-id", Property: "userId", Required: boolPtr(true)},
{Name: "userid", Property: "userId", Required: boolPtr(false)},
},
},
})
// ── flags 注册 ───────────────────────────────────────────────
contactUserSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
@@ -1372,11 +1445,12 @@ contact user profile fields 获取可用字段列表。
_ = contactUserGetCmd.Flags().MarkHidden("userid")
userCmd.AddCommand(
contactUserGetSelfCmd, contactUserSearchCmd, contactUserSearchMobileCmd, contactUserGetCmd,
contactUserInviteCmd, // 邀请员工加入企业
contactUserUpdateCmd, // 修改员工信息
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
contactUserProfileCmd, // 花名册档案
contactUserDismissionCmd, // 离职员工
contactUserInviteCmd, // 邀请员工加入企业
contactUserUpdateCmd, // 修改员工信息
contactUserUpdateSelfCmd, // 更新当前用户自己的 profile 信息
contactUserUpdateOwnnessCmd, // 更新用户个人状态
contactUserProfileCmd, // 花名册档案
contactUserDismissionCmd, // 离职员工
)
contactDeptSearchCmd.Flags().String("query", "", "搜索关键词 (必填)")
@@ -48,6 +48,7 @@ func TestCrossPlatformCoverageContactUpdateCommandsExposeExpectedFlags(t *testin
{[]string{"dept", "update"}, []string{"dept", "name", "parent"}},
{[]string{"user", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id"}},
{[]string{"user", "update-self"}, []string{"nick", "avatar-file-id"}},
{[]string{"user", "update-ownness"}, []string{"user-id", "ownness-text"}},
{[]string{"account", "update"}, []string{"user-id", "org-user-name", "depts", "master-user-id", "nick", "avatar-file-id"}},
}
for _, tc := range cases {
@@ -98,6 +99,18 @@ func TestCrossPlatformCoverageContactUpdateCommandsMapMCPArguments(t *testing.T)
toolName: "self_user_profile_update",
wantArgs: map[string]any{"nick": "新昵称", "avatarFileId": "file-1"},
},
{
name: "update user ownness",
args: []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中", "--yes"},
toolName: "user_ownness_update",
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "居家办公中"},
},
{
name: "update user ownness with aliases",
args: []string{"user", "set-ownness", "--userId", "user-1", "--ownnessText", "专注开发中", "--yes"},
toolName: "user_ownness_update",
wantArgs: map[string]any{"userId": "user-1", "ownnessText": "专注开发中"},
},
{
name: "update enterprise account",
args: []string{"account", "edit", "--user-id", "user-2", "--org-user-name", "李四", "--depts", `[{"deptId":2}]`, "--master-user-id", "manager-2", "--nick", "小李", "--avatar-file-id", "file-2", "--yes"},
@@ -139,6 +152,7 @@ func TestCrossPlatformCoverageContactUpdateCommandsRequireConfirmation(t *testin
{"dept", "update", "--dept", "7", "--name", "研发中心"},
{"user", "update", "--user-id", "user-1", "--org-user-name", "张三"},
{"user", "update-self", "--nick", "新昵称"},
{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", "居家办公中"},
{"account", "update", "--user-id", "user-2", "--nick", "小李"},
}
for _, args := range tests {
@@ -174,6 +188,10 @@ func TestCrossPlatformCoverageContactUpdateCommandsValidateInput(t *testing.T) {
{"employee no changes", []string{"user", "update", "--user-id", "user-1", "--org-user-name", " ", "--depts", " ", "--master-user-id", " ", "--yes"}, "至少需要一个修改项"},
{"employee invalid departments", []string{"user", "update", "--user-id", "user-1", "--depts", "bad", "--yes"}, "--depts JSON 解析失败"},
{"self no changes", []string{"user", "update-self", "--nick", " ", "--avatar-file-id", " ", "--yes"}, "至少需要一个修改项"},
{"ownness missing id", []string{"user", "update-ownness", "--ownness-text", "居家办公中", "--yes"}, "required"},
{"ownness blank id", []string{"user", "update-ownness", "--user-id", " ", "--ownness-text", "居家办公中", "--yes"}, "不能为空"},
{"ownness missing text", []string{"user", "update-ownness", "--user-id", "user-1", "--yes"}, "required"},
{"ownness blank text", []string{"user", "update-ownness", "--user-id", "user-1", "--ownness-text", " ", "--yes"}, "不能为空"},
{"account missing id", []string{"account", "update", "--nick", "小李", "--yes"}, "required"},
{"account blank id", []string{"account", "update", "--user-id", " ", "--nick", "小李", "--yes"}, "不能为空"},
{"account no changes", []string{"account", "update", "--user-id", "user-2", "--nick", " ", "--yes"}, "至少需要一个修改项"},
+53 -3
View File
@@ -816,6 +816,8 @@ func newDocCommand() *cobra.Command {
dws doc create 创建文档
dws doc update 更新文档内容
dws doc block [list|insert|update|delete] 块级编辑
dws doc whiteboard insert 插入空白板卡片 (返回 blockId 与白板 partId)
dws doc media [upload|download] 文档媒体资源 (上传可复用资源 / 下载附件)
dws doc comment [list|create|reply|update|delete|create-inline] 文档评论管理
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
dws doc export get 查询导出任务结果 (手动兜底)
@@ -2525,6 +2527,54 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
mediaDownloadCmd.Flags().String("node", "", "目标文档的标识,支持传入 URL 或 ID (必填)")
mediaDownloadCmd.Flags().String("resource-id", "", "附件资源 ID,可通过 dws doc block list 获取 (必填)")
mediaUploadCmd := &cobra.Command{
Use: "upload",
Short: "上传可复用的文档媒体资源",
Long: `将本地文件上传为绑定到目标 nodeId 的文档媒体资源,但不插入文档正文。
成功输出稳定的 resourceId 和 resourceUrl,可供同一 nodeId 下的白板 Vector/SVG
等后续写入使用;临时 uploadUrl 不会输出。`,
Example: ` dws doc media upload --node DOC_ID --file ./icon.svg --mime-type image/svg+xml --format json`,
RunE: runDocMediaUpload,
}
DeclareLeafMetadata(mediaUploadCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: "media_upload",
CanonicalPath: "doc.media_upload",
CLIPath: "doc media upload",
PrimaryCLIPath: "doc media upload",
},
Description: "上传可复用的文档媒体资源",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令先获取临时文档上传凭证,再在本地执行 OSS PUT,并仅暴露稳定的 node 绑定资源契约,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后上传绑定到文档 nodeId 的可复用媒体资源而不插入正文",
UseWhen: []string{"为同一文档内白板的 Vector/SVG 写入准备 resourceId 和 resourceUrl 时"},
AvoidWhen: []string{"需要把附件直接插入文档正文时用 doc media insert;不要跨 nodeId 复用资源"},
Examples: []string{"dws doc media upload --node <DOC_ID> --file ./icon.svg --mime-type image/svg+xml --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "file", Required: boolPtr(true)},
},
},
})
mediaUploadCmd.Flags().String("node", "", "绑定媒体资源的文档标识,支持传入 URL 或 ID (必填)")
mediaUploadCmd.Flags().String("file", "", "本地文件路径 (必填)")
mediaUploadCmd.Flags().String("name", "", "资源文件名 (默认使用本地文件名)")
mediaUploadCmd.Flags().String("mime-type", "", "文件 MIME 类型 (默认根据扩展名推断)")
mediaUploadCmd.Flags().Bool("yes", false, "确认上传可复用文档媒体资源")
mediaInsertCmd := &cobra.Command{
Use: "insert",
Short: "上传附件并插入文档",
@@ -2587,7 +2637,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
mediaInsertCmd.Flags().String("ref-block", "", "参考块 ID (配合 --where)")
// media 子命令的 --node 隐藏别名
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaInsertCmd}
mediaNodeAliasCmds := []*cobra.Command{mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd}
for _, c := range mediaNodeAliasCmds {
c.Flags().String("url", "", "--node 的别名")
c.Flags().String("id", "", "--node 的别名")
@@ -2601,7 +2651,7 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表
_ = c.Flags().MarkHidden("file-id")
}
mediaCmd.AddCommand(mediaDownloadCmd, mediaInsertCmd)
mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd)
// ── comment (文档评论) ──────────────────────────────────
commentCmd := &cobra.Command{
@@ -4227,7 +4277,7 @@ CLI 内部自动完成全部流程:
folderCmd.Hidden = true
permissionCmd.Hidden = true
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand())
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd, newDocStyleCommand(), newDocWhiteboardCommand())
return root
}
+85
View File
@@ -0,0 +1,85 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
// runDocMediaUpload 上传绑定到文档 nodeId 的可复用媒体资源,但不插入正文块。
// 白板 Vector/SVG 使用返回的 resourceId 与 resourceUrl 引用同一文档下的资源。
func runDocMediaUpload(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
filePath := mustGetFlag(cmd, "file")
if filePath == "" {
return fmt.Errorf("flag --file is required")
}
fileInfo, err := os.Stat(filePath)
if err != nil {
return fmt.Errorf("cannot read file %s: %w", filePath, err)
}
if fileInfo.IsDir() {
return fmt.Errorf("%s is a directory, not a file", filePath)
}
fileName, _ := cmd.Flags().GetString("name")
if fileName == "" {
fileName = filepath.Base(filePath)
} else if filepath.Ext(fileName) == "" {
fileName += filepath.Ext(filePath)
}
mimeType, _ := cmd.Flags().GetString("mime-type")
if mimeType == "" {
mimeType = inferMimeType(fileName)
}
if deps.Caller.DryRun() {
return callMCPToolOnServer("doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": fileName,
"fileSize": float64(fileInfo.Size()),
"mimeType": mimeType,
})
}
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
// 推迟到首次 deps.Caller.CallTool(下方 get_doc_attachment_upload_info),
// 避免与门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
text, err := callMCPToolReturnTextOnServer(cmd.Context(), "doc", "get_doc_attachment_upload_info", map[string]any{
"nodeId": nodeID,
"fileName": fileName,
"fileSize": float64(fileInfo.Size()),
"mimeType": mimeType,
})
if err != nil {
return err
}
uploadURL, resourceID, resourceURL, err := parseAttachmentUploadInfo(text)
if err != nil {
return err
}
if resourceURL == "" {
return fmt.Errorf("incomplete attachment upload info: missing resourceUrl")
}
if err := httpPutFile(cmd.Context(), uploadURL, map[string]string{"Content-Type": mimeType}, filePath, fileInfo.Size()); err != nil {
message := strings.ReplaceAll(err.Error(), uploadURL, "<redacted upload URL>")
return fmt.Errorf("document media upload failed: %s", message)
}
return deps.Out.PrintJSON(map[string]any{
"nodeId": nodeID,
"resourceId": resourceID,
"resourceUrl": resourceURL,
"fileName": fileName,
"mimeType": mimeType,
"size": fileInfo.Size(),
})
}
+24
View File
@@ -0,0 +1,24 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package helpers
import "github.com/spf13/cobra"
// RunDocImportShortcut exposes the existing, fully-tested Doc import pipeline
// to the Shortcut application layer. The Cobra leaf still owns its own flags
// and Contract; this bridge only shares the raw/API execution primitive.
func RunDocImportShortcut(cmd *cobra.Command) error {
return runImportCommand(cmd, nil, docImportFlowConfig())
}
// RunDocMediaInsertShortcut shares the existing prepare + OSS PUT + block
// insertion implementation with the canonical Doc Shortcut.
func RunDocMediaInsertShortcut(cmd *cobra.Command) error {
return runMediaInsert(cmd, nil)
}
// RunDocResourceUpdateShortcut shares the cover upload/transfer pipeline.
func RunDocResourceUpdateShortcut(cmd *cobra.Command) error {
return runDocStyleCoverSet(cmd, nil)
}
+284
View File
@@ -0,0 +1,284 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
const (
whiteboardDrawPluginType = "application/x-alidocs-plugin-draw"
whiteboardDefaultHeight = 600
)
// errWhiteboardBlockPending 标记「块查询成功但目标块尚不可见」这一最终一致性场景。
// 只有它允许插入后回查退化成 soft success;鉴权失败、MCP 错误、响应/JSONML 解析失败
// 都是硬失败,必须 fail-closed,否则 Agent 会把它误判成最终一致性并带着空 partId
// 继续调用 whiteboard query/update。
var errWhiteboardBlockPending = errors.New("whiteboard card block is not visible yet")
var (
whiteboardRetryDelays = []time.Duration{500 * time.Millisecond, time.Second, 2 * time.Second}
whiteboardSleep = time.Sleep
whiteboardJSONMarshal = json.Marshal
prepareWhiteboardCard = prepareJsonMLNode
)
func buildWhiteboardCardJSONML(blockUUID, whiteboardID string) string {
node := []any{
"card",
map[string]any{
"uuid": blockUUID,
"cardType": "hetu",
"height": whiteboardDefaultHeight,
"metadata": map[string]any{"type": whiteboardDrawPluginType, "id": whiteboardID},
},
[]any{"span", map[string]any{"data-type": "text"},
[]any{"span", map[string]any{"data-type": "leaf"}, ""}},
}
out, err := whiteboardJSONMarshal(node)
if err != nil {
return ""
}
return string(out)
}
func extractWhiteboardID(attrs map[string]any) string {
meta, _ := attrs["metadata"].(map[string]any)
if meta == nil {
return ""
}
id, _ := meta["id"].(string)
return id
}
func queryWhiteboardCardNode(ctx context.Context, nodeID, blockID string) ([]any, error) {
text, err := callMCPToolReturnTextOnServer(ctx, "doc", "list_document_blocks", map[string]any{
"nodeId": nodeID,
"blockId": blockID,
"format": "jsonml",
})
if err != nil {
return nil, err
}
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return nil, fmt.Errorf("parse list_document_blocks response: %w", err)
}
if result, ok := data["result"].(map[string]any); ok {
data = result
}
blocksField, ok := data["blocks"]
if !ok {
return nil, fmt.Errorf("list_document_blocks 响应缺少 blocks 字段")
}
blocks, ok := blocksField.([]any)
if !ok {
return nil, fmt.Errorf("list_document_blocks 响应的 blocks 字段不是数组")
}
var raw string
for _, block := range blocks {
entry, _ := block.(map[string]any)
if entry == nil || entry["blockId"] != blockID {
continue
}
raw, _ = entry["jsonml"].(string)
break
}
if raw == "" {
return nil, fmt.Errorf("块 %s 不存在或查询无结果: %w", blockID, errWhiteboardBlockPending)
}
var node []any
if err := json.Unmarshal([]byte(raw), &node); err != nil {
return nil, fmt.Errorf("parse block jsonml: %w", err)
}
return node, nil
}
func queryWhiteboardCardAttrs(ctx context.Context, nodeID, blockID string) (map[string]any, error) {
node, err := queryWhiteboardCardNode(ctx, nodeID, blockID)
if err != nil {
return nil, err
}
if len(node) < 2 {
return nil, fmt.Errorf("块 %s 的 jsonml 节点缺少 attrs", blockID)
}
attrs, _ := node[1].(map[string]any)
if attrs == nil {
return nil, fmt.Errorf("块 %s 的 jsonml attrs 不是对象", blockID)
}
return attrs, nil
}
func runWhiteboardInsert(cmd *cobra.Command, _ []string) error {
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
blockUUID := uuid.New().String()
whiteboardID := uuid.New().String()
element := buildWhiteboardCardJSONML(blockUUID, whiteboardID)
normalized, err := prepareWhiteboardCard(cmd, element)
if err != nil {
return fmt.Errorf("内部错误: 白板卡片模板未通过 JSONML 校验: %w", err)
}
toolArgs := map[string]any{
"nodeId": nodeID,
"jsonml": normalized,
"format": "jsonml",
}
// --ref-block 与 --parent-block 已由 MarkFlagsMutuallyExclusive 保证互斥,
// 这里用 else if 让「只有一条定位分支会写 referenceBlockId/where」在代码上自证。
if v, _ := cmd.Flags().GetString("ref-block"); v != "" {
toolArgs["referenceBlockId"] = v
where, _ := cmd.Flags().GetString("where")
if where == "" {
where = "after"
}
toolArgs["where"] = where
} else if v, _ := cmd.Flags().GetString("parent-block"); v != "" {
toolArgs["referenceBlockId"] = v
}
if cmd.Flags().Changed("index") {
index, _ := cmd.Flags().GetInt("index")
toolArgs["index"] = index
}
if deps.Caller.DryRun() {
return callMCPToolOnServer("doc", "insert_document_block", toolArgs)
}
// 用户确认由 DeclareLeafMetadata(user_required) 的 ConfirmSafety 门控接管:
// 推迟到首次 deps.Caller.CallTool(下方 insert_document_block),避免与
// 门控双读 stdin。--yes / --dry-run 经 confirmationBypass 跳过。
ctx := cmd.Context()
deps.Out.PrintProgress("[1/2] 插入白板卡片...")
if _, err := callMCPToolReturnTextOnServer(ctx, "doc", "insert_document_block", toolArgs); err != nil {
return err
}
deps.Out.PrintProgress("[2/2] 验证白板资源 ID 落库...")
persistedID := ""
for attempt := 0; attempt <= len(whiteboardRetryDelays); attempt++ {
attrs, queryErr := queryWhiteboardCardAttrs(ctx, nodeID, blockUUID)
switch {
case queryErr == nil:
// 块已可见;metadata.id 仍可能未落库,交给下方 soft success 分支重试。
persistedID = extractWhiteboardID(attrs)
case errors.Is(queryErr, errWhiteboardBlockPending):
// 块暂不可见,属于最终一致性,继续重试。
default:
// 查询本身失败(鉴权 / MCP / 响应解析),不是最终一致性:
// 必须 fail-closed,同时带出已插入的 blockId 供人工或后续回查复原。
return fmt.Errorf(
"白板卡片已插入 (blockId=%s),但回查验证失败,无法确认 whiteboardId: %w",
blockUUID, queryErr)
}
if persistedID != "" {
break
}
if attempt < len(whiteboardRetryDelays) {
whiteboardSleep(whiteboardRetryDelays[attempt])
}
}
result := map[string]any{"blockId": blockUUID}
if persistedID == "" {
result["whiteboardId"] = nil
deps.Out.PrintWarning(fmt.Sprintf(
"白板已插入但未验证到 whiteboardId 落库,可稍后回查: dws doc block list --node %s --content-format jsonml --block-id %s",
nodeID, blockUUID))
} else {
result["whiteboardId"] = persistedID
}
return deps.Out.PrintJSON(map[string]any{"success": true, "result": result})
}
func newDocWhiteboardCommand() *cobra.Command {
root := &cobra.Command{
Use: "whiteboard",
Short: "白板卡片管理",
Long: `管理钉钉文档中的白板卡片:插入空白板并获取白板资源 ID。删除白板卡片请使用 dws doc block delete。`,
RunE: groupRunE,
}
insertCmd := &cobra.Command{
Use: "insert",
Short: "插入白板卡片",
Long: `向文档插入一个空白板卡片(hetu draw card),并返回 blockId 与 whiteboardId。
CLI 生成卡片块 UUID 与白板资源 ID,插入后按块 UUID 回查并验证 metadata.id 落库。
如果块暂不可见或 metadata.id 尚未落库,插入仍成功并返回 blockId,whiteboardId 为 null。
如果回查本身失败(鉴权 / MCP 错误 / 响应解析失败),命令报错并在错误中带出已插入的 blockId。
定位方式互斥: --ref-block(配合 --where 同级插入)与 --parent-block(配合 --index 容器内插入)
不能同时使用。`,
Example: ` dws doc whiteboard insert --node DOC_ID
dws doc whiteboard insert --node DOC_ID --ref-block BLOCK_ID --where before
dws doc whiteboard insert --node DOC_ID --parent-block PARENT_ID --index 2`,
RunE: runWhiteboardInsert,
}
insertCmd.Flags().String("node", "", "文档 ID 或 URL (必填)")
insertCmd.Flags().String("ref-block", "", "参照块 UUID(同级插入,配合 --where)")
insertCmd.Flags().String("where", "", "插入方向: before / after (默认 after,配合 --ref-block)")
insertCmd.Flags().String("parent-block", "", "父容器 UUID(容器内插入,与 --index 配合)")
insertCmd.Flags().Int("index", 0, "位置索引 (从 0 开始)")
insertCmd.Flags().Bool("yes", false, "确认插入白板卡片")
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者会让 parent 静默
// 覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。显式互斥而非静默取舍。
insertCmd.MarkFlagsMutuallyExclusive("ref-block", "parent-block")
insertCmd.MarkFlagsMutuallyExclusive("where", "parent-block")
for _, name := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
insertCmd.Flags().String(name, "", "--node 的兼容别名")
_ = insertCmd.Flags().MarkHidden(name)
}
DeclareLeafMetadata(insertCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium",
Confirmation: "user_required", Idempotency: "non_idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: "whiteboard_insert",
CanonicalPath: "doc.whiteboard_insert",
CLIPath: "doc whiteboard insert",
PrimaryCLIPath: "doc whiteboard insert",
},
Description: "向文档插入空白板卡片并返回块 ID 与白板 part ID",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令生成卡片与白板 UUID、插入规范 JSONML,再回读块验证 metadata.id,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后向钉钉文档插入空白板卡片并返回块 ID 与白板 part ID",
UseWhen: []string{"目标文档还没有可操作白板,需要创建空白板卡片并取得后续 query/update 使用的 partId 时"},
AvoidWhen: []string{"已有白板只需读取或编辑时使用 whiteboard query/update;删除卡片使用 doc block delete"},
Examples: []string{"dws doc whiteboard insert --node <DOC_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
},
},
})
root.AddCommand(insertCmd)
return root
}
+3 -1
View File
@@ -66,6 +66,8 @@ var (
driveFileStat = (*os.File).Stat
)
var driveWorkerContextErr = func(ctx context.Context) error { return ctx.Err() }
// ──────────────────────────────────────────────────────────
// HTTP 状态错误
// ──────────────────────────────────────────────────────────
@@ -629,7 +631,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP
go func() {
defer wg.Done()
for part := range jobs {
if runCtx.Err() != nil {
if driveWorkerContextErr(runCtx) != nil {
return
}
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
+25 -39
View File
@@ -13,6 +13,8 @@ import (
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// ──────────────────────────────────────────────────────────
@@ -2868,48 +2870,32 @@ func TestCrossPlatformCoverageDriveDownloadVersionCancelNoResume(t *testing.T) {
func TestCrossPlatformCoverageDriveTransferWorkerCtxCancelBeforeProcess(t *testing.T) {
// 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。
// 策略:让 workers 正常处理分片,通过 context timeout 在处理过程中过期。
// 当 worker 完成某个分片后循环回来收到新 job 时,发现 runCtx 已取消。
// transport 每次请求加 50μs 延迟,使总处理时间接近 timeout,最大化命中率。
totalSize := int64(200)
content := makeTestContent(int(totalSize))
origClient := driveRangeClient
t.Cleanup(func() { driveRangeClient = origClient })
driveRangeClient = &http.Client{
// 通过结构化 seam 让 worker 在收到唯一分片后确定性观察到取消状态;
// 不再依赖微秒级 timeout 与 goroutine 调度概率。
var checks atomic.Int32
testseam.Swap(t, &driveWorkerContextErr, func(context.Context) error {
checks.Add(1)
return context.Canceled
})
var requests atomic.Int32
testseam.Swap(t, &driveRangeClient, &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
// 每次请求加小延迟,让总处理时间接近 deadline
time.Sleep(50 * time.Microsecond)
var start, end int64
if _, err := fmt.Sscanf(req.Header.Get("Range"), "bytes=%d-%d", &start, &end); err != nil {
return &http.Response{StatusCode: 400, Body: io.NopCloser(strings.NewReader("bad"))}, nil
}
if end >= int64(len(content)) {
end = int64(len(content)) - 1
}
resp := &http.Response{
StatusCode: http.StatusPartialContent,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(string(content[start : end+1]))),
}
resp.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content)))
return resp, nil
requests.Add(1)
return nil, errors.New("worker context guard did not stop the request")
}),
})
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
dest := filepath.Join(t.TempDir(), "worker-context-guard.bin")
opts := driveDownloadOptions{partSize: 1, parallel: 1, resume: false, knownSize: 1}
if err := downloadRangedParts(context.Background(), creds, dest, 1, opts); err != nil {
t.Fatalf("downloadRangedParts context guard: %v", err)
}
// 多次尝试以确保覆盖(goroutine 调度非确定性)
for attempt := 0; attempt < 50; attempt++ {
// timeout 设为约为总处理时间的50%,确保在处理过程中过期
// 40分片/4workers=10轮*50μs=500μs,timeout设300μs使其在中间过期
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Microsecond)
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
dest := filepath.Join(t.TempDir(), fmt.Sprintf("wkr-%d.bin", attempt))
opts := driveDownloadOptions{partSize: 5, parallel: 4, resume: false, knownSize: totalSize}
_ = downloadRangedParts(ctx, creds, dest, totalSize, opts)
cancel()
if checks.Load() != 1 {
t.Fatalf("worker context checks = %d, want 1", checks.Load())
}
if requests.Load() != 0 {
t.Fatalf("worker requests = %d, want 0", requests.Load())
}
}
+1 -1
View File
@@ -31,7 +31,7 @@ func TestCrossPlatformCoveragePublicProductCommandsBuildCompleteUniqueTrees(t *t
for _, want := range []string{
"agoal", "aisearch", "aitable", "attendance", "calendar", "chat",
"contact", "devdoc", "ding", "doc", "drive", "live", "mail",
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki",
"markdown", "minutes", "oa", "report", "sheet", "todo", "wiki", "whiteboard",
} {
if !seenProducts[want] {
t.Errorf("public product %q was not registered", want)
+11
View File
@@ -0,0 +1,11 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
// 白板是显式编排的公开命令,不依赖 Wukong 的生成式产品注册表。
func init() {
RegisterPublic(func() Handler {
return wukongHandler{name: "whiteboard", buildFn: newWhiteboardCommand}
})
}
+352
View File
@@ -0,0 +1,352 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
)
const (
whiteboardServerID = "whiteboard"
whiteboardQueryTool = "read_whiteboard_content"
whiteboardUpdateTool = "update_whiteboard"
)
type whiteboardUpdateFile struct {
Overwrite bool `json:"overwrite"`
Source *whiteboardOpenSource `json:"source"`
}
type whiteboardOpenSource struct {
SchemaVersion string `json:"schemaVersion"`
CatalogVersion string `json:"catalogVersion"`
Nodes json.RawMessage `json:"nodes"`
}
var compactWhiteboardJSON = json.Compact
func newWhiteboardCommand() *cobra.Command {
contract.RegisterProductDecl(contract.ProductDecl{
ID: "whiteboard",
Selection: contract.ProductSelectionDecl{
AgentSummary: "读取和更新钉钉在线文档中的内嵌白板",
UseWhen: []string{"操作已有文档内嵌白板的 OpenNodes 内容时"},
AvoidWhen: []string{"普通文档正文和块使用 doc;创建白板卡片先用 doc whiteboard insert"},
},
})
root := &cobra.Command{
Use: "whiteboard",
Short: "钉钉文档内嵌白板管理",
Long: `读取或更新钉钉在线文档中已经存在的内嵌白板。
当前仅支持单页白板。每次操作都必须同时提供文档 ID 或 URL 和白板 part ID;
本命令不负责创建白板(请使用 dws doc whiteboard insert),也不支持通过已有节点 ID 做局部修改。`,
RunE: groupRunE,
}
queryCmd := &cobra.Command{
Use: "query",
Short: "读取白板内容",
Example: ` dws whiteboard query --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --format json`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := rejectWhiteboardOutputFilters(cmd); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "node", "part-id"); err != nil {
return err
}
return callWhiteboardTool(cmd, whiteboardQueryTool, map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"partId": mustGetFlag(cmd, "part-id"),
})
},
}
queryCmd.Flags().String("node", "", "承载白板的钉钉文档 ID 或 URL(必填)")
queryCmd.Flags().String("part-id", "", "文档内白板 part ID(必填)")
DeclareLeafMetadata(queryCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "whiteboard",
Name: "query",
CanonicalPath: "whiteboard.query",
CLIPath: "whiteboard query",
PrimaryCLIPath: "whiteboard query",
},
Description: "读取钉钉文档内已有白板的 OpenNodes 内容",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "白板端点通过显式服务适配器调用并解码 resultJson,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "读取钉钉文档内已有白板的 OpenNodes 内容",
UseWhen: []string{"已知承载文档 nodeId 和白板 partId,需要检查当前白板节点、布局或写入支持时"},
AvoidWhen: []string{"创建新白板卡片用 doc whiteboard insert;缺少 partId 时先从文档 card metadata.id 定位"},
Examples: []string{"dws whiteboard query --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --format json"},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "part-id", Property: "partId", Required: boolPtr(true)},
},
},
})
updateCmd := &cobra.Command{
Use: "update",
Short: "追加或整页重建白板内容",
Long: `从 JSON 文件读取 OpenNodes V1 更新请求并更新已有白板。
更新模式由文件顶层的 overwrite 字段决定。overwrite=false 表示追加,
overwrite=true 表示整页重建。两种模式都会写入远端白板,必须同时传入 --yes。`,
Example: ` dws whiteboard update --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --source ./whiteboard.json --format json
dws whiteboard update --node DOC_ID_OR_URL --part-id WHITEBOARD_PART_ID --source ./overwrite.json --yes --format json`,
RunE: func(cmd *cobra.Command, _ []string) error {
if err := rejectWhiteboardOutputFilters(cmd); err != nil {
return err
}
if err := validateRequiredFlags(cmd, "node", "part-id", "source"); err != nil {
return err
}
input, nodesJSON, err := loadWhiteboardUpdateFile(mustGetFlag(cmd, "source"))
if err != nil {
return err
}
mode := "append"
if input.Overwrite {
mode = "overwrite"
}
return callWhiteboardTool(cmd, whiteboardUpdateTool, map[string]any{
"nodeId": mustGetFlag(cmd, "node"),
"partId": mustGetFlag(cmd, "part-id"),
"mode": mode,
"nodes": nodesJSON,
})
},
}
updateCmd.Flags().String("node", "", "承载白板的钉钉文档 ID 或 URL(必填)")
updateCmd.Flags().String("part-id", "", "文档内白板 part ID(必填)")
updateCmd.Flags().String("source", "", "OpenNodes V1 更新请求 JSON 文件(必填)")
updateCmd.Flags().Bool("yes", false, "确认写入远端白板")
updateExampleIndex := 0
DeclareLeafMetadata(updateCmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "high",
Confirmation: "user_required", Idempotency: "unknown",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "whiteboard",
Name: "update",
CanonicalPath: "whiteboard.update",
CLIPath: "whiteboard update",
PrimaryCLIPath: "whiteboard update",
},
Description: "经用户确认后向已有白板追加 OpenNodes 或整页重建",
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "命令包含本地 OpenNodes 校验、显式白板服务路由与结构化结果解码,不能绑定为单一 interface_ref",
},
Selection: contract.SelectionSpec{
AgentSummary: "经用户确认后向已有白板追加 OpenNodes 或整页重建",
UseWhen: []string{"已有 nodeId、partId 和合规 OpenNodes V1 文件,用户确认后要追加图形、文本、连接线或整页替换时"},
AvoidWhen: []string{"只读取内容用 whiteboard query;创建白板卡片用 doc whiteboard insert;不要用真实节点 ID 做局部修改"},
Examples: []string{"dws whiteboard update --node <DOC_ID> --part-id <WHITEBOARD_PART_ID> --source ./whiteboard.json --format json"},
ExampleDispositions: []contract.ExampleDisposition{{
Index: &updateExampleIndex,
Mode: contract.ExampleDispositionModeContractOnly,
ReasonCode: contract.ExampleDispositionReasonLocalState,
Reason: "运行时需要用户提供可读且通过 OpenNodes V1 校验的本地 JSON 文件",
Reviewed: true,
}},
},
Parameters: []contract.ParamDecl{
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
{Name: "part-id", Property: "partId", Required: boolPtr(true)},
{Name: "source", Required: boolPtr(true)},
},
},
})
root.AddCommand(queryCmd, updateCmd)
return root
}
func rejectWhiteboardOutputFilters(cmd *cobra.Command) error {
for _, name := range []string{"jq", "fields"} {
flag := cmd.Flags().Lookup(name)
if flag == nil {
flag = cmd.InheritedFlags().Lookup(name)
}
if flag != nil && flag.Changed {
return &CLIError{
Code: CodeInvalidParam,
Message: fmt.Sprintf("whiteboard 命令不支持 --%s", name),
Suggestion: "直接读取命令返回的结构化 JSON",
}
}
}
return nil
}
func loadWhiteboardUpdateFile(path string) (*whiteboardUpdateFile, string, error) {
data, err := os.ReadFile(path)
if err != nil {
code := CodeInvalidPath
if os.IsNotExist(err) {
code = CodeFileNotFound
}
return nil, "", &CLIError{
Code: code,
Message: fmt.Sprintf("无法读取白板更新文件 %q", path),
Suggestion: "确认 --source 指向可读的 UTF-8 JSON 文件",
Cause: err,
}
}
var input whiteboardUpdateFile
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
return nil, "", invalidWhiteboardSourceJSON(err)
}
if err := ensureWhiteboardJSONEOF(decoder); err != nil {
return nil, "", invalidWhiteboardSourceJSON(err)
}
if input.Source == nil {
return nil, "", invalidWhiteboardSourceParam("source is required")
}
if input.Source.SchemaVersion != "1.0" {
return nil, "", invalidWhiteboardSourceParam(`source.schemaVersion must be "1.0"`)
}
if input.Source.CatalogVersion != "dml-v1" {
return nil, "", invalidWhiteboardSourceParam(`source.catalogVersion must be "dml-v1"`)
}
nodesJSON, nodeCount, err := validateWhiteboardNodes(input.Source.Nodes)
if err != nil {
return nil, "", err
}
if !input.Overwrite && nodeCount == 0 {
return nil, "", invalidWhiteboardSourceParam("append requires at least one source.nodes item")
}
return &input, nodesJSON, nil
}
func ensureWhiteboardJSONEOF(decoder *json.Decoder) error {
var trailing any
if err := decoder.Decode(&trailing); err == nil {
return fmt.Errorf("multiple JSON values are not allowed")
} else if !errors.Is(err, io.EOF) {
return err
}
return nil
}
func validateWhiteboardNodes(raw json.RawMessage) (string, int, error) {
if len(raw) == 0 || !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
return "", 0, invalidWhiteboardSourceParam("source.nodes must be an array")
}
var nodes []json.RawMessage
if err := json.Unmarshal(raw, &nodes); err != nil {
return "", 0, invalidWhiteboardSourceJSON(err)
}
for i, node := range nodes {
var object map[string]any
if err := json.Unmarshal(node, &object); err != nil || object == nil {
return "", 0, invalidWhiteboardSourceParam(fmt.Sprintf("source.nodes[%d] must be an object", i))
}
}
var compact bytes.Buffer
if err := compactWhiteboardJSON(&compact, raw); err != nil {
return "", 0, invalidWhiteboardSourceJSON(err)
}
return compact.String(), len(nodes), nil
}
func invalidWhiteboardSourceJSON(err error) error {
return &CLIError{
Code: CodeInvalidJSON,
Message: "白板更新文件不是合法的 OpenNodes V1 JSON",
Suggestion: "检查 JSON 语法、未知字段以及 source 对象结构",
Cause: err,
}
}
func invalidWhiteboardSourceParam(message string) error {
return &CLIError{
Code: CodeInvalidParam,
Message: message,
Suggestion: "参考 whiteboard Skill 中的 OpenNodes V1 文件格式",
}
}
func callWhiteboardTool(cmd *cobra.Command, toolName string, args map[string]any) error {
if deps.Caller.DryRun() {
return callMCPToolOnServer(whiteboardServerID, toolName, args)
}
text, err := callMCPToolReturnTextOnServer(cmd.Context(), whiteboardServerID, toolName, args)
if err != nil {
return err
}
if text == "" {
return nil
}
var response map[string]any
decoder := json.NewDecoder(strings.NewReader(text))
decoder.UseNumber()
if err := decoder.Decode(&response); err != nil {
return invalidWhiteboardToolResult(toolName, err)
}
if err := ensureWhiteboardJSONEOF(decoder); err != nil {
return invalidWhiteboardToolResult(toolName, err)
}
if response == nil {
return invalidWhiteboardToolResult(toolName, fmt.Errorf("response must be a JSON object"))
}
if encoded, ok := response["resultJson"].(string); ok && strings.TrimSpace(encoded) != "" {
var result any
resultDecoder := json.NewDecoder(strings.NewReader(encoded))
resultDecoder.UseNumber()
if err := resultDecoder.Decode(&result); err != nil {
return invalidWhiteboardToolResult(toolName, fmt.Errorf("invalid resultJson: %w", err))
}
if err := ensureWhiteboardJSONEOF(resultDecoder); err != nil {
return invalidWhiteboardToolResult(toolName, fmt.Errorf("invalid resultJson: %w", err))
}
response["resultJson"] = result
}
return deps.Out.PrintJSON(response)
}
func invalidWhiteboardToolResult(toolName string, err error) error {
return &CLIError{
Code: CodeMCPToolError,
Message: "白板服务返回了无法解析的 JSON",
Suggestion: "使用 --debug 获取调用信息并联系白板服务维护者",
Operation: whiteboardServerID + "/" + toolName,
Cause: err,
}
}
@@ -0,0 +1,310 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestWhiteboardInjectedEncodingFailures(t *testing.T) {
previousMarshal := whiteboardJSONMarshal
whiteboardJSONMarshal = func(any) ([]byte, error) { return nil, errors.New("marshal") }
if got := buildWhiteboardCardJSONML("b", "w"); got != "" {
t.Fatalf("got %q", got)
}
whiteboardJSONMarshal = previousMarshal
previousPrepare := prepareWhiteboardCard
prepareWhiteboardCard = func(*cobra.Command, string) (string, error) { return "", errors.New("prepare") }
t.Cleanup(func() { prepareWhiteboardCard = previousPrepare })
caller := &whiteboardTestCaller{}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "n", "--yes"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "模板未通过") {
t.Fatalf("err=%v", err)
}
previousCompact := compactWhiteboardJSON
compactWhiteboardJSON = func(*bytes.Buffer, []byte) error { return errors.New("compact") }
t.Cleanup(func() { compactWhiteboardJSON = previousCompact })
if _, _, err := validateWhiteboardNodes(json.RawMessage(`[{"id":"n"}]`)); err == nil {
t.Fatal("expected compact error")
}
}
func TestDocWhiteboardInsertDryRun(t *testing.T) {
caller := &whiteboardTestCaller{dry: true}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "n"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
}
func writeWhiteboardFixture(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "whiteboard.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func TestLoadWhiteboardUpdateFileRejectsInvalidInputs(t *testing.T) {
tests := []struct {
name string
content string
}{
{name: "invalid json", content: `{`},
{name: "trailing value", content: `{}` + ` {}`},
{name: "missing source", content: `{}`},
{name: "schema version", content: `{"source":{"schemaVersion":"2.0","catalogVersion":"dml-v1","nodes":[]}}`},
{name: "catalog version", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"v2","nodes":[]}}`},
{name: "nodes missing", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1"}}`},
{name: "nodes malformed", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[}}`},
{name: "node primitive", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[1]}}`},
{name: "append empty", content: `{"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`},
{name: "unknown field", content: `{"unknown":true}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, _, err := loadWhiteboardUpdateFile(writeWhiteboardFixture(t, test.content)); err == nil {
t.Fatal("expected validation error")
}
})
}
if _, _, err := loadWhiteboardUpdateFile(filepath.Join(t.TempDir(), "missing.json")); err == nil {
t.Fatal("expected missing-file error")
}
if _, _, err := loadWhiteboardUpdateFile(t.TempDir()); err == nil {
t.Fatal("expected directory read error")
}
if _, _, err := validateWhiteboardNodes(json.RawMessage(`[`)); err == nil {
t.Fatal("expected malformed nodes array error")
}
input, nodes, err := loadWhiteboardUpdateFile(writeWhiteboardFixture(t,
`{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`))
if err != nil || !input.Overwrite || nodes != "[]" {
t.Fatalf("input=%#v nodes=%q err=%v", input, nodes, err)
}
}
func TestWhiteboardOutputFiltersAndToolResponseErrors(t *testing.T) {
for _, name := range []string{"jq", "fields"} {
t.Run(name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String(name, "", "")
if err := cmd.Flags().Set(name, ".result"); err != nil {
t.Fatal(err)
}
if err := rejectWhiteboardOutputFilters(cmd); err == nil {
t.Fatal("expected rejected output filter")
}
})
}
responses := []string{
`{`,
`{} {}`,
`null`,
`{"resultJson":"{"}`,
`{"resultJson":"{} {}"}`,
}
for _, response := range responses {
caller := &whiteboardTestCaller{format: "json", response: func(whiteboardTestCall, int) string { return response }}
installWhiteboardTestCaller(t, caller)
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, nil); err == nil {
t.Fatalf("response %q should fail", response)
}
}
caller := &whiteboardTestCaller{dry: true}
installWhiteboardTestCaller(t, caller)
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, map[string]any{"partId": "p"}); err != nil {
t.Fatal(err)
}
caller = &whiteboardTestCaller{response: func(whiteboardTestCall, int) string { return "" }}
installWhiteboardTestCaller(t, caller)
if err := callWhiteboardTool(&cobra.Command{}, whiteboardQueryTool, nil); err != nil {
t.Fatal(err)
}
}
func TestWhiteboardDocumentQueryValidation(t *testing.T) {
tests := []struct {
name string
response string
attrs bool
}{
{name: "invalid response", response: `{`},
{name: "missing block", response: `{"blocks":[]}`},
{name: "non object block", response: `{"blocks":[1]}`},
{name: "invalid jsonml", response: `{"blocks":[{"blockId":"b","jsonml":"{"}]}`},
{name: "missing attrs", response: `{"blocks":[{"blockId":"b","jsonml":"[]"}]}`, attrs: true},
{name: "attrs not object", response: `{"blocks":[{"blockId":"b","jsonml":"[\"card\",1]"}]}`, attrs: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &whiteboardTestCaller{response: func(whiteboardTestCall, int) string { return test.response }}
installWhiteboardTestCaller(t, caller)
var err error
if test.attrs {
_, err = queryWhiteboardCardAttrs(context.Background(), "n", "b")
} else {
_, err = queryWhiteboardCardNode(context.Background(), "n", "b")
}
if err == nil {
t.Fatal("expected query validation error")
}
})
}
caller := &whiteboardTestCaller{err: func(whiteboardTestCall, int) error { return errors.New("boom") }}
installWhiteboardTestCaller(t, caller)
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
t.Fatal("expected caller error")
}
}
func TestWhiteboardCommandValidationBranches(t *testing.T) {
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
for _, args := range [][]string{
{"query", "--node", "n"},
{"query", "--node", "n", "--part-id", "p", "--jq", "."},
{"update", "--node", "n", "--part-id", "p"},
{"update", "--node", "n", "--part-id", "p", "--fields", "result"},
} {
cmd := newWhiteboardCommand()
cmd.PersistentFlags().String("jq", "", "")
cmd.PersistentFlags().String("fields", "", "")
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil {
t.Fatalf("args %v should fail", args)
}
}
}
func TestWhiteboardUpdateOverwriteAndSourceErrors(t *testing.T) {
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
cmd := newWhiteboardCommand()
cmd.SetArgs([]string{"update", "--node", "n", "--part-id", "p", "--source", writeWhiteboardFixture(t,
`{"overwrite":true,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[]}}`), "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if caller.calls[0].args["mode"] != "overwrite" {
t.Fatalf("args=%#v", caller.calls[0].args)
}
cmd = newWhiteboardCommand()
cmd.SetArgs([]string{"update", "--node", "n", "--part-id", "p", "--source", filepath.Join(t.TempDir(), "missing")})
if err := cmd.Execute(); err == nil {
t.Fatal("expected source error")
}
}
func TestDocMediaUploadValidationAndSuccess(t *testing.T) {
caller := &whiteboardTestCaller{response: func(whiteboardTestCall, int) string {
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"r","resourceUrl":"https://resource.example.test/icon"}`
}}
installWhiteboardTestCaller(t, caller)
previousPut := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
t.Cleanup(func() { httpPutFile = previousPut })
file := writeWhiteboardFixture(t, "svg")
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file, "--name", "icon", "--mime-type", "image/custom", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if caller.calls[0].args["fileName"] != "icon.json" || caller.calls[0].args["mimeType"] != "image/custom" {
t.Fatalf("args=%#v", caller.calls[0].args)
}
for _, args := range [][]string{
{"media", "upload", "--node", "n"},
{"media", "upload", "--node", "n", "--file", filepath.Join(t.TempDir(), "missing")},
{"media", "upload", "--node", "n", "--file", t.TempDir()},
} {
cmd = newDocCommand()
cmd.SetArgs(args)
if err := cmd.Execute(); err == nil {
t.Fatalf("args %v should fail", args)
}
}
}
func TestDocMediaUploadRemainingBranches(t *testing.T) {
file := writeWhiteboardFixture(t, "svg")
caller := &whiteboardTestCaller{dry: true}
installWhiteboardTestCaller(t, caller)
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
for _, test := range []struct {
name string
caller *whiteboardTestCaller
response string
}{
{name: "caller error", caller: &whiteboardTestCaller{err: func(whiteboardTestCall, int) error { return errors.New("call") }}},
{name: "missing resource url", caller: &whiteboardTestCaller{response: func(whiteboardTestCall, int) string {
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"r"}`
}}},
} {
t.Run(test.name, func(t *testing.T) {
installWhiteboardTestCaller(t, test.caller)
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "n", "--file", file, "--yes"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected upload error")
}
})
}
}
func TestDocWhiteboardInsertCallerError(t *testing.T) {
caller := &whiteboardTestCaller{err: func(call whiteboardTestCall, index int) error {
if index == 0 {
return errors.New("insert")
}
return nil
}}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "n", "--yes"})
if err := cmd.Execute(); err == nil {
t.Fatal("expected insert error")
}
}
func TestExtractWhiteboardIDAndJSONEOF(t *testing.T) {
if got := extractWhiteboardID(nil); got != "" {
t.Fatalf("got %q", got)
}
if got := extractWhiteboardID(map[string]any{"metadata": map[string]any{"id": 1}}); got != "" {
t.Fatalf("got %q", got)
}
decoder := json.NewDecoder(strings.NewReader(`{} trailing`))
var value any
if err := decoder.Decode(&value); err != nil {
t.Fatal(err)
}
if err := ensureWhiteboardJSONEOF(decoder); err == nil {
t.Fatal("expected trailing token error")
}
}
+411
View File
@@ -0,0 +1,411 @@
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type whiteboardTestCall struct {
server string
tool string
args map[string]any
}
type whiteboardTestCaller struct {
dry bool
format string
err func(whiteboardTestCall, int) error
response func(whiteboardTestCall, int) string
calls []whiteboardTestCall
}
func (c *whiteboardTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
call := whiteboardTestCall{server: server, tool: tool, args: args}
c.calls = append(c.calls, call)
if c.err != nil {
if err := c.err(call, len(c.calls)-1); err != nil {
return nil, err
}
}
text := `{}`
if c.response != nil {
text = c.response(call, len(c.calls)-1)
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (c *whiteboardTestCaller) Format() string { return c.format }
func (c *whiteboardTestCaller) DryRun() bool { return c.dry }
func (*whiteboardTestCaller) Fields() string { return "" }
func (*whiteboardTestCaller) JQ() string { return "" }
func installWhiteboardTestCaller(t *testing.T, caller *whiteboardTestCaller) *bytes.Buffer {
t.Helper()
testseam.Protect(t, &deps)
InitDeps(caller)
output := &bytes.Buffer{}
deps.Out.w = output
deps.Out.errW = &bytes.Buffer{}
return output
}
func TestWhiteboardQueryRoutesAndDecodesResultJSON(t *testing.T) {
caller := &whiteboardTestCaller{
format: "json",
response: func(whiteboardTestCall, int) string {
return `{"success":true,"resultJson":"{\"nodes\":[{\"type\":\"text\"}]}"}`
},
}
output := installWhiteboardTestCaller(t, caller)
cmd := newWhiteboardCommand()
cmd.SetArgs([]string{"query", "--node", "doc-1", "--part-id", "part-1"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].server != "whiteboard" || caller.calls[0].tool != whiteboardQueryTool {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].args["nodeId"] != "doc-1" || caller.calls[0].args["partId"] != "part-1" {
t.Fatalf("args = %#v", caller.calls[0].args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
if _, ok := payload["resultJson"].(map[string]any); !ok {
t.Fatalf("resultJson was not decoded: %#v", payload)
}
}
func TestWhiteboardUpdateValidatesSourceAndRequiresConfirmation(t *testing.T) {
path := filepath.Join(t.TempDir(), "whiteboard.json")
if err := os.WriteFile(path, []byte(`{"overwrite":false,"source":{"schemaVersion":"1.0","catalogVersion":"dml-v1","nodes":[{"id":"n1","type":"text"}]}}`), 0o600); err != nil {
t.Fatal(err)
}
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
cmd := newWhiteboardCommand()
cmd.SetIn(strings.NewReader("no\n"))
cmd.SetArgs([]string{"update", "--node", "doc-1", "--part-id", "part-1", "--source", path})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "用户取消了操作") {
t.Fatalf("err = %v, want cancellation", err)
}
if len(caller.calls) != 0 {
t.Fatalf("remote call happened before confirmation: %#v", caller.calls)
}
cmd = newWhiteboardCommand()
cmd.SetArgs([]string{"update", "--node", "doc-1", "--part-id", "part-1", "--source", path, "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].tool != whiteboardUpdateTool {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].args["mode"] != "append" || caller.calls[0].args["nodes"] != `[{"id":"n1","type":"text"}]` {
t.Fatalf("args = %#v", caller.calls[0].args)
}
}
func TestDocWhiteboardInsertBuildsCardAndReturnsPersistedPartID(t *testing.T) {
var blockID string
caller := &whiteboardTestCaller{
format: "json",
response: func(call whiteboardTestCall, index int) string {
if index == 0 {
var node []any
if err := json.Unmarshal([]byte(call.args["jsonml"].(string)), &node); err != nil {
t.Fatalf("jsonml: %v", err)
}
attrs := node[1].(map[string]any)
blockID = attrs["uuid"].(string)
return `{}`
}
jsonml := fmt.Sprintf(`["card",{"uuid":%q,"cardType":"hetu","metadata":{"id":"part-real"}}]`, blockID)
encoded, _ := json.Marshal(jsonml)
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":%s}]}`, blockID, encoded)
},
}
output := installWhiteboardTestCaller(t, caller)
previousDelays := whiteboardRetryDelays
whiteboardRetryDelays = nil
t.Cleanup(func() { whiteboardRetryDelays = previousDelays })
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 2 || caller.calls[0].tool != "insert_document_block" || caller.calls[1].tool != "list_document_blocks" {
t.Fatalf("calls = %#v", caller.calls)
}
if caller.calls[0].server != "doc" || caller.calls[1].server != "doc" {
t.Fatalf("unexpected servers: %#v", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
result, _ := payload["result"].(map[string]any)
if result["whiteboardId"] != "part-real" {
t.Fatalf("output = %#v", payload)
}
}
// whiteboardCardBlockID 从 insert_document_block 的请求里取出 CLI 生成的卡片块 UUID,
// 让回查桩可以用真实块 ID 组装响应。
func whiteboardCardBlockID(t *testing.T, call whiteboardTestCall) string {
t.Helper()
raw, _ := call.args["jsonml"].(string)
var node []any
if err := json.Unmarshal([]byte(raw), &node); err != nil {
t.Fatalf("jsonml: %v", err)
}
if len(node) < 2 {
t.Fatalf("jsonml node missing attrs: %q", raw)
}
attrs, _ := node[1].(map[string]any)
id, _ := attrs["uuid"].(string)
if id == "" {
t.Fatalf("jsonml node missing uuid: %q", raw)
}
return id
}
// stubWhiteboardRetries 把重试节奏换成可观测的桩,返回已休眠次数的读取器。
func stubWhiteboardRetries(t *testing.T, delays int) func() int {
t.Helper()
previousDelays := whiteboardRetryDelays
previousSleep := whiteboardSleep
stub := make([]time.Duration, delays)
for i := range stub {
stub[i] = time.Millisecond
}
slept := 0
whiteboardRetryDelays = stub
whiteboardSleep = func(time.Duration) { slept++ }
t.Cleanup(func() {
whiteboardRetryDelays = previousDelays
whiteboardSleep = previousSleep
})
return func() int { return slept }
}
// 插入成功后的回查如果自身失败(鉴权 / MCP 错误 / 响应解析失败),不能退化成
// “暂未落库” 的 soft success,否则 Agent 会把硬失败误判成最终一致性,
// 继续带着空 partId 调用 whiteboard query/update。
func TestDocWhiteboardInsertFailsClosedWhenVerificationQueryFails(t *testing.T) {
tests := []struct {
name string
queryErr error
queryBody func(blockID string) string
}{
{name: "mcp call failed", queryErr: errors.New("unauthorized")},
{
name: "response missing blocks field",
queryBody: func(string) string { return `{"success":true}` },
},
{
name: "blocks field is not an array",
queryBody: func(string) string { return `{"blocks":{}}` },
},
{
name: "block jsonml unparsable",
queryBody: func(blockID string) string {
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":"{"}]}`, blockID)
},
},
{
name: "card node without attrs",
queryBody: func(blockID string) string {
encoded, _ := json.Marshal(`[]`)
return fmt.Sprintf(`{"blocks":[{"blockId":%q,"jsonml":%s}]}`, blockID, encoded)
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
blockID := ""
caller := &whiteboardTestCaller{format: "json"}
caller.response = func(call whiteboardTestCall, index int) string {
if index == 0 {
blockID = whiteboardCardBlockID(t, call)
return `{}`
}
if test.queryBody == nil {
return `{}`
}
return test.queryBody(blockID)
}
if test.queryErr != nil {
caller.err = func(_ whiteboardTestCall, index int) error {
if index == 0 {
return nil
}
return test.queryErr
}
}
installWhiteboardTestCaller(t, caller)
slept := stubWhiteboardRetries(t, 2)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "回查验证失败") {
t.Fatalf("err = %v, want fail-closed verification error", err)
}
if !strings.Contains(err.Error(), blockID) {
t.Fatalf("err = %v, want inserted blockId %s carried in the message", err, blockID)
}
if len(caller.calls) != 2 || slept() != 0 {
t.Fatalf("calls = %d, slept = %d, want a single query and no retry on hard failure",
len(caller.calls), slept())
}
})
}
}
// 块暂不可见是真正的最终一致性:重试耗尽后仍按 soft success 返回 blockId,
// whiteboardId 为 null。
func TestDocWhiteboardInsertSoftSucceedsWhenBlockNotYetVisible(t *testing.T) {
caller := &whiteboardTestCaller{
format: "json",
response: func(_ whiteboardTestCall, index int) string {
if index == 0 {
return `{}`
}
return `{"blocks":[]}`
},
}
output := installWhiteboardTestCaller(t, caller)
slept := stubWhiteboardRetries(t, 2)
cmd := newDocWhiteboardCommand()
cmd.SetArgs([]string{"insert", "--node", "doc-1", "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatalf("block-not-visible must stay a soft success: %v", err)
}
// 1 次插入 + 3 次回查(attempt 0..2),其间休眠 2 次。
if len(caller.calls) != 4 || slept() != 2 {
t.Fatalf("calls = %d, slept = %d, want retries to be exhausted", len(caller.calls), slept())
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
result, _ := payload["result"].(map[string]any)
whiteboardID, present := result["whiteboardId"]
if payload["success"] != true || !present || whiteboardID != nil {
t.Fatalf("output = %#v, want soft success with an explicit null whiteboardId", payload)
}
if result["blockId"] == "" || result["blockId"] == nil {
t.Fatalf("output = %#v, want blockId preserved on soft success", payload)
}
}
// 同级插入与容器内插入共用 MCP 的 referenceBlockId:同时传两者过去会让 parent
// 静默覆盖 ref-block、而 --where 仍留在请求里污染容器插入语义。现在必须显式报错。
func TestDocWhiteboardInsertRejectsConflictingBlockAnchors(t *testing.T) {
for _, test := range []struct {
name string
args []string
}{
{
name: "ref-block with parent-block",
args: []string{"insert", "--node", "doc-1", "--ref-block", "b1", "--parent-block", "p1", "--yes"},
},
{
name: "where with parent-block",
args: []string{"insert", "--node", "doc-1", "--parent-block", "p1", "--where", "before", "--yes"},
},
} {
t.Run(test.name, func(t *testing.T) {
caller := &whiteboardTestCaller{format: "json"}
installWhiteboardTestCaller(t, caller)
cmd := newDocWhiteboardCommand()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs(test.args)
err := cmd.Execute()
if err == nil {
t.Fatalf("args %v must be rejected as mutually exclusive", test.args)
}
if len(caller.calls) != 0 {
t.Fatalf("args %v reached a remote call: %#v", test.args, caller.calls)
}
})
}
}
func TestDocMediaUploadReturnsStableResourceContract(t *testing.T) {
file := filepath.Join(t.TempDir(), "icon.svg")
if err := os.WriteFile(file, []byte("<svg/>"), 0o600); err != nil {
t.Fatal(err)
}
caller := &whiteboardTestCaller{
format: "json",
response: func(whiteboardTestCall, int) string {
return `{"uploadUrl":"https://upload.example.test/token","resourceId":"res-1","resourceUrl":"https://resource.example.test/icon.svg"}`
},
}
output := installWhiteboardTestCaller(t, caller)
previousPut := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
t.Cleanup(func() { httpPutFile = previousPut })
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "doc-1", "--file", file, "--yes"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.calls) != 1 || caller.calls[0].server != "doc" || caller.calls[0].tool != "get_doc_attachment_upload_info" {
t.Fatalf("calls = %#v", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("output = %q: %v", output.String(), err)
}
if strings.Contains(output.String(), "upload.example.test") || payload["resourceId"] != "res-1" {
t.Fatalf("output = %#v", payload)
}
}
func TestDocMediaUploadRedactsTemporaryURLFromUploadError(t *testing.T) {
file := filepath.Join(t.TempDir(), "icon.svg")
if err := os.WriteFile(file, []byte("<svg/>"), 0o600); err != nil {
t.Fatal(err)
}
uploadURL := "https://upload.example.test/secret-token"
caller := &whiteboardTestCaller{
format: "json",
response: func(whiteboardTestCall, int) string {
return fmt.Sprintf(`{"uploadUrl":%q,"resourceId":"res-1","resourceUrl":"https://resource.example.test/icon.svg"}`, uploadURL)
},
}
installWhiteboardTestCaller(t, caller)
previousPut := httpPutFile
httpPutFile = func(context.Context, string, map[string]string, string, int64) error {
return fmt.Errorf("PUT %s: connection reset", uploadURL)
}
t.Cleanup(func() { httpPutFile = previousPut })
cmd := newDocCommand()
cmd.SetArgs([]string{"media", "upload", "--node", "doc-1", "--file", file, "--yes"})
err := cmd.Execute()
if err == nil || strings.Contains(err.Error(), uploadURL) || !strings.Contains(err.Error(), "<redacted upload URL>") {
t.Fatalf("err = %v, want redacted temporary upload URL", err)
}
}
+475
View File
@@ -0,0 +1,475 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
// Package localio owns safe local artifact publication shared by product
// shortcuts. Remote names and URLs are always treated as untrusted input.
package localio
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"os"
pathpkg "path"
"path/filepath"
"strings"
"sync/atomic"
"time"
)
const (
downloadTimeout = 10 * time.Minute
maxDownloadBytes = int64(512 << 20)
)
type downloadTempFile interface {
io.Writer
Sync() error
Close() error
}
var (
createDownloadTemp = createDownloadTempInRoot
lookupDownloadIPs = net.DefaultResolver.LookupIPAddr
dialDownloadIP = (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext
localGetwd = os.Getwd
localAbs = filepath.Abs
localEvalSymlinks = filepath.EvalSymlinks
openDownloadRoot = os.OpenRoot
openDownloadParent = func(root *os.Root, name string) (*os.Root, error) { return root.OpenRoot(name) }
downloadRootStat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Stat(name) }
downloadRootLstat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Lstat(name) }
downloadRootMkdir = func(root *os.Root, name string, mode os.FileMode) error { return root.Mkdir(name, mode) }
downloadRootLink = func(root *os.Root, oldName, newName string) error { return root.Link(oldName, newName) }
downloadRootRemove = func(root *os.Root, name string) error { return root.Remove(name) }
)
var downloadTempCounter atomic.Uint64
// DownloadOptions controls safe, atomic publication beneath BaseDir.
type DownloadOptions struct {
BaseDir string
Output string
PreferredName string
Headers map[string]string
}
// DownloadResult describes the published local artifact.
type DownloadResult struct {
AbsolutePath string
RelativePath string
SizeBytes int64
}
// Download validates a platform-owned HTTPS URL, resolves a workspace-relative
// output path without following symlink escapes, streams into a sibling temp
// file, fsyncs it, and atomically publishes the completed file.
func Download(ctx context.Context, rawURL string, opts DownloadOptions) (DownloadResult, error) {
return downloadWithClient(ctx, rawURL, opts, secureHTTPClient())
}
func downloadWithClient(ctx context.Context, rawURL string, opts DownloadOptions, client *http.Client) (DownloadResult, error) {
return downloadWithClientLimit(ctx, rawURL, opts, client, maxDownloadBytes)
}
func downloadWithClientLimit(ctx context.Context, rawURL string, opts DownloadOptions, client *http.Client, maxBytes int64) (DownloadResult, error) {
parsed, err := ValidateDownloadURL(rawURL)
if err != nil {
return DownloadResult{}, err
}
target, err := openDownloadTarget(opts.BaseDir, opts.Output, parsed.String(), opts.PreferredName)
if err != nil {
return DownloadResult{}, err
}
defer target.close()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) // URL was fully validated above
for key, value := range opts.Headers {
if strings.TrimSpace(key) != "" {
req.Header.Set(key, value)
}
}
resp, err := client.Do(req)
if err != nil {
return DownloadResult{}, fmt.Errorf("下载资源失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return DownloadResult{}, fmt.Errorf("下载资源失败: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
if resp.ContentLength > maxBytes {
return DownloadResult{}, fmt.Errorf("LOCAL_DOWNLOAD_TOO_LARGE: 响应大小 %d 超过上限 %d 字节", resp.ContentLength, maxBytes)
}
if err := target.verifyParent(); err != nil {
return DownloadResult{}, err
}
tmp, tmpName, err := createDownloadTemp(target.parentRoot)
if err != nil {
return DownloadResult{}, fmt.Errorf("创建下载临时文件失败: %w", err)
}
cleanup := func() {
_ = tmp.Close()
_ = target.parentRoot.Remove(tmpName)
}
size, copyErr := io.Copy(tmp, io.LimitReader(resp.Body, maxBytes+1))
if copyErr == nil && size > maxBytes {
copyErr = fmt.Errorf("LOCAL_DOWNLOAD_TOO_LARGE: 下载内容超过上限 %d 字节", maxBytes)
}
if copyErr == nil {
copyErr = tmp.Sync()
}
if closeErr := tmp.Close(); copyErr == nil {
copyErr = closeErr
}
if copyErr != nil {
cleanup()
return DownloadResult{}, fmt.Errorf("写入下载临时文件失败: %w", copyErr)
}
if err := target.verifyParent(); err != nil {
cleanup()
return DownloadResult{}, err
}
if err := publishTempFile(target.parentRoot, tmpName, target.destinationName); err != nil {
cleanup()
return DownloadResult{}, err
}
return DownloadResult{AbsolutePath: target.absolutePath, RelativePath: filepath.ToSlash(target.relativePath), SizeBytes: size}, nil
}
// ValidateOutput rejects absolute paths and portable `..` escapes.
func ValidateOutput(output string) error {
output = strings.TrimSpace(output)
if output == "" {
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 不能为空")
}
portable := strings.ReplaceAll(output, "\\", "/")
if filepath.IsAbs(output) || pathpkg.IsAbs(portable) ||
(len(portable) >= 2 && portable[1] == ':' && ((portable[0] >= 'a' && portable[0] <= 'z') || (portable[0] >= 'A' && portable[0] <= 'Z'))) {
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 只接受工作目录内的相对路径")
}
clean := pathpkg.Clean(portable)
if clean == ".." || strings.HasPrefix(clean, "../") {
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 不允许使用 .. 逃逸工作目录")
}
return nil
}
// ResolveOutputPath returns a symlink-safe destination below baseDir.
type downloadTarget struct {
baseRoot *os.Root
parentRoot *os.Root
parentInfo os.FileInfo
parentRelative string
destinationName string
absolutePath string
relativePath string
}
func (target *downloadTarget) close() {
_ = target.parentRoot.Close()
_ = target.baseRoot.Close()
}
func (target *downloadTarget) verifyParent() error {
current, err := downloadRootStat(target.baseRoot, target.parentRelative)
if err != nil || !os.SameFile(target.parentInfo, current) {
return fmt.Errorf("LOCAL_PATH_CHANGED: 下载期间输出目录被替换")
}
return nil
}
func ResolveOutputPath(baseDir, output, rawURL, preferredName string) (string, string, error) {
target, err := openDownloadTarget(baseDir, output, rawURL, preferredName)
if err != nil {
return "", "", err
}
defer target.close()
return target.absolutePath, target.relativePath, nil
}
func openDownloadTarget(baseDir, output, rawURL, preferredName string) (*downloadTarget, error) {
if err := ValidateOutput(output); err != nil {
return nil, err
}
if strings.TrimSpace(baseDir) == "" {
var err error
baseDir, err = localGetwd()
if err != nil {
return nil, fmt.Errorf("读取工作目录失败: %w", err)
}
}
absBase, err := localAbs(baseDir)
if err != nil {
return nil, fmt.Errorf("解析工作目录失败: %w", err)
}
realBase, err := localEvalSymlinks(absBase)
if err != nil {
return nil, fmt.Errorf("解析工作目录失败: %w", err)
}
baseRoot, err := openDownloadRoot(realBase)
if err != nil {
return nil, fmt.Errorf("打开工作目录失败: %w", err)
}
fail := func(err error) (*downloadTarget, error) {
_ = baseRoot.Close()
return nil, err
}
rawOutput := strings.TrimSpace(output)
directoryIntent := rawOutput == "." || strings.HasSuffix(rawOutput, "/") || strings.HasSuffix(rawOutput, string(os.PathSeparator))
candidate := filepath.Clean(rawOutput)
if info, statErr := downloadRootStat(baseRoot, candidate); statErr == nil && info.IsDir() {
directoryIntent = true
} else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: 检查输出路径失败: %w", statErr))
}
if directoryIntent {
candidate = filepath.Join(candidate, SafeFilename(preferredName, rawURL))
}
parent := filepath.Dir(candidate)
if err := ensureSafeParent(baseRoot, parent); err != nil {
return fail(err)
}
parentRoot, err := openDownloadParent(baseRoot, parent)
if err != nil {
return fail(fmt.Errorf("固定输出目录失败: %w", err))
}
parentInfo, err := downloadRootStat(parentRoot, ".")
if err != nil {
_ = parentRoot.Close()
return fail(fmt.Errorf("读取输出目录身份失败: %w", err))
}
currentParent, err := downloadRootStat(baseRoot, parent)
if err != nil || !os.SameFile(parentInfo, currentParent) {
_ = parentRoot.Close()
return fail(fmt.Errorf("LOCAL_PATH_CHANGED: 输出目录在解析期间被替换"))
}
destinationName := filepath.Base(candidate)
if info, statErr := downloadRootLstat(parentRoot, destinationName); statErr == nil {
if info.Mode()&os.ModeSymlink != 0 {
_ = parentRoot.Close()
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: --output 目标不能是符号链接"))
}
if info.IsDir() {
_ = parentRoot.Close()
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: --output 目标是目录"))
}
_ = parentRoot.Close()
return fail(fmt.Errorf("LOCAL_FILE_EXISTS: 目标文件已存在;请选择新的输出路径"))
} else if !errors.Is(statErr, os.ErrNotExist) {
_ = parentRoot.Close()
return fail(fmt.Errorf("检查输出文件失败: %w", statErr))
}
return &downloadTarget{
baseRoot: baseRoot,
parentRoot: parentRoot,
parentInfo: parentInfo,
parentRelative: parent,
destinationName: destinationName,
absolutePath: filepath.Join(realBase, candidate),
relativePath: candidate,
}, nil
}
// SafeFilename selects a portable basename from a preferred server name or URL.
func SafeFilename(preferredName, rawURL string) string {
if name := sanitizeFilename(preferredName); name != "" {
return name
}
if parsed, err := url.Parse(rawURL); err == nil {
if decoded, decodeErr := url.PathUnescape(filepath.Base(parsed.Path)); decodeErr == nil {
if name := sanitizeFilename(decoded); name != "" {
return name
}
}
}
return "download"
}
// ValidateDownloadURL accepts only public DingTalk and Aliyun OSS HTTPS hosts.
func ValidateDownloadURL(rawURL string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
return nil, fmt.Errorf("下载地址必须是受信任域名上的 HTTPS URL")
}
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
if host == "" || net.ParseIP(host) != nil || !allowedDownloadHost(host) {
return nil, fmt.Errorf("下载地址域名 %q 不属于受信任的钉钉或 OSS 域名", host)
}
if port := parsed.Port(); port != "" && port != "443" {
return nil, fmt.Errorf("下载地址只允许 HTTPS 默认端口")
}
return parsed, nil
}
func secureHTTPClient() *http.Client {
transport := &http.Transport{
// Do not use environment proxies here. DialContext must resolve and dial
// the validated download host itself; with a proxy it would receive the
// proxy address and could not enforce the target host's public-IP policy.
Proxy: nil,
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, err
}
ips, err := lookupDownloadIPs(ctx, host)
if err != nil {
return nil, err
}
for _, resolved := range ips {
if !publicIP(resolved.IP) {
return nil, fmt.Errorf("下载域名解析到非公网地址 %s", resolved.IP)
}
}
// Dial the already validated address, not the hostname, to avoid a
// second DNS lookup opening a rebinding window.
var lastErr error
for _, resolved := range ips {
conn, dialErr := dialDownloadIP(ctx, network, net.JoinHostPort(resolved.IP.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, lastErr
},
}
client := &http.Client{Transport: transport, Timeout: downloadTimeout}
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("下载重定向次数超过上限")
}
if _, err := ValidateDownloadURL(req.URL.String()); err != nil {
return err
}
// net/http copies arbitrary request headers from the initial request to
// every redirect. Never forward service-provided download credentials to
// a different origin, even when both hosts are on the download allowlist.
if len(via) > 0 && !sameDownloadOrigin(via[0].URL, req.URL) {
req.Header = make(http.Header)
}
return nil
}
return client
}
func sameDownloadOrigin(left, right *url.URL) bool {
return downloadOrigin(left) == downloadOrigin(right)
}
func downloadOrigin(parsed *url.URL) string {
port := parsed.Port()
if port == "" {
port = "443"
}
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
return strings.ToLower(parsed.Scheme) + "://" + net.JoinHostPort(host, port)
}
func allowedDownloadHost(host string) bool {
return host == "dingtalk.com" || strings.HasSuffix(host, ".dingtalk.com") ||
(strings.HasSuffix(host, ".aliyuncs.com") && strings.Contains(host, "oss") && !strings.Contains(host, "internal"))
}
func publicIP(ip net.IP) bool {
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return false
}
addr = addr.Unmap()
if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsMulticast() || addr.IsUnspecified() {
return false
}
for _, prefix := range nonPublicPrefixes {
if prefix.Contains(addr) {
return false
}
}
return true
}
var nonPublicPrefixes = []netip.Prefix{
netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT
netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments
netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1
netip.MustParsePrefix("198.18.0.0/15"), // benchmark networks
netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2
netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3
netip.MustParsePrefix("240.0.0.0/4"), // reserved
netip.MustParsePrefix("2001:db8::/32"), // IPv6 documentation
}
func ensureSafeParent(root *os.Root, parent string) error {
if parent == "." {
return nil
}
current := "."
for _, part := range strings.Split(parent, string(os.PathSeparator)) {
current = filepath.Join(current, part)
info, statErr := downloadRootLstat(root, current)
if errors.Is(statErr, os.ErrNotExist) {
if err := downloadRootMkdir(root, current, 0o755); err != nil && !errors.Is(err, os.ErrExist) {
return fmt.Errorf("创建输出目录失败: %w", err)
}
info, statErr = downloadRootLstat(root, current)
}
if statErr != nil {
return fmt.Errorf("检查输出目录失败: %w", statErr)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 父路径必须是非符号链接目录")
}
}
return nil
}
func createDownloadTempInRoot(root *os.Root) (downloadTempFile, string, error) {
name := fmt.Sprintf(".dws-download-%d-%d", os.Getpid(), downloadTempCounter.Add(1))
file, err := root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return nil, "", err
}
return file, name, nil
}
func publishTempFile(root *os.Root, tempName, destinationName string) error {
if err := downloadRootLink(root, tempName, destinationName); err != nil {
if errors.Is(err, os.ErrExist) {
return fmt.Errorf("LOCAL_FILE_EXISTS: 目标文件已存在")
}
return fmt.Errorf("发布下载文件失败: %w", err)
}
if err := downloadRootRemove(root, tempName); err != nil {
return fmt.Errorf("清理下载临时文件失败: %w", err)
}
return nil
}
func sanitizeFilename(raw string) string {
normalized := strings.ReplaceAll(raw, "\\", "/")
if strings.TrimSpace(normalized) != normalized {
return ""
}
name := filepath.Base(normalized)
if name == "" || name == "." || name == ".." || strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
return ""
}
for _, char := range name {
if char < 0x20 || char == 0x7f || strings.ContainsRune(`<>:"/\|?*`, char) {
return ""
}
}
stem := strings.ToUpper(strings.TrimRight(strings.SplitN(name, ".", 2)[0], " ."))
if stem == "CON" || stem == "PRN" || stem == "AUX" || stem == "NUL" ||
(len(stem) == 4 && (strings.HasPrefix(stem, "COM") || strings.HasPrefix(stem, "LPT")) && stem[3] >= '1' && stem[3] <= '9') {
return ""
}
return name
}
+726
View File
@@ -0,0 +1,726 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package localio
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) }
type failingBody struct{}
func (failingBody) Read([]byte) (int, error) { return 0, errors.New("read failed") }
func (failingBody) Close() error { return nil }
type coverageTempFile struct {
file *os.File
writeErr error
syncErr error
closeErr error
onClose func()
}
func (f *coverageTempFile) Write(value []byte) (int, error) {
if f.writeErr != nil {
return 0, f.writeErr
}
return f.file.Write(value)
}
func (f *coverageTempFile) Name() string { return f.file.Name() }
func (f *coverageTempFile) Sync() error {
if f.syncErr != nil {
return f.syncErr
}
return f.file.Sync()
}
func (f *coverageTempFile) Close() error {
err := f.file.Close()
if f.onClose != nil {
f.onClose()
f.onClose = nil
}
if f.closeErr != nil {
return f.closeErr
}
return err
}
func TestCrossPlatformCoverageDownloadURLAndPublicIPPolicy(t *testing.T) {
valid := []string{
"https://alidocs.dingtalk.com/file.docx",
"https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md",
}
for _, raw := range valid {
if _, err := ValidateDownloadURL(raw); err != nil {
t.Errorf("ValidateDownloadURL(%q): %v", raw, err)
}
}
invalid := []string{
"http://alidocs.dingtalk.com/file.docx",
"https://127.0.0.1/file.docx",
"https://evil.example/file.docx",
"https://oss-cn-hangzhou-internal.aliyuncs.com/file.docx",
"https://user@alidocs.dingtalk.com/file.docx",
"https://alidocs.dingtalk.com:8443/file.docx",
}
for _, raw := range invalid {
if _, err := ValidateDownloadURL(raw); err == nil {
t.Errorf("ValidateDownloadURL(%q) unexpectedly succeeded", raw)
}
}
for _, raw := range []string{"127.0.0.1", "10.0.0.1", "100.64.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "224.0.0.1", "2001:db8::1"} {
if publicIP(net.ParseIP(raw)) {
t.Errorf("publicIP(%s) = true", raw)
}
}
for _, raw := range []string{"8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"} {
if !publicIP(net.ParseIP(raw)) {
t.Errorf("publicIP(%s) = false", raw)
}
}
}
func TestCrossPlatformCoverageOutputPathPolicy(t *testing.T) {
for _, output := range []string{"", "../escape", "nested/../../escape", "/tmp/absolute", `C:\\absolute\\file`} {
if err := ValidateOutput(output); err == nil {
t.Errorf("ValidateOutput(%q) unexpectedly succeeded", output)
}
}
base := t.TempDir()
destination, rel, err := ResolveOutputPath(base, "nested/file.md", "https://alidocs.dingtalk.com/file.md", "")
if err != nil {
t.Fatal(err)
}
realBase, err := filepath.EvalSymlinks(base)
if err != nil {
t.Fatal(err)
}
if rel != filepath.Join("nested", "file.md") || filepath.Dir(destination) != filepath.Join(realBase, "nested") {
t.Fatalf("destination=%q rel=%q", destination, rel)
}
if err := os.WriteFile(destination, []byte("existing"), 0o600); err != nil {
t.Fatal(err)
}
if _, _, err := ResolveOutputPath(base, "nested/file.md", "https://alidocs.dingtalk.com/file.md", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") {
t.Fatalf("no-clobber error = %v", err)
}
outside := t.TempDir()
link := filepath.Join(base, "outside-link")
if err := os.Symlink(outside, link); err == nil {
if _, _, err := ResolveOutputPath(base, "outside-link/file", "https://alidocs.dingtalk.com/file", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_UNSAFE") {
t.Fatalf("symlink escape error = %v", err)
}
}
if got := SafeFilename("../evil", "https://alidocs.dingtalk.com/"); got != "evil" {
t.Errorf("SafeFilename traversal basename = %q", got)
}
for _, name := range []string{"CON", "bad?.txt", " trailing.txt"} {
if got := SafeFilename(name, "https://alidocs.dingtalk.com/"); got != "download" {
t.Errorf("SafeFilename(%q) = %q", name, got)
}
}
}
func TestCrossPlatformCoverageDownloadAtomicNoClobber(t *testing.T) {
base := t.TempDir()
payload := "first payload"
requests := 0
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
requests++
if req.URL.Host != "alidocs.oss-cn-zhangjiakou.aliyuncs.com" {
return nil, errors.New("unexpected host")
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(payload)), Header: make(http.Header)}, nil
})}
result, err := downloadWithClient(context.Background(), "https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md", DownloadOptions{
BaseDir: base, Output: "nested/result.md",
}, client)
if err != nil {
t.Fatal(err)
}
if result.RelativePath != "nested/result.md" || result.SizeBytes != int64(len(payload)) {
t.Fatalf("result = %#v", result)
}
got, err := os.ReadFile(result.AbsolutePath)
if err != nil || string(got) != payload {
t.Fatalf("published content = %q, err=%v", got, err)
}
if _, err := downloadWithClient(context.Background(), "https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md", DownloadOptions{
BaseDir: base, Output: "nested/result.md",
}, client); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") {
t.Fatalf("second download error = %v", err)
}
if requests != 1 {
t.Fatalf("existing destination performed %d network requests, want 1 total", requests)
}
got, err = os.ReadFile(result.AbsolutePath)
if err != nil || string(got) != payload {
t.Fatalf("no-clobber content = %q, err=%v", got, err)
}
}
func TestCrossPlatformCoverageDownloadSizeLimitCleansPartialFiles(t *testing.T) {
base := t.TempDir()
for _, tc := range []struct {
name string
contentLength int64
}{
{name: "declared", contentLength: 6},
{name: "streamed", contentLength: -1},
} {
t.Run(tc.name, func(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("123456")),
Header: make(http.Header),
ContentLength: tc.contentLength,
}, nil
})}
output := tc.name + ".bin"
if _, err := downloadWithClientLimit(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{
BaseDir: base, Output: output,
}, client, 5); err == nil || !strings.Contains(err.Error(), "LOCAL_DOWNLOAD_TOO_LARGE") {
t.Fatalf("oversized download error = %v", err)
}
if _, err := os.Stat(filepath.Join(base, output)); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("oversized destination exists: %v", err)
}
entries, err := os.ReadDir(base)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".dws-download-") {
t.Fatalf("oversized download left temp file %q", entry.Name())
}
}
})
}
}
func TestCrossPlatformCoverageDownloadRejectsParentReplacementDuringNetwork(t *testing.T) {
base := t.TempDir()
parent := filepath.Join(base, "nested")
if err := os.Mkdir(parent, 0o700); err != nil {
t.Fatal(err)
}
original := filepath.Join(base, "original-parent")
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
if err := os.Rename(parent, original); err != nil {
t.Skipf("platform cannot replace an open directory: %v", err)
}
if err := os.Mkdir(parent, 0o700); err != nil {
t.Fatal(err)
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil
})}
if _, err := downloadWithClient(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{
BaseDir: base, Output: "nested/result.bin",
}, client); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_CHANGED") {
t.Fatalf("parent replacement error = %v", err)
}
for _, candidate := range []string{filepath.Join(parent, "result.bin"), filepath.Join(original, "result.bin")} {
if _, err := os.Stat(candidate); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("parent replacement wrote %q: %v", candidate, err)
}
}
}
func TestCrossPlatformCoverageDownloadRejectsParentReplacementBeforePublish(t *testing.T) {
base := t.TempDir()
parent := filepath.Join(base, "nested")
if err := os.Mkdir(parent, 0o700); err != nil {
t.Fatal(err)
}
original := filepath.Join(base, "original-parent")
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil
})}
testseam.Swap(t, &createDownloadTemp, func(root *os.Root) (downloadTempFile, string, error) {
created, name, err := createDownloadTempInRoot(root)
if err != nil {
return nil, "", err
}
return &coverageTempFile{file: created.(*os.File), onClose: func() {
if renameErr := os.Rename(parent, original); renameErr != nil {
t.Skipf("platform cannot replace an open directory: %v", renameErr)
}
if mkdirErr := os.Mkdir(parent, 0o700); mkdirErr != nil {
t.Fatal(mkdirErr)
}
}}, name, nil
})
if _, err := downloadWithClient(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{
BaseDir: base, Output: "nested/result.bin",
}, client); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_CHANGED") {
t.Fatalf("parent replacement error = %v", err)
}
for _, candidate := range []string{filepath.Join(parent, "result.bin"), filepath.Join(original, "result.bin")} {
if _, err := os.Stat(candidate); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("parent replacement wrote %q: %v", candidate, err)
}
}
}
func TestCrossPlatformCoverageDownloadFailureBoundaries(t *testing.T) {
base := t.TempDir()
validURL := "https://download.dingtalk.com/file.bin"
if _, err := Download(context.Background(), "bad", DownloadOptions{BaseDir: base, Output: "x"}); err == nil {
t.Fatal("invalid URL download succeeded")
}
if _, err := Download(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "../x"}); err == nil {
t.Fatal("unsafe output download succeeded")
}
clientError := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("transport") })}
statusClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Header.Get("x-test") != "ok" || req.Header.Get("") != "" {
t.Errorf("headers = %#v", req.Header)
}
return &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader("backend")), Header: make(http.Header)}, nil
})}
bodyErrorClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: failingBody{}, Header: make(http.Header)}, nil
})}
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "transport.bin"}, clientError); err == nil {
t.Fatal("transport error was ignored")
}
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "status.bin", Headers: map[string]string{"x-test": "ok", " ": "ignored"}}, statusClient); err == nil {
t.Fatal("HTTP status error was ignored")
}
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "copy.bin"}, bodyErrorClient); err == nil {
t.Fatal("body read error was ignored")
}
okClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil
})}
for _, tc := range []struct {
name string
makeTemp func(*os.Root) (downloadTempFile, string, error)
}{
{"create", func(*os.Root) (downloadTempFile, string, error) { return nil, "", errors.New("create") }},
{"sync", func(root *os.Root) (downloadTempFile, string, error) {
created, name, err := createDownloadTempInRoot(root)
if err != nil {
return nil, "", err
}
return &coverageTempFile{file: created.(*os.File), syncErr: errors.New("sync")}, name, nil
}},
{"close", func(root *os.Root) (downloadTempFile, string, error) {
created, name, err := createDownloadTempInRoot(root)
if err != nil {
return nil, "", err
}
return &coverageTempFile{file: created.(*os.File), closeErr: errors.New("close")}, name, nil
}},
{"publish-race", func(root *os.Root) (downloadTempFile, string, error) {
created, name, err := createDownloadTempInRoot(root)
if err != nil {
return nil, "", err
}
return &coverageTempFile{file: created.(*os.File), onClose: func() {
file, createErr := root.OpenFile("publish-race.bin", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if createErr == nil {
_ = file.Close()
}
}}, name, nil
}},
} {
t.Run(tc.name, func(t *testing.T) {
testseam.Swap(t, &createDownloadTemp, tc.makeTemp)
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: tc.name + ".bin"}, okClient); err == nil {
t.Fatalf("%s failure was ignored", tc.name)
}
})
}
}
func TestCrossPlatformCoverageSecureHTTPClientAndFilesystemEdges(t *testing.T) {
client := secureHTTPClient()
transport := client.Transport.(*http.Transport)
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://download.dingtalk.com/x")}, make([]*http.Request, 5)); err == nil {
t.Fatal("redirect limit accepted")
}
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://evil.example/x")}, nil); err == nil {
t.Fatal("unsafe redirect accepted")
}
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://download.dingtalk.com/x")}, nil); err != nil {
t.Fatal(err)
}
if _, err := transport.DialContext(context.Background(), "tcp", "bad-address"); err == nil {
t.Fatal("bad address dial succeeded")
}
t.Run("lookup error", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) { return nil, errors.New("lookup") })
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
t.Fatal("lookup error ignored")
}
})
t.Run("private answer", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
})
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
t.Fatal("private DNS answer accepted")
}
})
t.Run("public dial fallback and success", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}, {IP: net.ParseIP("1.1.1.1")}}, nil
})
left, right := net.Pipe()
t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
calls := 0
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) {
calls++
if calls == 1 {
return nil, errors.New("first")
}
return left, nil
})
if conn, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err != nil {
t.Fatal(err)
} else {
_ = conn.Close()
}
})
t.Run("all public dials fail", func(t *testing.T) {
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil
})
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) { return nil, errors.New("dial") })
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
t.Fatal("dial failure ignored")
}
})
base := t.TempDir()
if _, _, err := ResolveOutputPath("", "default-base.tmp", "https://download.dingtalk.com/x", ""); err != nil {
t.Fatal(err)
}
if _, _, err := ResolveOutputPath(filepath.Join(base, "missing"), "x", "https://download.dingtalk.com/x", ""); err == nil {
t.Fatal("missing base succeeded")
}
dir := filepath.Join(base, "directory")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatal(err)
}
for _, output := range []string{".", "directory/", "directory"} {
if _, _, err := ResolveOutputPath(base, output, "https://download.dingtalk.com/path/name.txt", "preferred.txt"); err != nil {
t.Errorf("directory output %q: %v", output, err)
}
}
if _, _, err := ResolveOutputPath(base, "directory", "https://download.dingtalk.com/x", ""); err != nil {
t.Fatal(err)
}
targetDir := filepath.Join(base, "target-dir")
_ = os.Mkdir(targetDir, 0o700)
if _, _, err := ResolveOutputPath(base, "target-dir", "https://download.dingtalk.com/x", "x"); err != nil {
t.Fatal(err)
}
targetFile := filepath.Join(base, "existing.txt")
_ = os.WriteFile(targetFile, []byte("x"), 0o600)
if _, _, err := ResolveOutputPath(base, "existing.txt", "https://download.dingtalk.com/x", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") {
t.Fatalf("existing destination error = %v", err)
}
link := filepath.Join(base, "target-link")
if err := os.Symlink(targetFile, link); err == nil {
if _, _, err := ResolveOutputPath(base, "target-link", "https://download.dingtalk.com/x", ""); err == nil {
t.Fatal("symlink destination accepted")
}
}
fileParent := filepath.Join(base, "file-parent")
_ = os.WriteFile(fileParent, []byte("x"), 0o600)
if _, _, err := ResolveOutputPath(base, "file-parent/child", "https://download.dingtalk.com/x", ""); err == nil {
t.Fatal("file parent accepted")
}
root, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = root.Close() })
if err := ensureSafeParent(root, "../escape"); err == nil {
t.Fatal("escaping parent accepted")
}
if err := ensureSafeParent(root, "."); err != nil {
t.Fatal(err)
}
source, err := root.OpenFile("source.tmp", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
t.Fatal(err)
}
_, _ = source.WriteString("x")
_ = source.Close()
destination := filepath.Join(base, "publish.txt")
_ = os.WriteFile(destination, []byte("old"), 0o600)
if err := publishTempFile(root, "source.tmp", "publish.txt"); err == nil {
t.Fatal("publish existing destination succeeded")
}
if err := publishTempFile(root, "missing-source", "new.txt"); err == nil {
t.Fatal("publish missing source succeeded")
}
symlinkDestination := filepath.Join(base, "publish-link")
if err := os.Symlink(destination, symlinkDestination); err == nil {
if err := publishTempFile(root, "source.tmp", "publish-link"); err == nil {
t.Fatal("publish to symlink succeeded")
}
}
closedRoot, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
_ = closedRoot.Close()
if _, _, err := createDownloadTempInRoot(closedRoot); err == nil {
t.Fatal("temp creation in closed root succeeded")
}
for _, name := range []string{"", ".", "..", "name.", "name ", "bad\x00", "AUX", "COM1", "LPT9"} {
_ = sanitizeFilename(name)
}
_ = SafeFilename("", "https://download.dingtalk.com/path/fallback.txt")
_ = SafeFilename("", "https://download.dingtalk.com/%zz")
_ = SafeFilename("", "://bad")
_ = publicIP(net.IP{1, 2, 3})
}
func TestCrossPlatformCoverageSecureHTTPClientDisablesEnvironmentProxy(t *testing.T) {
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:3128")
transport := secureHTTPClient().Transport.(*http.Transport)
if transport.Proxy != nil {
t.Fatal("secure download client accepted an environment proxy")
}
}
func TestCrossPlatformCoverageSecureHTTPClientStripsCrossOriginHeaders(t *testing.T) {
client := secureHTTPClient()
original := &http.Request{
URL: mustURL(t, "https://download.dingtalk.com/source"),
Header: http.Header{
"X-Oss-Security-Token": []string{"credential-a"},
"X-Download-Auth": []string{"credential-b"},
},
}
sameOrigin := &http.Request{
URL: mustURL(t, "https://DOWNLOAD.dingtalk.com.:443/next"),
Header: original.Header.Clone(),
}
if err := client.CheckRedirect(sameOrigin, []*http.Request{original}); err != nil {
t.Fatal(err)
}
if sameOrigin.Header.Get("X-Oss-Security-Token") == "" {
t.Fatal("same-origin redirect unexpectedly stripped request headers")
}
crossOrigin := &http.Request{
URL: mustURL(t, "https://attacker-bucket.oss-cn-hangzhou.aliyuncs.com/next"),
Header: original.Header.Clone(),
}
if err := client.CheckRedirect(crossOrigin, []*http.Request{original}); err != nil {
t.Fatal(err)
}
if len(crossOrigin.Header) != 0 {
t.Fatalf("cross-origin redirect retained %d request headers", len(crossOrigin.Header))
}
multiHop := &http.Request{
URL: mustURL(t, "https://attacker-bucket.oss-cn-hangzhou.aliyuncs.com/final"),
Header: original.Header.Clone(),
}
if err := client.CheckRedirect(multiHop, []*http.Request{original, crossOrigin}); err != nil {
t.Fatal(err)
}
if len(multiHop.Header) != 0 {
t.Fatalf("later cross-origin redirect restored %d initial request headers", len(multiHop.Header))
}
}
func TestCrossPlatformCoverageFilesystemInjectedFailures(t *testing.T) {
base := t.TempDir()
validURL := "https://download.dingtalk.com/x"
cancelled, cancel := context.WithCancel(context.Background())
cancel()
if _, err := Download(cancelled, validURL, DownloadOptions{BaseDir: base, Output: "default-client.bin"}); err == nil {
t.Fatal("cancelled default client download succeeded")
}
t.Run("getwd", func(t *testing.T) {
testseam.Swap(t, &localGetwd, func() (string, error) { return "", errors.New("getwd") })
_, _, _ = ResolveOutputPath("", "x", validURL, "")
})
t.Run("abs", func(t *testing.T) {
testseam.Swap(t, &localAbs, func(string) (string, error) { return "", errors.New("abs") })
_, _, _ = ResolveOutputPath(base, "x", validURL, "")
})
t.Run("eval base", func(t *testing.T) {
testseam.Swap(t, &localEvalSymlinks, func(string) (string, error) { return "", errors.New("eval") })
_, _, _ = ResolveOutputPath(base, "x", validURL, "")
})
t.Run("open base", func(t *testing.T) {
testseam.Swap(t, &openDownloadRoot, func(string) (*os.Root, error) { return nil, errors.New("open root") })
_, _, _ = ResolveOutputPath(base, "x", validURL, "")
})
t.Run("mkdir", func(t *testing.T) {
testseam.Swap(t, &downloadRootMkdir, func(*os.Root, string, os.FileMode) error { return errors.New("mkdir") })
_, _, _ = ResolveOutputPath(base, "new/target", validURL, "")
})
t.Run("lstat after mkdir", func(t *testing.T) {
calls := 0
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "new-after" {
calls++
if calls > 1 {
return nil, errors.New("after mkdir")
}
}
return root.Lstat(name)
})
_, _, _ = ResolveOutputPath(base, "new-after/target", validURL, "")
})
t.Run("open parent", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "open-parent"), 0o700); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &openDownloadParent, func(*os.Root, string) (*os.Root, error) { return nil, errors.New("open parent") })
_, _, _ = ResolveOutputPath(base, "open-parent/target", validURL, "")
})
t.Run("parent stat", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "parent-stat"), 0o700); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &downloadRootStat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "." {
return nil, errors.New("parent stat")
}
return root.Stat(name)
})
_, _, _ = ResolveOutputPath(base, "parent-stat/target", validURL, "")
})
t.Run("parent identity", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "parent-identity"), 0o700); err != nil {
t.Fatal(err)
}
otherInfo, err := os.Stat(t.TempDir())
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &downloadRootStat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "parent-identity" {
return otherInfo, nil
}
return root.Stat(name)
})
_, _, _ = ResolveOutputPath(base, "parent-identity/target", validURL, "")
})
t.Run("destination directory", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "destination-directory"), 0o700); err != nil {
t.Fatal(err)
}
dirInfo, err := os.Stat(base)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "target" {
return dirInfo, nil
}
return root.Lstat(name)
})
_, _, _ = ResolveOutputPath(base, "destination-directory/target", validURL, "")
})
t.Run("destination symlink", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "destination-symlink"), 0o700); err != nil {
t.Fatal(err)
}
link := filepath.Join(base, "coverage-link")
if err := os.Symlink(filepath.Join(base, "destination-symlink"), link); err != nil {
t.Skipf("symlink unavailable: %v", err)
}
linkInfo, err := os.Lstat(link)
if err != nil {
t.Fatal(err)
}
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "target" {
return linkInfo, nil
}
return root.Lstat(name)
})
_, _, _ = ResolveOutputPath(base, "destination-symlink/target", validURL, "")
})
t.Run("destination lstat", func(t *testing.T) {
if err := os.Mkdir(filepath.Join(base, "destination-lstat"), 0o700); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
if name == "target" {
return nil, errors.New("destination lstat")
}
return root.Lstat(name)
})
_, _, _ = ResolveOutputPath(base, "destination-lstat/target", validURL, "")
})
t.Run("unsafe parent type", func(t *testing.T) {
filePath := filepath.Join(base, "unsafe-parent")
if err := os.WriteFile(filePath, []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
root, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
defer root.Close()
if err := ensureSafeParent(root, "unsafe-parent"); err == nil {
t.Fatal("regular file accepted as output parent")
}
})
t.Run("publish remove", func(t *testing.T) {
root, err := os.OpenRoot(base)
if err != nil {
t.Fatal(err)
}
defer root.Close()
file, err := root.OpenFile("remove-source", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
t.Fatal(err)
}
_ = file.Close()
testseam.Swap(t, &downloadRootRemove, func(*os.Root, string) error { return errors.New("remove") })
if err := publishTempFile(root, "remove-source", "remove-destination"); err == nil {
t.Fatal("publish remove error ignored")
}
})
}
func mustURL(t *testing.T, raw string) *url.URL {
t.Helper()
parsed, err := url.Parse(raw)
if err != nil {
t.Fatal(err)
}
return parsed
}
+29 -1
View File
@@ -77,11 +77,28 @@ func FromShortcut(s Shortcut) corecmd.Spec {
}
func fromShortcutPostMount(s Shortcut) func(*cobra.Command) {
if len(s.Aliases) == 0 && strings.TrimSpace(s.SinglePositionalAliasFor) == "" {
hasVisibleFlagAliases := false
for _, flag := range s.Flags {
if flag.AliasesVisible && len(flag.Aliases) > 0 {
hasVisibleFlagAliases = true
break
}
}
if len(s.Aliases) == 0 && strings.TrimSpace(s.SinglePositionalAliasFor) == "" && !hasVisibleFlagAliases {
return nil
}
return func(cmd *cobra.Command) {
cmd.Aliases = append([]string(nil), s.Aliases...)
for _, flag := range s.Flags {
if !flag.AliasesVisible {
continue
}
for _, alias := range flag.Aliases {
if mounted := cmd.Flags().Lookup(alias); mounted != nil {
mounted.Hidden = false
}
}
}
name := strings.TrimSpace(s.SinglePositionalAliasFor)
if name == "" {
return
@@ -117,6 +134,16 @@ func safetySpecDeclared(safety contract.SafetySpec) bool {
strings.TrimSpace(safety.Idempotency) != ""
}
// EffectiveSafety returns the exact safety declaration used by the runtime and
// ContractFinal. Management/listing projections must use this instead of
// re-inferring confirmation from the legacy Risk enum.
func EffectiveSafety(s Shortcut) contract.SafetySpec {
if safetySpecDeclared(s.Safety) {
return s.Safety
}
return shortcutSafetySpec(s.risk())
}
func shortcutExamples(tips []string) string {
if len(tips) == 0 {
return ""
@@ -188,6 +215,7 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec {
ValidationMode: corecmd.ValidationShortcut,
RequiredError: fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc),
Enum: append([]string(nil), f.Enum...),
Aliases: append([]string(nil), f.Aliases...),
})
}
return out
+11 -1
View File
@@ -66,6 +66,13 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) {
cs.Safety.Confirmation != "user_required" || cs.Safety.Idempotency != "unknown" {
t.Fatalf("adapter safety = %#v, want destructive/high/user_required/unknown", cs.Safety)
}
if got := EffectiveSafety(Shortcut{Risk: RiskWrite}); got.Effect != "write" || got.Confirmation != "user_required" {
t.Fatalf("legacy effective safety = %#v", got)
}
explicit := contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
if got := EffectiveSafety(Shortcut{Risk: RiskHighWrite, Safety: explicit}); got != explicit {
t.Fatalf("explicit effective safety = %#v, want %#v", got, explicit)
}
if cs.Orchestrate == nil {
t.Fatal("multi-step Execute must project into Orchestrate")
}
@@ -142,7 +149,7 @@ func TestCrossPlatformCoverageFromShortcutAliasesAndPositionalAlias(t *testing.T
ProductID: "chat", Name: "shortcut_search", CanonicalPath: "chat.shortcut_search", CLIPath: "chat +search", PrimaryCLIPath: "chat +search",
},
},
Flags: []Flag{{Name: "query", Desc: "关键词", Required: true}},
Flags: []Flag{{Name: "query", Desc: "关键词", Required: true, Aliases: []string{"keyword"}, AliasesVisible: true}},
Execute: func(rt *RuntimeContext) error { executed = rt.Str("query"); return nil },
}
spec := FromShortcut(s)
@@ -153,6 +160,9 @@ func TestCrossPlatformCoverageFromShortcutAliasesAndPositionalAlias(t *testing.T
if !cmd.HasAlias("+search-group") {
t.Fatalf("cobra aliases = %#v", cmd.Aliases)
}
if alias := cmd.Flags().Lookup("keyword"); alias == nil || alias.Hidden {
t.Fatalf("historically public flag alias = %#v, want visible", alias)
}
cmd.SetArgs([]string{"项目群"})
if err := cmd.Execute(); err != nil || executed != "项目群" {
t.Fatalf("positional execute err=%v value=%q", err, executed)
+4 -3
View File
@@ -12,9 +12,10 @@
// limitations under the License.
// Package builtin aggregates all built-in shortcut service packages via blank
// imports so their init() registrations run, then re-exports the compiled cobra
// commands. The host application depends only on this package, keeping the
// service packages free to import the core shortcut package without a cycle.
// imports so their init() registrations run, applies the reviewed semantic and
// public-catalog decorations in the core registry, then re-exports the compiled
// cobra commands. The host application depends only on this package, keeping
// the service packages free to import the core shortcut package without a cycle.
//
// Add a blank import here when a new service package is generated under
// internal/shortcut/<service>/.
@@ -27,7 +27,7 @@ import (
// TestCmdcoreMountPreservesEveryBuiltInShortcutSurface is the differential
// guard for the live mount migration. It derives the historical Cobra surface
// directly from each Shortcut declaration and checks the command-built tree.
func TestCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) {
func TestCrossPlatformCoverageCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) {
mounted := map[string]*cobra.Command{}
for _, service := range builtin.BaseCommands() {
for _, command := range service.Commands() {
@@ -83,6 +83,18 @@ func TestCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) {
spec.Service, spec.Command, flag.Name, got.Hidden, flag.Hidden)
}
assertShortcutDefault(t, command, spec, flag)
for _, alias := range flag.Aliases {
declaredFlags[alias] = flag
gotAlias := command.Flags().Lookup(alias)
if gotAlias == nil {
t.Errorf("%s %s: flag alias --%s is not mounted", spec.Service, spec.Command, alias)
continue
}
wantHidden := !flag.AliasesVisible
if gotAlias.Hidden != wantHidden {
t.Errorf("%s %s: flag alias --%s hidden = %v, want %v", spec.Service, spec.Command, alias, gotAlias.Hidden, wantHidden)
}
}
}
command.Flags().VisitAll(func(flag *pflag.Flag) {
if flag.Name == "help" {
@@ -0,0 +1,83 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package builtin_test
import (
"encoding/json"
"os"
"sort"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageDocSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
raw, err := os.ReadFile("../semantic_catalog_doc.json")
if err != nil {
t.Fatal(err)
}
var source chatSemanticCatalogFixture
if err := json.Unmarshal(raw, &source); err != nil {
t.Fatal(err)
}
if source.Service != "doc" {
t.Fatalf("semantic catalog service = %q", source.Service)
}
registered := map[string]shortcut.Shortcut{}
for _, item := range shortcut.All() {
if item.Service == "doc" {
registered[item.Command] = item
}
}
if len(registered) != 47 || len(source.Shortcuts) != 47 {
t.Fatalf("registered/catalog = %d/%d, want 47/47", len(registered), len(source.Shortcuts))
}
var missing, stale []string
public, hidden := 0, 0
for command, item := range registered {
record, ok := source.Shortcuts[command]
if !ok {
missing = append(missing, command)
continue
}
if !record.Reviewed || !item.SemanticReviewed || record.SemanticDelta != item.SemanticDelta || record.Disposition != item.Disposition {
t.Errorf("%s: reviewed semantic delivery mismatch", command)
}
if got := shortcut.InPublicCatalog("doc", command); got != record.Public || item.Hidden == record.Public {
t.Errorf("%s: public/hidden mismatch: catalog=%v runtimeHidden=%v", command, record.Public, item.Hidden)
}
if record.Public {
public++
if item.Contract.Empty() {
t.Errorf("%s: public Doc shortcut has empty Contract", command)
}
} else {
hidden++
}
}
for command := range source.Shortcuts {
if _, ok := registered[command]; !ok {
stale = append(stale, command)
}
}
sort.Strings(missing)
sort.Strings(stale)
if len(missing) > 0 || len(stale) > 0 {
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
}
if public != 45 || hidden != 2 {
t.Fatalf("public/hidden = %d/%d, want 45/2", public, hidden)
}
wantPrimaries := map[string]string{
"+find-doc": "+search", "+doc-append": "+update", "+version-save": "+history-save",
"+version-list": "+history-list", "+version-revert": "+history-revert", "+share-doc": "+share",
}
for command, primary := range wantPrimaries {
item := registered[command]
if item.Disposition != shortcut.DispositionAliasInternal || item.PrimaryCommand != primary {
t.Errorf("%s compatibility routing = %s/%s, want alias_internal/%s", command, item.Disposition, item.PrimaryCommand, primary)
}
}
}
+182
View File
@@ -0,0 +1,182 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"encoding/json"
"fmt"
"io"
"path/filepath"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
const compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract."
func docContract(command, description, intent string, examples []string, params ...contract.ParamDecl) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "doc " + command
return corecmd.ContractDecl{
Description: description,
Parameters: params,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: compositeInterfaceReason,
},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{intent},
AvoidWhen: []string{
"需要文件树移动、复制或普通钉盘文件操作时改用 drive;非文字文档按对象类型路由到 sheet、aitable、slides 或 wiki",
},
Examples: examples,
},
Identity: contract.ToolIdentitySpec{
ProductID: "doc",
Name: name,
CanonicalPath: "doc." + name,
CLIPath: cliPath,
PrimaryCLIPath: cliPath,
},
}
}
func withDryRun(decl corecmd.ContractDecl, kind string, remoteReads bool) corecmd.ContractDecl {
decl.DryRun = &contract.DryRunSpec{PreviewKind: kind, RemoteReads: remoteReads}
return decl
}
func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, error) {
raw := rt.Str(flag)
if raw == "-" {
data, err := io.ReadAll(rt.Command().InOrStdin())
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取 stdin 失败: %v", flag, err))
}
return string(data), nil
}
if !strings.HasPrefix(raw, "@") {
return raw, nil
}
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
if path == "" || filepath.IsAbs(path) {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径", flag))
}
cwd, err := docGetwd()
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err))
}
realBase, err := docEvalSymlinks(cwd)
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err))
}
realPath, err := docEvalSymlinks(filepath.Join(realBase, filepath.Clean(path)))
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err))
}
rel, err := docRel(realBase, realPath)
if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录", flag))
}
data, err := docReadFile(realPath)
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err))
}
return string(data), nil
}
func validateJSONML(raw string) (string, error) {
var value any
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("JSONML 解析失败: %v", err))
}
if _, ok := value.([]any); !ok {
return "", apperrors.NewValidation("JSONML 顶层必须是数组")
}
normalized, _ := json.Marshal(value) // decoded JSON trees are always marshalable
return string(normalized), nil
}
func docEnvelope(operation string, data any, steps ...map[string]any) map[string]any {
return map[string]any{
"ok": true,
"status": "success",
"operation": operation,
"steps": steps,
"data": data,
"warnings": []string{},
"compensation": map[string]any{"available": false, "reason": ""},
}
}
func docPartialWriteError(operation, reason, stage, message string, cause error, data map[string]any, steps []map[string]any, compensation map[string]any) error {
return apperrors.NewAPI(
message,
apperrors.WithOperation(operation),
apperrors.WithReason(reason),
apperrors.WithFailureStage(stage),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("inspect the completed steps before retrying", "use the compensation details to clean up or restore the document"),
apperrors.WithDetails(map[string]any{
"status": "partial_success",
"data": data,
"steps": steps,
"compensation": compensation,
}),
apperrors.WithCause(cause),
)
}
func nestedString(data map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
for _, wrapper := range []string{"result", "data", "content"} {
if inner, ok := data[wrapper].(map[string]any); ok {
if value := nestedString(inner, keys...); value != "" {
return value
}
}
}
return ""
}
func nestedMap(data map[string]any) map[string]any {
for _, wrapper := range []string{"result", "data"} {
if inner, ok := data[wrapper].(map[string]any); ok {
return nestedMap(inner)
}
}
return data
}
func stringSliceNonEmpty(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
out = append(out, value)
}
}
return out
}
// blockIdentity normalizes the currently observed block response shapes. The
// element API returns element.id, while JSONML and older payloads use blockId
// or uuid. Callers pass an inherited parent identity for nested text maps.
func blockIdentity(values map[string]any, inherited string) string {
for _, key := range []string{"blockId", "id", "uuid"} {
if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return inherited
}
+795
View File
@@ -0,0 +1,795 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"unicode"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var (
docGetwd = os.Getwd
docEvalSymlinks = filepath.EvalSymlinks
docRel = filepath.Rel
docReadFile = os.ReadFile
docMkdirTemp = os.MkdirTemp
docRemoveAll = os.RemoveAll
docDownload = localio.Download
)
var Create = shortcut.Shortcut{
Service: "doc",
Command: "+create",
Product: productDoc,
Description: "从 Markdown 或 JSONML 创建在线文字文档",
Intent: "当用户要新建钉钉在线文字文档,并可同时写入 Markdown/JSONML 初始内容、指定文件夹或知识库位置时使用;不会用于普通文件上传或其他在线对象类型。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
},
Contract: docContract(
"+create", "从 Markdown 或 JSONML 创建在线文字文档",
"当用户要新建钉钉在线文字文档,并可同时写入 Markdown/JSONML 初始内容、指定文件夹或知识库位置时使用;不会用于普通文件上传或其他在线对象类型。",
[]string{`dws doc +create --name "项目周报" --content "# 本周进展"`, `dws doc +create --name "模板" --content @body.json --doc-format jsonml`},
contract.ParamDecl{Name: "folder", Property: "folderId"},
contract.ParamDecl{Name: "workspace", Property: "workspaceId"},
),
Flags: []shortcut.Flag{
{Name: "name", Type: shortcut.FlagString, Desc: "新文档名称", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "内容字面量、@工作目录相对文件或 - 表示 stdin"},
{Name: "doc-format", Type: shortcut.FlagString, Default: "markdown", Desc: "内容格式", Enum: []string{"markdown", "jsonml"}},
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文档文件夹 ID"},
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
},
Tips: []string{`dws doc +create --name "项目周报" --content "# 本周进展"`, `dws doc +create --name "模板" --content @body.json --doc-format jsonml`},
Execute: func(rt *shortcut.RuntimeContext) error {
content, err := readShortcutContent(rt, "content")
if err != nil {
return err
}
format := rt.Str("doc-format")
if format == "jsonml" && content != "" {
content, err = validateJSONML(content)
if err != nil {
return err
}
}
params := map[string]any{"name": rt.Str("name")}
if rt.Str("folder") != "" {
params["folderId"] = rt.Str("folder")
}
if rt.Str("workspace") != "" {
params["workspaceId"] = rt.Str("workspace")
}
if format == "markdown" && content != "" {
params["markdown"] = content
}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.create", map[string]any{"executed": false, "previewKind": "plan", "create": params, "docFormat": format, "contentBytes": len(content)}))
}
created, err := rt.CallMCPWriteData(productDoc, "create_document", params)
if err != nil {
return err
}
nodeID := nestedString(created, "nodeId", "documentId", "id")
steps := []map[string]any{{"name": "create_document", "status": "success"}}
if format == "jsonml" && content != "" {
if nodeID == "" {
return docPartialWriteError(
"doc.create", "doc_create_missing_node_id", "resolve_created_document",
"创建文档成功但响应缺少 nodeId;JSONML 尚未写入,请先在钉钉中定位新文档,不要直接重试",
nil,
map[string]any{"nodeId": "", "docFormat": format},
append(steps, map[string]any{"name": "write_jsonml", "status": "not_started"}),
map[string]any{"available": false, "reason": "create_document did not return nodeId; locate the new document in DingTalk"},
)
}
if _, err := rt.CallMCPWriteData(productDoc, "update_document", map[string]any{"nodeId": nodeID, "format": "jsonml", "jsonml": content, "mode": "overwrite"}); err != nil {
return docPartialWriteError(
"doc.create", "doc_create_initial_content_failed", "write_jsonml",
fmt.Sprintf("文档已创建但 JSONML 写入失败(nodeId=%s);不要直接重试创建", nodeID),
err,
map[string]any{"nodeId": nodeID, "docFormat": format},
append(steps, map[string]any{"name": "write_jsonml", "status": "failed"}),
map[string]any{"available": true, "action": "delete_created_document", "nodeId": nodeID, "reason": "remove the empty document before retrying create"},
)
}
steps = append(steps, map[string]any{"name": "write_jsonml", "status": "success"})
}
return rt.Output(docEnvelope("doc.create", map[string]any{"nodeId": nodeID, "result": created}, steps...))
},
}
var Fetch = shortcut.Shortcut{
Service: "doc",
Command: "+fetch",
Product: productDoc,
Description: "读取完整或局部文档内容,并按 detail 控制保真度",
Intent: "当用户要读取在线文字文档正文,或需要 block ID、JSONML、outline/range/section/keyword/tags 局部内容用于精确编辑和评论时使用;非最新历史 revision 会明确拒绝。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract(
"+fetch", "读取完整或局部文档内容,并按 detail 控制保真度",
"当用户要读取在线文字文档正文,或需要 block ID、JSONML、outline/range/section/keyword/tags 局部内容用于精确编辑和评论时使用;非最新历史 revision 会明确拒绝。",
[]string{`dws doc +fetch --node <DOC_ID>`, `dws doc +fetch --node <DOC_ID> --detail with-ids --scope keyword --keyword "结论"`},
contract.ParamDecl{Name: "node", Property: "nodeId"},
),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "detail", Type: shortcut.FlagString, Default: "simple", Desc: "输出细节", Enum: []string{"simple", "with-ids", "full"}},
{Name: "scope", Type: shortcut.FlagString, Default: "full", Desc: "读取范围;keyword 时 --keyword 不能为空", Enum: []string{"full", "outline", "range", "section", "keyword", "tags"}},
{Name: "start-block-id", Type: shortcut.FlagString, Desc: "range/section 起始块 ID"},
{Name: "end-block-id", Type: shortcut.FlagString, Desc: "range 结束块 ID"},
{Name: "keyword", Type: shortcut.FlagString, Desc: "keyword 范围搜索词,不能为空,支持 foo|bar"},
{Name: "tags", Type: shortcut.FlagStringSlice, Desc: "tags 范围的 JSONML tag"},
{Name: "context-before", Type: shortcut.FlagInt, Desc: "关键词命中前的上下文字符数"},
{Name: "context-after", Type: shortcut.FlagInt, Desc: "关键词命中后的上下文字符数"},
{Name: "max-depth", Type: shortcut.FlagInt, Desc: "outline/section 最大深度"},
{Name: "revision", Type: shortcut.FlagInt, Desc: "只接受当前最新版;历史 revision 暂不支持"},
},
Tips: []string{`dws doc +fetch --node <DOC_ID>`, `dws doc +fetch --node <DOC_ID> --detail with-ids --scope keyword --keyword "结论"`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Changed("revision") {
return apperrors.NewValidation("HISTORICAL_READ_UNSUPPORTED: 当前接口不能读取指定历史 revision")
}
if rt.Str("scope") == "keyword" && rt.Str("keyword") == "" {
return apperrors.NewValidation("--scope keyword 时必须提供 --keyword")
}
return nil
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"scope", "keyword"}, Description: "--scope keyword 时 --keyword 不能为空"}},
Execute: func(rt *shortcut.RuntimeContext) error {
format := "markdown"
if rt.Str("detail") != "simple" || rt.Str("scope") != "full" {
format = "jsonml"
}
params := map[string]any{"nodeId": rt.Str("node"), "format": format}
scope := rt.Str("scope")
if scope != "keyword" && scope != "full" {
params["scope"] = scope
}
if value := rt.Str("start-block-id"); value != "" {
params["startBlockId"] = value
}
if value := rt.Str("end-block-id"); value != "" {
params["endBlockId"] = value
}
if rt.Changed("tags") {
params["tags"] = rt.StrSlice("tags")
}
if rt.Changed("max-depth") {
params["maxDepth"] = rt.Int("max-depth")
}
data, err := rt.CallMCPData(productDoc, "get_document_content", params)
if err != nil {
return err
}
if scope == "keyword" {
return rt.Output(projectKeywordMatches(data, rt.Str("keyword"), rt.Int("context-before"), rt.Int("context-after")))
}
return rt.Output(data)
},
}
var Inspect = shortcut.Shortcut{
Service: "doc",
Command: "+inspect",
Product: productDoc,
Description: "聚合文档元信息,并按需附带样式、权限、历史、媒体和评论",
Intent: "当用户需要在一次调用中了解文档类型、标题、链接和可选的协作/样式/历史/媒体/评论状态,而不是读取正文时使用。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+inspect", "聚合文档元信息,并按需附带样式、权限、历史、媒体和评论",
"当用户需要在一次调用中了解文档类型、标题、链接和可选的协作/样式/历史/媒体/评论状态,而不是读取正文时使用。",
[]string{`dws doc +inspect --node <DOC_ID>`, `dws doc +inspect --node <DOC_ID> --include-style --include-permissions --include-comments`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "include-style", Type: shortcut.FlagBool, Desc: "附带封面和背景"},
{Name: "include-permissions", Type: shortcut.FlagBool, Desc: "附带权限列表"},
{Name: "include-history", Type: shortcut.FlagBool, Desc: "附带最近历史版本"},
{Name: "include-media", Type: shortcut.FlagBool, Desc: "附带正文媒体列表"},
{Name: "include-comments", Type: shortcut.FlagBool, Desc: "附带评论列表"},
},
Tips: []string{`dws doc +inspect --node <DOC_ID>`, `dws doc +inspect --node <DOC_ID> --include-style --include-permissions --include-comments`},
Execute: func(rt *shortcut.RuntimeContext) error {
node := rt.Str("node")
result := map[string]any{}
info, err := rt.CallMCPData(productDoc, "get_document_info", map[string]any{"nodeId": node})
if err != nil {
return err
}
result["document"] = info
reads := []struct {
flag, key, product, tool string
params map[string]any
}{
{"include-style", "style", productDoc, "get_document_style", map[string]any{"nodeId": node}},
{"include-permissions", "permissions", productDoc, "list_permission", map[string]any{"nodeId": node}},
{"include-history", "history", productDoc, "list_doc_versions", map[string]any{"nodeId": node}},
{"include-media", "media", productDoc, "list_document_blocks", map[string]any{"nodeId": node, "format": "jsonml"}},
{"include-comments", "comments", productComment, "list_comments", map[string]any{"nodeId": node}},
}
for _, read := range reads {
if !rt.Bool(read.flag) {
continue
}
value, callErr := rt.CallMCPData(read.product, read.tool, read.params)
if callErr != nil {
return callErr
}
result[read.key] = value
}
return rt.Output(docEnvelope("doc.inspect", result, map[string]any{"name": "inspect", "status": "success"}))
},
}
var Update = shortcut.Shortcut{
Service: "doc",
Command: "+update",
Product: productDoc,
Description: "追加、覆盖或按 block 精确更新文档内容",
Intent: "当用户要修改已有在线文字文档时使用;支持整篇 append/overwrite、block 插入/替换/删除,以及受限的唯一纯文本 str_replace,所有模式统一经过静态确认门禁。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: docContract("+update", "追加、覆盖或按 block 精确更新文档内容",
"当用户要修改已有在线文字文档时使用;支持整篇 append/overwrite、block 插入/替换/删除,以及受限的唯一纯文本 str_replace,所有模式统一经过静态确认门禁。",
[]string{`dws doc +update --node <DOC_ID> --command append --content "补充说明"`, `dws doc +update --node <DOC_ID> --command block_replace --block-id <BLOCK_ID> --content "新内容"`},
contract.ParamDecl{Name: "doc", Property: "node"},
contract.ParamDecl{Name: "text", Property: "content"}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true, Aliases: []string{"doc"}, AliasesVisible: true},
{Name: "command", Type: shortcut.FlagString, Desc: "更新动作;不能为空", Enum: []string{"append", "overwrite", "block_insert_after", "block_replace", "block_delete", "str_replace", "block_copy_insert_after"}},
{Name: "content", Type: shortcut.FlagString, Desc: "内容字面量、@相对文件或 - 表示 stdin;相关动作要求时不能为空", Aliases: []string{"text"}, AliasesVisible: true},
{Name: "doc-format", Type: shortcut.FlagString, Default: "markdown", Desc: "内容格式", Enum: []string{"markdown", "jsonml"}},
{Name: "block-id", Type: shortcut.FlagString, Desc: "目标或源 block ID;相关动作要求时不能为空"},
{Name: "after-block-id", Type: shortcut.FlagString, Desc: "插入位置参考 block ID"},
{Name: "old", Type: shortcut.FlagString, Desc: "str_replace 原文字,不能为空"},
{Name: "new", Type: shortcut.FlagString, Desc: "str_replace 新文字;--old 不能为空,新值可为空但参数必须显式提供"},
{Name: "expected-revision", Type: shortcut.FlagInt, Desc: "best-effort 乐观 revision 检查"},
},
Tips: []string{`dws doc +update --node <DOC_ID> --command append --content "补充说明"`, `dws doc +update --node <DOC_ID> --command block_replace --block-id <BLOCK_ID> --content "新内容"`},
Validate: func(rt *shortcut.RuntimeContext) error {
command := rt.Str("command")
if command == "" {
return apperrors.NewValidation("缺少 --command")
}
if rt.StrFirst("node", "doc") == "" {
return apperrors.NewValidation("缺少 --node")
}
if command == "append" || command == "overwrite" || command == "block_insert_after" || command == "block_replace" {
if rt.StrFirst("content", "text") == "" {
return apperrors.NewValidation("该更新动作的 --content 不能为空")
}
}
if strings.HasPrefix(command, "block_") && command != "block_insert_after" && rt.Str("block-id") == "" {
return apperrors.NewValidation("该 block 操作必须提供 --block-id")
}
if command == "str_replace" && (rt.Str("old") == "" || !rt.Changed("new")) {
return apperrors.NewValidation("--command str_replace 必须同时提供 --old 和 --new")
}
return nil
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"command", "content", "block-id", "old", "new"}, Description: "依 command 校验,所需文本参数不能为空"}},
Execute: executeUpdate,
}
var CheckpointUpdate = shortcut.Shortcut{
Service: "doc",
Command: "+checkpoint-update",
Product: productDoc,
Description: "先保存可回滚版本,再更新并读回验证",
Intent: "当用户要进行重要追加或整篇覆盖,并希望自动创建恢复点、执行更新、再读回确认时使用;任一步失败都会返回已经完成的步骤。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: docContract("+checkpoint-update", "先保存可回滚版本,再更新并读回验证",
"当用户要进行重要追加或整篇覆盖,并希望自动创建恢复点、执行更新、再读回确认时使用;任一步失败都会返回已经完成的步骤。",
[]string{`dws doc +checkpoint-update --node <DOC_ID> --mode append --content @section.md`, `dws doc +checkpoint-update --node <DOC_ID> --mode overwrite --content @document.md`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "mode", Type: shortcut.FlagString, Default: "append", Desc: "更新模式", Enum: []string{"append", "overwrite"}},
{Name: "content", Type: shortcut.FlagString, Desc: "内容字面量、@相对文件或 - 表示 stdin", Required: true},
},
Tips: []string{`dws doc +checkpoint-update --node <DOC_ID> --mode append --content @section.md`, `dws doc +checkpoint-update --node <DOC_ID> --mode overwrite --content @document.md`},
Execute: func(rt *shortcut.RuntimeContext) error {
content, err := readShortcutContent(rt, "content")
if err != nil {
return err
}
plan := map[string]any{"nodeId": rt.Str("node"), "mode": rt.Str("mode"), "contentBytes": len(content), "steps": []string{"save_doc_version", "update_document", "get_document_content"}}
if rt.DryRun() {
plan["executed"] = false
return rt.Output(docEnvelope("doc.checkpoint_update", plan))
}
steps := []map[string]any{}
checkpoint, err := rt.CallMCPWriteData(productDoc, "save_doc_version", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
steps = append(steps, map[string]any{"name": "checkpoint", "status": "success"})
if _, err := rt.CallMCPWriteData(productDoc, "update_document", map[string]any{"nodeId": rt.Str("node"), "markdown": content, "mode": rt.Str("mode")}); err != nil {
return checkpointPartialWriteError(rt.Str("node"), checkpoint, "update", "doc_checkpoint_update_failed", err,
append(steps, map[string]any{"name": "update", "status": "failed"}, map[string]any{"name": "verify", "status": "not_started"}))
}
steps = append(steps, map[string]any{"name": "update", "status": "success"})
verified, err := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": rt.Str("node"), "format": "markdown"})
if err != nil {
return checkpointPartialWriteError(rt.Str("node"), checkpoint, "verify", "doc_checkpoint_verification_failed", err,
append(steps, map[string]any{"name": "verify", "status": "failed"}))
}
steps = append(steps, map[string]any{"name": "verify", "status": "success"})
return rt.Output(docEnvelope("doc.checkpoint_update", map[string]any{"verified": verified}, steps...))
},
}
var Export = shortcut.Shortcut{
Service: "doc",
Command: "+export",
Product: productDoc,
Description: "提交、轮询并安全下载在线文档导出文件",
Intent: "当用户要把在线文档导出成 docx、markdown 或 PDF 并保存到工作目录时使用;自动完成 job 提交、轮询与 no-clobber 原子下载。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+export", "提交、轮询并安全下载在线文档导出文件",
"当用户要把在线文档导出成 docx、markdown 或 PDF 并保存到工作目录时使用;自动完成 job 提交、轮询与 no-clobber 原子下载。",
[]string{`dws doc +export --node <DOC_ID> --export-format docx --output ./exports/`, `dws doc +export --node <DOC_ID> --export-format markdown --output ./document.md`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "export-format", Type: shortcut.FlagString, Default: "docx", Desc: "导出格式", Enum: []string{"docx", "markdown", "pdf"}},
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内相对路径(文件或目录)"},
{Name: "max-polls", Type: shortcut.FlagInt, Default: "30", Desc: "最大轮询次数"},
},
Tips: []string{`dws doc +export --node <DOC_ID> --export-format docx --output ./exports/`, `dws doc +export --node <DOC_ID> --export-format markdown --output ./document.md`},
Validate: func(rt *shortcut.RuntimeContext) error { return localio.ValidateOutput(rt.Str("output")) },
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "--output 必须是工作目录内相对路径;默认 no-clobber"}},
Execute: executeExport,
}
var Import = shortcut.Shortcut{
Service: "doc",
Command: "+import",
Product: productDoc,
Description: "上传本地文件并等待转换成在线文档对象",
Intent: "当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
Contract: docContract("+import", "上传本地文件并等待转换成在线文档对象",
"当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。",
[]string{`dws doc +import --file ./report.docx --folder <FOLDER_ID>`, `dws doc +import --file ./notes.md --workspace <WORKSPACE_ID> --name "会议纪要"`}),
Flags: []shortcut.Flag{
{Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"},
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
{Name: "name", Type: shortcut.FlagString, Desc: "导入后名称"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"folder", "workspace"}, Description: "--folder 与 --workspace 至少提供一个导入目标"}},
Tips: []string{`dws doc +import --file ./report.docx --folder <FOLDER_ID>`, `dws doc +import --file ./notes.md --workspace <WORKSPACE_ID> --name "会议纪要"`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocImportShortcut(rt.Command()) },
}
func executeUpdate(rt *shortcut.RuntimeContext) error {
command := rt.Str("command")
contentFlag := "content"
if rt.Str("content") == "" && rt.Str("text") != "" {
contentFlag = "text"
}
content, err := readShortcutContent(rt, contentFlag)
if err != nil {
return err
}
if rt.Str("doc-format") == "jsonml" && content != "" {
content, err = validateJSONML(content)
if err != nil {
return err
}
}
nodeID := rt.StrFirst("node", "doc")
currentRevision := 0
if rt.Changed("expected-revision") {
current, revisionErr := rt.CallMCPData(productDoc, "get_document_content", map[string]any{"nodeId": nodeID, "format": "jsonml"})
if revisionErr != nil {
return revisionErr
}
var found bool
currentRevision, found = nestedRevision(current)
if !found {
return apperrors.NewAPI("REVISION_CONFLICT: 服务响应缺少当前 revision,无法安全执行乐观更新")
}
if expected := rt.Int("expected-revision"); currentRevision != expected {
return apperrors.NewValidation(fmt.Sprintf("REVISION_CONFLICT: 期望 revision %d,当前为 %d", expected, currentRevision))
}
}
plan := map[string]any{"nodeId": nodeID, "command": command, "blockId": rt.Str("block-id"), "afterBlockId": rt.Str("after-block-id"), "contentBytes": len(content)}
if rt.Changed("expected-revision") {
plan["expectedRevision"] = rt.Int("expected-revision")
plan["currentRevision"] = currentRevision
plan["optimisticCheck"] = "best_effort"
}
if rt.DryRun() {
plan["executed"] = false
return rt.Output(docEnvelope("doc.update", plan))
}
node := nodeID
switch command {
case "append", "overwrite":
params := map[string]any{"nodeId": node, "mode": command}
if rt.Str("doc-format") == "jsonml" {
if command == "append" {
return apperrors.NewValidation("JSONML 当前不支持 append")
}
params["format"], params["jsonml"] = "jsonml", content
} else {
params["markdown"] = content
}
return rt.CallMCP("update_document", params)
case "block_insert_after":
params := map[string]any{"nodeId": node, "referenceBlockId": rt.Str("after-block-id"), "where": "after"}
if rt.Str("doc-format") == "jsonml" {
params["format"], params["jsonml"] = "jsonml", content
} else {
params["element"] = map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": content}}
}
return rt.CallMCP("insert_document_block", params)
case "block_replace":
params := map[string]any{"nodeId": node, "blockId": rt.Str("block-id")}
if rt.Str("doc-format") == "jsonml" {
params["format"], params["jsonml"] = "jsonml", content
} else {
params["element"] = map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": content}}
}
return rt.CallMCP("update_document_block", params)
case "block_delete":
return rt.CallMCP("delete_document_block", map[string]any{"nodeId": node, "blockId": rt.Str("block-id")})
case "str_replace":
return executePlainTextReplace(rt, node)
case "block_copy_insert_after":
return executeBlockCopy(rt, node)
default:
return apperrors.NewValidation(fmt.Sprintf("不支持的 update command %q", command))
}
}
func nestedRevision(value any) (int, bool) {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
if normalized == "revision" || normalized == "version" || normalized == "versionnumber" {
switch number := child.(type) {
case float64:
if number >= 0 && number == float64(int(number)) {
return int(number), true
}
case json.Number:
parsed, err := number.Int64()
if err == nil && parsed >= 0 {
return int(parsed), true
}
case string:
var parsed int
if _, err := fmt.Sscan(strings.TrimSpace(number), &parsed); err == nil && parsed >= 0 {
return parsed, true
}
}
}
if revision, ok := nestedRevision(child); ok {
return revision, true
}
}
case []any:
for _, child := range typed {
if revision, ok := nestedRevision(child); ok {
return revision, true
}
}
}
return 0, false
}
func executePlainTextReplace(rt *shortcut.RuntimeContext, nodeID string) error {
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "format": "element"})
if err != nil {
return err
}
oldText := rt.Str("old")
type match struct{ blockID, text string }
matches := []match{}
var walk func(any, string)
walk = func(value any, inheritedID string) {
switch typed := value.(type) {
case map[string]any:
blockID := blockIdentity(typed, inheritedID)
for key, child := range typed {
if key == "text" {
if text, ok := child.(string); ok && strings.Contains(text, oldText) && blockID != "" {
matches = append(matches, match{blockID: blockID, text: text})
}
}
walk(child, blockID)
}
case []any:
for _, child := range typed {
walk(child, inheritedID)
}
}
}
walk(data, "")
if len(matches) != 1 {
return apperrors.NewValidation(fmt.Sprintf("UNSAFE_RICH_TEXT_REPLACE: 需要唯一普通文本块匹配,实际 %d 处", len(matches)))
}
updated := strings.Replace(matches[0].text, oldText, rt.Str("new"), 1)
return rt.CallMCP("update_document_block", map[string]any{"nodeId": nodeID, "blockId": matches[0].blockID, "element": map[string]any{"blockType": "paragraph", "paragraph": map[string]any{"text": updated}}})
}
func executeBlockCopy(rt *shortcut.RuntimeContext, nodeID string) error {
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": nodeID, "blockId": rt.Str("block-id"), "format": "element"})
if err != nil {
return err
}
block := findBlock(data, rt.Str("block-id"))
if block == nil {
return apperrors.NewValidation("DOCUMENT_NOT_FOUND: 未找到要复制的 block")
}
if containsResourceReference(block) {
return apperrors.NewValidation("UNSUPPORTED_RESOURCE_TYPE: 含资源引用的 block 暂不支持复制")
}
stripBlockIDs(block)
return rt.CallMCP("insert_document_block", map[string]any{"nodeId": nodeID, "referenceBlockId": rt.Str("after-block-id"), "where": "after", "element": block})
}
func executeExport(rt *shortcut.RuntimeContext) error {
plan := map[string]any{"nodeId": rt.Str("node"), "exportFormat": rt.Str("export-format"), "output": rt.Str("output")}
if rt.DryRun() {
plan["executed"] = false
plan["steps"] = []string{"submit_export_job", "query_export_job", "safe_atomic_download"}
return rt.Output(docEnvelope("doc.export", plan))
}
submit, err := rt.CallMCPData(productDoc, "submit_export_job", map[string]any{"nodeId": rt.Str("node"), "exportFormat": rt.Str("export-format")})
if err != nil {
return err
}
jobID := nestedString(submit, "jobId", "jobID")
if jobID == "" {
return apperrors.NewAPI("导出任务响应缺少 jobId")
}
maxPolls := rt.Int("max-polls")
if maxPolls <= 0 {
maxPolls = 30
}
var query map[string]any
for attempt := 1; attempt <= maxPolls; attempt++ {
query, err = rt.CallMCPData(productDoc, "query_export_job", map[string]any{"jobId": jobID})
if err != nil {
return err
}
status := strings.ToUpper(nestedString(query, "status"))
if status == "SUCCESS" {
break
}
if status != "PROCESSING" {
return apperrors.NewAPI(fmt.Sprintf("导出任务失败 (jobId=%s, status=%s): %s", jobID, status, nestedString(query, "message")))
}
if attempt == maxPolls {
return apperrors.NewAPI(fmt.Sprintf("导出任务超时 (jobId=%s),可用 doc +export-get 恢复查询", jobID))
}
timer := time.NewTimer(time.Duration(min(attempt, 5)) * time.Second)
select {
case <-rt.Command().Context().Done():
timer.Stop()
return rt.Command().Context().Err()
case <-timer.C:
}
}
downloadURL := nestedString(query, "downloadUrl", "resourceUrl")
if downloadURL == "" {
return apperrors.NewAPI(fmt.Sprintf("导出成功但响应缺少 downloadUrl (jobId=%s)", jobID))
}
cwd, err := docGetwd()
if err != nil {
return err
}
ext := map[string]string{"docx": ".docx", "markdown": ".md", "pdf": ".pdf"}[rt.Str("export-format")]
preferred := "document" + ext
result, err := docDownload(rt.Command().Context(), downloadURL, localio.DownloadOptions{BaseDir: cwd, Output: rt.Str("output"), PreferredName: preferred})
if err != nil {
return err
}
return rt.Output(docEnvelope("doc.export", map[string]any{"jobId": jobID, "localPath": result.RelativePath, "sizeBytes": result.SizeBytes},
map[string]any{"name": "submit", "status": "success"}, map[string]any{"name": "poll", "status": "success"}, map[string]any{"name": "download", "status": "success"}))
}
func projectKeywordMatches(data map[string]any, rawQuery string, before, after int) map[string]any {
queries := stringSliceNonEmpty(strings.Split(rawQuery, "|"))
if before <= 0 {
before = 80
}
if after <= 0 {
after = 120
}
matches := []map[string]any{}
appendTextMatch := func(text, blockID string) {
textRunes := []rune(text)
foldedText := foldRunes(textRunes)
for _, query := range queries {
foldedQuery := foldRunes([]rune(query))
index := indexRunes(foldedText, foldedQuery)
if index < 0 {
continue
}
start, end := max(0, index-before), min(len(textRunes), index+len(foldedQuery)+after)
matches = append(matches, map[string]any{
"blockId": blockID, "topBlockId": blockID, "parentBlockPath": []string{},
"content": string(textRunes[start:end]), "truncated": start > 0 || end < len(textRunes),
})
return
}
}
var walk func(any, string)
walk = func(value any, inheritedID string) {
switch typed := value.(type) {
case map[string]any:
blockID := blockIdentity(typed, inheritedID)
for key, child := range typed {
if key == "jsonml" {
if raw, ok := child.(string); ok {
var decoded any
if json.Unmarshal([]byte(raw), &decoded) == nil {
walk(decoded, blockID)
continue
}
}
}
if key == "text" {
if text, ok := child.(string); ok {
appendTextMatch(text, blockID)
continue
}
}
walk(child, blockID)
}
case []any:
blockID := inheritedID
start := 0
if len(typed) >= 2 {
if _, isTag := typed[0].(string); isTag {
start = 2
if attrs, ok := typed[1].(map[string]any); ok {
blockID = blockIdentity(attrs, blockID)
}
}
}
for _, child := range typed[start:] {
walk(child, blockID)
}
case string:
appendTextMatch(typed, inheritedID)
}
}
walk(data, "")
return map[string]any{"count": len(matches), "matches": matches}
}
func foldRunes(value []rune) []rune {
folded := make([]rune, len(value))
for index, char := range value {
folded[index] = unicode.ToLower(char)
}
return folded
}
func indexRunes(value, target []rune) int {
if len(target) == 0 || len(target) > len(value) {
return -1
}
for start := 0; start+len(target) <= len(value); start++ {
matched := true
for offset := range target {
if value[start+offset] != target[offset] {
matched = false
break
}
}
if matched {
return start
}
}
return -1
}
func checkpointPartialWriteError(nodeID string, checkpoint map[string]any, stage, reason string, cause error, steps []map[string]any) error {
data := map[string]any{"nodeId": nodeID, "checkpointSaved": true}
compensation := map[string]any{
"available": true,
"action": "revert_to_checkpoint",
"nodeId": nodeID,
"reason": "a checkpoint was saved before the update started",
}
if version, ok := nestedRevision(checkpoint); ok {
data["checkpointVersion"] = version
compensation["version"] = version
}
return docPartialWriteError(
"doc.checkpoint_update", reason, stage,
fmt.Sprintf("checkpoint-update 在 %s 阶段失败;恢复点已保存,nodeId=%s,请勿直接重试整个复合命令", stage, nodeID),
cause, data, steps, compensation,
)
}
func findBlock(value any, target string) map[string]any {
switch typed := value.(type) {
case map[string]any:
if blockIdentity(typed, "") == target {
copy := map[string]any{}
for key, value := range typed {
copy[key] = value
}
return copy
}
for _, child := range typed {
if found := findBlock(child, target); found != nil {
return found
}
}
case []any:
for _, child := range typed {
if found := findBlock(child, target); found != nil {
return found
}
}
}
return nil
}
func containsResourceReference(value any) bool {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
if (key == "resourceId" || key == "resourceUrl" || key == "src") && fmt.Sprint(child) != "" {
return true
}
if containsResourceReference(child) {
return true
}
}
case []any:
for _, child := range typed {
if containsResourceReference(child) {
return true
}
}
}
return false
}
func stripBlockIDs(value any) {
switch typed := value.(type) {
case map[string]any:
for _, key := range []string{"blockId", "id", "uuid"} {
delete(typed, key)
}
for _, child := range typed {
stripBlockIDs(child)
}
case []any:
for _, child := range typed {
stripBlockIDs(child)
}
}
}
func init() {
_ = json.Valid
_ = filepath.Separator
shortcut.Register(Create, Fetch, Inspect, Update, CheckpointUpdate, Export, Import)
}
+8
View File
@@ -982,6 +982,11 @@ var TemplateApply = shortcut.Shortcut{
}
func init() {
// Expert/recovery leaves remain callable without entering Agent discovery.
CommentCreateInline.Contract = corecmd.ContractDecl{}
TemplateApply.Contract = corecmd.ContractDecl{}
canonicalizeHistoryShortcuts()
canonicalizeCommentShortcuts()
shortcut.Register(
Search,
List,
@@ -993,6 +998,9 @@ func init() {
CommentCreateInline,
ExportSubmit,
ExportGet,
legacyVersionSave,
legacyVersionList,
legacyVersionRevert,
VersionSave,
VersionList,
VersionRevert,
+763
View File
@@ -0,0 +1,763 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"context"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"unicode/utf8"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type docCoverageCaller struct {
failAt int
calls int
responses map[string][]map[string]any
ctx context.Context
history []docCoverageCall
}
type docCoverageCall struct {
tool string
params map[string]any
}
type docCoverageErrorReader struct{}
func (docCoverageErrorReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }
func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) {
f.calls++
f.history = append(f.history, docCoverageCall{tool: tool, params: params})
if f.failAt == f.calls {
return nil, errors.New("injected doc coverage failure")
}
value := docCoveragePayload(tool)
if queue := f.responses[tool]; len(queue) > 0 {
value = queue[0]
f.responses[tool] = queue[1:]
}
encoded, err := json.Marshal(value)
if err != nil {
return nil, err
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil
}
func (f *docCoverageCaller) Format() string { return "json" }
func (f *docCoverageCaller) DryRun() bool { return false }
func (f *docCoverageCaller) Fields() string { return "" }
func (f *docCoverageCaller) JQ() string { return "" }
func docCoveragePayload(tool string) map[string]any {
switch tool {
case "create_document":
return map[string]any{"data": map[string]any{"nodeId": "node-1"}}
case "get_document_content":
return map[string]any{"data": map[string]any{"revision": 1, "jsonml": `["root",{},["p",{"uuid":"block-1"},"alpha beta"]]`}}
case "list_document_blocks":
return map[string]any{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta"}}}}}
case "submit_export_job":
return map[string]any{"jobId": "job-1"}
case "query_export_job":
return map[string]any{"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/export.docx"}
case "list_doc_versions":
return map[string]any{"versions": []any{map[string]any{"version": 3.0}, map[string]any{"versionNumber": "4"}}}
case "search_doc_templates":
return map[string]any{"templates": []any{map[string]any{"templateId": "template-1"}}}
case "get_document_style":
return map[string]any{"data": map[string]any{"cover": map[string]any{"resourceId": "resource-1", "imageUrl": "https://download.dingtalk.com/cover.png"}}}
case "download_doc_attachment":
return map[string]any{"downloadUrl": "https://download.dingtalk.com/file.bin", "fileName": "file.bin", "headers": map[string]any{"x-test": "ok", "ignored": 1}}
case "list_comments":
return map[string]any{"commentList": []any{map[string]any{"commentKey": "comment-1", "content": "review", "quote": "alpha"}}}
default:
return map[string]any{"ok": true, "result": map[string]any{"id": "id-1"}}
}
}
func runDocCoverage(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, args ...string) error {
return runDocCoverageInput(t, declaration, caller, strings.NewReader(""), args...)
}
func runDocCoverageInput(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, input io.Reader, args ...string) error {
return runDocCoveragePath(t, declaration, caller, input, declaration.Command, args...)
}
func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, input io.Reader, commandPath string, args ...string) error {
t.Helper()
helpers.InitDeps(caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
service := &cobra.Command{Use: "doc"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
root.AddCommand(service)
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetIn(input)
if caller.ctx != nil {
root.SetContext(caller.ctx)
}
root.SetArgs(append([]string{"doc", commandPath}, args...))
return root.Execute()
}
func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testing.T) {
revisionPayload := map[string]any{"data": map[string]any{"revision": json.Number("9")}}
if got, ok := nestedRevision(revisionPayload); !ok || got != 9 {
t.Fatalf("nestedRevision = %d/%v", got, ok)
}
blocks := map[string]any{"blocks": []any{
map[string]any{"element": map[string]any{
"id": "block-1", "paragraph": map[string]any{"text": "前缀😀真实追加:beta。后缀"},
}},
}}
matches := findSelectionMatches(blocks, "真实追加:beta。")
if len(matches) != 1 {
t.Fatalf("matches = %#v", matches)
}
if got := matches[0]; got.blockID != "block-1" || got.start != 4 || got.end != 14 {
t.Fatalf("selection match = %#v", got)
}
jsonml := `["root",{},["p",{"uuid":"block-jsonml"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"旧入口兼容追加:gamma。"]]]]`
projected := projectKeywordMatches(map[string]any{"jsonml": jsonml}, "gamma", 80, 120)
if projected["count"] != 1 {
t.Fatalf("keyword projection = %#v", projected)
}
rows := projected["matches"].([]map[string]any)
if rows[0]["blockId"] != "block-jsonml" || rows[0]["content"] != "旧入口兼容追加:gamma。" {
t.Fatalf("keyword row = %#v", rows[0])
}
unicodeProjection := projectKeywordMatches(map[string]any{"id": "unicode-block", "text": "KABtargetCD"}, "TARGET", 1, 1)
unicodeRows := unicodeProjection["matches"].([]map[string]any)
if len(unicodeRows) != 1 || unicodeRows[0]["content"] != "BtargetC" || !utf8.ValidString(unicodeRows[0]["content"].(string)) {
t.Fatalf("unicode keyword projection = %#v", unicodeProjection)
}
}
func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) {
assertPartial := func(t *testing.T, err error, reason, stage, nodeID string, wantSteps int) *apperrors.Error {
t.Helper()
if err == nil {
t.Fatal("partial write unexpectedly succeeded")
}
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("partial write error = %#v", err)
}
if typed.Reason != reason || typed.FailureStage != stage || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
t.Fatalf("partial write metadata = %#v", typed)
}
if typed.Details["status"] != "partial_success" {
t.Fatalf("partial write details = %#v", typed.Details)
}
data, _ := typed.Details["data"].(map[string]any)
if data["nodeId"] != nodeID {
t.Fatalf("partial write data = %#v", data)
}
steps, _ := typed.Details["steps"].([]map[string]any)
if len(steps) != wantSteps || steps[0]["status"] != "success" || steps[len(steps)-1]["status"] == "success" {
t.Fatalf("partial write steps = %#v", steps)
}
return typed
}
create := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}}
err := runDocCoverage(t, Create, create, "--name", "n", "--content", `[]`, "--doc-format", "jsonml")
typed := assertPartial(t, err, "doc_create_initial_content_failed", "write_jsonml", "node-1", 2)
compensation, _ := typed.Details["compensation"].(map[string]any)
if compensation["available"] != true || compensation["nodeId"] != "node-1" || len(create.history) != 2 {
t.Fatalf("create compensation=%#v history=%#v", compensation, create.history)
}
checkpointUpdate := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{
"save_doc_version": {{"version": 7.0}},
}}
err = runDocCoverage(t, CheckpointUpdate, checkpointUpdate, "--node", "n", "--content", "body", "--yes")
typed = assertPartial(t, err, "doc_checkpoint_update_failed", "update", "n", 3)
data, _ := typed.Details["data"].(map[string]any)
compensation, _ = typed.Details["compensation"].(map[string]any)
if data["checkpointVersion"] != 7 || compensation["version"] != 7 {
t.Fatalf("checkpoint recovery metadata data=%#v compensation=%#v", data, compensation)
}
checkpointVerify := &docCoverageCaller{failAt: 3, responses: map[string][]map[string]any{}}
err = runDocCoverage(t, CheckpointUpdate, checkpointVerify, "--node", "n", "--content", "body", "--yes")
assertPartial(t, err, "doc_checkpoint_verification_failed", "verify", "n", 3)
historyVerify := &docCoverageCaller{failAt: 3, responses: map[string][]map[string]any{}}
err = runDocCoverage(t, VersionRevert, historyVerify, "--node", "n", "--version", "3", "--yes")
assertPartial(t, err, "doc_history_revert_verification_failed", "verify", "n", 3)
}
func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T) {
tests := []struct {
name string
args []string
wantTools []string
}{
{
name: "plain text replace",
args: []string{"--doc", "alias-node", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"},
wantTools: []string{"list_document_blocks", "update_document_block"},
},
{
name: "block copy",
args: []string{"--doc", "alias-node", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "after", "--yes"},
wantTools: []string{"list_document_blocks", "insert_document_block"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, Update, caller, tc.args...); err != nil {
t.Fatal(err)
}
if len(caller.history) != len(tc.wantTools) {
t.Fatalf("calls = %#v", caller.history)
}
for index, call := range caller.history {
if call.tool != tc.wantTools[index] || call.params["nodeId"] != "alias-node" {
t.Fatalf("call %d = %#v, want tool=%s nodeId=alias-node", index, call, tc.wantTools[index])
}
}
})
}
}
func TestCrossPlatformCoverageSelectionMatchesEnumerateEveryCandidate(t *testing.T) {
tests := []struct {
name string
text string
selection string
want int
}{
{name: "repeated omitted range", text: "left A right; left B right", selection: "left...right", want: 3},
{name: "empty prefix", text: "one right; two right", selection: "...right", want: 2},
{name: "empty suffix", text: "left one; left two", selection: "left...", want: 2},
{name: "both empty anchors", text: "whole block", selection: "...", want: 1},
{name: "empty block", text: "", selection: "...", want: 0},
{name: "overlapping literal", text: "aaa", selection: "aa", want: 2},
{name: "empty selection", text: "text", selection: "", want: 0},
{name: "missing prefix", text: "text", selection: "left...right", want: 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
matches := findSelectionMatches(map[string]any{"id": "block", "text": tc.text}, tc.selection)
if len(matches) != tc.want {
t.Fatalf("matches = %#v, want %d", matches, tc.want)
}
})
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{
"list_document_blocks": {{"items": []any{map[string]any{"id": "block", "text": "left A right; left B right"}}}},
}}
err := runDocCoverage(t, CommentCreate, caller, "--node", "n", "--content", "review", "--selection", "left...right", "--yes")
if err == nil || !strings.Contains(err.Error(), "AMBIGUOUS_SELECTION") {
t.Fatalf("same-block ambiguity error = %v", err)
}
if len(caller.history) != 1 || caller.history[0].tool != "list_document_blocks" {
t.Fatalf("ambiguous selection reached a write: %#v", caller.history)
}
}
func TestCrossPlatformCoverageDocDestructiveConfirmationBoundaries(t *testing.T) {
tests := []struct {
name string
decl shortcut.Shortcut
args []string
want []docCoverageCall
}{
{
name: "comment delete",
decl: CommentDelete,
args: []string{"--node", "n", "--comment-key", "c"},
want: []docCoverageCall{{tool: "delete_comment", params: map[string]any{"nodeId": "n", "commentKey": "c"}}},
},
{
name: "resource delete",
decl: ResourceDelete,
args: []string{"--node", "n"},
want: []docCoverageCall{{tool: "update_document_style", params: map[string]any{"nodeId": "n", "cover": map[string]any{"action": "clear"}}}},
},
{
name: "history revert",
decl: VersionRevert,
args: []string{"--node", "n", "--version", "3"},
want: []docCoverageCall{
{tool: "list_doc_versions", params: map[string]any{"nodeId": "n"}},
{tool: "revert_doc_version", params: map[string]any{"nodeId": "n", "version": 3}},
{tool: "get_document_info", params: map[string]any{"nodeId": "n"}},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
unconfirmed := &docCoverageCaller{responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, tc.decl, unconfirmed, tc.args...); err == nil {
t.Fatal("destructive shortcut without --yes must reject")
}
if unconfirmed.calls != 0 || len(unconfirmed.history) != 0 {
t.Fatalf("unconfirmed shortcut called MCP: %#v", unconfirmed.history)
}
confirmed := &docCoverageCaller{responses: map[string][]map[string]any{}}
if err := runDocCoverage(t, tc.decl, confirmed, append(tc.args, "--yes")...); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(confirmed.history, tc.want) {
t.Fatalf("confirmed calls = %#v, want %#v", confirmed.history, tc.want)
}
})
}
}
func TestCrossPlatformCoverageReviewInfersInlineBlockFromUniqueQuote(t *testing.T) {
comments := map[string]any{"commentList": []any{
map[string]any{"commentKey": "inline", "content": "review", "isGlobal": false, "quote": "真实追加:beta。"},
map[string]any{"commentKey": "global", "content": "global", "isGlobal": true},
}}
blocks := map[string]any{"blocks": []any{
map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "真实追加:beta。"}}},
}}
items := projectReviewComments(comments, blocks)
if len(items) != 2 {
t.Fatalf("review items = %#v", items)
}
if items[0]["blockId"] != "block-1" || items[0]["context"] != "真实追加:beta。" {
t.Fatalf("inline review = %#v", items[0])
}
if items[1]["blockId"] != "" {
t.Fatalf("global review = %#v", items[1])
}
}
func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("body.json", []byte(`["root",{},"body"]`), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("body.md", []byte("from file"), 0o600); err != nil {
t.Fatal(err)
}
cases := []struct {
name string
cmd shortcut.Shortcut
args []string
}{
{"create markdown", Create, []string{"--name", "n", "--content", "body", "--folder", "f", "--workspace", "w"}},
{"create dry", Create, []string{"--name", "n", "--content", "body", "--dry-run"}},
{"create jsonml file", Create, []string{"--name", "n", "--content", "@body.json", "--doc-format", "jsonml"}},
{"create stdin", Create, []string{"--name", "n", "--content", "-"}},
{"fetch simple", Fetch, []string{"--node", "n"}},
{"fetch keyword", Fetch, []string{"--node", "n", "--detail", "full", "--scope", "keyword", "--keyword", "alpha|none", "--context-before", "1", "--context-after", "1"}},
{"fetch scoped", Fetch, []string{"--node", "n", "--scope", "range", "--start-block-id", "a", "--end-block-id", "b", "--tags", "p,h1", "--max-depth", "2"}},
{"inspect base", Inspect, []string{"--node", "n"}},
{"inspect all", Inspect, []string{"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"}},
{"update append", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--yes"}},
{"update overwrite jsonml", Update, []string{"--node", "n", "--command", "overwrite", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{"update insert text", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", "x", "--yes"}},
{"update insert jsonml", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{"update replace text", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", "x", "--yes"}},
{"update replace jsonml", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
{"update delete", Update, []string{"--node", "n", "--command", "block_delete", "--block-id", "b", "--yes"}},
{"update replace", Update, []string{"--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"}},
{"update copy", Update, []string{"--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "b", "--yes"}},
{"update revision", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"}},
{"update dry", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--dry-run", "--yes"}},
{"checkpoint dry", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--dry-run", "--yes"}},
{"checkpoint success", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--yes"}},
{"export dry", Export, []string{"--node", "n", "--output", "out.docx", "--dry-run"}},
{"export success", Export, []string{"--node", "n", "--output", "out.docx"}},
}
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{RelativePath: "out.docx", SizeBytes: 7}, nil
})
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
var err error
if tc.name == "create stdin" {
err = runDocCoverageInput(t, tc.cmd, caller, strings.NewReader("stdin body"), tc.args...)
} else {
err = runDocCoverage(t, tc.cmd, caller, tc.args...)
}
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
})
}
for _, command := range []shortcut.Shortcut{Create, Fetch, Inspect, Update, CheckpointUpdate, Export} {
for failAt := 1; failAt <= 7; failAt++ {
args := map[string][]string{
"+create": {"--name", "n", "--content", `[]`, "--doc-format", "jsonml"},
"+fetch": {"--node", "n", "--scope", "keyword", "--keyword", "x"},
"+inspect": {"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"},
"+update": {"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"},
"+checkpoint-update": {"--node", "n", "--content", "x", "--yes"},
"+export": {"--node", "n", "--output", "out.docx"},
}[command.Command]
_ = runDocCoverage(t, command, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}}, args...)
}
}
}
func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("body.md", []byte("body"), 0o600); err != nil {
t.Fatal(err)
}
outside := filepath.Join(filepath.Dir(t.TempDir()), "outside.md")
_ = outside
badCases := []struct {
cmd shortcut.Shortcut
args []string
}{
{Create, []string{"--name", "n", "--content", "@"}},
{Create, []string{"--name", "n", "--content", "@/absolute"}},
{Create, []string{"--name", "n", "--content", "not-json", "--doc-format", "jsonml"}},
{Create, []string{"--name", "n", "--content", `{}`, "--doc-format", "jsonml"}},
{Fetch, []string{"--node", "n", "--revision", "1"}},
{Fetch, []string{"--node", "n", "--scope", "keyword"}},
{Update, []string{"--node", "n"}},
{Update, []string{"--command", "append", "--content", "x", "--yes"}},
{Update, []string{"--node", "n", "--command", "append", "--yes"}},
{Update, []string{"--node", "n", "--command", "block_delete", "--yes"}},
{Update, []string{"--node", "n", "--command", "str_replace", "--old", "x", "--yes"}},
{Update, []string{"--node", "n", "--command", "append", "--content", `[]`, "--doc-format", "jsonml", "--yes"}},
}
for _, tc := range badCases {
if err := runDocCoverage(t, tc.cmd, &docCoverageCaller{responses: map[string][]map[string]any{}}, tc.args...); err == nil {
t.Errorf("%s %#v unexpectedly succeeded", tc.cmd.Command, tc.args)
}
}
createNoNode := &docCoverageCaller{responses: map[string][]map[string]any{"create_document": {{"ok": true}}}}
if err := runDocCoverage(t, Create, createNoNode, "--name", "n", "--content", `[]`, "--doc-format", "jsonml"); err == nil {
t.Fatal("jsonml create without node id succeeded")
}
conflict := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"revision": 2}}}}
if err := runDocCoverage(t, Update, conflict, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil {
t.Fatal("revision conflict succeeded")
}
missingRevision := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"ok": true}}}}
if err := runDocCoverage(t, Update, missingRevision, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil {
t.Fatal("missing revision succeeded")
}
for _, value := range []any{
map[string]any{"revision": 2.5}, map[string]any{"revision": json.Number("bad")}, map[string]any{"revision": "bad"},
map[string]any{"data": []any{map[string]any{"versionNumber": "3"}}}, []any{map[string]any{"version": 4.0}}, "none",
} {
_, _ = nestedRevision(value)
}
if _, err := validateJSONML(`[`); err == nil {
t.Fatal("invalid jsonml succeeded")
}
if _, err := validateJSONML(`{}`); err == nil {
t.Fatal("object jsonml succeeded")
}
if nestedMap(map[string]any{"result": map[string]any{"data": map[string]any{"x": 1}}})["x"] != 1 {
t.Fatal("nestedMap did not unwrap")
}
_ = stringSliceNonEmpty([]string{"", " a "})
blocks := map[string]any{"items": []any{map[string]any{"id": "b", "text": "alpha alpha"}, map[string]any{"id": "resource", "src": "x"}}}
_ = projectKeywordMatches(blocks, "alpha|beta", -1, -1)
_ = projectKeywordMatches(map[string]any{"jsonml": "bad"}, "none", 1, 1)
_ = findBlock(blocks, "b")
_ = findBlock([]any{blocks}, "missing")
_ = containsResourceReference(blocks)
_ = containsResourceReference([]any{map[string]any{"x": "y"}})
stripBlockIDs(blocks)
if err := runDocCoveragePath(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, strings.NewReader(""), "+update", "--doc", "n", "--command", "append", "--text", "legacy", "--yes"); err != nil {
t.Fatalf("visible update flag aliases: %v", err)
}
missingNode := Update
missingNode.Flags = append([]shortcut.Flag(nil), Update.Flags...)
missingNode.Flags[0].Required = false
if err := runDocCoverage(t, missingNode, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--command", "append", "--content", "x", "--yes"); err == nil {
t.Fatal("custom missing-node validation was not reached")
}
unknown := Update
unknown.Flags = append([]shortcut.Flag(nil), Update.Flags...)
unknown.Flags[1].Enum = append(append([]string(nil), unknown.Flags[1].Enum...), "bogus")
if err := runDocCoverage(t, unknown, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--command", "bogus", "--yes"); err == nil {
t.Fatal("unknown update command succeeded")
}
_ = runDocCoverageInput(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, docCoverageErrorReader{}, "--name", "n", "--content", "-")
for _, seamCase := range []struct {
name string
run func(*testing.T)
}{
{"getwd", func(t *testing.T) {
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") })
_ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md")
}},
{"eval-base", func(t *testing.T) {
testseam.Swap(t, &docEvalSymlinks, func(string) (string, error) { return "", errors.New("eval") })
_ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md")
}},
{"eval-file", func(t *testing.T) {
calls := 0
testseam.Swap(t, &docEvalSymlinks, func(value string) (string, error) {
calls++
if calls == 2 {
return "", errors.New("eval file")
}
return value, nil
})
_ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md")
}},
{"rel", func(t *testing.T) {
testseam.Swap(t, &docRel, func(string, string) (string, error) { return "", errors.New("rel") })
_ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md")
}},
{"read", func(t *testing.T) {
testseam.Swap(t, &docReadFile, func(string) ([]byte, error) { return nil, errors.New("read") })
_ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md")
}},
} {
t.Run(seamCase.name, seamCase.run)
}
_ = runDocCoverage(t, CheckpointUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--content", "@missing", "--yes")
_ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--command", "append", "--content", "@missing", "--yes")
_ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--command", "overwrite", "--content", "bad", "--doc-format", "jsonml", "--yes")
_ = runDocCoverage(t, Update, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "x", "--yes")
_ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"items": []any{map[string]any{"id": "a", "text": "alpha"}, map[string]any{"id": "b", "text": "alpha"}}}}}}, "--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "x", "--yes")
_ = runDocCoverage(t, Update, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--yes")
_ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"ok": true}}}}, "--node", "n", "--command", "block_copy_insert_after", "--block-id", "missing", "--yes")
_ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "block-1", "resourceId": "r"}}}}, "--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--yes")
for _, response := range []map[string]any{
{"ok": true},
{"jobId": "j"},
} {
caller := &docCoverageCaller{responses: map[string][]map[string]any{"submit_export_job": {response}, "query_export_job": {{"status": "FAILED", "message": "bad"}}}}
_ = runDocCoverage(t, Export, caller, "--node", "n", "--output", "x", "--max-polls", "1")
}
timeout := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}}
_ = runDocCoverage(t, Export, timeout, "--node", "n", "--output", "x", "--max-polls", "1")
processingThenSuccess := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}, {"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/x"}}}}
_ = runDocCoverage(t, Export, processingThenSuccess, "--node", "n", "--output", "x", "--max-polls", "2")
cancelled, cancel := context.WithCancel(context.Background())
cancel()
cancelledCaller := &docCoverageCaller{ctx: cancelled, responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}}
_ = runDocCoverage(t, Export, cancelledCaller, "--node", "n", "--output", "x", "--max-polls", "2")
_ = runDocCoverage(t, Export, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--output", "x", "--max-polls", "0")
missingURL := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS"}}}}
_ = runDocCoverage(t, Export, missingURL, "--node", "n", "--output", "x")
}
func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) {
t.Chdir(t.TempDir())
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{RelativePath: "artifact.bin", SizeBytes: 9}, nil
})
commands := []struct {
decl shortcut.Shortcut
args []string
}{
{VersionList, []string{"--node", "n", "--page-size", "2", "--page-token", "p"}},
{VersionList, []string{"--node", "n", "--limit", "2", "--cursor", "p"}},
{VersionRevert, []string{"--node", "n", "--version", "3", "--yes"}},
{VersionRevert, []string{"--node", "n", "--version", "3", "--dry-run", "--yes"}},
{CreateFromTemplate, []string{"--template-id", "t", "--name", "n", "--folder", "f", "--workspace", "w"}},
{CreateFromTemplate, []string{"--query", "q", "--source", "PUBLIC", "--dry-run"}},
{Review, []string{"--node", "n"}},
{CommentUpdate, []string{"--node", "n", "--comment-key", "c", "--content", "x", "--mention", "u"}},
{CommentDelete, []string{"--node", "n", "--comment-key", "c", "--dry-run", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--block-id", "b", "--start", "0", "--end", "1", "--selected-text", "a", "--mention", "u", "--yes"}},
{CommentCreate, []string{"--node", "n", "--content", "x", "--selection", "alpha", "--yes"}},
{MediaList, []string{"--node", "n"}},
{MediaPreview, []string{"--node", "n", "--resource-id", "r"}},
{MediaPreview, []string{"--node", "n", "--resource-id", "r", "--dry-run"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "m.bin"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "m.bin", "--dry-run"}},
{ResourceDownload, []string{"--node", "n", "--output", "cover.png"}},
{ResourceDownload, []string{"--node", "n", "--output", "cover.png", "--dry-run"}},
{ResourceDelete, []string{"--node", "n", "--dry-run", "--yes"}},
{BackgroundUpdate, []string{"--node", "n", "--color", "#ABCDEF"}},
{BackgroundDelete, []string{"--node", "n", "--dry-run", "--yes"}},
{BackgroundDelete, []string{"--node", "n", "--yes"}},
{ResourceDelete, []string{"--node", "n", "--yes"}},
{CommentDelete, []string{"--node", "n", "--comment-key", "c", "--yes"}},
}
for _, item := range commands {
if err := runDocCoverage(t, item.decl, &docCoverageCaller{responses: map[string][]map[string]any{}}, item.args...); err != nil {
t.Errorf("%s: %v", item.decl.Command, err)
}
}
for _, declaration := range []shortcut.Shortcut{VersionRevert, CreateFromTemplate, Review, MediaList, MediaDownload, ResourceDownload} {
args := map[string][]string{
"+history-revert": {"--node", "n", "--version", "3", "--yes"},
"+create-from-template": {"--query", "q"},
"+review": {"--node", "n"},
"+media-list": {"--node", "n"},
"+media-download": {"--node", "n", "--resource-id", "r", "--output", "m.bin"},
"+resource-download": {"--node", "n", "--output", "cover.png"},
}[declaration.Command]
for failAt := 1; failAt <= 4; failAt++ {
_ = runDocCoverage(t, declaration, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}}, args...)
}
}
for _, value := range []any{
map[string]any{"version": 3.0}, map[string]any{"version": 3.5}, map[string]any{"version": "3"}, map[string]any{"version": "bad"},
[]any{map[string]any{"revision": 3.0}}, "none",
} {
_ = containsVersion(value, 3)
}
_ = collectTemplateIDs(map[string]any{"template_id": "t1", "nested": []any{map[string]any{"templateId": "t1"}, map[string]any{"templateId": "t2"}, "x"}})
_ = collectTemplateIDs("none")
_ = collectMediaItems(map[string]any{"id": "b", "resourceId": "r", "src": "u", "name": "n", "type": "file", "mimeType": "x", "viewType": "v", "children": []any{map[string]any{"resourceUrl": "u2"}}})
_ = nestedStringDeep([]any{map[string]any{"x": map[string]any{"url": " u "}}}, "url")
_ = nestedStringDeep("none", "url")
badComments := [][]string{
{"--node", "n", "--content", "x", "--block-id", "b", "--yes"},
{"--node", "n", "--content", "x", "--block-id", "b", "--start", "2", "--end", "1", "--yes"},
{"--node", "n", "--content", "x", "--block-id", "b", "--start", "0", "--end", "1", "--selection", "x", "--yes"},
}
for _, args := range badComments {
if err := runDocCoverage(t, CommentCreate, &docCoverageCaller{responses: map[string][]map[string]any{}}, args...); err == nil {
t.Errorf("invalid comment args succeeded: %#v", args)
}
}
ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"items": []any{map[string]any{"id": "a", "text": "x"}, map[string]any{"id": "b", "text": "x"}}}}}}
if err := runDocCoverage(t, CommentCreate, ambiguous, "--node", "n", "--content", "c", "--selection", "x", "--yes"); err == nil {
t.Fatal("ambiguous comment selection succeeded")
}
_ = findSelectionMatches(map[string]any{"id": "b", "text": "left middle right"}, "left...right")
_ = findSelectionMatches([]any{map[string]any{"id": "b", "text": "none"}}, "x")
_ = runDocCoverage(t, CommentCreate, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--content", "c", "--selection", "x", "--yes")
globalReview := &docCoverageCaller{responses: map[string][]map[string]any{"list_comments": {{"comments": []any{map[string]any{"commentKey": "global", "content": "g"}}}}}}
_ = runDocCoverage(t, Review, globalReview, "--node", "n")
_ = runDocCoverage(t, VersionRevert, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--version", "3", "--yes")
_ = runDocCoverage(t, VersionRevert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--version", "99", "--yes")
_ = runDocCoverage(t, CreateFromTemplate, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--template-id", "t")
multipleTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "a"}, map[string]any{"templateId": "b"}}}}}}
_ = runDocCoverage(t, CreateFromTemplate, multipleTemplates, "--query", "q")
if err := os.WriteFile("media.bin", []byte("media"), 0o600); err != nil {
t.Fatal(err)
}
_ = runDocCoverage(t, MediaInsert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "media.bin", "--dry-run", "--yes")
_ = runDocCoverage(t, ResourceUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--image", "https://example.com/cover.png", "--dry-run", "--yes")
_ = runDocCoverage(t, Import, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--file", "media.bin", "--folder", "f", "--dry-run")
resourceOnly := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_style": {{"resourceId": "r"}}}}
_ = runDocCoverage(t, ResourceDownload, resourceOnly, "--node", "n", "--output", "cover.png")
emptyStyle := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_style": {{"ok": true}}}}
_ = runDocCoverage(t, ResourceDownload, emptyStyle, "--node", "n", "--output", "cover.png")
_ = runDocCoverage(t, ResourceDownload, &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{"get_document_style": {{"resourceId": "r"}}}}, "--node", "n", "--output", "cover.png")
_, _ = downloadResolvedResource(nil, map[string]any{}, ".", "x")
_ = runDocCoverage(t, MediaPreview, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r")
_ = runDocCoverage(t, BackgroundUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--color", "bad")
_ = runDocCoverage(t, BackgroundUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--color", "#ABCDEG")
t.Run("preview mkdir failure", func(t *testing.T) {
testseam.Swap(t, &docMkdirTemp, func(string, string) (string, error) { return "", errors.New("mkdir") })
_ = runDocCoverage(t, MediaPreview, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r")
})
t.Run("preview download cleanup", func(t *testing.T) {
removed := false
testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, errors.New("download")
})
testseam.Swap(t, &docRemoveAll, func(string) error { removed = true; return nil })
_ = runDocCoverage(t, MediaPreview, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r")
if !removed {
t.Fatal("preview failure did not clean temporary directory")
}
})
}
func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T) {
t.Chdir(t.TempDir())
testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) {
return localio.DownloadResult{}, errors.New("download failed")
})
for _, item := range []struct {
decl shortcut.Shortcut
args []string
}{
{Export, []string{"--node", "n", "--output", "out.docx"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
} {
if err := runDocCoverage(t, item.decl, &docCoverageCaller{responses: map[string][]map[string]any{}}, item.args...); err == nil {
t.Errorf("%s download error was ignored", item.decl.Command)
}
}
testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd failed") })
for _, item := range []struct {
decl shortcut.Shortcut
args []string
}{
{Export, []string{"--node", "n", "--output", "out.docx"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
} {
_ = runDocCoverage(t, item.decl, &docCoverageCaller{responses: map[string][]map[string]any{}}, item.args...)
}
}
func TestCrossPlatformCoverageDocDownloadsHaveNoOverwriteEscape(t *testing.T) {
for _, item := range []struct {
decl shortcut.Shortcut
args []string
}{
{Export, []string{"--node", "n", "--output", "out.docx"}},
{MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}},
{ResourceDownload, []string{"--node", "n", "--output", "out.png"}},
} {
t.Run(item.decl.Command, func(t *testing.T) {
for _, flag := range item.decl.Flags {
if flag.Name == "overwrite" {
t.Fatal("download shortcut still declares --overwrite")
}
}
caller := &docCoverageCaller{responses: map[string][]map[string]any{}}
err := runDocCoverage(t, item.decl, caller, append(item.args, "--overwrite")...)
if err == nil {
t.Fatal("--overwrite unexpectedly accepted")
}
if caller.calls != 0 {
t.Fatalf("rejected --overwrite performed %d MCP calls", caller.calls)
}
})
}
}
@@ -0,0 +1,249 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var (
legacyVersionSave shortcut.Shortcut
legacyVersionList shortcut.Shortcut
legacyVersionRevert shortcut.Shortcut
)
func canonicalizeHistoryShortcuts() {
legacyVersionSave = VersionSave
legacyVersionList = VersionList
legacyVersionRevert = VersionRevert
VersionSave.Command = "+history-save"
VersionSave.Aliases = nil
VersionSave.Description = "手动保存当前文档版本快照"
VersionSave.Intent = "当用户要在重要修改前后手动建立一个可回滚的文档历史快照时使用;保存快照本身无需交互确认。"
VersionSave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"}
VersionSave.Contract = docContract("+history-save", VersionSave.Description, VersionSave.Intent, []string{`dws doc +history-save --node <DOC_ID>`})
VersionSave.Tips = []string{`dws doc +history-save --node <DOC_ID>`}
VersionList.Command = "+history-list"
VersionList.Aliases = nil
VersionList.Description = "分页列出文档历史版本"
VersionList.Intent = "当用户要查看文档已有版本、选择回滚目标或审计版本时间线时使用;返回版本号和分页游标。"
VersionList.Contract = docContract("+history-list", VersionList.Description, VersionList.Intent, []string{`dws doc +history-list --node <DOC_ID>`, `dws doc +history-list --node <DOC_ID> --page-size 20`})
VersionList.Flags = []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "page-size", Type: shortcut.FlagInt, Desc: "每页版本数量"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的兼容别名"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "--page-token 的兼容别名"},
}
VersionList.Tips = []string{`dws doc +history-list --node <DOC_ID>`, `dws doc +history-list --node <DOC_ID> --page-size 20`}
VersionList.Execute = func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node")}
if size := rt.IntFirst("page-size", "limit"); size > 0 {
params["maxResults"] = size
}
if token := rt.StrFirst("page-token", "cursor"); token != "" {
params["nextCursor"] = token
}
return rt.CallMCP("list_doc_versions", params)
}
VersionRevert.Command = "+history-revert"
VersionRevert.Aliases = nil
VersionRevert.Description = "预检并回滚文档到指定历史版本"
VersionRevert.Intent = "当用户明确要把整篇文档恢复到某个历史版本时使用;先确认目标版本存在,再执行高风险回滚并读回验证。"
VersionRevert.Contract = docContract("+history-revert", VersionRevert.Description, VersionRevert.Intent, []string{`dws doc +history-revert --node <DOC_ID> --version 3`})
VersionRevert.Tips = []string{`dws doc +history-revert --node <DOC_ID> --version 3`}
VersionRevert.Execute = executeHistoryRevert
TemplateList.Description = "浏览当前用户可用的 MY/PUBLIC 文档模板"
TemplateList.Intent = "当用户要浏览自己的或公开的文档模板并获取 templateId 时使用;若已知名称可改用 template-search。"
TemplateList.Contract = docContract("+template-list", TemplateList.Description, TemplateList.Intent, []string{`dws doc +template-list --source PUBLIC`})
TemplateSearch.Description = "按名称检索文档模板"
TemplateSearch.Intent = "当用户知道模板名称关键词、要快速定位唯一 templateId 后继续创建文档时使用。"
TemplateSearch.Contract = docContract("+template-search", TemplateSearch.Description, TemplateSearch.Intent, []string{`dws doc +template-search --query "周报"`})
}
func canonicalizeCommentShortcuts() {
// Preserve the historical confirmation contract for comment writes. The
// richer canonical implementations must not silently weaken that gate.
CommentCreate.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
CommentReply.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"}
CommentCreate.Description = "创建全文评论,或按 selection 创建划词评论"
CommentCreate.Intent = "当用户要对整篇文档留言,或针对文档中唯一匹配的一段文字创建精确划词评论时使用;已知 block/start/end 时也可直接走高级通道。"
CommentCreate.Contract = docContract("+comment-create", CommentCreate.Description, CommentCreate.Intent, []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`})
CommentCreate.Flags = []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "评论文字内容", Required: true},
{Name: "selection", Type: shortcut.FlagString, Desc: "完整文字或 前缀...后缀 selection;使用时不能为空且必须唯一匹配"},
{Name: "block-id", Type: shortcut.FlagString, Desc: "高级通道 block ID;使用时不能为空且须与 start/end 一起提供"},
{Name: "start", Type: shortcut.FlagInt, Desc: "块内起始字符偏移;高级通道参数不能为空且须一起提供"},
{Name: "end", Type: shortcut.FlagInt, Desc: "块内结束字符偏移;高级通道参数不能为空且须一起提供"},
{Name: "selected-text", Type: shortcut.FlagString, Desc: "高级通道引用原文"},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
}
CommentCreate.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"selection", "block-id", "start", "end"}, Description: "selection/高级通道参数不能为空;selection 必须唯一匹配,block-id/start/end 必须一起提供"}}
CommentCreate.Validate = validateCommentCreate
CommentCreate.Execute = executeCommentCreate
CommentCreate.Tips = []string{`dws doc +comment-create --node <DOC_ID> --content "请补充数据来源"`, `dws doc +comment-create --node <DOC_ID> --selection "计划下周发布" --content "请确认日期"`}
}
func executeHistoryRevert(rt *shortcut.RuntimeContext) error {
nodeID := rt.Str("node")
target := rt.Int("version")
versions, err := rt.CallMCPData(productDoc, "list_doc_versions", map[string]any{"nodeId": nodeID})
if err != nil {
return err
}
if !containsVersion(versions, target) {
return apperrors.NewValidation(fmt.Sprintf("目标版本 %d 不存在,已停止回滚", target))
}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.history_revert", map[string]any{"executed": false, "nodeId": nodeID, "version": target, "preflight": "version_exists"}))
}
if _, err := rt.CallMCPWriteData(productDoc, "revert_doc_version", map[string]any{"nodeId": nodeID, "version": target}); err != nil {
return err
}
current, err := rt.CallMCPData(productDoc, "get_document_info", map[string]any{"nodeId": nodeID})
if err != nil {
return docPartialWriteError(
"doc.history_revert", "doc_history_revert_verification_failed", "verify", fmt.Sprintf("版本 %d 已回滚,但读回验证失败(nodeId=%s);不要直接重试回滚", target, nodeID), err,
map[string]any{"nodeId": nodeID, "version": target, "reverted": true},
[]map[string]any{
{"name": "preflight", "status": "success"},
{"name": "revert", "status": "success"},
{"name": "verify", "status": "failed"},
},
map[string]any{"available": false, "reason": "the requested revert completed; verify the current document before any further write"},
)
}
return rt.Output(docEnvelope("doc.history_revert", map[string]any{"version": target, "current": current},
map[string]any{"name": "preflight", "status": "success"},
map[string]any{"name": "revert", "status": "success"},
map[string]any{"name": "verify", "status": "success"}))
}
func containsVersion(value any, target int) bool {
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
normalized := strings.ToLower(strings.ReplaceAll(key, "_", ""))
if normalized == "version" || normalized == "versionnumber" || normalized == "revision" {
switch number := child.(type) {
case float64:
if int(number) == target && number == float64(target) {
return true
}
case string:
parsed, err := strconv.Atoi(strings.TrimSpace(number))
if err == nil && parsed == target {
return true
}
}
}
if containsVersion(child, target) {
return true
}
}
case []any:
for _, child := range typed {
if containsVersion(child, target) {
return true
}
}
}
return false
}
var CreateFromTemplate = shortcut.Shortcut{
Service: "doc",
Command: "+create-from-template",
Product: productDoc,
Description: "按 templateId 直达或搜索消歧后创建文档",
Intent: "当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
Contract: docContract("+create-from-template", "按 templateId 直达或搜索消歧后创建文档",
"当用户要基于文档模板创建新文档时使用;可直接给 template-id,或给 query 搜索且只在唯一命中时继续创建。",
[]string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`}),
Flags: []shortcut.Flag{
{Name: "template-id", Type: shortcut.FlagString, Desc: "模板 ID"},
{Name: "query", Type: shortcut.FlagString, Desc: "模板搜索名称"},
{Name: "source", Type: shortcut.FlagString, Desc: "模板来源", Enum: []string{"MY", "PUBLIC"}},
{Name: "name", Type: shortcut.FlagString, Desc: "新文档名称"},
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"},
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"template-id", "query"}, Description: "--template-id 与 --query 必须且只能提供一个"}},
Tips: []string{`dws doc +create-from-template --template-id <TEMPLATE_ID> --name "我的周报"`, `dws doc +create-from-template --query "会议纪要" --name "项目例会"`},
Execute: func(rt *shortcut.RuntimeContext) error {
templateID := rt.Str("template-id")
if templateID == "" {
params := map[string]any{"searchName": rt.Str("query")}
if rt.Str("source") != "" {
params["templateSource"] = rt.Str("source")
}
found, err := rt.CallMCPData(productDoc, "search_doc_templates", params)
if err != nil {
return err
}
ids := collectTemplateIDs(found)
if len(ids) != 1 {
return apperrors.NewValidation(fmt.Sprintf("模板搜索需要唯一命中,实际 %d 个候选: %v", len(ids), ids))
}
templateID = ids[0]
}
params := map[string]any{"templateId": templateID}
for flag, property := range map[string]string{"name": "name", "folder": "folderId", "workspace": "workspaceId"} {
if value := rt.Str(flag); value != "" {
params[property] = value
}
}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.create_from_template", map[string]any{"executed": false, "params": params}))
}
result, err := rt.CallMCPWriteData(productDoc, "apply_doc_template", params)
if err != nil {
return err
}
return rt.Output(docEnvelope("doc.create_from_template", result, map[string]any{"name": "apply_template", "status": "success"}))
},
}
func collectTemplateIDs(value any) []string {
seen := map[string]bool{}
var out []string
var walk func(any)
walk = func(current any) {
switch typed := current.(type) {
case map[string]any:
for key, child := range typed {
if strings.EqualFold(key, "templateId") || strings.EqualFold(key, "template_id") {
if id, ok := child.(string); ok && strings.TrimSpace(id) != "" && !seen[id] {
seen[id] = true
out = append(out, id)
}
}
walk(child)
}
case []any:
for _, child := range typed {
walk(child)
}
}
}
walk(value)
return out
}
func init() {
shortcut.Register(CreateFromTemplate)
}
@@ -0,0 +1,360 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var MediaList = shortcut.Shortcut{
Service: "doc", Command: "+media-list", Product: productDoc,
Description: "列出文档正文中的图片和附件资源",
Intent: "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+media-list", "列出文档正文中的图片和附件资源",
"当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。",
[]string{`dws doc +media-list --node <DOC_ID>`}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
Tips: []string{`dws doc +media-list --node <DOC_ID>`},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "format": "element"})
if err != nil {
return err
}
items := collectMediaItems(data)
return rt.Output(map[string]any{"count": len(items), "media": items})
},
}
var MediaInsert = shortcut.Shortcut{
Service: "doc", Command: "+media-insert", Product: productDoc,
Description: "上传本地图片或文件并插入文档正文",
Intent: "当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: withDryRun(docContract("+media-insert", "上传本地图片或文件并插入文档正文",
"当用户要把本地图片或附件作为正文 block 插入在线文档时使用;组合本地校验、上传凭证、OSS PUT 和插块,失败时不会伪造完整回滚。",
[]string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`}), contract.DryRunPreviewPlan, false),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "显示名称"},
{Name: "mime-type", Type: shortcut.FlagString, Desc: "MIME 类型"},
{Name: "index", Type: shortcut.FlagInt, Desc: "顶层插入索引"},
{Name: "where", Type: shortcut.FlagString, Desc: "相对参考块的位置", Enum: []string{"before", "after"}},
{Name: "ref-block", Type: shortcut.FlagString, Desc: "参考 block ID"},
},
Tips: []string{`dws doc +media-insert --node <DOC_ID> --file ./report.pdf`, `dws doc +media-insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where after`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocMediaInsertShortcut(rt.Command()) },
}
var MediaDownload = shortcut.Shortcut{
Service: "doc", Command: "+media-download", Product: productDoc,
Description: "安全下载文档正文附件到工作目录",
Intent: "当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+media-download", "安全下载文档正文附件到工作目录",
"当用户已从 media-list 或 block 数据拿到 resourceId,要把正文附件保存到本地时使用;默认拒绝覆盖并原子发布文件。",
[]string{`dws doc +media-download --node <DOC_ID> --resource-id <RESOURCE_ID> --output ./downloads/`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "resource-id", Type: shortcut.FlagString, Desc: "附件 resourceId", Required: true},
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内相对路径(文件或目录)"},
},
Validate: func(rt *shortcut.RuntimeContext) error { return localio.ValidateOutput(rt.Str("output")) },
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "--output 必须是工作目录内相对路径;默认 no-clobber"}},
Tips: []string{`dws doc +media-download --node <DOC_ID> --resource-id <RESOURCE_ID> --output ./downloads/`},
Execute: executeMediaDownload,
}
var MediaPreview = shortcut.Shortcut{
Service: "doc", Command: "+media-preview", Product: productDoc,
Description: "下载正文媒体到受控临时目录并返回预览路径",
Intent: "当用户要临时查看文档附件或图片内容而不指定持久保存路径时使用;下载到独立临时目录并返回 artifact 路径。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+media-preview", "下载正文媒体到受控临时目录并返回预览路径",
"当用户要临时查看文档附件或图片内容而不指定持久保存路径时使用;下载到独立临时目录并返回 artifact 路径。",
[]string{`dws doc +media-preview --node <DOC_ID> --resource-id <RESOURCE_ID>`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "resource-id", Type: shortcut.FlagString, Desc: "附件 resourceId", Required: true},
},
Tips: []string{`dws doc +media-preview --node <DOC_ID> --resource-id <RESOURCE_ID>`},
Execute: func(rt *shortcut.RuntimeContext) error {
if rt.DryRun() {
return rt.Output(docEnvelope("doc.media_preview", map[string]any{"executed": false, "nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "output": "managed_temp_dir"}))
}
data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")})
if err != nil {
return err
}
dir, err := docMkdirTemp("", "dws-doc-preview-*")
if err != nil {
return err
}
result, err := downloadResolvedResource(rt, data, dir, ".")
if err != nil {
_ = docRemoveAll(dir)
return err
}
return rt.Output(docEnvelope("doc.media_preview", map[string]any{"previewPath": result.AbsolutePath, "sizeBytes": result.SizeBytes}))
},
}
var ResourceUpdate = shortcut.Shortcut{
Service: "doc", Command: "+resource-update", Product: productDoc,
Description: "从本地图片或 HTTPS URL 设置文档封面",
Intent: "当用户要设置或替换文档顶部封面图时使用;本地图片会先上传,HTTPS URL 由服务端转存。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
Contract: withDryRun(docContract("+resource-update", "从本地图片或 HTTPS URL 设置文档封面",
"当用户要设置或替换文档顶部封面图时使用;本地图片会先上传,HTTPS URL 由服务端转存。",
[]string{`dws doc +resource-update --node <DOC_ID> --image https://example.com/cover.png`, `dws doc +resource-update --node <DOC_ID> --file ./cover.png`}), contract.DryRunPreviewRequest, false),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "image", Type: shortcut.FlagString, Desc: "HTTPS 封面图片 URL"},
{Name: "file", Type: shortcut.FlagString, Desc: "本地封面图片"},
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"image", "file"}, Description: "--image 与 --file 必须且只能提供一个"}},
Tips: []string{`dws doc +resource-update --node <DOC_ID> --image https://example.com/cover.png`, `dws doc +resource-update --node <DOC_ID> --file ./cover.png`},
Execute: func(rt *shortcut.RuntimeContext) error { return helpers.RunDocResourceUpdateShortcut(rt.Command()) },
}
var ResourceDownload = shortcut.Shortcut{
Service: "doc", Command: "+resource-download", Product: productDoc,
Description: "读取并安全下载当前文档封面",
Intent: "当用户要把当前文档封面保存到本地时使用;先读 style,必要时用 resourceId 换临时链接,再按安全本地下载策略保存。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+resource-download", "读取并安全下载当前文档封面",
"当用户要把当前文档封面保存到本地时使用;先读 style,必要时用 resourceId 换临时链接,再按安全本地下载策略保存。",
[]string{`dws doc +resource-download --node <DOC_ID> --output ./cover.png`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "output", Type: shortcut.FlagString, Default: ".", Desc: "工作目录内相对路径(文件或目录)"},
},
Validate: func(rt *shortcut.RuntimeContext) error { return localio.ValidateOutput(rt.Str("output")) },
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"output"}, Description: "--output 必须是工作目录内相对路径;默认 no-clobber"}},
Tips: []string{`dws doc +resource-download --node <DOC_ID> --output ./cover.png`},
Execute: executeResourceDownload,
}
var ResourceDelete = shortcut.Shortcut{
Service: "doc", Command: "+resource-delete", Product: productDoc,
Description: "幂等清除文档封面",
Intent: "当用户明确要移除文档当前封面时使用;发送 cover clear,重复执行保持无封面状态。",
Risk: shortcut.RiskHighWrite,
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "idempotent"},
Contract: docContract("+resource-delete", "幂等清除文档封面",
"当用户明确要移除文档当前封面时使用;发送 cover clear,重复执行保持无封面状态。",
[]string{`dws doc +resource-delete --node <DOC_ID>`}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
Tips: []string{`dws doc +resource-delete --node <DOC_ID>`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "cover": map[string]any{"action": "clear"}}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.resource_delete", map[string]any{"executed": false, "params": params}))
}
return rt.CallMCP("update_document_style", params)
},
}
var BackgroundUpdate = shortcut.Shortcut{
Service: "doc", Command: "+background-update", Product: productDoc,
Description: "设置文档 #RRGGBB 背景纯色",
Intent: "当用户要设置在线文档背景纯色时使用;只接受 #RRGGBB,不支持背景图片。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+background-update", "设置文档 #RRGGBB 背景纯色",
"当用户要设置在线文档背景纯色时使用;只接受 #RRGGBB,不支持背景图片。",
[]string{`dws doc +background-update --node <DOC_ID> --color "#E8F2FE"`}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}, {Name: "color", Type: shortcut.FlagString, Desc: "#RRGGBB 背景色", Required: true}},
Tips: []string{`dws doc +background-update --node <DOC_ID> --color "#E8F2FE"`},
Validate: func(rt *shortcut.RuntimeContext) error {
color := rt.Str("color")
if len(color) != 7 || color[0] != '#' {
return apperrors.NewValidation("--color 必须是 #RRGGBB")
}
for _, char := range color[1:] {
if !strings.ContainsRune("0123456789abcdefABCDEF", char) {
return apperrors.NewValidation("--color 必须是 #RRGGBB")
}
}
return nil
},
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"color"}, Description: "#RRGGBB"}},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("update_document_style", map[string]any{"nodeId": rt.Str("node"), "background": map[string]any{"action": "set", "backgroundColor": rt.Str("color")}})
},
}
var BackgroundDelete = shortcut.Shortcut{
Service: "doc", Command: "+background-delete", Product: productDoc,
Description: "清除文档背景色",
Intent: "当用户明确要恢复文档默认背景、移除当前背景色时使用;执行 background clear 并要求确认。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent"},
Contract: docContract("+background-delete", "清除文档背景色",
"当用户明确要恢复文档默认背景、移除当前背景色时使用;执行 background clear 并要求确认。",
[]string{`dws doc +background-delete --node <DOC_ID>`}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
Tips: []string{`dws doc +background-delete --node <DOC_ID>`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "background": map[string]any{"action": "clear"}}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.background_delete", map[string]any{"executed": false, "params": params}))
}
return rt.CallMCP("update_document_style", params)
},
}
func executeMediaDownload(rt *shortcut.RuntimeContext) error {
if rt.DryRun() {
return rt.Output(docEnvelope("doc.media_download", map[string]any{"executed": false, "nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id"), "output": rt.Str("output")}))
}
data, err := rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": rt.Str("resource-id")})
if err != nil {
return err
}
cwd, err := docGetwd()
if err != nil {
return err
}
result, err := downloadResolvedResource(rt, data, cwd, rt.Str("output"))
if err != nil {
return err
}
return rt.Output(docEnvelope("doc.media_download", map[string]any{"resourceId": rt.Str("resource-id"), "localPath": result.RelativePath, "sizeBytes": result.SizeBytes}))
}
func executeResourceDownload(rt *shortcut.RuntimeContext) error {
style, err := rt.CallMCPData(productDoc, "get_document_style", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.resource_download", map[string]any{"executed": false, "styleResolved": true, "output": rt.Str("output")}))
}
resourceURL := nestedStringDeep(style, "imageUrl", "resourceUrl", "downloadUrl", "url")
resourceID := nestedStringDeep(style, "resourceId")
data := style
if resourceURL == "" && resourceID != "" {
data, err = rt.CallMCPData(productDoc, "download_doc_attachment", map[string]any{"nodeId": rt.Str("node"), "resourceId": resourceID})
if err != nil {
return err
}
} else if resourceURL != "" {
data = map[string]any{"downloadUrl": resourceURL}
}
if nestedStringDeep(data, "downloadUrl", "resourceUrl", "imageUrl", "url") == "" {
return apperrors.NewAPI("当前文档没有可下载的封面,或 style 响应缺少资源地址")
}
cwd, err := docGetwd()
if err != nil {
return err
}
result, err := downloadResolvedResource(rt, data, cwd, rt.Str("output"))
if err != nil {
return err
}
return rt.Output(docEnvelope("doc.resource_download", map[string]any{"localPath": result.RelativePath, "sizeBytes": result.SizeBytes}))
}
func downloadResolvedResource(rt *shortcut.RuntimeContext, data map[string]any, baseDir, output string) (localio.DownloadResult, error) {
resourceURL := nestedStringDeep(data, "downloadUrl", "resourceUrl", "imageUrl", "url")
if resourceURL == "" {
return localio.DownloadResult{}, apperrors.NewAPI("附件下载响应缺少 downloadUrl/resourceUrl")
}
headers := map[string]string{}
if raw := nestedMap(data)["headers"]; raw != nil {
if values, ok := raw.(map[string]any); ok {
for key, value := range values {
if text, ok := value.(string); ok {
headers[key] = text
}
}
}
}
return docDownload(rt.Command().Context(), resourceURL, localio.DownloadOptions{BaseDir: baseDir, Output: output, PreferredName: nestedStringDeep(data, "fileName", "name"), Headers: headers})
}
func collectMediaItems(value any) []map[string]any {
var out []map[string]any
var walk func(any, string)
walk = func(current any, inheritedID string) {
switch typed := current.(type) {
case map[string]any:
blockID := blockIdentity(typed, inheritedID)
resourceID := fmt.Sprint(typed["resourceId"])
resourceURL := ""
for _, key := range []string{"resourceUrl", "src", "imageUrl", "downloadUrl"} {
if text, ok := typed[key].(string); ok && text != "" {
resourceURL = text
break
}
}
if (resourceID != "" && resourceID != "<nil>") || resourceURL != "" {
row := map[string]any{"blockId": blockID}
if resourceID != "" && resourceID != "<nil>" {
row["resourceId"] = resourceID
}
if resourceURL != "" {
row["resourceUrl"] = resourceURL
}
for _, key := range []string{"name", "type", "mimeType", "viewType"} {
if value, ok := typed[key]; ok {
row[key] = value
}
}
out = append(out, row)
}
for _, child := range typed {
walk(child, blockID)
}
case []any:
for _, child := range typed {
walk(child, inheritedID)
}
}
}
walk(value, "")
return out
}
func nestedStringDeep(value any, keys ...string) string {
switch typed := value.(type) {
case map[string]any:
for _, key := range keys {
if text, ok := typed[key].(string); ok && strings.TrimSpace(text) != "" {
return strings.TrimSpace(text)
}
}
for _, child := range typed {
if found := nestedStringDeep(child, keys...); found != "" {
return found
}
}
case []any:
for _, child := range typed {
if found := nestedStringDeep(child, keys...); found != "" {
return found
}
}
}
return ""
}
func init() {
shortcut.Register(MediaList, MediaInsert, MediaDownload, MediaPreview, ResourceUpdate, ResourceDownload, ResourceDelete, BackgroundUpdate, BackgroundDelete)
}
+306
View File
@@ -0,0 +1,306 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package doc
import (
"fmt"
"strings"
"unicode/utf16"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var Review = shortcut.Shortcut{
Service: "doc", Command: "+review", Product: productComment,
Description: "聚合未解决评论、引用原文和块上下文",
Intent: "当用户要确定性查看一篇文档仍待处理的 review 意见时使用;聚合 unresolved 评论与 block 上下文,不调用模型生成总结。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
Contract: docContract("+review", "聚合未解决评论、引用原文和块上下文",
"当用户要确定性查看一篇文档仍待处理的 review 意见时使用;聚合 unresolved 评论与 block 上下文,不调用模型生成总结。",
[]string{`dws doc +review --node <DOC_ID>`}),
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}},
Tips: []string{`dws doc +review --node <DOC_ID>`},
Execute: func(rt *shortcut.RuntimeContext) error {
node := rt.Str("node")
comments, err := rt.CallMCPData(productComment, "list_comments", map[string]any{"nodeId": node, "resolveStatus": "unresolved"})
if err != nil {
return err
}
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": node, "format": "element"})
if err != nil {
return err
}
items := projectReviewComments(comments, blocks)
global, inline := 0, 0
for _, item := range items {
if item["blockId"] == "" {
global++
} else {
inline++
}
}
return rt.Output(map[string]any{"status": "unresolved", "counts": map[string]any{"total": len(items), "global": global, "inline": inline}, "comments": items})
},
}
var CommentUpdate = shortcut.Shortcut{
Service: "doc", Command: "+comment-update", Product: productComment,
Description: "更新指定文档评论正文和 mention",
Intent: "当用户要修改一条已有评论的文字内容或 @ 用户列表,且已知 commentKey 时使用;不会创建回复或改变解决状态。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
Contract: docContract("+comment-update", "更新指定文档评论正文和 mention",
"当用户要修改一条已有评论的文字内容或 @ 用户列表,且已知 commentKey 时使用;不会创建回复或改变解决状态。",
[]string{`dws doc +comment-update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "comment-key", Type: shortcut.FlagString, Desc: "评论 commentKey", Required: true},
{Name: "content", Type: shortcut.FlagString, Desc: "更新后的评论正文", Required: true},
{Name: "mention", Type: shortcut.FlagStringSlice, Desc: "被 @ 的用户 uid 列表"},
},
Tips: []string{`dws doc +comment-update --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已按最新数据修正"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "commentKey": rt.Str("comment-key"), "content": rt.Str("content")}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
}
return rt.CallMCP("update_comment", params)
},
}
var CommentDelete = shortcut.Shortcut{
Service: "doc", Command: "+comment-delete", Product: productComment,
Description: "永久删除指定文档评论",
Intent: "当用户明确要求永久删除某条文档评论,且已核对 node 与 commentKey 时使用;不可用于标记 resolved。",
Risk: shortcut.RiskHighWrite,
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"},
Contract: docContract("+comment-delete", "永久删除指定文档评论",
"当用户明确要求永久删除某条文档评论,且已核对 node 与 commentKey 时使用;不可用于标记 resolved。",
[]string{`dws doc +comment-delete --node <DOC_ID> --comment-key <COMMENT_KEY>`}),
Flags: []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "comment-key", Type: shortcut.FlagString, Desc: "评论 commentKey", Required: true},
},
Tips: []string{`dws doc +comment-delete --node <DOC_ID> --comment-key <COMMENT_KEY>`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "commentKey": rt.Str("comment-key")}
if rt.DryRun() {
return rt.Output(docEnvelope("doc.comment_delete", map[string]any{"executed": false, "params": params}))
}
return rt.CallMCP("delete_comment", params)
},
}
func validateCommentCreate(rt *shortcut.RuntimeContext) error {
hasBlock := rt.Str("block-id") != ""
hasOffsets := rt.Changed("start") || rt.Changed("end")
if hasBlock != hasOffsets || (hasOffsets && (!rt.Changed("start") || !rt.Changed("end"))) {
return apperrors.NewValidation("--block-id、--start、--end 必须一起提供")
}
if hasBlock && rt.Int("end") <= rt.Int("start") {
return apperrors.NewValidation("--end 必须大于 --start")
}
if hasBlock && rt.Str("selection") != "" {
return apperrors.NewValidation("--selection 与 block-id/start/end 高级通道不能同时提供")
}
return nil
}
func executeCommentCreate(rt *shortcut.RuntimeContext) error {
params := map[string]any{"nodeId": rt.Str("node"), "content": rt.Str("content")}
if rt.Changed("mention") {
params["mentionedUserIds"] = rt.StrSlice("mention")
}
if rt.Str("block-id") != "" {
params["blockId"], params["start"], params["end"] = rt.Str("block-id"), rt.Int("start"), rt.Int("end")
if rt.Str("selected-text") != "" {
params["selectedText"] = rt.Str("selected-text")
}
return rt.CallMCP("create_inline_comment", params)
}
if selection := rt.Str("selection"); selection != "" {
blocks, err := rt.CallMCPData(productDoc, "list_document_blocks", map[string]any{"nodeId": rt.Str("node"), "format": "element"})
if err != nil {
return err
}
matches := findSelectionMatches(blocks, selection)
if len(matches) != 1 {
candidates := make([]map[string]any, 0, len(matches))
for _, match := range matches {
candidates = append(candidates, map[string]any{"blockId": match.blockID, "excerpt": match.text})
}
return apperrors.NewValidation(fmt.Sprintf("AMBIGUOUS_SELECTION: selection 需要唯一匹配,实际 %d 处;候选=%v", len(matches), candidates))
}
params["blockId"], params["start"], params["end"], params["selectedText"] = matches[0].blockID, matches[0].start, matches[0].end, matches[0].selected
return rt.CallMCP("create_inline_comment", params)
}
return rt.CallMCP("create_comment", params)
}
type selectionMatch struct {
blockID string
text, selected string
start, end int
}
func findSelectionMatches(value any, selection string) []selectionMatch {
if selection == "" {
return nil
}
var prefix, suffix string
omitted := false
if parts := strings.SplitN(selection, "...", 2); len(parts) == 2 {
prefix, suffix = parts[0], parts[1]
omitted = true
}
var out []selectionMatch
appendMatch := func(blockID, text string, startByte, endByte int) {
out = append(out, selectionMatch{
blockID: blockID, text: text, selected: text[startByte:endByte],
start: utf16Length(text[:startByte]), end: utf16Length(text[:endByte]),
})
}
var walk func(any, string)
walk = func(current any, inheritedID string) {
switch typed := current.(type) {
case map[string]any:
blockID := blockIdentity(typed, inheritedID)
if text, ok := typed["text"].(string); ok && blockID != "" {
if !omitted {
for searchStart := 0; searchStart < len(text); {
index := strings.Index(text[searchStart:], selection)
if index < 0 {
break
}
startByte := searchStart + index
endByte := startByte + len(selection)
appendMatch(blockID, text, startByte, endByte)
searchStart = startByte + 1
}
} else {
prefixStarts := []int{0}
if prefix != "" {
prefixStarts = nil
for searchStart := 0; searchStart < len(text); {
index := strings.Index(text[searchStart:], prefix)
if index < 0 {
break
}
startByte := searchStart + index
prefixStarts = append(prefixStarts, startByte)
searchStart = startByte + 1
}
}
for _, startByte := range prefixStarts {
suffixSearch := startByte + len(prefix)
if suffix == "" {
if startByte < len(text) {
appendMatch(blockID, text, startByte, len(text))
}
continue
}
for suffixSearch <= len(text) {
index := strings.Index(text[suffixSearch:], suffix)
if index < 0 {
break
}
suffixStart := suffixSearch + index
appendMatch(blockID, text, startByte, suffixStart+len(suffix))
suffixSearch = suffixStart + 1
}
}
}
}
for _, child := range typed {
walk(child, blockID)
}
case []any:
for _, child := range typed {
walk(child, inheritedID)
}
}
}
walk(value, "")
return out
}
func utf16Length(value string) int {
return len(utf16.Encode([]rune(value)))
}
func projectReviewComments(comments, blocks map[string]any) []map[string]any {
blockText := map[string]string{}
collectBlockText(blocks, "", blockText)
var out []map[string]any
var walk func(any)
walk = func(value any) {
switch typed := value.(type) {
case map[string]any:
key := firstString(typed, "commentKey", "commentId")
if key != "" {
blockID := firstString(typed, "blockId")
selectedText := firstString(typed, "selectedText", "quote")
// list_comments currently omits blockId for inline comments but
// includes isGlobal=false plus the selected quote. Resolve the
// block deterministically when that quote is unique.
if blockID == "" && selectedText != "" {
if matches := findSelectionMatches(blocks, selectedText); len(matches) == 1 {
blockID = matches[0].blockID
}
}
out = append(out, map[string]any{
"commentKey": key,
"content": firstString(typed, "content", "text"),
"selectedText": selectedText,
"blockId": blockID,
"context": blockText[blockID],
"replies": typed["replies"],
})
return
}
for _, child := range typed {
walk(child)
}
case []any:
for _, child := range typed {
walk(child)
}
}
}
walk(comments)
return out
}
func collectBlockText(value any, inheritedID string, out map[string]string) {
switch typed := value.(type) {
case map[string]any:
blockID := blockIdentity(typed, inheritedID)
if text, ok := typed["text"].(string); ok && blockID != "" {
out[blockID] = text
}
for _, child := range typed {
collectBlockText(child, blockID, out)
}
case []any:
for _, child := range typed {
collectBlockText(child, inheritedID, out)
}
}
}
func firstString(values map[string]any, keys ...string) string {
for _, key := range keys {
if text, ok := values[key].(string); ok {
return text
}
}
return ""
}
func init() {
shortcut.Register(Review, CommentUpdate, CommentDelete)
}
@@ -92,6 +92,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"chat\u0000+chat-dismiss": {},
"chat\u0000+chat-get-by-id": {},
"chat\u0000+chat-invite-url": {},
"chat\u0000+chat-list": {},
"chat\u0000+chat-list-all": {},
"chat\u0000+chat-list-join-requests": {},
"chat\u0000+chat-list-mine": {},
@@ -208,20 +209,48 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
"ding\u0000+recall-personal": {},
"ding\u0000+receiver-status": {},
"ding\u0000+send-personal": {},
"doc\u0000+access-change": {},
"doc\u0000+access-grant": {},
"doc\u0000+access-revoke": {},
"doc\u0000+background-delete": {},
"doc\u0000+background-update": {},
"doc\u0000+checkpoint-update": {},
"doc\u0000+comment-create": {},
"doc\u0000+comment-delete": {},
"doc\u0000+comment-list": {},
"doc\u0000+comment-reply": {},
"doc\u0000+comment-update": {},
"doc\u0000+copy": {},
"doc\u0000+create": {},
"doc\u0000+create-from-template": {},
"doc\u0000+doc-append": {},
"doc\u0000+export": {},
"doc\u0000+export-get": {},
"doc\u0000+export-submit": {},
"doc\u0000+fetch": {},
"doc\u0000+find-doc": {},
"doc\u0000+grant-and-share": {},
"doc\u0000+history-list": {},
"doc\u0000+history-revert": {},
"doc\u0000+history-save": {},
"doc\u0000+import": {},
"doc\u0000+inspect": {},
"doc\u0000+list": {},
"doc\u0000+media-download": {},
"doc\u0000+media-insert": {},
"doc\u0000+media-list": {},
"doc\u0000+media-preview": {},
"doc\u0000+move": {},
"doc\u0000+resource-delete": {},
"doc\u0000+resource-download": {},
"doc\u0000+resource-update": {},
"doc\u0000+review": {},
"doc\u0000+search": {},
"doc\u0000+share": {},
"doc\u0000+share-doc": {},
"doc\u0000+template-list": {},
"doc\u0000+template-search": {},
"doc\u0000+update": {},
"doc\u0000+version-list": {},
"doc\u0000+version-revert": {},
"doc\u0000+version-save": {},
+28 -5
View File
@@ -13,6 +13,9 @@ import (
//go:embed semantic_catalog.json
var semanticCatalogJSON []byte
//go:embed semantic_catalog_doc.json
var docSemanticCatalogJSON []byte
type semanticCatalogFile struct {
Version int `json:"version"`
Service string `json:"service"`
@@ -30,17 +33,34 @@ type semanticCatalogRecord struct {
Reviewed bool `json:"reviewed"`
}
var reviewedSemanticCatalog = mustLoadSemanticCatalog()
var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
semanticCatalogJSON,
docSemanticCatalogJSON,
)
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
out := make(map[string]semanticCatalogRecord)
for _, raw := range sources {
loadSemanticCatalog(raw, out)
}
return out
}
// mustLoadSemanticCatalog is retained for focused validation tests of the
// legacy single-source loader. Production loads every reviewed product source
// through mustLoadSemanticCatalogs above.
func mustLoadSemanticCatalog() map[string]semanticCatalogRecord {
return mustLoadSemanticCatalogs(semanticCatalogJSON)
}
func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) {
var source semanticCatalogFile
if err := json.Unmarshal(semanticCatalogJSON, &source); err != nil {
if err := json.Unmarshal(raw, &source); err != nil {
panic(fmt.Sprintf("invalid shortcut semantic catalog: %v", err))
}
if source.Version != 1 || strings.TrimSpace(source.Service) == "" {
panic("invalid shortcut semantic catalog header")
}
out := make(map[string]semanticCatalogRecord, len(source.Shortcuts))
for command, record := range source.Shortcuts {
if !strings.HasPrefix(command, "+") {
panic(fmt.Sprintf("semantic catalog command %q lacks + prefix", command))
@@ -76,9 +96,12 @@ func mustLoadSemanticCatalog() map[string]semanticCatalogRecord {
panic(fmt.Sprintf("semantic catalog command %q cannot be public with availability %q",
command, record.Availability))
}
out[publicCatalogKey(source.Service, command)] = record
key := publicCatalogKey(source.Service, command)
if _, exists := out[key]; exists {
panic(fmt.Sprintf("duplicate shortcut semantic catalog entry %s %s", source.Service, command))
}
out[key] = record
}
return out
}
func applyReviewedSemanticCatalog(s Shortcut) (Shortcut, bool) {
@@ -137,6 +137,29 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T)
}
}
func TestCrossPlatformCoverageSemanticCatalogRejectsCrossSourceDuplicates(t *testing.T) {
valid := []byte(`{
"version": 1,
"service": "duplicate-test",
"default_availability": "available",
"shortcuts": {
"+same": {
"disposition": "semantic_adapter",
"semantic_delta": "reviewed",
"risk": "read",
"public": true,
"reviewed": true
}
}
}`)
defer func() {
if recover() == nil {
t.Fatal("duplicate semantic records did not panic")
}
}()
_ = mustLoadSemanticCatalogs(valid, valid)
}
func TestCrossPlatformCoveragePublicCatalogSemanticAndGeneratedLookups(t *testing.T) {
if !InPublicCatalog("chat", "+messages-send") {
t.Fatal("reviewed public semantic shortcut is missing")
@@ -0,0 +1,56 @@
{
"version": 1,
"service": "doc",
"default_availability": "available",
"shortcuts": {
"+search": {"disposition":"semantic_adapter","semantic_delta":"统一关键词、最近访问、过滤、分页和稳定精简投影,作为文档定位的 canonical 入口。","risk":"read","public":true,"reviewed":true},
"+create": {"disposition":"semantic_adapter","semantic_delta":"统一 Markdown/JSONML 内容输入、目标位置与创建后保真写入。","risk":"write","public":true,"reviewed":true},
"+fetch": {"disposition":"semantic_adapter","semantic_delta":"统一 simple/with-ids/full 细节层级与 full/outline/range/section/keyword/tags 局部读取。","risk":"read","public":true,"reviewed":true},
"+inspect": {"disposition":"primary_smart","semantic_delta":"聚合文档元信息,并按需读取样式、权限、历史、媒体和评论。","risk":"read","public":true,"reviewed":true},
"+update": {"disposition":"primary_smart","semantic_delta":"统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。","risk":"write","public":true,"reviewed":true},
"+checkpoint-update": {"disposition":"primary_smart","semantic_delta":"写入前保存版本快照,更新后读回验证并输出逐步 ledger。","risk":"write","public":true,"reviewed":true},
"+export": {"disposition":"primary_smart","semantic_delta":"一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。","risk":"read","public":true,"reviewed":true},
"+import": {"disposition":"primary_smart","semantic_delta":"一体化创建会话、上传、确认转换并轮询导入结果。","risk":"write","public":true,"reviewed":true},
"+history-save": {"disposition":"semantic_adapter","semantic_delta":"以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。","risk":"write","public":true,"reviewed":true},
"+history-list": {"disposition":"semantic_adapter","semantic_delta":"统一历史版本分页参数并返回可用于回滚的版本列表。","risk":"read","public":true,"reviewed":true},
"+history-revert": {"disposition":"primary_smart","semantic_delta":"先验证目标版本存在,再执行回滚并读回当前文档状态。","risk":"high-risk-write","public":true,"reviewed":true},
"+template-list": {"disposition":"semantic_adapter","semantic_delta":"统一 MY/PUBLIC 模板浏览和分页参数。","risk":"read","public":true,"reviewed":true},
"+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称检索模板并返回可继续创建的 templateId。","risk":"read","public":true,"reviewed":true},
"+create-from-template": {"disposition":"primary_smart","semantic_delta":"支持 templateId 直达或按名称搜索消歧后创建文档。","risk":"write","public":true,"reviewed":true},
"+media-list": {"disposition":"semantic_adapter","semantic_delta":"从文档块中提取图片、附件及其 block/resource 标识。","risk":"read","public":true,"reviewed":true},
"+media-insert": {"disposition":"primary_smart","semantic_delta":"组合本地文件校验、上传凭证、OSS PUT、插块和验证。","risk":"write","public":true,"reviewed":true},
"+media-download": {"disposition":"primary_smart","semantic_delta":"解析附件临时链接并通过受控相对路径、no-clobber、原子发布安全下载。","risk":"read","public":true,"reviewed":true},
"+media-preview": {"disposition":"primary_smart","semantic_delta":"将正文媒体下载到受控临时目录并返回本地预览 artifact。","risk":"read","public":true,"reviewed":true},
"+resource-update": {"disposition":"primary_smart","semantic_delta":"支持本地图片或 HTTPS 图片转存后设置文档封面。","risk":"write","public":true,"reviewed":true},
"+resource-download": {"disposition":"primary_smart","semantic_delta":"读取当前文档封面配置并安全下载资源到本地。","risk":"read","public":true,"reviewed":true},
"+resource-delete": {"disposition":"semantic_adapter","semantic_delta":"以幂等 clear 语义移除当前文档封面。","risk":"high-risk-write","public":true,"reviewed":true},
"+background-update": {"disposition":"semantic_adapter","semantic_delta":"校验并设置 #RRGGBB 文档背景纯色。","risk":"write","public":true,"reviewed":true},
"+background-delete": {"disposition":"semantic_adapter","semantic_delta":"以 clear 语义移除文档背景色。","risk":"write","public":true,"reviewed":true},
"+comment-list": {"disposition":"semantic_adapter","semantic_delta":"统一评论类型、解决状态与分页过滤。","risk":"read","public":true,"reviewed":true},
"+review": {"disposition":"primary_smart","semantic_delta":"聚合未解决评论、划词引用和确定性上下文,不调用模型生成总结。","risk":"read","public":true,"reviewed":true},
"+comment-create": {"disposition":"primary_smart","semantic_delta":"无 selection 创建全文评论,有 selection 时定位文本并创建划词评论。","risk":"write","public":true,"reviewed":true},
"+comment-reply": {"disposition":"semantic_adapter","semantic_delta":"统一评论回复、表情回复和 mention 参数。","risk":"write","public":true,"reviewed":true},
"+comment-update": {"disposition":"semantic_adapter","semantic_delta":"更新指定评论正文与 mention。","risk":"write","public":true,"reviewed":true},
"+comment-delete": {"disposition":"semantic_adapter","semantic_delta":"永久删除指定评论,并由静态安全契约强制确认。","risk":"high-risk-write","public":true,"reviewed":true},
"+access-grant": {"disposition":"primary_smart","semantic_delta":"在第一次写入前解析全部接收人,再批量授予文档权限并输出逐项 ledger。","risk":"write","public":true,"reviewed":true},
"+access-change": {"disposition":"primary_smart","semantic_delta":"读取当前权限后再变更角色,避免把不存在的协作者当作成功更新。","risk":"write","public":true,"reviewed":true},
"+access-revoke": {"disposition":"primary_smart","semantic_delta":"预检目标协作者权限后移除并输出逐项结果。","risk":"high-risk-write","public":true,"reviewed":true},
"+share": {"disposition":"primary_smart","semantic_delta":"按姓名解析唯一用户后发送文档链接,不改变文档权限。","risk":"write","public":true,"reviewed":true},
"+grant-and-share": {"disposition":"primary_smart","semantic_delta":"先确保目标角色,再发送链接;消息失败保留逐人 ledger,并以非零退出报告 failed/partial_success。","risk":"write","public":true,"reviewed":true},
"+find-doc": {"disposition":"alias_internal","semantic_delta":"保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。","risk":"read","primary":"+search","public":true,"reviewed":true},
"+doc-append": {"disposition":"alias_internal","semantic_delta":"保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。","risk":"write","primary":"+update","public":true,"reviewed":true},
"+version-save": {"disposition":"alias_internal","semantic_delta":"保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。","risk":"write","primary":"+history-save","public":true,"reviewed":true},
"+version-list": {"disposition":"alias_internal","semantic_delta":"保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。","risk":"read","primary":"+history-list","public":true,"reviewed":true},
"+version-revert": {"disposition":"alias_internal","semantic_delta":"保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。","risk":"high-risk-write","primary":"+history-revert","public":true,"reviewed":true},
"+share-doc": {"disposition":"alias_internal","semantic_delta":"保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。","risk":"write","primary":"+share","public":true,"reviewed":true},
"+list": {"disposition":"alias_internal","semantic_delta":"旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。","risk":"read","primary":"drive list","public":true,"reviewed":true},
"+copy": {"disposition":"alias_internal","semantic_delta":"旧 Doc 复制入口,仅为兼容保留;新的文件复制应使用 Drive 命令。","risk":"write","primary":"drive copy","public":true,"reviewed":true},
"+move": {"disposition":"alias_internal","semantic_delta":"旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。","risk":"write","primary":"drive move","public":true,"reviewed":true},
"+export-submit": {"disposition":"alias_internal","semantic_delta":"导出中断恢复所需的专家入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true},
"+export-get": {"disposition":"alias_internal","semantic_delta":"按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true},
"+comment-create-inline": {"disposition":"alias_internal","semantic_delta":"已知 block/start/end 时使用的低级批注入口;常规场景使用 +comment-create。","risk":"write","primary":"+comment-create","public":false,"reviewed":true},
"+template-apply": {"disposition":"alias_internal","semantic_delta":"已知 templateId 时使用的低级入口;常规场景使用 +create-from-template。","risk":"write","primary":"+create-from-template","public":false,"reviewed":true}
}
}
@@ -20,19 +20,24 @@ type smartCoverageCaller struct {
responses map[string][]string
failAt map[string]int
counts map[string]int
arguments map[string][]map[string]any
}
func (c *smartCoverageCaller) CallTool(
_ context.Context,
product, tool string,
_ map[string]any,
args map[string]any,
) (*edition.ToolResult, error) {
if c.counts == nil {
c.counts = map[string]int{}
}
if c.arguments == nil {
c.arguments = map[string][]map[string]any{}
}
key := product + "/" + tool
c.counts[key]++
if c.failAt[key] == c.counts[key] {
c.arguments[key] = append(c.arguments[key], args)
if c.failAt[key] == -1 || c.failAt[key] == c.counts[key] {
return nil, errors.New("fixture failure")
}
responses := c.responses[key]
+541
View File
@@ -0,0 +1,541 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package smart
import (
"encoding/json"
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
var (
resolveDocPermissionUser = resolveUser
resolveDocShareUser = resolveOpenDingTalkUser
legacyShareDoc shortcut.Shortcut
)
func docSmartContract(command, description, intent string, examples []string, dryRun bool) corecmd.ContractDecl {
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
cliPath := "doc " + command
decl := corecmd.ContractDecl{
Description: description,
Interface: &contract.InterfaceSpec{
Mode: contract.InterfaceModeComposite,
Availability: contract.InterfaceAvailable,
Reason: "Reviewed cross-product Doc Shortcut composite: contact resolution, document permissions, messaging, confirmation, and output ledger cannot be represented by one MCP interface.",
},
Selection: contract.SelectionSpec{
AgentSummary: description,
UseWhen: []string{intent},
AvoidWhen: []string{
"已知 userId 且只需原子权限变更时可直接使用 doc permission 命令;知识库容器成员使用 wiki;普通文件权限使用 drive",
},
Examples: examples,
},
Identity: contract.ToolIdentitySpec{
ProductID: "doc", Name: name, CanonicalPath: "doc." + name,
CLIPath: cliPath, PrimaryCLIPath: cliPath,
},
}
if dryRun {
decl.DryRun = &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: true}
}
return decl
}
func canonicalizeShareDoc() {
legacyShareDoc = ShareDoc
// Preserve the historical identity and string-typed --to flag while using
// the same pre-resolve-and-send implementation as the canonical command.
legacyShareDoc.Execute = executeShare
ShareDoc.Command = "+share"
ShareDoc.Aliases = nil
ShareDoc.Description = "按姓名发送文档链接,不改变文档权限"
ShareDoc.Intent = "当用户已有文档 URL、要按姓名私信给一个或多个人但不改变权限时使用;第一次发消息前先完成全部姓名唯一解析。"
ShareDoc.Contract = docSmartContract("+share", ShareDoc.Description, ShareDoc.Intent,
[]string{`dws doc +share --to 张三 --url https://alidocs.dingtalk.com/i/nodes/<DOC_ID> --note "请帮忙 review"`}, false)
ShareDoc.Flags = []shortcut.Flag{
{Name: "to", Type: shortcut.FlagString, Desc: "接收人姓名列表(多个姓名用逗号分隔)", Required: true},
{Name: "url", Type: shortcut.FlagString, Desc: "文档链接", Required: true},
{Name: "note", Type: shortcut.FlagString, Desc: "附言"},
shortcut.AIMessageTagFlag(),
}
ShareDoc.Tips = []string{`dws doc +share --to 张三 --url https://alidocs.dingtalk.com/i/nodes/<DOC_ID> --note "请帮忙 review"`}
ShareDoc.Execute = executeShare
}
var AccessGrant = shortcut.Shortcut{
Service: "doc", Command: "+access-grant", Product: "doc",
Description: "按姓名解析后批量授予文档权限",
Intent: "当用户要给一个或多位同事授予单篇文档 READER/DOWNLOADER/EDITOR/MANAGER 权限时使用;所有姓名唯一解析成功后才执行一次批量授权。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: docSmartContract("+access-grant", "按姓名解析后批量授予文档权限",
"当用户要给一个或多位同事授予单篇文档 READER/DOWNLOADER/EDITOR/MANAGER 权限时使用;所有姓名唯一解析成功后才执行一次批量授权。",
[]string{`dws doc +access-grant --node <DOC_ID> --to 张三,李四 --role READER`}, false),
Flags: permissionFlags(true),
Tips: []string{`dws doc +access-grant --node <DOC_ID> --to 张三,李四 --role READER`},
Execute: func(rt *shortcut.RuntimeContext) error {
users, err := resolveDocUsers(rt, false)
if err != nil {
return err
}
params := permissionParams(rt, users)
if rt.DryRun() {
return rt.Output(docAccessEnvelope("doc.access_grant", map[string]any{"executed": false, "resolved": resolvedUserLedger(users), "params": params}))
}
result, err := rt.CallMCPWriteData("doc", "add_permission", params)
if err != nil {
return err
}
return rt.Output(docAccessEnvelope("doc.access_grant", map[string]any{"resolved": resolvedUserLedger(users), "result": result}))
},
}
var AccessChange = shortcut.Shortcut{
Service: "doc", Command: "+access-change", Product: "doc",
Description: "预检已有协作者后变更文档角色",
Intent: "当用户要修改文档上已有协作者的权限角色时使用;先按姓名解析并读取当前权限,目标不是现有协作者时停止,不把 update 当 add。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: docSmartContract("+access-change", "预检已有协作者后变更文档角色",
"当用户要修改文档上已有协作者的权限角色时使用;先按姓名解析并读取当前权限,目标不是现有协作者时停止,不把 update 当 add。",
[]string{`dws doc +access-change --node <DOC_ID> --to 张三 --role EDITOR`}, false),
Flags: permissionFlags(true),
Tips: []string{`dws doc +access-change --node <DOC_ID> --to 张三 --role EDITOR`},
Execute: func(rt *shortcut.RuntimeContext) error {
users, err := resolveDocUsers(rt, false)
if err != nil {
return err
}
current, err := rt.CallMCPData("doc", "list_permission", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
missing := usersMissingPermission(current, users)
if len(missing) > 0 {
return apperrors.NewValidation(fmt.Sprintf("以下用户不是当前直接协作者,不能 change;请改用 access-grant: %v", missing))
}
params := permissionParams(rt, users)
if rt.DryRun() {
return rt.Output(docAccessEnvelope("doc.access_change", map[string]any{"executed": false, "preflight": "existing_collaborators", "params": params}))
}
result, err := rt.CallMCPWriteData("doc", "update_permission", params)
if err != nil {
return err
}
return rt.Output(docAccessEnvelope("doc.access_change", result))
},
}
var AccessRevoke = shortcut.Shortcut{
Service: "doc", Command: "+access-revoke", Product: "doc",
Description: "预检并移除指定协作者的文档权限",
Intent: "当用户明确要撤销一位或多位现有协作者对单篇文档的直接权限时使用;先解析姓名并读取权限预检,再执行高风险移除。",
Risk: shortcut.RiskHighWrite,
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"},
Contract: docSmartContract("+access-revoke", "预检并移除指定协作者的文档权限",
"当用户明确要撤销一位或多位现有协作者对单篇文档的直接权限时使用;先解析姓名并读取权限预检,再执行高风险移除。",
[]string{`dws doc +access-revoke --node <DOC_ID> --to 张三`}, false),
Flags: permissionFlags(false),
Tips: []string{`dws doc +access-revoke --node <DOC_ID> --to 张三`},
Execute: func(rt *shortcut.RuntimeContext) error {
users, err := resolveDocUsers(rt, false)
if err != nil {
return err
}
current, err := rt.CallMCPData("doc", "list_permission", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
missing := usersMissingPermission(current, users)
if len(missing) > 0 {
return apperrors.NewValidation(fmt.Sprintf("以下用户没有可移除的直接权限: %v", missing))
}
params := map[string]any{"nodeId": rt.Str("node"), "userIds": docUserIDs(users)}
if rt.Str("workspace") != "" {
params["workspaceId"] = rt.Str("workspace")
}
if rt.DryRun() {
return rt.Output(docAccessEnvelope("doc.access_revoke", map[string]any{"executed": false, "preflight": "existing_collaborators", "params": params}))
}
result, err := rt.CallMCPWriteData("doc", "remove_permission", params)
if err != nil {
return err
}
return rt.Output(docAccessEnvelope("doc.access_revoke", result))
},
}
var GrantAndShare = shortcut.Shortcut{
Service: "doc", Command: "+grant-and-share", Product: "doc",
Description: "确保目标角色后按姓名逐人发送文档链接",
Intent: "当用户要确保多人获得指定文档角色后再私信链接时使用;缺少权限时授权、角色不足时升级,无法识别当前角色则停止,再只向权限已经足够的人发送。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
Contract: docSmartContract("+grant-and-share", "确保目标角色后按姓名逐人发送文档链接",
"当用户要确保多人获得指定文档角色后再私信链接时使用;缺少权限时授权、角色不足时升级,无法识别当前角色则停止,再只向权限已经足够的人发送。",
[]string{`dws doc +grant-and-share --node <DOC_ID> --url https://alidocs.dingtalk.com/i/nodes/<DOC_ID> --to 张三,李四 --role READER`}, false),
Flags: append(permissionFlags(true),
shortcut.Flag{Name: "url", Type: shortcut.FlagString, Desc: "文档链接", Required: true},
shortcut.Flag{Name: "note", Type: shortcut.FlagString, Desc: "附言"},
shortcut.AIMessageTagFlag(),
),
Tips: []string{`dws doc +grant-and-share --node <DOC_ID> --url https://alidocs.dingtalk.com/i/nodes/<DOC_ID> --to 张三,李四 --role READER`},
Execute: executeGrantAndShare,
}
func permissionFlags(withRole bool) []shortcut.Flag {
flags := []shortcut.Flag{
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true},
{Name: "to", Type: shortcut.FlagStringSlice, Desc: "协作者姓名列表", Required: true},
}
if withRole {
flags = append(flags, shortcut.Flag{Name: "role", Type: shortcut.FlagString, Default: "READER", Desc: "目标角色", Enum: []string{"READER", "DOWNLOADER", "EDITOR", "MANAGER"}})
}
flags = append(flags, shortcut.Flag{Name: "workspace", Type: shortcut.FlagString, Desc: "可选知识库 ID"})
return flags
}
func resolveDocUsers(rt *shortcut.RuntimeContext, requireOpenID bool) ([]contactUser, error) {
names := rt.StrSlice("to")
if len(names) == 0 {
names = strings.Split(rt.Str("to"), ",")
}
users := make([]contactUser, 0, len(names))
seen := map[string]bool{}
for _, name := range names {
name = strings.TrimSpace(name)
if name == "" {
continue
}
var user contactUser
var err error
if requireOpenID {
user, err = resolveDocShareUser(rt, name)
} else {
user, err = resolveDocPermissionUser(rt, name)
}
if err != nil {
return nil, err
}
if user.userID == "" && !requireOpenID {
return nil, apperrors.NewValidation(fmt.Sprintf("%s 缺少 userId,不能管理文档权限", name))
}
key := user.userID + "\x00" + user.openDingTalkID
if !seen[key] {
seen[key] = true
users = append(users, user)
}
}
if len(users) == 0 {
return nil, apperrors.NewValidation("--to 至少需要一个可唯一解析的姓名")
}
return users, nil
}
func permissionParams(rt *shortcut.RuntimeContext, users []contactUser) map[string]any {
params := map[string]any{"nodeId": rt.Str("node"), "roleId": strings.ToUpper(rt.Str("role")), "userIds": docUserIDs(users)}
if rt.Str("workspace") != "" {
params["workspaceId"] = rt.Str("workspace")
}
return params
}
func docUserIDs(users []contactUser) []string {
ids := make([]string, 0, len(users))
for _, user := range users {
ids = append(ids, user.userID)
}
return ids
}
func resolvedUserLedger(users []contactUser) []map[string]any {
out := make([]map[string]any, 0, len(users))
for _, user := range users {
out = append(out, map[string]any{"name": user.name, "userId": user.userID, "openDingTalkId": user.openDingTalkID})
}
return out
}
func usersMissingPermission(current map[string]any, users []contactUser) []string {
missingUsers := usersWithoutPermission(current, users)
var missing []string
for _, user := range missingUsers {
missing = append(missing, user.name+"("+user.userID+")")
}
return missing
}
func usersWithoutPermission(current map[string]any, users []contactUser) []contactUser {
present := map[string]bool{}
collectPermissionUserIDs(current, present)
missing := make([]contactUser, 0, len(users))
for _, user := range users {
if user.userID == "" || !present[user.userID] {
missing = append(missing, user)
}
}
return missing
}
func collectPermissionUserIDs(value any, into map[string]bool) {
switch typed := value.(type) {
case map[string]any:
for key, item := range typed {
if (key == "userId" || key == "id") && item != nil {
if id, ok := item.(string); ok && strings.TrimSpace(id) != "" {
into[strings.TrimSpace(id)] = true
}
}
collectPermissionUserIDs(item, into)
}
case []any:
for _, item := range typed {
collectPermissionUserIDs(item, into)
}
}
}
type permissionChangePlan struct {
missing []contactUser
upgrade []contactUser
unknown []contactUser
}
func planPermissionChanges(current map[string]any, users []contactUser, targetRole string) permissionChangePlan {
present := map[string]bool{}
collectPermissionUserIDs(current, present)
roles := map[string]string{}
collectPermissionRoles(current, roles)
targetRank := permissionRoleRank(targetRole)
plan := permissionChangePlan{}
for _, user := range users {
if user.userID == "" || !present[user.userID] {
plan.missing = append(plan.missing, user)
continue
}
currentRole, ok := roles[user.userID]
currentRank := permissionRoleRank(currentRole)
if !ok || currentRank == 0 || targetRank == 0 {
plan.unknown = append(plan.unknown, user)
continue
}
if currentRank < targetRank {
plan.upgrade = append(plan.upgrade, user)
}
}
return plan
}
func collectPermissionRoles(value any, into map[string]string) {
switch typed := value.(type) {
case map[string]any:
role := firstPermissionString(typed, "roleId", "roleID", "role", "permissionRole", "permissionType", "roleType")
userID := firstPermissionString(typed, "userId", "userID", "memberId", "targetId", "uid")
if userID == "" && role != "" {
userID = firstPermissionString(typed, "id")
}
if userID != "" && role != "" {
previous := into[userID]
if previous == "" || permissionRoleRank(role) > permissionRoleRank(previous) {
into[userID] = role
}
}
for _, item := range typed {
collectPermissionRoles(item, into)
}
case []any:
for _, item := range typed {
collectPermissionRoles(item, into)
}
}
}
func firstPermissionString(values map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func permissionRoleRank(role string) int {
switch strings.ToUpper(strings.TrimSpace(role)) {
case "READER":
return 1
case "DOWNLOADER":
return 2
case "EDITOR":
return 3
case "MANAGER":
return 4
case "OWNER":
return 5
default:
return 0
}
}
func permissionUserLabels(users []contactUser) []string {
labels := make([]string, 0, len(users))
for _, user := range users {
labels = append(labels, user.name+"("+user.userID+")")
}
return labels
}
func executeShare(rt *shortcut.RuntimeContext) error {
users, err := resolveDocUsers(rt, true)
if err != nil {
return err
}
if rt.DryRun() {
return rt.Output(docAccessEnvelope("doc.share", map[string]any{"executed": false, "resolved": resolvedUserLedger(users), "url": rt.Str("url")}))
}
return sendDocLinks(rt, users, "doc.share", nil)
}
func executeGrantAndShare(rt *shortcut.RuntimeContext) error {
users, err := resolveDocUsers(rt, false)
if err != nil {
return err
}
for _, user := range users {
if user.openDingTalkID == "" {
return apperrors.NewValidation(fmt.Sprintf("%s 缺少 openDingTalkId,已在授权前停止", user.name))
}
}
current, err := rt.CallMCPData("doc", "list_permission", map[string]any{"nodeId": rt.Str("node")})
if err != nil {
return err
}
plan := planPermissionChanges(current, users, rt.Str("role"))
if len(plan.unknown) > 0 {
return apperrors.NewValidation(fmt.Sprintf("以下用户的当前文档角色无法识别,已在授权和发消息前停止: %v", permissionUserLabels(plan.unknown)))
}
if rt.DryRun() {
return rt.Output(docAccessEnvelope("doc.grant_and_share", map[string]any{
"executed": false, "resolved": resolvedUserLedger(users),
"wouldGrant": permissionUserLabels(plan.missing), "wouldUpgrade": permissionUserLabels(plan.upgrade), "wouldMessage": len(users),
}))
}
permissionStatus := make(map[string]string, len(users))
for _, user := range users {
permissionStatus[user.userID] = "unchanged"
}
if len(plan.missing) > 0 {
if _, err := rt.CallMCPWriteData("doc", "add_permission", permissionParams(rt, plan.missing)); err != nil {
return err
}
for _, user := range plan.missing {
permissionStatus[user.userID] = "granted"
}
}
if len(plan.upgrade) > 0 {
if _, err := rt.CallMCPWriteData("doc", "update_permission", permissionParams(rt, plan.upgrade)); err != nil {
if len(plan.missing) > 0 {
return apperrors.NewAPI(
"部分新增权限已写入,但既有用户的角色升级失败;消息尚未发送,请勿直接重试整个命令",
apperrors.WithOperation("doc.grant_and_share"),
apperrors.WithReason("doc_grant_permission_partial_failure"),
apperrors.WithFailureStage("update_permission"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithActions("inspect current permissions before retrying", "revoke newly added permissions if the whole operation should be rolled back"),
apperrors.WithDetails(map[string]any{
"status": "partial_success",
"steps": []map[string]any{
{"name": "add_permission", "status": "success"},
{"name": "update_permission", "status": "failed"},
{"name": "send_messages", "status": "not_started"},
},
"data": map[string]any{
"granted": permissionUserLabels(plan.missing),
"upgradePending": permissionUserLabels(plan.upgrade),
"messagesSent": 0,
},
"compensation": map[string]any{"available": true, "action": "revoke_new_permissions", "users": permissionUserLabels(plan.missing)},
}),
apperrors.WithCause(err),
)
}
return err
}
for _, user := range plan.upgrade {
permissionStatus[user.userID] = "upgraded"
}
}
return sendDocLinks(rt, users, "doc.grant_and_share", permissionStatus)
}
func sendDocLinks(rt *shortcut.RuntimeContext, users []contactUser, operation string, permissionStatus map[string]string) error {
body := shareDocBuildText(rt.Str("url"), rt.Str("note"))
content, _ := json.Marshal(map[string]string{"title": "文档分享", "text": body})
ledger := make([]map[string]any, 0, len(users))
failed := 0
for _, user := range users {
params := rt.AddAIMessageTag(map[string]any{"receiverOpenDingTalkId": user.openDingTalkID, "msgType": "markdown", "content": string(content)})
result, err := rt.CallMCPWriteData("chat", "send_personal_message", params)
permission := permissionStatus[user.userID]
if permission == "" {
permission = "unchanged"
}
row := map[string]any{"name": user.name, "userId": user.userID, "permission": permission, "message": "success"}
if err != nil {
failed++
row["message"] = "failed"
row["error"] = err.Error()
} else {
row["result"] = result
}
ledger = append(ledger, row)
}
status := "success"
succeeded := len(users) - failed
if failed == len(users) {
status = "failed"
} else if failed > 0 {
status = "partial_success"
}
payload := map[string]any{
"ok": failed == 0, "status": status, "operation": operation,
"data": map[string]any{
"requestedCount": len(users), "succeededCount": succeeded, "failedCount": failed,
"recipients": ledger,
},
"warnings": []string{"权限与消息不构成跨产品事务;消息失败不会自动撤销既有权限"},
}
if err := rt.Output(payload); err != nil {
return err
}
if failed > 0 {
return apperrors.NewAPI(
fmt.Sprintf("文档链接发送未全部完成:%d/%d 个接收人失败", failed, len(users)),
apperrors.WithOperation(operation),
apperrors.WithReason("doc_share_message_failed"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(false),
apperrors.WithDetails(map[string]any{
"requestedCount": len(users), "succeededCount": succeeded, "failedCount": failed,
"partial": succeeded > 0,
}),
)
}
return nil
}
func docAccessEnvelope(operation string, data any) map[string]any {
return map[string]any{"ok": true, "status": "success", "operation": operation, "data": data, "warnings": []string{}, "compensation": map[string]any{"available": false, "reason": "cross-product writes are not transactional"}}
}
func init() {
shortcut.Register(AccessGrant, AccessChange, AccessRevoke, GrantAndShare)
}
+396
View File
@@ -0,0 +1,396 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package smart
import (
"bytes"
"encoding/json"
"errors"
"io"
"reflect"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/spf13/cobra"
)
func docAccessCoverageResponses(permission string) map[string][]string {
return map[string][]string{
"contact/search_contact_by_key_word": {`{"result":[{"userId":"u1","name":"张三","openDingTalkId":"open1"}]}`},
"doc/list_permission": {permission},
"doc/add_permission": {`{"result":{"ok":true}}`},
"doc/update_permission": {`{"result":{"ok":true}}`},
"doc/remove_permission": {`{"result":{"ok":true}}`},
"chat/send_personal_message": {`{"result":{"messageId":"m1"}}`},
}
}
type docAccessErrorWriter struct{}
func (docAccessErrorWriter) Write([]byte) (int, error) { return 0, errors.New("output failure") }
func runDocAccessCoverage(t *testing.T, caller *smartCoverageCaller, args ...string) error {
t.Helper()
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetIn(bytes.NewReader(nil))
root.SetArgs(args)
return root.Execute()
}
func runDocAccessCoverageOutput(t *testing.T, caller *smartCoverageCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var stdout bytes.Buffer
root.SetOut(&stdout)
root.SetErr(io.Discard)
root.SetIn(bytes.NewReader(nil))
root.SetArgs(args)
err := root.Execute()
if stdout.Len() == 0 {
return nil, err
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("decode output %q: %v", stdout.String(), err)
}
return payload, err
}
func runDocAccessDeclaration(t *testing.T, declaration shortcut.Shortcut, caller *smartCoverageCaller, args ...string) error {
t.Helper()
helpers.InitDeps(caller)
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.PersistentFlags().Bool("yes", false, "")
root.PersistentFlags().Bool("dry-run", false, "")
root.PersistentFlags().String("format", "json", "")
service := &cobra.Command{Use: "doc"}
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
root.AddCommand(service)
root.SetArgs(append([]string{"doc", declaration.Command}, args...))
return root.Execute()
}
func TestCrossPlatformCoverageDocAccessSuccessDryRunAndPreflight(t *testing.T) {
if docSmartContract("+test", "d", "i", nil, true).DryRun == nil {
t.Fatal("explicit doc smart dry-run declaration missing")
}
present := `{"result":[{"userId":"u1","roleId":"READER"}]}`
empty := `{"result":[]}`
cases := []struct {
name string
permission string
args []string
wantErr bool
}{
{"grant", present, []string{"doc", "+access-grant", "--node", "n", "--to", "张三", "--role", "READER", "--workspace", "w", "--yes"}, false},
{"grant dry", present, []string{"doc", "+access-grant", "--node", "n", "--to", "张三", "--dry-run", "--yes"}, false},
{"change", present, []string{"doc", "+access-change", "--node", "n", "--to", "张三", "--role", "EDITOR", "--yes"}, false},
{"change dry", present, []string{"doc", "+access-change", "--node", "n", "--to", "张三", "--dry-run", "--yes"}, false},
{"change missing", empty, []string{"doc", "+access-change", "--node", "n", "--to", "张三", "--yes"}, true},
{"revoke", present, []string{"doc", "+access-revoke", "--node", "n", "--to", "张三", "--workspace", "w", "--yes"}, false},
{"revoke dry", present, []string{"doc", "+access-revoke", "--node", "n", "--to", "张三", "--dry-run", "--yes"}, false},
{"revoke missing", empty, []string{"doc", "+access-revoke", "--node", "n", "--to", "张三", "--yes"}, true},
{"share", present, []string{"doc", "+share", "--to", "张三", "--url", "https://example.com/doc", "--note", "看一下", "--yes"}, false},
{"share dry", present, []string{"doc", "+share", "--to", "张三", "--url", "https://example.com/doc", "--dry-run", "--yes"}, false},
{"grant share existing", present, []string{"doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes"}, false},
{"grant share missing", empty, []string{"doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes"}, false},
{"grant share dry", empty, []string{"doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--dry-run", "--yes"}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
caller := &smartCoverageCaller{responses: docAccessCoverageResponses(tc.permission), failAt: map[string]int{}}
err := runDocAccessCoverage(t, caller, tc.args...)
if (err != nil) != tc.wantErr {
t.Fatalf("error = %v, wantErr %v", err, tc.wantErr)
}
})
}
}
func TestCrossPlatformCoverageDocGrantAndShareProjectionMatchesWrite(t *testing.T) {
permission := func(payload map[string]any) string {
t.Helper()
data, _ := payload["data"].(map[string]any)
recipients, _ := data["recipients"].([]any)
if len(recipients) != 1 {
t.Fatalf("recipients = %#v", data["recipients"])
}
row, _ := recipients[0].(map[string]any)
value, _ := row["permission"].(string)
return value
}
missingCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[]}`), failAt: map[string]int{}}
missing, err := runDocAccessCoverageOutput(t, missingCaller, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes")
if err != nil {
t.Fatal(err)
}
if missing["operation"] != "doc.grant_and_share" || permission(missing) != "granted" {
t.Fatalf("missing projection = %#v", missing)
}
if missingCaller.counts["doc/add_permission"] != 1 {
t.Fatalf("missing add calls = %d", missingCaller.counts["doc/add_permission"])
}
presentCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[{"id":"u1","roleId":"READER"}]}`), failAt: map[string]int{}}
present, err := runDocAccessCoverageOutput(t, presentCaller, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes")
if err != nil {
t.Fatal(err)
}
if present["operation"] != "doc.grant_and_share" || permission(present) != "unchanged" {
t.Fatalf("present projection = %#v", present)
}
if presentCaller.counts["doc/add_permission"] != 0 || presentCaller.counts["doc/update_permission"] != 0 {
t.Fatalf("present writes = %#v", presentCaller.counts)
}
upgradeCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[{"userId":"u1","roleId":"READER"}]}`), failAt: map[string]int{}}
upgraded, err := runDocAccessCoverageOutput(t, upgradeCaller, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--role", "EDITOR", "--yes")
if err != nil {
t.Fatal(err)
}
if permission(upgraded) != "upgraded" || upgradeCaller.counts["doc/add_permission"] != 0 || upgradeCaller.counts["doc/update_permission"] != 1 || upgradeCaller.counts["chat/send_personal_message"] != 1 {
t.Fatalf("upgrade projection=%#v calls=%#v", upgraded, upgradeCaller.counts)
}
wantUpgrade := map[string]any{"nodeId": "n", "roleId": "EDITOR", "userIds": []string{"u1"}}
if got := upgradeCaller.arguments["doc/update_permission"]; len(got) != 1 || !reflect.DeepEqual(got[0], wantUpgrade) {
t.Fatalf("upgrade params = %#v, want %#v", got, wantUpgrade)
}
unknownCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[{"userId":"u1"}]}`), failAt: map[string]int{}}
if _, err := runDocAccessCoverageOutput(t, unknownCaller, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes"); err == nil {
t.Fatal("unknown existing role must stop before permission writes and messaging")
}
if unknownCaller.counts["doc/add_permission"] != 0 || unknownCaller.counts["doc/update_permission"] != 0 || unknownCaller.counts["chat/send_personal_message"] != 0 {
t.Fatalf("unknown role continued: %#v", unknownCaller.counts)
}
shareCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[]}`), failAt: map[string]int{}}
shared, err := runDocAccessCoverageOutput(t, shareCaller, "doc", "+share", "--to", "张三", "--url", "https://example.com/doc", "--yes")
if err != nil {
t.Fatal(err)
}
if shared["operation"] != "doc.share" || permission(shared) != "unchanged" {
t.Fatalf("share projection = %#v", shared)
}
if missing := usersMissingPermission(map[string]any{"result": []any{map[string]any{"userId": "u10"}}}, []contactUser{{name: "张三", userID: "u1"}}); len(missing) != 1 {
t.Fatalf("substring permission match must not pass: %#v", missing)
}
roles := map[string]int{"READER": 1, "DOWNLOADER": 2, "EDITOR": 3, "MANAGER": 4, "OWNER": 5, "unknown": 0}
for role, want := range roles {
if got := permissionRoleRank(role); got != want {
t.Fatalf("permissionRoleRank(%q) = %d, want %d", role, got, want)
}
}
collected := map[string]string{}
collectPermissionRoles(map[string]any{"result": []any{
map[string]any{"userID": "u2", "permissionRole": "READER"},
map[string]any{"memberId": "u2", "permissionType": "EDITOR"},
map[string]any{"targetId": "u3", "roleType": "DOWNLOADER"},
map[string]any{"uid": "u4", "roleID": "MANAGER"},
map[string]any{"id": "u5", "role": "OWNER"},
}}, collected)
if !reflect.DeepEqual(collected, map[string]string{"u2": "EDITOR", "u3": "DOWNLOADER", "u4": "MANAGER", "u5": "OWNER"}) {
t.Fatalf("collected roles = %#v", collected)
}
}
func TestCrossPlatformCoverageDocAccessRevokeConfirmationBoundary(t *testing.T) {
present := `{"result":[{"userId":"u1","roleId":"READER"}]}`
unconfirmed := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}
if err := runDocAccessCoverage(t, unconfirmed, "doc", "+access-revoke", "--node", "n", "--to", "张三"); err == nil {
t.Fatal("access revoke without --yes must reject")
}
if len(unconfirmed.counts) != 0 {
t.Fatalf("unconfirmed access revoke called MCP: %#v", unconfirmed.counts)
}
confirmed := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}
if err := runDocAccessCoverage(t, confirmed, "doc", "+access-revoke", "--node", "n", "--to", "张三", "--yes"); err != nil {
t.Fatal(err)
}
wantCounts := map[string]int{
"contact/search_contact_by_key_word": 1,
"doc/list_permission": 1,
"doc/remove_permission": 1,
}
if !reflect.DeepEqual(confirmed.counts, wantCounts) {
t.Fatalf("confirmed calls = %#v, want %#v", confirmed.counts, wantCounts)
}
wantRemove := map[string]any{"nodeId": "n", "userIds": []string{"u1"}}
if got := confirmed.arguments["doc/remove_permission"]; len(got) != 1 || !reflect.DeepEqual(got[0], wantRemove) {
t.Fatalf("remove params = %#v, want %#v", got, wantRemove)
}
}
func TestCrossPlatformCoverageDocShareKeepsLegacyStringFlagAndCSV(t *testing.T) {
testseam.Swap(t, &resolveDocShareUser, func(_ *shortcut.RuntimeContext, name string) (contactUser, error) {
return contactUser{name: name, userID: "user-" + name, openDingTalkID: "open-" + name}, nil
})
responses := docAccessCoverageResponses(`{"result":[]}`)
responses["chat/send_personal_message"] = []string{
`{"result":{"messageId":"m1"}}`,
`{"result":{"messageId":"m2"}}`,
}
caller := &smartCoverageCaller{responses: responses, failAt: map[string]int{}}
payload, err := runDocAccessCoverageOutput(t, caller, "doc", "+share-doc", "--to", "alice,bob", "--url", "https://example.com/doc", "--yes")
if err != nil {
t.Fatal(err)
}
data, _ := payload["data"].(map[string]any)
recipients, _ := data["recipients"].([]any)
if len(recipients) != 2 || caller.counts["chat/send_personal_message"] != 2 {
t.Fatalf("recipients=%#v send calls=%d", recipients, caller.counts["chat/send_personal_message"])
}
}
func TestCrossPlatformCoverageDocShareFailureExitContracts(t *testing.T) {
resolve := func(_ *shortcut.RuntimeContext, name string) (contactUser, error) {
return contactUser{name: name, userID: "user-" + name, openDingTalkID: "open-" + name}, nil
}
testseam.Swap(t, &resolveDocShareUser, resolve)
testseam.Swap(t, &resolveDocPermissionUser, resolve)
assertFailure := func(t *testing.T, payload map[string]any, err error, wantStatus string, wantSucceeded, wantFailed int) {
t.Helper()
if err == nil {
t.Fatal("message failure must return a non-zero exit error")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_share_message_failed" || typed.ExitCode() != 1 {
t.Fatalf("message error = %#v", err)
}
if payload["ok"] != false || payload["status"] != wantStatus {
t.Fatalf("failure payload = %#v", payload)
}
data, _ := payload["data"].(map[string]any)
if data["succeededCount"] != float64(wantSucceeded) || data["failedCount"] != float64(wantFailed) {
t.Fatalf("failure counts = %#v", data)
}
}
allShareCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[]}`), failAt: map[string]int{"chat/send_personal_message": -1}}
allShare, err := runDocAccessCoverageOutput(t, allShareCaller, "doc", "+share", "--to", "alice", "--url", "https://example.com/doc", "--yes")
assertFailure(t, allShare, err, "failed", 0, 1)
permissions := `{"result":[{"userId":"user-alice","roleId":"READER"},{"userId":"user-bob","roleId":"READER"}]}`
allGrantCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(permissions), failAt: map[string]int{"chat/send_personal_message": -1}}
allGrant, err := runDocAccessCoverageOutput(t, allGrantCaller, "doc", "+grant-and-share", "--node", "n", "--to", "alice,bob", "--url", "https://example.com/doc", "--yes")
assertFailure(t, allGrant, err, "failed", 0, 2)
partialCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(permissions), failAt: map[string]int{"chat/send_personal_message": 2}}
partial, err := runDocAccessCoverageOutput(t, partialCaller, "doc", "+grant-and-share", "--node", "n", "--to", "alice,bob", "--url", "https://example.com/doc", "--yes")
assertFailure(t, partial, err, "partial_success", 1, 1)
helpers.InitDeps(&smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[]}`), failAt: map[string]int{}})
root := newPlatformCoverageRoot()
root.SetOut(docAccessErrorWriter{})
root.SetErr(io.Discard)
root.SetIn(bytes.NewReader(nil))
root.SetArgs([]string{"doc", "+share", "--to", "alice", "--url", "https://example.com/doc", "--yes"})
if err := root.Execute(); err == nil || err.Error() != "output failure" {
t.Fatalf("output failure = %v", err)
}
}
func TestCrossPlatformCoverageDocGrantAndSharePartialPermissionFailure(t *testing.T) {
resolve := func(_ *shortcut.RuntimeContext, name string) (contactUser, error) {
return contactUser{name: name, userID: "user-" + name, openDingTalkID: "open-" + name}, nil
}
testseam.Swap(t, &resolveDocPermissionUser, resolve)
responses := docAccessCoverageResponses(`{"result":[{"userId":"user-bob","roleId":"READER"}]}`)
caller := &smartCoverageCaller{responses: responses, failAt: map[string]int{"doc/update_permission": 1}}
err := runDocAccessCoverage(t, caller, "doc", "+grant-and-share", "--node", "n", "--to", "alice,bob", "--url", "https://example.com/doc", "--role", "EDITOR", "--yes")
if err == nil {
t.Fatal("partial permission write unexpectedly succeeded")
}
var typed *apperrors.Error
if !errors.As(err, &typed) || typed.Reason != "doc_grant_permission_partial_failure" || typed.FailureStage != "update_permission" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable {
t.Fatalf("partial permission error = %#v", err)
}
if typed.Details["status"] != "partial_success" {
t.Fatalf("partial permission details = %#v", typed.Details)
}
steps, _ := typed.Details["steps"].([]map[string]any)
if len(steps) != 3 || steps[0]["status"] != "success" || steps[1]["status"] != "failed" || steps[2]["status"] != "not_started" {
t.Fatalf("partial permission steps = %#v", steps)
}
if caller.counts["doc/add_permission"] != 1 || caller.counts["doc/update_permission"] != 1 || caller.counts["chat/send_personal_message"] != 0 {
t.Fatalf("partial permission calls = %#v", caller.counts)
}
}
func TestCrossPlatformCoverageDocAccessFailureBoundaries(t *testing.T) {
present := `{"result":[{"userId":"u1","roleId":"READER"}]}`
commands := []struct {
args []string
fails []string
}{
{[]string{"doc", "+access-grant", "--node", "n", "--to", "张三", "--yes"}, []string{"contact/search_contact_by_key_word", "doc/add_permission"}},
{[]string{"doc", "+access-change", "--node", "n", "--to", "张三", "--yes"}, []string{"contact/search_contact_by_key_word", "doc/list_permission", "doc/update_permission"}},
{[]string{"doc", "+access-revoke", "--node", "n", "--to", "张三", "--yes"}, []string{"contact/search_contact_by_key_word", "doc/list_permission", "doc/remove_permission"}},
{[]string{"doc", "+share", "--to", "张三", "--url", "https://example.com/doc", "--yes"}, []string{"contact/search_contact_by_key_word", "chat/send_personal_message"}},
{[]string{"doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes"}, []string{"contact/search_contact_by_key_word", "doc/list_permission", "chat/send_personal_message"}},
}
for _, command := range commands {
for _, fail := range command.fails {
responses := docAccessCoverageResponses(present)
if fail == "doc/add_permission" {
responses["doc/list_permission"] = []string{`{"result":[]}`}
}
caller := &smartCoverageCaller{responses: responses, failAt: map[string]int{fail: 1}}
_ = runDocAccessCoverage(t, caller, command.args...)
}
}
external := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}
external.responses["contact/search_contact_by_key_word"] = []string{`{"result":[{"name":"外部","openDingTalkId":"open-ext"}]}`}
_ = runDocAccessCoverage(t, external, "doc", "+access-grant", "--node", "n", "--to", "外部", "--yes")
noOpenID := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}
noOpenID.responses["contact/search_contact_by_key_word"] = []string{`{"result":[{"name":"张三","userId":"u1"}]}`}
_ = runDocAccessCoverage(t, noOpenID, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes")
emptyName := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}
_ = runDocAccessCoverage(t, emptyName, "doc", "+access-grant", "--node", "n", "--to", " ", "--yes")
duplicate := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}
_ = runDocAccessCoverage(t, duplicate, "doc", "+access-grant", "--node", "n", "--to", "张三,张三", "--yes")
_ = usersMissingPermission(map[string]any{}, []contactUser{{name: "empty"}})
optionalTo := AccessGrant
optionalTo.Flags = append([]shortcut.Flag(nil), AccessGrant.Flags...)
optionalTo.Flags[1].Required = false
_ = runDocAccessDeclaration(t, optionalTo, &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}, "--node", "n", "--to", " ", "--yes")
_ = runDocAccessDeclaration(t, optionalTo, &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}, "--node", "n", "--yes")
t.Run("permission resolver defensive identity check", func(t *testing.T) {
testseam.Swap(t, &resolveDocPermissionUser, func(*shortcut.RuntimeContext, string) (contactUser, error) {
return contactUser{name: "external", openDingTalkID: "open"}, nil
})
_ = runDocAccessCoverage(t, &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}, "doc", "+access-grant", "--node", "n", "--to", "external", "--yes")
})
t.Run("permission resolver error", func(t *testing.T) {
testseam.Swap(t, &resolveDocPermissionUser, func(*shortcut.RuntimeContext, string) (contactUser, error) {
return contactUser{}, errors.New("resolve")
})
_ = runDocAccessCoverage(t, &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}, "doc", "+access-grant", "--node", "n", "--to", "x", "--yes")
})
grantShareAddFailure := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[]}`), failAt: map[string]int{"doc/add_permission": 1}}
_ = runDocAccessCoverage(t, grantShareAddFailure, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes")
grantShareUpdateFailure := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[{"userId":"u1","roleId":"READER"}]}`), failAt: map[string]int{"doc/update_permission": 1}}
_ = runDocAccessCoverage(t, grantShareUpdateFailure, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--role", "EDITOR", "--yes")
if grantShareUpdateFailure.counts["chat/send_personal_message"] != 0 {
t.Fatalf("message sent after permission upgrade failure: %#v", grantShareUpdateFailure.counts)
}
}
+2
View File
@@ -102,5 +102,7 @@ var DocAppend = shortcut.Shortcut{
}
func init() {
// Keep the historical command and Schema identity alongside the richer
// canonical doc +update surface for backwards compatibility.
shortcut.Register(DocAppend)
}
+2
View File
@@ -170,5 +170,7 @@ func shortcutFindDocStr(m map[string]any, keys ...string) string {
}
func init() {
// Keep the historical command and Schema identity alongside the richer
// canonical doc +search surface for backwards compatibility.
shortcut.Register(FindDoc)
}
+2 -1
View File
@@ -116,5 +116,6 @@ func shareDocBuildText(url, note string) string {
}
func init() {
shortcut.Register(ShareDoc)
canonicalizeShareDoc()
shortcut.Register(legacyShareDoc, ShareDoc)
}
+6
View File
@@ -100,6 +100,12 @@ type Flag struct {
Enum []string `json:"enum"`
// Hidden hides the flag from --help while keeping it usable.
Hidden bool `json:"-"`
// Aliases are hidden executable flag spellings for compatibility. They do
// not create additional Schema parameters; validation and value fallback
// remain attached to the canonical Name. AliasesVisible is a narrow
// compatibility escape hatch for aliases that were historically public.
Aliases []string `json:"-"`
AliasesVisible bool `json:"-"`
}
// ConstraintKind is a machine-readable cross-parameter or custom validation
+1 -4
View File
@@ -103,10 +103,7 @@ func newShortcutListRow(s shortcut.Shortcut) shortcutListRow {
if risk == "" {
risk = string(shortcut.RiskRead)
}
confirmation := "not_required"
if risk != string(shortcut.RiskRead) {
confirmation = "user_required"
}
confirmation := shortcut.EffectiveSafety(s).Confirmation
flags := make([]shortcut.Flag, 0, len(s.Flags))
for _, flag := range s.Flags {
if flag.Hidden {
+5
View File
@@ -55,6 +55,11 @@ func openSupplementServers() []ServerInfo {
Name: "MCP 元服务",
Endpoint: "https://mcp-gw.dingtalk.com/server/89833ea5debf30c260a07ffcb5127ffa3bf0c830cd76babadb293d9861485d44",
},
{
ID: "whiteboard",
Name: "钉钉白板",
Endpoint: "https://mcp-gw.dingtalk.com/server/whiteboard",
},
}
}
+12 -2
View File
@@ -105,17 +105,27 @@ func TestOpenVisibleProductsExcludesCompatibilityOnlyCommands(t *testing.T) {
func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) {
servers := openSupplementServers()
foundMCPMeta := false
foundWhiteboard := false
for _, server := range servers {
if server.ID == "whiteboard" {
foundWhiteboard = server.Endpoint == "https://mcp-gw.dingtalk.com/server/whiteboard"
}
if server.ID != "mcp-meta" {
continue
}
foundMCPMeta = true
if server.Endpoint == "" {
t.Fatal("mcp-meta has empty endpoint")
}
if len(server.Prefixes) != 0 {
t.Fatal("mcp-meta must remain helper-only without command prefixes")
}
return
}
t.Fatal("openSupplementServers() missing mcp-meta")
if !foundMCPMeta {
t.Fatal("openSupplementServers() missing mcp-meta")
}
if !foundWhiteboard {
t.Fatal("openSupplementServers() missing helper-only whiteboard endpoint")
}
}
+43 -38
View File
@@ -18,7 +18,10 @@ GO_PATH = ROOT / "internal" / "shortcut" / "public_catalog_generated.go"
CATALOG_PATH = ROOT / "docs" / "shortcut-public-catalog.json"
FOLLOWUP_MD_PATH = ROOT / "docs" / "shortcut-real-test-followups.md"
FOLLOWUP_JSON_PATH = ROOT / "docs" / "shortcut-real-test-followups.json"
SEMANTIC_PATH = ROOT / "internal" / "shortcut" / "semantic_catalog.json"
SEMANTIC_PATHS = [
ROOT / "internal" / "shortcut" / "semantic_catalog.json",
ROOT / "internal" / "shortcut" / "semantic_catalog_doc.json",
]
def load(path: Path) -> dict[str, Any]:
@@ -107,44 +110,46 @@ def collect() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
# whether the current account happened to have a fixture for a real run.
# Keep the real-run rows as evidence/follow-ups, but publish Chat entries
# exclusively from the reviewed semantic catalog.
semantic = load(SEMANTIC_PATH)
service = semantic.get("service") or ""
if service != "chat":
raise ValueError(f"unexpected semantic catalog service: {service!r}")
public = [row for row in evidence_public if row["service"] != service]
for command, record in semantic.get("shortcuts", {}).items():
if not record.get("public"):
continue
if not record.get("reviewed"):
raise ValueError(f"public semantic shortcut is not reviewed: {command}")
availability = (
record.get("availability")
or semantic.get("default_availability")
or ""
)
if availability != "available":
raise ValueError(
f"public semantic shortcut is not available: {command}={availability}"
semantics = [load(path) for path in SEMANTIC_PATHS]
semantic_services = {semantic.get("service") or "" for semantic in semantics}
if "" in semantic_services or len(semantic_services) != len(semantics):
raise ValueError(f"invalid or duplicate semantic catalog services: {semantic_services!r}")
public = [row for row in evidence_public if row["service"] not in semantic_services]
for semantic in semantics:
service = semantic["service"]
for command, record in semantic.get("shortcuts", {}).items():
if not record.get("public"):
continue
if not record.get("reviewed"):
raise ValueError(f"public semantic shortcut is not reviewed: {service} {command}")
availability = (
record.get("availability")
or semantic.get("default_availability")
or ""
)
observed = evidence_by_key.get((service, command), {})
risk = record.get("risk") or ""
if not risk:
raise ValueError(f"public semantic shortcut lacks reviewed risk: {command}")
if observed.get("risk") and observed["risk"] != risk:
raise ValueError(
f"semantic shortcut risk drift: {command}: "
f"reviewed={risk} observed={observed['risk']}"
)
public.append({
"suite": "semantic",
"service": service,
"command": command,
"risk": risk,
"status": "reviewed_available",
"disposition": record.get("disposition") or "",
"semantic_delta": record.get("semantic_delta") or "",
"availability": availability,
})
if availability != "available":
raise ValueError(
f"public semantic shortcut is not available: {service} {command}={availability}"
)
observed = evidence_by_key.get((service, command), {})
risk = record.get("risk") or ""
if not risk:
raise ValueError(f"public semantic shortcut lacks reviewed risk: {service} {command}")
if observed.get("risk") and observed["risk"] != risk:
raise ValueError(
f"semantic shortcut risk drift: {service} {command}: "
f"reviewed={risk} observed={observed['risk']}"
)
public.append({
"suite": "semantic",
"service": service,
"command": command,
"risk": risk,
"status": "reviewed_available",
"disposition": record.get("disposition") or "",
"semantic_delta": record.get("semantic_delta") or "",
"availability": availability,
})
public.sort(key=lambda r: (r["service"], r["command"]))
followups.sort(key=lambda r: (r["suite"], r["service"], r["command"]))
return public, followups
+4 -2
View File
@@ -46,11 +46,11 @@ cli_version: ">=1.0.15"
| `aitable` | 29 | `dingtalk-aitable` |
| `attendance` | 19 | `dingtalk-misc` |
| `calendar` | 20 | `dingtalk-calendar` |
| `chat` | 97 | `dingtalk-chat` |
| `chat` | 98 | `dingtalk-chat` |
| `contact` | 14 | `dingtalk-contact` |
| `devapp` | 19 | `dingtalk-dev` |
| `ding` | 4 | `dingtalk-misc` |
| `doc` | 17 | `dingtalk-doc` |
| `doc` | 41 | `dingtalk-doc` |
| `drive` | 7 | `dingtalk-drive` |
| `mail` | 10 | `dingtalk-mail` |
| `minutes` | 6 | `dingtalk-minutes` |
@@ -94,6 +94,7 @@ cli_version: ">=1.0.15"
| `sheet` | 在线电子表格(axls):工作表 CRUD/区域读写/CSV 批量写入/行列增删/合并/查找替换/筛选视图/全局筛选/排序/下拉列表/条件格式/浮动图片/浮动图表/模板/导出 xlsx(单命令一站式) | [sheet.md](./references/products/sheet.md) |
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) |
| `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) |
| `event` | 个人 IM 事件:监听消息接收、指定发送人、已读、撤回、表情回应,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
## 意图判断决策树
@@ -120,6 +121,7 @@ cli_version: ">=1.0.15"
用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet`
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki`
用户提到"文档内嵌白板/画布/OpenNodes/白板节点/连接线/整页重建白板" → `whiteboard`;创建空白板卡片先走 `doc whiteboard insert`
用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/监听并自动回复消息/驱动 Agent 处理消息" → `event +listen-im`;群成员加入/退出、群改名/解散或明确原始 EventKey/Filter DSL → `event consume`
普通消息、reaction、已读、撤回监听优先由一个 `dws event +listen-im` 进程表达目标;不同用户、不同群或不同过滤条件拆成独立进程。只有高级事件控制才生成 `dws event consume <event_key> [event_key...] --flatten`。
+4
View File
@@ -1245,8 +1245,11 @@ Usage:
dws chat message reply [flags]
Example:
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>
# 被引用消息的 openMessageId、发送者 openDingTalkId 通过 dws chat message list 获取
Flags:
--at-all @所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐)
--at-open-dingtalk-ids string @指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效;正文缺少对应 <@id> 时自动补齐)
--conversation-id string 会话 openConversationId (必填,支持单聊/群聊)
--ref-msg-id string 被引用的消息 openMessageId (必填)
--ref-sender string 被引用消息的发送者 openDingTalkId (必填)
@@ -1256,6 +1259,7 @@ Flags:
注意:
- 以当前用户身份引用回复,语义同 chat message send;目前回复类型仅支持 text
- 群聊 @指定成员时,正文缺少对应 <@openDingTalkId> 会自动补齐,已有裸 @openDingTalkId 会规范化;--at-all 会自动补齐 <@all>
```
#### 转发单条消息 — 将一条消息从源会话转发到目标会话(源/目标均支持单聊/群聊)
+13
View File
@@ -1071,6 +1071,19 @@ EOF
- `comment create` 是全文评论;`comment create-inline` 是划词评论,必须先 `block list` 拿到 `blockId` 并确定 `--start` / `--end` 偏移(按块内纯文本字符算,从 0 开始)
- 全文评论 `create` / `reply` / `update` 支持通过 `--mentioned-open-conversation-id` @群;划词评论 `create-inline` 不支持 @群
## 白板卡片与白板媒体资源
```bash
dws doc whiteboard insert --node <DOC_ID> --yes --format json
dws doc media upload --node <DOC_ID> --file ./icon.svg \
--mime-type image/svg+xml --yes --format json
```
两个命令都是远端写入,必须先获得用户确认。insert 返回的 `whiteboardId` 是
`dws whiteboard query/update` 使用的 partId;`blockId` 只用于文档块定位/删除。
media upload 返回的 `resourceId` / `resourceUrl` 只能用于同一 nodeId 下的白板
Vector/SVG。完整协议见 [whiteboard.md](./whiteboard.md)。
## 自动化脚本
| 脚本 | 场景 | 用法 |
@@ -0,0 +1,78 @@
# 钉钉文档内嵌白板
`dws whiteboard` 读取和更新已存在于在线文档中的单页白板。创建白板卡片使用
`dws doc whiteboard insert`;删除卡片使用已有的 `dws doc block delete`。
OpenNodes V1 的完整字段、节点类型、目录枚举和错误语义按需读取
[协议索引](./whiteboard/open-nodes-v1.md);不要根据本页概要猜测节点字段或
`geometry`、`catalogId` 等枚举值。渐变卡片、Frame 分支、SVG/Vector 等完整
工作流见 [常用 Recipes](./whiteboard/recipes.md)。
## 标准流程
1. 从用户输入或真实文档 JSONML 取得 `nodeId` 和 card `metadata.id`(partId)。
2. `dws whiteboard query --node <DOC_ID> --part-id <PART_ID> --format json` 保存当前内容。
3. 生成 OpenNodes V1 文件;不能把 query 响应直接回写。
4. 向用户展示写入范围并取得确认。
5. `dws whiteboard update --node <DOC_ID> --part-id <PART_ID> --source <FILE> --yes --format json`。
6. 再次 query 验证节点、层级和连接关系。
更新文件:
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "n1",
"type": "text",
"x": 40,
"y": 40,
"width": 240,
"height": 48,
"text": {
"blocks": [
{
"type": "paragraph",
"runs": [{"text": "方案"}]
}
]
}
}
]
}
}
```
- append (`overwrite=false`) 至少包含一个节点。
- overwrite (`true`) 整页重建并允许空数组;必须先备份当前 query 结果。
- 所有 update 都要求用户确认和 `--yes`。
- 当前只支持单页,不支持 `pageId`,也不支持使用真实节点 ID 做局部更新。
- `--jq` / `--fields` 不适用于白板命令。
## 创建白板
```bash
dws doc whiteboard insert --node <DOC_ID> --yes --format json
```
返回的 `whiteboardId` 是 partId,`blockId` 是文档块 ID。删除卡片走:
```bash
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes --format json
```
## Vector / SVG
先上传绑定到同一 nodeId 的媒体资源:
```bash
dws doc media upload --node <DOC_ID> --file ./icon.svg \
--mime-type image/svg+xml --yes --format json
```
只使用稳定输出 `resourceId` / `resourceUrl`,不要使用临时 uploadUrl、本地路径或
跨 nodeId 资源。
@@ -0,0 +1,45 @@
# OpenNodes V1(DWS 白板协议索引)
本目录承载 `dws whiteboard query/update` 使用的 OpenNodes V1 协议。协议按
调用阶段拆分,Agent 只加载当前任务所需章节,避免一次性读取全部内容。
## DWS 使用规则
- 只通过 `dws whiteboard query/update` 读写白板。
- 每次调用必须提供承载白板的文档 `--node` 和目标白板 `--part-id`。
- DWS 当前只支持单页白板:命令没有 `--page-id`,update 文件禁止包含
`pageId`。
- `query` 不接收请求体;CLI 会把返回的 `resultJson` 解析成对象。
- 白板命令不支持使用全局 `--jq` 或 `--fields` 过滤输出;传入任一参数都会报错,
Agent 直接读取 CLI 返回的结构化 JSON。
- `update --source` 使用 `overwrite + source` 信封;append 和 overwrite 都是
远端写入,获得用户确认后必须通过 `--yes` 显式确认。
- CLI 只预检 JSON、信封、版本和 `nodes` 数组等外层结构;节点字段、枚举、层级、
引用和业务约束由白板服务完整校验。任一层失败都不会保留部分更新。
- DWS 返回以 `success`、`nodeId`、`partId`、`resultJson` 和可选的
`resultSummary` 为准。
## 按任务读取
| 当前任务 | 必读章节 |
|---|---|
| 理解版本、兼容和命令语义 | [01-overview](open-nodes-v1/01-overview.md) |
| 读取或解释 query 结果 | [02-query](open-nodes-v1/02-query.md) |
| 构造 append、overwrite 或清空请求 | [03-update](open-nodes-v1/03-update.md) |
| 写富文本、列表、链接、主题色、渐变或阴影 | [04-text-style](open-nodes-v1/04-text-style.md) |
| 写 shape、便签、frame、group 或 connector | [05-shape-frame-group-connector](open-nodes-v1/05-shape-frame-group-connector.md) |
| 写已上传 Vector、内置 Icon 或自由 Path | [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md) |
| 处理错误、query 转写或判断 writeSupport | [07-examples-errors-write-support](open-nodes-v1/07-examples-errors-write-support.md) |
| 选择合法 geometry 或 icon catalogId | [08-catalogs](open-nodes-v1/08-catalogs.md) |
## 强制读取规则
- 使用 `shape.geometry` 前必须读取 [08-catalogs](open-nodes-v1/08-catalogs.md),
不得猜测 geometry。
- 使用 `icon.catalogId` 前必须读取 [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md)
和 [08-catalogs](open-nodes-v1/08-catalogs.md)。
- 使用 `path` 前必须读取 [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md),
不得把它当作通用 SVG Path。
- Query 结果不能直接作为 update source;转换前必须读取
[03-update](open-nodes-v1/03-update.md) 和
[07-examples-errors-write-support](open-nodes-v1/07-examples-errors-write-support.md)。
@@ -0,0 +1,57 @@
# DWS OpenNodes V1 协议说明
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
> 协议版本:`schemaVersion = "1.0"`,`catalogVersion = "dml-v1"`。
## 1. 协议用途
OpenNodes 是 DWS 白板命令使用的语义节点协议,提供两类能力:
- `dws whiteboard query`:返回稳定、可理解的页面和节点数据。
- `dws whiteboard update`:接收受约束的节点描述,以 `append` 或
`overwrite` 模式修改白板。
调用方只应依赖本文声明的语义字段和行为:
- `query` 不修改白板。
- `update` 全部成功或全部回滚,不返回中间状态。
- 未声明的存储字段、类型名称和处理过程不属于协议承诺。
OpenNodes V1 支持的节点类型、字段和读写范围见第 7 节。
DWS 负责身份认证和权限校验。Vector 资源准备使用 `dws doc media upload`,
具体流程见白板命令参考。
## 2. 版本与兼容原则
| 字段 | 当前值 | 作用 |
| --- | --- | --- |
| `schemaVersion` | `1.0` | 控制文档结构、节点字段和字段语义。 |
| `catalogVersion` | `dml-v1` | 控制允许写入的 DML 几何、连接线标记和内置 icon 目录。 |
V1 采用严格校验:
- 必填字段缺失会失败。
- 未声明字段会失败,不会被静默忽略。
- query-only 字段出现在 update 中会以 `readOnlyField` 失败。
- 不支持的节点类型、目录值或引用范围会失败。
- `null` 不代表“使用默认值”;除非字段类型明确允许,否则会失败。
本文列出的请求枚举值都是协议字面量,调用方必须按文档中的大小写和拼写原样传入,
不能自行转换或猜测。响应中未来可能增加可选字段,调用方应忽略不认识的响应字段。
调用方必须原样携带当前版本值。新增不兼容结构时应升级
`schemaVersion`;修改 DML、marker 或 icon 目录时应评估并升级
`catalogVersion`。
## 3. DWS 命令一览
| 命令 | 所需权限 | 效果 |
| --- | --- | --- |
| `dws whiteboard query --node ... --part-id ...` | 可查看白板 | 读取单页白板,不修改内容。 |
| `dws whiteboard update --node ... --part-id ... --source ... --yes` | 可编辑白板 | 追加节点或整页重建;所有更新都需先取得用户确认。 |
DWS 当前只支持文字文档中已有的单页内嵌白板。命令不接收 `pageId`,也不提供
创建页面、切换页面或按既有节点 ID 局部修改的能力。
@@ -0,0 +1,288 @@
# OpenNodes V1 — Query 请求、返回结构和节点公共字段
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 4. Query 协议
### 4.1 请求
```bash
dws whiteboard query \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--format json
```
`query` 不接收请求体或 `pageId`。`--node` 和 `--part-id` 的发现规则见
[白板命令参考](../../whiteboard.md)。CLI 会把服务返回的 `resultJson` JSON
字符串解析成对象。
### 4.2 返回结构
```ts
interface OpenNodesDocument {
schemaVersion: "1.0";
catalogVersion: "dml-v1";
pages: OpenPage[];
}
interface OpenPage {
id: string;
nodes: OpenNode[];
}
```
DWS 当前只支持单页白板,因此 `pages` 固定包含一个页面。调用方仍应从返回值读取
页面 `id`,但不能把它作为 `pageId` 传给 DWS 命令。
母版节点不会作为独立页面返回,而会合并到引用它的页面 `nodes` 中,并带有:
```json
{
"source": "master",
"writeSupport": "readOnly",
"unsupportedFeatures": ["node.source.master"]
}
```
### 4.3 节点公共字段
`type` 的完整公开枚举如下。前一组可由 update 创建,后一组仅供 query 返回:
```ts
type WritableOpenNodeType =
| "shape"
| "text"
| "connector"
| "stickyNote"
| "frame"
| "group"
| "vector"
| "icon"
| "path";
type ReadOnlyOpenNodeType =
| "image"
| "pdf"
| "media"
| "webLink"
| "table"
| "chart"
| "uml"
| "swimlane"
| "mind"
| "timer"
| "placeholder"
| "unknown";
type OpenNodeType = WritableOpenNodeType | ReadOnlyOpenNodeType;
```
每个 query 节点都包含以下公共字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | `string` | 白板中真实、稳定的节点 ID。 |
| `type` | `OpenNodeType` | OpenNodes 公开语义类型,取值见上方枚举。 |
| `parentId` | `string?` | group/frame 父节点 ID。无父节点时省略。 |
| `children` | `string[]?` | group/frame 的直接子节点 ID,由服务端推导。 |
| `x`、`y` | `number` | 有父节点时相对父节点;否则相对页面。单位为 px。 |
| `width`、`height` | `number` | 节点包围盒尺寸,单位为 px。连接线允许其中一个为 `0`。 |
| `angle` | `number` | 归一化到 `[0, 360)` 的角度。 |
| `absoluteBounds` | `OpenBounds` | 页面坐标系中的绝对包围盒。 |
| `layer` | `background \| normal \| foreground` | 节点所在层。 |
| `zIndex` | `number` | 同一父节点、同一 layer 内的非负顺序,值越小越靠后。 |
| `hidden` | `boolean` | 节点是否隐藏。 |
| `locked` | `boolean` | 节点是否锁定。 |
| `source` | `page \| master` | 节点来自当前页面还是母版。 |
| `writeSupport` | `readWrite \| readOnly` | 当前节点能否由 V1 update 表达。 |
| `unsupportedFeatures` | `string[]?` | 只读原因;`readWrite` 节点省略。 |
公共结构和各节点分支定义如下;分支中的字段含义与写入限制见第 7 节:
```ts
interface OpenPoint {
x: number;
y: number;
}
interface OpenBounds {
x: number;
y: number;
width: number;
height: number;
angle: number;
}
interface OpenNodeBase {
id: string;
type: OpenNodeType;
parentId?: string;
children?: string[];
x: number;
y: number;
width: number;
height: number;
angle: number;
absoluteBounds: OpenBounds;
layer: "background" | "normal" | "foreground";
zIndex: number;
hidden: boolean;
locked: boolean;
source: "page" | "master";
writeSupport: "readWrite" | "readOnly";
unsupportedFeatures?: string[];
}
interface OpenShapeNode extends OpenNodeBase {
type: "shape";
geometry: `dml:${string}`;
adjustments?: Record<string, number>;
text?: OpenText;
style?: OpenNodeStyle;
}
interface OpenTextNode extends OpenNodeBase {
type: "text";
text: OpenText;
style?: OpenNodeStyle;
}
interface OpenConnectorNode extends OpenNodeBase {
type: "connector";
start: OpenConnectorEndpoint;
end: OpenConnectorEndpoint;
routing: OpenConnectorRouting;
waypoints?: OpenPoint[];
style?: OpenNodeStyle;
resolvedPath: OpenResolvedConnectorPath;
}
interface OpenStickyNoteNode extends OpenNodeBase {
type: "stickyNote";
text?: OpenText;
style?: OpenNodeStyle;
creator?: {
displayName?: string;
hasAvatar?: boolean;
};
tags?: Array<{
id: string;
text: string;
background: OpenPaint;
}>;
}
interface OpenFrameNode extends OpenNodeBase {
type: "frame";
title?: {
text: OpenText;
box: { width: number; height: number };
};
style?: OpenNodeStyle;
presentationOrder?: number;
resizeMode: "free" | "fixedAspectRatio";
}
interface OpenGroupNode extends OpenNodeBase {
type: "group";
children: string[];
}
interface OpenVectorNode extends OpenNodeBase {
type: "vector";
resource: OpenVectorResource;
}
interface OpenIconNode extends OpenNodeBase {
type: "icon";
catalogId: string;
}
interface OpenPathNode extends OpenNodeBase {
type: "path";
path: OpenPathData;
style?: OpenNodeStyle;
}
interface OpenReadOnlyNode extends OpenNodeBase {
type: ReadOnlyOpenNodeType;
writeSupport: "readOnly";
unsupportedFeatures: string[];
}
type OpenNode =
| OpenShapeNode
| OpenTextNode
| OpenConnectorNode
| OpenStickyNoteNode
| OpenFrameNode
| OpenGroupNode
| OpenVectorNode
| OpenIconNode
| OpenPathNode
| OpenReadOnlyNode;
```
query 中 `icon.catalogId` 使用 `string`,是为了让无法映射到当前目录的既有图标仍可
被诊断;update 只能传入 7.10 节 `OpenIconCatalogId` 中列出的值。
### 4.4 Query 示例
```json
{
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"pages": [
{
"id": "page",
"nodes": [
{
"id": "real-node-id",
"type": "text",
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"angle": 0,
"absoluteBounds": {
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"angle": 0
},
"layer": "normal",
"zIndex": 0,
"hidden": false,
"locked": false,
"source": "page",
"writeSupport": "readWrite",
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "left",
"runs": [
{
"text": "Hello OpenNodes",
"marks": {
"fontSize": 16,
"color": "#223344"
}
}
]
}
],
"verticalAlign": "center",
"padding": [2, 4],
"plainText": "Hello OpenNodes",
"writeSupport": "readWrite"
}
}
]
}
]
}
```
@@ -0,0 +1,249 @@
# OpenNodes V1 — Update 信封、Append/Overwrite 和公共写入字段
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 5. Update 协议
### 5.1 请求信封
```ts
interface OpenNodesUpdateRequest {
overwrite?: boolean;
source: {
schemaVersion: "1.0";
catalogVersion: "dml-v1";
nodes: OpenNodeWrite[];
};
}
```
字段含义:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `overwrite` | 否 | `false` 或省略为 append;`true` 为 overwrite。 |
| `source.schemaVersion` | 是 | 必须为 `1.0`。 |
| `source.catalogVersion` | 是 | 必须为 `dml-v1`。 |
| `source.nodes` | 是 | 本次创建的节点数组。append 至少一个;overwrite 允许空数组。 |
`source` 不能直接使用 query 返回的 `OpenNodesDocument`,也不接受 `pages`。
DWS 不接受 `pageId`;传入会在 CLI 本地校验阶段失败。
V1 没有“按真实节点 ID patch 既有节点”的语义。`source.nodes` 中的每一项都会
创建一个新节点,`id` 仅是本次请求内建立父子关系和连接线引用的临时 ID:
append 是新增节点,overwrite 是整页删除后重新创建。
### 5.2 Append 与 Overwrite
> **注意:** `overwrite: true` 是破坏性操作,会删除当前白板页面的全部自有
> 节点。调用前应先 query 并确认影响范围;`nodes: []` 会清空页面。不希望删除
> 既有内容时,应使用 append。
| 行为 | append | overwrite |
| --- | --- | --- |
| `overwrite` | `false` 或省略 | `true` |
| 空 `nodes` | 禁止 | 允许,用于清空当前页面 |
| 当前页面旧节点 | 全部保留 | 删除页面自有节点后创建新节点 |
| 母版节点 | 保留 | 保留 |
| 页面级设置 | 保留 | 保留 |
| 原子性 | 全部成功或全部回滚 | 全部成功或全部回滚 |
overwrite 会替换当前页面的全部自有节点,不要求旧节点本身可由 OpenNodes V1
写入。因此页面中存在 image、PDF、复杂文本等只读节点,不会单独阻止清空页面。
overwrite 会在删除前执行安全预检;以下情况会拒绝执行:
- 节点被锁定:`lockedNode`。
- 节点不允许被删除:`deleteForbidden`。
- 页面包含无法安全保留或清理的关联数据:`unknownMetadataReference`。
- 目标节点未能完整删除:`deleteFailed`。
与被删除节点绑定且有明确清理规则的关联数据会随节点清理;无法安全保留或清理的
关联数据会使 overwrite 失败。
overwrite 只替换当前页面节点,不会重建页面,也不会修改主题等页面级设置。
白板已有的主题会被保留;白板没有有效主题时也不会自动添加。调用方应使用
已配置有效主题的白板,或者显式提供不依赖主题的颜色。
### 5.3 成功结果
```ts
interface DWSWhiteboardUpdateResponse {
success: true;
nodeId: string;
partId: string;
resultJson: {
mode: "append" | "overwrite";
createdNodeIds: string[];
idMap: Record<string, string>;
deletedNodeCount: number;
message: string;
};
}
```
| 字段 | 说明 |
| --- | --- |
| `success` | `true` 表示本次 DWS 调用成功。 |
| `nodeId` | 输入的文档节点 ID。 |
| `partId` | 输入的白板标识。 |
| `resultJson.mode` | 实际执行的模式。 |
| `resultJson.createdNodeIds` | 按请求节点顺序返回真实节点 ID。 |
| `resultJson.idMap` | 请求中显式临时 ID 到真实节点 ID 的映射。 |
| `resultJson.deletedNodeCount` | append 恒为 `0`;overwrite 为删除的页面自有节点数。 |
| `resultJson.message` | 供人阅读的结果摘要,不应作为机器判断依据。 |
响应可能增加其他可选字段;Agent 不应依赖本节未声明的字段。
示例:
```json
{
"success": true,
"nodeId": "DOC_NODE_ID",
"partId": "WHITEBOARD_PART_ID",
"resultJson": {
"mode": "append",
"createdNodeIds": ["generated-title-id", "generated-body-id"],
"idMap": {
"title": "generated-title-id",
"body": "generated-body-id"
},
"deletedNodeCount": 0,
"message": "Created 2 Whiteboard nodes"
}
}
```
## 6. Update 公共节点字段
V1 可写节点公共字段如下:
```ts
interface OpenNodeWriteBase {
id?: string;
layer?: "background" | "normal" | "foreground";
zIndex?: number;
hidden?: boolean;
}
interface OpenChildNodeWriteBase extends OpenNodeWriteBase {
parentId?: string;
}
interface OpenSizedNodeWriteBase extends OpenChildNodeWriteBase {
x: number;
y: number;
width: number;
height: number;
angle?: number;
}
interface OpenShapeNodeWrite extends OpenSizedNodeWriteBase {
type: "shape";
geometry: `dml:${string}`;
text?: OpenTextWrite;
style?: OpenNodeStyleWrite;
}
interface OpenTextNodeWrite extends OpenSizedNodeWriteBase {
type: "text";
text: OpenTextWrite;
style?: OpenNodeStyleWrite;
}
interface OpenConnectorNodeWrite extends OpenNodeWriteBase {
type: "connector";
start: OpenConnectorEndpointWrite;
end: OpenConnectorEndpointWrite;
routing: OpenConnectorRouting;
waypoints?: OpenPoint[];
style?: OpenNodeStyleWrite;
}
interface OpenStickyNoteNodeWrite extends OpenSizedNodeWriteBase {
type: "stickyNote";
text?: OpenTextWrite;
style?: OpenNodeStyleWrite;
}
interface OpenFrameNodeWrite extends OpenNodeWriteBase {
type: "frame";
x: number;
y: number;
width: number;
height: number;
angle?: 0;
title?: {
text: OpenTextWrite;
box?: { width: number; height: number };
};
style?: OpenNodeStyleWrite;
presentationOrder?: number;
resizeMode?: "free" | "fixedAspectRatio";
}
interface OpenGroupNodeWrite extends OpenChildNodeWriteBase {
id: string;
type: "group";
x: number;
y: number;
}
interface OpenVectorNodeWrite extends OpenSizedNodeWriteBase {
type: "vector";
resource: OpenManagedVectorResourceWrite;
}
interface OpenIconNodeWrite extends OpenSizedNodeWriteBase {
type: "icon";
catalogId: OpenIconCatalogId;
}
interface OpenPathNodeWrite extends OpenSizedNodeWriteBase {
type: "path";
path: OpenPathDataWrite;
style?: OpenNodeStyleWrite;
}
type OpenNodeWrite =
| OpenShapeNodeWrite
| OpenTextNodeWrite
| OpenConnectorNodeWrite
| OpenStickyNoteNodeWrite
| OpenFrameNodeWrite
| OpenGroupNodeWrite
| OpenVectorNodeWrite
| OpenIconNodeWrite
| OpenPathNodeWrite;
```
上面的联合类型是 update 的字段白名单。各辅助结构和完整枚举值在第 7 节定义;
未出现在对应分支中的字段不能发送。
| 字段 | 规则 |
| --- | --- |
| `id` | 可选的请求级临时 ID;非空、区分大小写、在请求内唯一。被引用时必须提供。 |
| `type` | 必填,必须是 V1 可写类型。 |
| `parentId` | 可选,引用同一请求中 group/frame 的临时 ID。 |
| `x`、`y` | 除 connector 外必填;有父节点时为父节点相对坐标。 |
| `width`、`height` | shape/text/stickyNote/frame/vector/icon/path 必填且大于 `0`;group/connector 只读。 |
| `angle` | 可选,默认 `0`;frame 只允许 `0`;group/connector 只读。 |
| `layer` | 可选;frame 默认 `background`,其他节点默认 `normal`。 |
| `zIndex` | 可选的非负整数;相同值时按请求顺序稳定排序。 |
| `hidden` | 可选布尔值,默认 `false`。 |
以下 query 字段禁止写回:
`children`、`absoluteBounds`、`locked`、`source`、`writeSupport`、
`unsupportedFeatures`。
关系规则:
- `parentId` 只能引用同一请求中的 group 或 frame。
- frame 和 connector 必须是页面直属节点,不能带 `parentId`。
- group 可以嵌套,也可以放在 frame 中。
- `children` 始终由各子节点的 `parentId` 推导。
- 父子关系不能成环。
- group 必须有临时 `id`、至少两个直接子节点,且不能全部隐藏。
@@ -0,0 +1,395 @@
# OpenNodes V1 — 支持矩阵、富文本和样式
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 7. 节点类型
### 7.1 支持矩阵
| `type` | query | update | 主要字段 |
| --- | --- | --- | --- |
| `shape` | 支持 | 支持 | `geometry`、`text?`、`style?` |
| `text` | 支持 | 支持 | `text`、`style?` |
| `connector` | 支持 | 支持 | `start`、`end`、`routing`、`waypoints?`、`style?` |
| `stickyNote` | 支持 | 支持 | `text?`、`style?` |
| `frame` | 支持 | 支持 | `title?`、`style?`、`presentationOrder?`、`resizeMode?` |
| `group` | 支持 | 支持 | 子关系通过 `parentId` 表达 |
| `image` | 支持 | 只读 | 仅公共字段 |
| `vector` | 支持 | 支持 | `resource` |
| `icon` | 支持 | 支持 | `catalogId` |
| `path` | 支持 | 支持 | `path`、`style?` |
| `pdf` | 支持 | 只读 | 仅公共字段 |
| `media` | 支持 | 只读 | 仅公共字段 |
| `webLink` | 支持 | 只读 | 仅公共字段 |
| `table` | 支持 | 只读 | 仅公共字段 |
| `chart` | 支持 | 只读 | 仅公共字段 |
| `uml` | 支持 | 只读 | 仅公共字段 |
| `swimlane` | 支持 | 只读 | 仅公共字段 |
| `mind` | 支持 | 只读 | 仅公共字段 |
| `timer` | 支持 | 只读 | 仅公共字段 |
| `placeholder` | 支持 | 只读 | 仅公共字段 |
| `unknown` | 支持 | 只读 | 未识别或尚未定义独立语义的节点统一映射到此类型 |
表中标记为只读的类型仍会完整返回公共几何、层级和顺序信息,但 update 提交会以
`nodeTypeUnsupported` 失败。
`timer`、`table`、`webLink` 不支持 V1 update。query 仅返回这些节点的公共几何、
层级、顺序和诊断字段,不承诺完整业务字段。文字 run 中的 `link` 是富文本能力,
不属于 `webLink` 节点,V1 支持读写。
`webLink` 只保留只读查询;OpenNodes V1 不支持创建或重建该节点。
### 7.2 Text
query 和 update 均支持普通段落、无序列表、有序列表、多 block、多 run 和文字
链接:
```ts
interface OpenTextRun {
text: string;
marks?: {
fontFamily?: string;
fontSize?: number;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
color?: string;
highlight?: string;
};
link?: { url: string };
}
interface OpenTextBlock {
type: "paragraph" | "bulletList" | "orderedList";
horizontalAlign?: "left" | "center" | "right";
runs: OpenTextRun[];
}
interface OpenTextWrite {
blocks: OpenTextBlock[];
verticalAlign?: "top" | "center" | "bottom";
padding?: number | [number, number];
}
interface OpenText extends OpenTextWrite {
plainText: string;
writeSupport: "readWrite" | "readOnly";
unsupportedFeatures?: string[];
}
```
约束:
- `blocks.length >= 1`,每个 block 的 `type` 必须是 `paragraph`、
`bulletList` 或 `orderedList`。
- 每个 block 都必须满足 `runs.length >= 1`。
- run 的 `text` 不能包含 `\r`、`\n`、U+2028 或 U+2029。换行和列表项使用
独立 block 表达;每一段或每个列表项应写成一个 block,而不是把原始换行符
放进单个 run。
- 每个 `bulletList` / `orderedList` block 表示一个列表项;服务端会把连续且同类的
block 解释为同一个列表中的多个列表项。
- `link.url` 长度必须为 `1..2048`,不能包含控制字符、`<`、`>`。支持无 scheme
的相对/裸链接,以及 `http`、`https`、`mailto`、`tel`、`dingtalk` scheme;
`javascript:`、`data:` 等可执行或未知 scheme 会被拒绝。
- 相邻且 URL 相同的 linked run 会呈现为同一个链接,同时保留各 run 自己的 marks。
- `fontSize > 0`,padding 各项必须大于等于 `0`。
- `plainText`、文本级 `writeSupport` 和 `unsupportedFeatures` 是 query-only。
- text 节点必须提供 `text`;shape 和 stickyNote 的 `text` 可省略。
- 受支持的 paragraph、列表、链接、多 run 都保持
`writeSupport = "readWrite"`;未知 list style、非法链接、未支持的 block 或
run marks 会令文本和所属节点变为 `readOnly`。
下面的文本会显示为两个段落,第一段由两个不同样式的 run 组成:
```json
{
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "left",
"runs": [
{
"text": "OpenNodes ",
"marks": { "fontSize": 18, "bold": true, "color": "#2563EB" }
},
{
"text": "rich text",
"marks": { "fontSize": 18, "italic": true, "color": "#0F172A" }
}
]
},
{
"type": "paragraph",
"horizontalAlign": "right",
"runs": [
{
"text": "第二段",
"marks": { "fontSize": 16, "underline": true, "color": "#047857" }
}
]
}
],
"verticalAlign": "center",
"padding": [4, 8]
}
```
同一 `OpenTextWrite` 结构适用于独立 text 节点、shape 文本、stickyNote 文本和
frame title。
下面三个 block 会生成两个无序列表项,其中第二项包含文字链接:
```json
{
"blocks": [
{
"type": "bulletList",
"runs": [{ "text": "准备输入数据" }]
},
{
"type": "bulletList",
"runs": [
{
"text": "查看钉钉文档",
"marks": { "underline": true },
"link": { "url": "https://alidocs.dingtalk.com" }
}
]
},
{
"type": "orderedList",
"runs": [{ "text": "执行生成" }]
}
]
}
```
颜色接受以下 CSS 形式:3/4/6/8 位十六进制、颜色名,以及
`rgb()`、`rgba()`、`hsl()`、`hsla()`、`oklch()`、`lab()`、`lch()`、
`color()`。字符串最长 128 字符,不能包含控制字符、`<`、`>` 或 `;`。
这里只接受可独立解析的字面量;依赖外部样式上下文的 `var()`、`calc()`、
`color-mix()` 等动态表达式不属于 V1。颜色名必须是标准 CSS named color,
任意字母串不会被当成颜色。
### 7.3 Style
query 可表达:
- `none`、`solid`、`theme`、线性渐变、径向渐变和图片 paint。
- shadow、blur 和 unknown effect。
V1 update 支持 `none`、`solid`、`theme`、线性渐变、九方向径向渐变,以及
单个自定义 shadow。主题明暗参数和渐变色标位置使用 `0~100` 的百分比,
不会归一化成 `0~1`:
```ts
type OpenRadialGradientPosition =
| "topLeft"
| "topCenter"
| "topRight"
| "centerLeft"
| "center"
| "centerRight"
| "bottomLeft"
| "bottomCenter"
| "bottomRight";
interface OpenColorStop {
offset: number;
color: string;
opacity?: number;
}
interface OpenImagePaint {
type: "image";
resource: {
kind: "managed" | "external" | "embedded" | "unresolved";
resourceId?: string;
};
intrinsicWidth: number;
intrinsicHeight: number;
}
type OpenPaint =
| { type: "none" }
| { type: "solid"; color: string; opacity?: number }
| {
type: "theme";
token: string;
lumMod?: number;
lumOff?: number;
resolvedColor?: string;
}
| {
type: "linearGradient";
angle: number;
stops: OpenColorStop[];
}
| {
type: "radialGradient";
position: OpenRadialGradientPosition | "custom";
stops: OpenColorStop[];
}
| OpenImagePaint;
type OpenEffect =
| {
type: "shadow";
offsetX: number;
offsetY: number;
blur: number;
color: string;
opacity: number;
}
| { type: "blur"; blur: number }
| { type: "unknown" };
interface OpenNodeStyle {
opacity?: number;
fill?: OpenPaint;
stroke?: {
paint: OpenPaint;
width?: number;
dash?: number[];
lineCap?: "butt" | "round" | "square";
lineJoin?: "miter" | "round" | "bevel";
};
effects?: OpenEffect[];
writeSupport: "readWrite" | "readOnly";
unsupportedFeatures?: string[];
}
interface OpenColorStopWrite {
offset: number; // [0, 100],百分比
color: string;
opacity?: number; // [0, 1]
}
type OpenPaintWrite =
| { type: "none" }
| { type: "solid"; color: string; opacity?: number }
| {
type: "theme";
token: string;
lumMod?: number; // [0, 100],默认 100
lumOff?: number; // [0, 100],默认 0
}
| {
type: "linearGradient";
angle: number;
stops: OpenColorStopWrite[];
}
| {
type: "radialGradient";
position: OpenRadialGradientPosition;
stops: OpenColorStopWrite[];
};
interface OpenShadowEffectWrite {
type: "shadow";
offsetX: number;
offsetY: number;
blur: number;
color: string;
opacity: number;
}
interface OpenNodeStyleWrite {
opacity?: number;
fill?: OpenPaintWrite;
stroke?: {
paint: OpenPaintWrite;
width?: number;
dash?: number[];
lineCap?: "butt" | "round" | "square";
lineJoin?: "miter" | "round" | "bevel";
};
effects?: OpenShadowEffectWrite[];
}
```
约束:
- opacity 范围为 `[0, 1]`。
- solid paint 的 `color` 与 `opacity` 在 query 后仍保持独立字段,不会合并为
动态 CSS 表达式。
- theme 的 `token` 必须能在当前白板主题中解析;
`lumMod`、`lumOff` 范围均为 `[0, 100]`。token 不存在时在
Request graph 阶段返回 `themeTokenNotFound`,不会写出部分节点。
token 长度为 `1~64`,首尾不能有空白,也不能包含空白、控制字符、
`<`、`>` 或 `;`。
- query 的 theme paint 还会返回按当前白板主题计算出的 query-only
`resolvedColor`。调用方写入时只传 `token/lumMod/lumOff`,不传
`resolvedColor`。
- 渐变必须至少有一个 stop;`offset` 范围为 `[0, 100]`,单位是百分比。
- 线性渐变 `angle` 范围为 `[0, 360]`。
- 径向渐变只接受上述九宫格位置。query 遇到九宫格以外的位置时返回
`position: "custom"` 并将该节点标为只读。
- `effects` 最多包含一个 `shadow`;`offsetX/offsetY` 必须是有限数,
`blur >= 0`,shadow opacity 范围为 `[0, 1]`。
- stroke width 和 dash 各项必须大于等于 `0`。
- 一旦提供 stroke,`stroke.paint` 必填。
- 样式级 `writeSupport` 和 `unsupportedFeatures` 是 query-only。
- 能在当前白板主题中解析且明暗参数合法的 theme paint 可写。无法解析的主题
token、越界的主题明暗参数、image paint、blur、unknown effect、叠加 effect
或自定义径向位置会令节点只读;受支持的 theme、渐变和单个 shadow 本身不会
令节点只读。
主题色示例:
```json
{
"fill": {
"type": "theme",
"token": "ac3",
"lumMod": 20,
"lumOff": 80
},
"stroke": {
"paint": {
"type": "theme",
"token": "sk1",
"lumMod": 80,
"lumOff": 20
},
"width": 2
}
}
```
示例中的 `ac3`、`sk1` 只是主题 token 示例,不是所有白板都可用的全局枚举。
调用方只能使用已确认可由当前白板主题解析的 token;无法确认时应改用
`solid` 颜色。
DWS 不提供新增、修改或切换主题的命令。当前白板没有有效主题时,应改用
`solid`。
示例:
```json
{
"fill": {
"type": "linearGradient",
"angle": 35,
"stops": [
{ "offset": 0, "color": "#1677ff", "opacity": 0.4 },
{ "offset": 100, "color": "#69b1ff" }
]
},
"effects": [
{
"type": "shadow",
"offsetX": 8,
"offsetY": 8,
"blur": 19,
"color": "rgba(93,190,172,1)",
"opacity": 0.5
}
]
}
```
未传 `style` 时使用节点类型的默认样式。调用方如需稳定的视觉结果,应显式传入
`fill` 和 `stroke`。
@@ -0,0 +1,186 @@
# OpenNodes V1 — Shape、Text、Sticky note、Frame、Group 和 Connector
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
### 7.4 Shape
shape 必须提供 `geometry`,格式为 `dml:<name>`:
```json
{
"id": "shape-1",
"type": "shape",
"x": 100,
"y": 80,
"width": 160,
"height": 100,
"geometry": "dml:roundRect",
"style": {
"fill": {
"type": "solid",
"color": "#DCEEFF"
},
"stroke": {
"paint": {
"type": "solid",
"color": "#225588"
},
"width": 2
}
}
}
```
query 可能返回 `adjustments`,但 V1 update 不支持写入;带 adjustments 的
shape 会标为只读。`dml-v1` 的完整 geometry 目录见附录 A。
### 7.5 Text node 与 Sticky note
text node 使用公共几何、必填 `text` 和可选 `style`。
stickyNote 使用公共几何、可选 `text` 和可选 `style`。省略 `text` 时创建空
便签。query 还可能返回:
- `creator`:创建者展示信息。
- `tags`:标签 ID、文本和背景 paint。
这两个字段是 query-only;存在 creator 或 tags 的 stickyNote 会被标为只读。
### 7.6 Frame
frame 的 update 结构见第 6 节 `OpenFrameNodeWrite`。
约束:
- frame 必须是页面直属节点,不能带 `parentId`。
- angle 只允许 `0`。
- `presentationOrder` 是非负整数,并且不能和已有或本次创建的 frame 冲突。
- frame 的子节点通过子节点 `parentId` 引用 frame 临时 ID。
- frame 不能包含 frame 或 connector。
- frame 默认 layer 为 `background`。
### 7.7 Group
group 的写入字段只有公共字段中的 `id`、`parentId?`、`x`、`y`、`layer?`、
`zIndex?` 和 `hidden?`。其 width、height、angle 和 children 都由服务端根据
子节点推导。
group 必须:
- 提供临时 `id`。
- 至少包含两个直接子节点。
- 至少有一个直接子节点可见。
group 的任一子节点为只读时,query 会把 group 一并标为只读。
### 7.8 Connector
connector 的几何由端点和路由推导,因此 update 不能提供 `x`、`y`、
`width`、`height` 或 `angle`。
query 的连接线结构如下:
```ts
type OpenConnectorRouting =
| "straight"
| "polyline"
| "curve"
| "orthogonal";
interface OpenConnectorMarker {
catalogId: string;
}
type OpenConnectorAnchor =
| {
mode: "fixed";
side: "top" | "right" | "bottom" | "left";
position: OpenPoint;
}
| {
mode: "fixed";
side: "custom";
position: OpenPoint;
};
type OpenConnectorEndpoint =
| {
type: "point";
point: OpenPoint;
marker: OpenConnectorMarker;
}
| {
type: "node";
nodeRef: { scope: "document"; id: string };
anchor: OpenConnectorAnchor;
resolvedPoint: OpenPoint;
marker: OpenConnectorMarker;
};
interface OpenBezierSegment {
start: OpenPoint;
control1: OpenPoint;
control2: OpenPoint;
end: OpenPoint;
}
type OpenResolvedConnectorPath =
| { type: "polyline"; points: OpenPoint[] }
| { type: "bezier"; segments: OpenBezierSegment[] };
```
update 端点有两种形式:
```ts
type OpenConnectorEndpointWrite =
| {
type: "point";
point: { x: number; y: number };
marker?: { catalogId: "none" | "arrow.open" | "arrow.filled" };
}
| {
type: "node";
nodeRef: { scope: "request"; id: string };
anchor?:
| { mode: "auto" }
| {
mode: "fixed";
side: "top" | "right" | "bottom" | "left";
};
marker?: { catalogId: "none" | "arrow.open" | "arrow.filled" };
};
```
query 的 marker `catalogId` 使用 `string`,以便返回无法映射的既有 marker;
update 只接受上面列出的 `"none"`、`"arrow.open"`、`"arrow.filled"`。
路由规则:
| `routing` | `waypoints` |
| --- | --- |
| `straight` | 禁止提供,包括空数组。 |
| `polyline` | 必须至少提供一个。 |
| `curve` | 可选。 |
| `orthogonal` | 可选;显式点路径的相邻线段必须水平或垂直。 |
其他约束:
- 所有 point 和 waypoint 都使用页面绝对坐标。
- node 端点只能引用同一请求中的 shape、text、stickyNote、frame、group 或 path。
- node 端点不能引用隐藏节点、connector 或 query 中既有节点。
- update 引用范围固定为 `scope: "request"`;query 返回的节点引用范围为
`scope: "document"`,不能直接回写。
- 同一连接线的两端不能引用同一个节点。
- 零长度或无效路径会被拒绝。
- marker 省略时默认为 `none`。
- anchor 省略时按 `auto` 处理。
query 额外返回服务端解析后的:
- node 端点 `resolvedPoint`。
- fixed anchor 的归一化 `position`。
- `resolvedPath`,类型为 polyline points 或 cubic bezier segments。
- 由真实路径推导的 `absoluteBounds`。
这些解析字段都是 query-only。
@@ -0,0 +1,313 @@
# OpenNodes V1 — Vector、Icon 和 Path
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
### 7.9 Vector(已上传 SVG/矢量资源)
`vector` 表示已经通过 `dws doc media upload` 获得稳定引用的 SVG/矢量图片。
OpenNodes 只接收上传结果中的资源引用,不接收本地路径或原始 SVG/XML 内容。
上传时必须使用与后续白板更新相同的文档 `nodeId`:
```bash
dws doc media upload \
--node <DOC_NODE_ID> \
--file ./icon.svg \
--mime-type image/svg+xml \
--format json
```
将上传结果的 `resourceId` 和 `resourceUrl` 分别写入
`resource.resourceId` 和 `resource.url`。
Update 必须提供完整的托管资源信息:
```ts
interface OpenManagedVectorResourceWrite {
kind: "managed";
resourceId: string;
url: string;
}
```
完整节点结构见第 6 节 `OpenVectorNodeWrite`。
`resource` 字段规则:
| 字段 | 必填 | 规则 |
| --- | --- | --- |
| `kind` | 是 | 当前只允许固定值 `managed`,表示资源已经通过 DWS 上传。 |
| `resourceId` | 是 | 资源稳定 ID,长度 1~256,只允许字母、数字、`.`、`_`、`:`、`-`。 |
| `url` | 是 | 已上传资源地址,最长 4096;必须包含且只能包含一个同值的 `resourceId` 查询参数。 |
`url` 必须直接使用 `dws doc media upload` 返回的 `resourceUrl`,不得自行拼装
或修改。
以下内容会被拒绝:
- 原始 SVG/XML、`data:`、`blob:`、`http:` URL。
- `//host/path` 协议相对地址,以及自行构造或修改的其他相对地址。
- 含空白、控制字符、反斜杠、fragment 或用户凭证的 URL。
- URL 缺少 `resourceId`、重复出现 `resourceId`,或者 URL 中 ID 与显式
`resource.resourceId` 不一致。
- `resource` 缺失、字段不完整、`kind` 不是 `managed`,或包含未知字段。
完整 Append 示例:
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "uploaded-svg-1",
"type": "vector",
"x": 100,
"y": 80,
"width": 240,
"height": 180,
"angle": 0,
"resource": {
"kind": "managed",
"resourceId": "0c1c94e1-f9af-4228-b32f-42bbd1555253",
"url": "https://resources.example.com/assets/opaque-path?resourceId=0c1c94e1-f9af-4228-b32f-42bbd1555253"
}
}
]
},
"overwrite": false
}
```
示例中的 `resources.example.com` 是占位域名,实际调用必须使用
`dws doc media upload` 返回的 `resourceUrl`。
`resourceId` 同时显式出现并包含在 URL 中,用于校验资源身份与地址是否一致。
两者必须来自同一次 `dws doc media upload` 结果。
Query 的 `resource` 可能是:
```ts
type OpenVectorResource =
| { kind: "managed"; resourceId: string; url: string }
| { kind: "external" | "embedded" | "unresolved" };
```
- 能识别为托管资源的引用返回完整 `managed` 信息。
- HTTP(S) 外链但不满足托管资源契约时返回 `external`。
- `data:`/`blob:` 返回 `embedded`。
- 其他缺失或无法识别的地址返回 `unresolved`。
- 非 `managed` 资源会令节点只读,并分别产生
`vector.resource.external`、`vector.resource.embedded` 或
`vector.resource.unresolved`。
V1 vector update 不接受 `style`。既有节点包含 V1 无法表达的 fill、stroke、
opacity、effect 或 adjustments 时,query 仍可读取资源和几何,但节点会标为只读。
不影响资源内容的兼容性装饰不会单独令节点变为只读。
DWS 会校验资源引用。上传与 `whiteboard update` 必须使用同一个文档
`nodeId`;不要跨文档复用资源,也不要使用临时 `uploadUrl`。
### 7.10 Icon(内置图标)
`icon` 表示内置图标。OpenNodes 使用版本化的 `catalogId` 作为稳定标识,
允许值见下列类型定义和附录 B。
Update 结构:
```ts
type OpenIconCatalogId =
| `emoji/${
| "happy"
| "smile"
| "laugh"
| "fighting"
| "like"
| "ok"
| "please"
| "face-plam"
| "tears-of-joy"
| "cry"
| "question"
| "face-with-sweat"
| "bloody-nose"
| "doggy"}`
| `tools/${
| "pad"
| "blue-note"
| "yellow-notes"
| "chart"
| "chart-2"
| "pencil"
| "pen"
| "bag"
| "rocket"
| "fire"
| "gold"
| "light"
| "pin"
| "red-flag"
| "tea"
| "island"
| "ball"
| "lucky-fish"
| "coffee"
| "milky-tea"
| "pan"}`
| `priority/priority-${1 | 2 | 3 | 4 | 5 | 6 | 7}`
| `task/${
| "task-start"
| "task-oct"
| "task-3oct"
| "task-half"
| "task-5oct"
| "task-7oct"
| "task-done"}`;
```
完整节点结构见第 6 节 `OpenIconNodeWrite`。
完整 Append 示例:
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "pencil-icon",
"type": "icon",
"x": 100,
"y": 80,
"width": 48,
"height": 48,
"catalogId": "tools/pencil"
}
]
},
"overwrite": false
}
```
规则:
- `catalogId` 必填,格式为 `<group>/<name>`,并且必须精确命中附录 B 的
`dml-v1` allowlist;大小写、连字符和历史拼写都不能自行修正。
- 当前目录有 `emoji`、`tools`、`priority`、`task` 四组,共 49 项。
- update 只接受 `catalogId`,不接受 `group`、`name`、`style` 或 `resource`。
- 内置 icon 不需要调用方上传资源,也不需要 `resourceId`。
- 任意已上传 SVG 或自定义图标应使用 `vector`,并按 7.9 节提供完整
`resource` 信息,不能伪造一个 icon `catalogId`。
- icon 可以作为 group/frame 子节点;V1 connector 的 node 端点当前仍不接受
icon 作为目标。
Query 返回相同的 `catalogId`。既有 icon 无法映射到当前目录时,节点仍会以
`type: "icon"` 返回以便诊断,但 `writeSupport` 为 `readOnly`,原因包含
`icon.catalogId`。非默认 opacity、filter/effect、text 或 adjustments 同样会令节点
只读。
### 7.11 Path(自由画笔)
`path` 表示自由画笔轨迹。OpenNodes 保留两组互相独立的尺寸:
- 节点公共 `width` / `height` 是画布上的实际渲染尺寸,缩放节点时会变化。
- `path.intrinsicWidth` / `path.intrinsicHeight` 是 SVG path 自身的坐标空间尺寸,
表示 `path.data` 使用的内部坐标空间。
```ts
interface OpenPathData {
data: string;
intrinsicWidth: number;
intrinsicHeight: number;
}
type OpenPathDataWrite = OpenPathData;
```
完整 update 节点结构见第 6 节 `OpenPathNodeWrite`。query 和 update 的 `path`
字段结构相同,但 update 仍必须满足下方命令子集和大小限制。
```json
{
"id": "freehand-stroke",
"type": "path",
"x": 120,
"y": 100,
"width": 500,
"height": 150,
"path": {
"data": "M0,75 Q50,0 100,75 Q150,150 200,75 Q250,0 300,75 Q350,150 400,75 Q450,0 500,75",
"intrinsicWidth": 500,
"intrinsicHeight": 150
},
"style": {
"fill": { "type": "none" },
"stroke": {
"paint": { "type": "solid", "color": "#7C3AED" },
"width": 10,
"lineCap": "round",
"lineJoin": "round"
}
}
}
```
下面是一个仍然只使用 V1 命令子集、但包含 12 段二次贝塞尔曲线的蝴蝶轮廓。
它显式回到起点,因此不需要使用尚未支持的 `Z`:
```json
{
"id": "complex-butterfly-path",
"type": "path",
"x": 1500,
"y": 1215,
"width": 500,
"height": 420,
"path": {
"data": "M250,180 Q210,105 145,70 Q55,25 35,110 Q10,195 125,220 Q35,275 80,355 Q125,415 210,315 Q235,285 250,250 Q265,285 290,315 Q375,415 420,355 Q465,275 375,220 Q490,195 465,110 Q445,25 355,70 Q290,105 250,180",
"intrinsicWidth": 500,
"intrinsicHeight": 420
},
"style": {
"opacity": 0.96,
"fill": {
"type": "solid",
"color": "#EDE9FE",
"opacity": 0.72
},
"stroke": {
"paint": {
"type": "solid",
"color": "#6D28D9"
},
"width": 8,
"lineCap": "round",
"lineJoin": "round"
}
}
}
```
V1 path 写入约束如下:
- `data` 必须是一个绝对 `M`,后跟至少一个显式写出的绝对 `Q`;不接受相对命令,
也不接受 `L`、`C`、`A`、`Z` 等通用 SVG 命令。
- 所有命令参数必须完整且为有限数值;单节点 `data` 最长 1 MiB,最多 50,000 个
命令。
- `intrinsicWidth` 和 `intrinsicHeight` 必须为有限正数;它们不要求等于节点的
`width` 和 `height`。
- 未传 `style` 时,默认使用透明填充、`#222222` 描边、宽度 5,以及 round
line cap/join。需要稳定视觉结果时仍应显式传入 `style`。
- path 可以作为 group/frame 的子节点,也可以作为同一 update 请求中 connector
的 node 端点。
query 会把其他能够解析的 SVG path 保留为 `type: "path"`,但标记为
`readOnly`,原因包含 `path.commands`;超出上述 V1 限制时使用
`path.data.size` 或 `path.commands.limit`。既有 path 带 text、adjustments、非
`nonzero` fillRule 或 V1 无法表达的样式时也会只读。仅包含不影响画笔几何的
兼容性信息时,不会因此变为只读。theme stroke 遵循通用 style 契约:query 会
保留 token 和明暗参数;只要 token 能在当前白板主题中解析,就可以按 theme
paint 回写。
@@ -0,0 +1,270 @@
# OpenNodes V1 — Update 示例、回写规则、错误模型和 writeSupport
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 8. Update 示例
### 8.1 Append 一个文本节点
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "title",
"type": "text",
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "left",
"runs": [
{
"text": "Hello OpenNodes",
"marks": {
"fontSize": 16,
"color": "#223344"
}
}
]
}
],
"verticalAlign": "center",
"padding": [2, 4]
}
}
]
}
}
```
### 8.2 Append 两个形状和一条引用连接线
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "left",
"type": "shape",
"x": 80,
"y": 100,
"width": 120,
"height": 80,
"geometry": "dml:roundRect"
},
{
"id": "right",
"type": "shape",
"x": 360,
"y": 100,
"width": 120,
"height": 80,
"geometry": "dml:roundRect"
},
{
"id": "line",
"type": "connector",
"start": {
"type": "node",
"nodeRef": {
"scope": "request",
"id": "left"
},
"anchor": {
"mode": "fixed",
"side": "right"
}
},
"end": {
"type": "node",
"nodeRef": {
"scope": "request",
"id": "right"
},
"anchor": {
"mode": "fixed",
"side": "left"
},
"marker": {
"catalogId": "arrow.filled"
}
},
"routing": "straight"
}
]
}
}
```
### 8.3 Overwrite 整页
```json
{
"overwrite": true,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "replacement",
"type": "text",
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"text": {
"blocks": [
{
"type": "paragraph",
"runs": [
{
"text": "Replacement content"
}
]
}
]
}
}
]
}
}
```
### 8.4 清空当前页面
```json
{
"overwrite": true,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": []
}
}
```
## 9. Query 数据不能直接回写
query 是完整可读投影,update 是受约束的创建协议,两者不是对称 JSON:
| Query 字段/能力 | Update 处理方式 |
| --- | --- |
| 真实 `id` | 只能作为请求级临时 ID;不能引用既有 document 节点。 |
| `children` | 删除,通过子节点 `parentId` 重建。 |
| `absoluteBounds` | 删除;普通节点使用 `x/y/width/height`,connector 使用端点和路由字段。 |
| `locked`、`source`、`writeSupport`、`unsupportedFeatures` | 删除,均为 query-only。 |
| 文本 `plainText` | 删除,由服务端根据 paragraph 和 run 重新计算。 |
| 多 paragraph、列表、多 run、文字链接 | 可以保留;每个 block 必须是受支持类型,且 run 内不能包含原始换行符。 |
| 未知 list style、非法链接或未支持的 block/marks | 需要移除或降级为受支持的 block/run。 |
| theme paint | 保留 `token/lumMod/lumOff`,删除 query-only `resolvedColor`;token 必须能在当前白板主题中解析。 |
| image paint | 需要降级成受支持的 paint,或不更新该节点。 |
| 受支持的 linear/radial gradient、单个 shadow | 可以保留;gradient offset 使用 `0~100`,radial `custom` 不能回写。 |
| blur、unknown 或叠加 effects | 需要删除、降级成单个 shadow,或不更新该节点。 |
| connector `scope: "document"` | 不能回写;改为引用同一请求节点的 `scope: "request"`。 |
| connector `resolvedPoint`、`position`、`resolvedPath` | 删除,均由服务端重新计算。 |
| shape `adjustments` | V1 不支持写入。 |
| stickyNote `creator`、`tags` | V1 不支持写入。 |
即使 query 节点显示 `writeSupport = "readWrite"`,update 仍会对版本、目录、
字段和请求关系做完整校验。调用方不应跳过 update 错误处理。
## 10. 错误模型
### 10.1 顶层错误码
| 错误码 | 含义 |
| --- | --- |
| `invalidRequest.whiteboard.schemaInvalid` | JSON、字段或节点 schema 不合法。 |
| `invalidRequest.whiteboard.catalogVersionUnsupported` | catalogVersion 不受支持。 |
| `invalidRequest.whiteboard.validationFailed` | 节点间引用、父子关系或路径关系不合法。 |
| `invalidRequest.whiteboard.emptySource` | append 的 nodes 为空。 |
| `invalidRequest.whiteboard.overwriteUnsafe` | overwrite 安全预检失败。 |
### 10.2 DWS 错误输出
远端校验失败时,DWS 以统一 CLI 错误结构返回:
```json
{
"error": {
"category": "api",
"reason": "business_error",
"server_key": "whiteboard",
"server_error_code": "invalidRequest.whiteboard.validationFailed",
"message": "Whiteboard request graph is invalid",
"trace_id": "TRACE_ID"
}
}
```
部分服务错误会使用更宽泛的 `invalidRequest.inputArgs.invalid`。Agent 应结合
`server_error_code` 和 `message` 修正输入;需要排障时保留 `trace_id`。JSON 或
信封级错误可能由 CLI 本地返回,不一定包含 `server_key` 和 `trace_id`。
校验类错误不可通过原样重试恢复。常见原因包括:
- Schema:缺少字段、未知字段、类型或枚举值错误、提交 query-only 字段、
节点类型不支持。
- 请求关系:临时 ID 重复、引用不存在、父子关系非法、连接线目标不支持、
路径退化或主题 token 不存在。
- Overwrite 预检:锁定节点、禁止删除、关联数据无法安全处理或删除失败。
任一阶段失败都不会保留部分更新。
## 11. writeSupport 的含义
`writeSupport` 表示当前 query 节点是否能由 V1 update 无损表达,不代表用户
权限,也不代表 overwrite 是否允许移除该既有节点。
以下 `unsupportedFeatures` 均为对外返回的诊断枚举值:
- `node.source.master`、`node.locked`、`node.role`、`node.placeholder`、
`node.extras`、`node.ability`。
- `node.type.image`、`node.type.pdf`、`node.type.media`、
`node.type.webLink`、`node.type.table`、`node.type.chart`、
`node.type.uml`、`node.type.swimlane`、`node.type.mind`、
`node.type.timer`、`node.type.placeholder`、`node.type.unknown`。
- `text.list.unsupported`、`text.link.unsupported`、`text.block.unsupported`、
`text.lineBreak.unsupported`、`text.marks.unsupported`、
`text.color.unsupported`、`text.highlight.unsupported`。
- `style.fill.color`、`style.fill.opacity`、`style.fill.theme.token`、
`style.fill.theme.unresolved`、`style.fill.theme.modifier`、
`style.fill.theme.opacity`、
`style.fill.gradient.angle`、`style.fill.gradient.offset`、
`style.fill.gradient.color`、`style.fill.gradient.opacity`、
`style.fill.gradient.position`、`style.fill.image`。
- `style.stroke.color`、`style.stroke.opacity`、`style.stroke.theme.token`、
`style.stroke.theme.unresolved`、
`style.stroke.theme.modifier`、`style.stroke.theme.opacity`、
`style.stroke.gradient.angle`、`style.stroke.gradient.offset`、
`style.stroke.gradient.color`、`style.stroke.gradient.opacity`、
`style.stroke.gradient.position`、`style.stroke.image`。
- `style.effects`。
- `shape.adjustments`、`stickyNote.creator`、`stickyNote.tags`。
- `vector.resource.external`、`vector.resource.embedded`、
`vector.resource.unresolved`、`vector.fill`、`vector.stroke`、
`vector.opacity`、`vector.effect`、`vector.adjustments`。
- `icon.catalogId`、`icon.opacity`、`icon.effect`、`icon.text`、
`icon.adjustments`。
- `path.commands`、`path.data.size`、`path.commands.limit`、`path.text`、
`path.fillRule`、`path.adjustments`。
- `connector.parent`、`connector.marker.unsupported`、
`connector.target.unexposed`、`connector.target.unsupported`、
`connector.anchor.unresolved`、`connector.anchor.custom`、
`connector.selfLoop`。
- `group.angle`、`group.children.minimum`、`group.children.hidden`、
`group.child.readOnly`。
- `frame.angle`、`frame.child.frame`、`frame.child.readOnly`。
调用方应把 `unsupportedFeatures` 当作诊断信息,不应把当前枚举穷举写死为
业务逻辑。真正可写与否以 `writeSupport` 和 update 校验结果为准。
@@ -0,0 +1,61 @@
# OpenNodes V1 — dml-v1 Geometry 和 Icon 完整目录
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 附录 A:dml-v1 geometry 目录
写入时在以下名称前加 `dml:`,例如 `rect` 写成 `dml:rect`。当前目录共
183 项,目录版本由 `catalogVersion = "dml-v1"` 标识。
```text
accentBorderCallout1 accentBorderCallout2 accentBorderCallout3
accentCallout1 accentCallout2 accentCallout3 actionButtonBackPrevious
actionButtonBeginning actionButtonBlank actionButtonDocument actionButtonEnd
actionButtonForwardNext actionButtonHelp actionButtonHome
actionButtonInformation actionButtonMovie actionButtonReturn actionButtonSound
active allGeneralization arc attribute bentArrow bentUpArrow bevel bind blockArc
borderCallout1 borderCallout2 borderCallout3 bracePair bracketPair callout1
callout2 callout3 can chevron chord circularArrow cloud cloudCallout comment
component control convert corner cube curvedDownArrow curvedLeftArrow
curvedRightArrow curvedUpArrow dataStorage decagon delete diagStripe diamond
dodecagon donut doubleWave downArrow downArrowCallout ellipse ellipseRibbon
ellipseRibbon2 entity entitySet flowChartAlternateProcess flowChartCollate
flowChartConnector flowChartDecision flowChartDelay flowChartDisplay
flowChartDocument flowChartExtract flowChartInputOutput flowChartInternalStorage
flowChartMagneticDisk flowChartMagneticDrum flowChartMagneticTape
flowChartManualInput flowChartManualOperation flowChartMerge
flowChartMultidocument flowChartOffpageConnector flowChartOnlineStorage
flowChartOr flowChartPredefinedProcess flowChartPreparation
flowChartPunchedCard flowChartPunchedTape flowChartSort
flowChartSummingJunction flowChartTerminator foldedCorner frame generalization
halfFrame heart heptagon hexagon history homePlate horizontalDivCircle
horizontalScroll irregularSeal1 irregularSeal2 leftArrow leftArrowCallout
leftBrace leftBracket leftRightArrow leftRightArrowCallout leftRightUpArrow
leftUpArrow lightningBolt mathDivide mathEqual mathMinus mathMultiply
mathNotEqual mathPlus moon multiClass multiValuedAttribute noSmoking node
nonIsoscelesTrapezoid notchedRightArrow octagon parallelogram pentagon person
pie plaque plus quadArrow quadArrowCallout receiveSignal rect relationship
ribbon ribbon2 rightArrow rightArrowCallout rightBrace rightBracket round1Rect
round2DiagRect round2SameRect roundRect rtTriangle smileyFace snip1Rect
snip2DiagRect snip2SameRect snipRoundRect star10 star12 star16 star24 star32
star4 star5 star6 star7 star8 stripedRightArrow sun teardrop triangle upArrow
upArrowCallout upDownArrow user uturnArrow verticalDivCircle verticalScroll
wave weakEntitySet weakRelationship wedgeEllipseCallout wedgeRectCallout
wedgeRoundRectCallout
```
## 附录 B:dml-v1 icon 目录
写入时必须使用完整的 `<group>/<name>`。当前共 4 组 49 项,目录版本由
`catalogVersion = "dml-v1"` 标识。
| group | 数量 | name(组成 `group/name`) |
| --- | ---: | --- |
| `emoji` | 14 | `happy`、`smile`、`laugh`、`fighting`、`like`、`ok`、`please`、`face-plam`、`tears-of-joy`、`cry`、`question`、`face-with-sweat`、`bloody-nose`、`doggy` |
| `tools` | 21 | `pad`、`blue-note`、`yellow-notes`、`chart`、`chart-2`、`pencil`、`pen`、`bag`、`rocket`、`fire`、`gold`、`light`、`pin`、`red-flag`、`tea`、`island`、`ball`、`lucky-fish`、`coffee`、`milky-tea`、`pan` |
| `priority` | 7 | `priority-1`、`priority-2`、`priority-3`、`priority-4`、`priority-5`、`priority-6`、`priority-7` |
| `task` | 7 | `task-start`、`task-oct`、`task-3oct`、`task-half`、`task-5oct`、`task-7oct`、`task-done` |
注意:`emoji/face-plam` 是 V1 保留的历史兼容枚举值,拼写虽然异常但属于协议值;
传入 `emoji/face-palm` 会被拒绝。
@@ -0,0 +1,308 @@
# 钉钉白板常用 Recipes
以下各 Recipe 的 JSON 都写入本地文件,再通过 `dws whiteboard update --source <FILE.json>`
传给白板。执行任何远端写入前,必须先向用户展示影响并取得明确确认;确认后
才可添加 `--yes`:
```bash
dws whiteboard update \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--source <FILE.json> \
--yes \
--format json
```
每次更新后都要再次执行 `dws whiteboard query ... --format json` 回读验证。
## 1. 追加两个流程节点和一条箭头
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "start",
"type": "shape",
"x": 80,
"y": 100,
"width": 160,
"height": 72,
"geometry": "dml:roundRect",
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "center",
"runs": [{ "text": "读取需求", "marks": { "bold": true } }]
}
],
"verticalAlign": "center"
}
},
{
"id": "finish",
"type": "shape",
"x": 360,
"y": 100,
"width": 160,
"height": 72,
"geometry": "dml:roundRect",
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "center",
"runs": [{ "text": "输出结果", "marks": { "bold": true } }]
}
],
"verticalAlign": "center"
}
},
{
"type": "connector",
"start": {
"type": "node",
"nodeRef": { "scope": "request", "id": "start" },
"anchor": { "mode": "fixed", "side": "right" }
},
"end": {
"type": "node",
"nodeRef": { "scope": "request", "id": "finish" },
"anchor": { "mode": "fixed", "side": "left" },
"marker": { "catalogId": "arrow.filled" }
},
"routing": "straight"
}
]
}
}
```
## 2. 追加带渐变和阴影的卡片
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "styled-card",
"type": "shape",
"x": 80,
"y": 260,
"width": 260,
"height": 120,
"geometry": "dml:roundRect",
"style": {
"fill": {
"type": "linearGradient",
"angle": 35,
"stops": [
{ "offset": 0, "color": "#DBEAFE" },
{ "offset": 100, "color": "#A7F3D0" }
]
},
"stroke": {
"paint": { "type": "solid", "color": "#2563EB" },
"width": 2
},
"effects": [
{
"type": "shadow",
"offsetX": 5,
"offsetY": 7,
"blur": 18,
"color": "#0F172A",
"opacity": 0.22
}
]
},
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "center",
"runs": [
{
"text": "复杂样式",
"marks": { "fontSize": 20, "bold": true, "color": "#0F172A" }
}
]
}
],
"verticalAlign": "center"
}
}
]
}
}
```
## 3. Frame 中放置分支流程
先创建 frame,再让子节点通过 `parentId` 引用 frame 的临时 ID。子节点坐标相对
frame 左上角:
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "pipeline",
"type": "frame",
"x": 60,
"y": 440,
"width": 720,
"height": 300,
"title": {
"text": {
"blocks": [
{
"type": "paragraph",
"runs": [{ "text": "生成流水线", "marks": { "bold": true } }]
}
]
}
}
},
{
"id": "branch-a",
"type": "shape",
"parentId": "pipeline",
"x": 60,
"y": 80,
"width": 180,
"height": 72,
"geometry": "dml:rect"
},
{
"id": "branch-b",
"type": "shape",
"parentId": "pipeline",
"x": 420,
"y": 80,
"width": 180,
"height": 72,
"geometry": "dml:rect"
}
]
}
}
```
## 4. 上传 SVG 并追加 Vector
Vector 固定使用“上传 → 字段映射 → update → query”流程,并且所有命令使用同一个
`DOC_NODE_ID`。
先上传 SVG。该命令只准备资源,不会插入文档正文:
```bash
dws doc media upload \
--node <DOC_NODE_ID> \
--file ./icon.svg \
--mime-type image/svg+xml \
--yes \
--format json
```
从成功输出取 `resourceId` 和 `resourceUrl`:
```json
{
"nodeId": "<DOC_NODE_ID>",
"resourceId": "resource-stable-id",
"resourceUrl": "https://resource.example/resource-stable-id?resourceId=resource-stable-id",
"fileName": "icon.svg",
"mimeType": "image/svg+xml",
"size": 1024
}
```
写入 `whiteboard-vector.json`,其中 `resourceId` 原样映射到
`resource.resourceId`,`resourceUrl` 映射到 `resource.url`:
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "uploaded-vector",
"type": "vector",
"x": 80,
"y": 80,
"width": 160,
"height": 160,
"resource": {
"kind": "managed",
"resourceId": "resource-stable-id",
"url": "https://resource.example/resource-stable-id?resourceId=resource-stable-id"
}
}
]
}
}
```
执行更新:
```bash
dws whiteboard update \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--source ./whiteboard-vector.json \
--yes \
--format json
```
最后独立回读,不以 update 的成功响应替代验证:
```bash
dws whiteboard query \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--format json
```
禁止跨 nodeId 复用资源,也不要把本地 SVG 路径、独立文件节点 URL 或临时
`uploadUrl` 写入 `resource.url`。
## 5. 整页替换或清空
把文件设为 `overwrite: true` 后,命令必须加 `--yes`:
```bash
dws whiteboard update \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--source ./overwrite.json \
--yes \
--format json
```
清空整页:
```json
{
"overwrite": true,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": []
}
}
```
仅当用户明确要求清空时使用。执行前必须 query、展示影响摘要并获得确认。
+1 -1
View File
@@ -28,7 +28,7 @@ metadata:
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcut 发现(按需)
`chat` 当前有 97 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
`chat` 当前有 98 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service chat --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
<!-- VISIBLE_SHORTCUTS_END -->
@@ -180,11 +180,18 @@ dws chat message edit --group <openConversationId> --msg-id <openMessageId> --co
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `message reply` | 引用回复,单聊/群聊均可 | `--conversation-id` `--ref-msg-id` `--ref-sender` `--text` |
| `message reply` | 引用回复,单聊/群聊均可;群聊可 @指定成员或 @所有人 | `--conversation-id` `--ref-msg-id` `--ref-sender` `--text`;可选 `--at-open-dingtalk-ids` `--at-all` |
| `message forward` | 转发单条消息,源/目标均支持单聊/群聊 | `--src-conversation-id` `--msg-id` `--dest-conversation-id` |
| `message combine-forward` | 多条消息合并为一条转发 | `--src-conversation-id` `--msg-ids` `--dest-conversation-id`,可选 `--uuid` |
| `message forward-topic` | 转发话题消息 | `--src-msg-id` `--src-conversation-id` `--src-thread-id` `--dest-conversation-id` |
群聊引用回复使用 `--at-open-dingtalk-ids` 传 `atOpenDingTalkIds`;正文缺少对应 `<@openDingTalkId>` 时自动补齐,已有裸 `@openDingTalkId` 会规范化。`--at-all` 会传 `atAll=true`,正文缺少 `<@all>` 时自动补齐。
```bash
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <senderOpenDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <senderOpenDingTalkId> --text "请大家确认" --at-all
```
### 话题与卡片
话题完整读取流程:
+28 -1
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-doc
description: 钉钉文档(adoc):创建、读取、编辑、块、评论、附件、导出、版本及Markdown/JSONML写入。原生 .md→dingtalk-misc;文件→dingtalk-drive;知识库→dingtalk-wiki;axls→dingtalk-misc,able→dingtalk-aitable。
description: 钉钉文档(adoc):创建、读取、编辑、块、评论、附件、白板卡片、导出、版本及Markdown/JSONML写入。原生 .md→dingtalk-misc;文件→dingtalk-drive;知识库→dingtalk-wiki;axls→dingtalk-misc,able→dingtalk-aitable。
metadata:
cli_version: ">=0.2.14"
category: product
@@ -25,6 +25,7 @@ metadata:
- 文档内容只用 `--content` / `--content-file`,不要写 `--markdown`。
- 复杂内容(换行、表格、代码块、长 Markdown)先写临时 `.md`,再用 `--content-file`,不要把大段 Markdown 塞进命令行。
- 每次 `create` / `update` / `block insert` / `media insert` 后必须 `dws doc read` 或 `dws doc block list` 回读关键内容。
- `doc whiteboard insert`、`doc media upload` 属于远端写入;必须先获得用户确认,再加 `--yes`。
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcuts(无专用脚本/recipe 时优先)
@@ -33,20 +34,44 @@ metadata:
| Shortcut | 风险 | 适用场景 |
|---|---|---|
| `dws doc +access-change` | write | 预检已有协作者后变更文档角色 |
| `dws doc +access-grant` | write | 按姓名解析后批量授予文档权限 |
| `dws doc +access-revoke` | high-risk-write | 预检并移除指定协作者的文档权限 |
| `dws doc +background-delete` | write | 清除文档背景色 |
| `dws doc +background-update` | write | 设置文档 #RRGGBB 背景纯色 |
| `dws doc +checkpoint-update` | write | 先保存可回滚版本,再更新并读回验证 |
| `dws doc +comment-create` | write | 在文档上创建一条评论 |
| `dws doc +comment-delete` | high-risk-write | 永久删除指定文档评论 |
| `dws doc +comment-list` | read | 查询文档评论列表 |
| `dws doc +comment-reply` | write | 回复文档中的一条评论 |
| `dws doc +comment-update` | write | 更新指定文档评论正文和 mention |
| `dws doc +copy` | write | 复制文档/文件到指定文件夹或知识库 |
| `dws doc +create` | write | 从 Markdown 或 JSONML 创建在线文字文档 |
| `dws doc +create-from-template` | write | 按 templateId 直达或搜索消歧后创建文档 |
| `dws doc +doc-append` | write | 在文档末尾追加一段文本(安全追加,不改动原有内容) |
| `dws doc +export` | read | 提交、轮询并安全下载在线文档导出文件 |
| `dws doc +export-get` | read | 根据 jobId 查询文档导出任务结果 |
| `dws doc +export-submit` | read | 提交在线文档导出任务 (docx/markdown/pdf),返回 jobId |
| `dws doc +fetch` | read | 读取完整或局部文档内容,并按 detail 控制保真度 |
| `dws doc +find-doc` | read | 按关键词搜索云文档并投影关键字段(只读) |
| `dws doc +grant-and-share` | write | 确保目标角色后按姓名逐人发送文档链接 |
| `dws doc +import` | write | 上传本地文件并等待转换成在线文档对象 |
| `dws doc +inspect` | read | 聚合文档元信息,并按需附带样式、权限、历史、媒体和评论 |
| `dws doc +list` | read | 列出文件夹或知识库下的直接子节点 |
| `dws doc +media-download` | read | 安全下载文档正文附件到工作目录 |
| `dws doc +media-insert` | write | 上传本地图片或文件并插入文档正文 |
| `dws doc +media-list` | read | 列出文档正文中的图片和附件资源 |
| `dws doc +media-preview` | read | 下载正文媒体到受控临时目录并返回预览路径 |
| `dws doc +move` | write | 移动文档/文件到指定文件夹或知识库 |
| `dws doc +resource-delete` | high-risk-write | 幂等清除文档封面 |
| `dws doc +resource-download` | read | 读取并安全下载当前文档封面 |
| `dws doc +resource-update` | write | 从本地图片或 HTTPS URL 设置文档封面 |
| `dws doc +review` | read | 聚合未解决评论、引用原文和块上下文 |
| `dws doc +search` | read | 按关键词搜索有权限的文档 (不传则返回最近访问) |
| `dws doc +share-doc` | write | 按姓名把文档链接私信发给某人(自动解析 userId) |
| `dws doc +template-list` | read | 获取文档模板列表 |
| `dws doc +template-search` | read | 根据关键词搜索文档模板 |
| `dws doc +update` | write | 追加、覆盖或按 block 精确更新文档内容 |
| `dws doc +version-list` | read | 查看文档历史版本列表 |
| `dws doc +version-revert` | high-risk-write | 回滚文档到指定历史版本 |
| `dws doc +version-save` | write | 手动保存文档版本快照 |
@@ -66,6 +91,8 @@ metadata:
| "导入本地文件为在线文档" | `dws doc import --file <path> --folder <FOLDER_NODE_ID> --name "<标题>" --format json`(详见 `references/doc/doc-import.md`) |
| "查模板 / 套用模板创建文档" | `dws doc template list|search|apply`(详见 `references/doc.md` 模板管理) |
| "保存 / 查看 / 回滚在线文字文档(adoc)版本" | `dws doc version save/list/revert` |
| "在文档里创建空白板" | `dws doc whiteboard insert --node <nodeId> --yes --format json` |
| "为白板上传 SVG/Vector 资源" | `dws doc media upload --node <nodeId> --file <path> --yes --format json` |
## 标准 SOP(必遵流程)
@@ -482,6 +482,26 @@ dws doc version revert --node <DOC_ID> --version <N> --yes --format json # 3.
| `import` | `documentUrl` / `documentName` / `documentType` | 导入完成后的在线文档地址和名称 |
| `import`(中断后) | `taskId` | `import get` 的 `--task-id`(查询后取结果获取文档地址) |
## 白板卡片与白板媒体资源
创建文档内空白板前先确认目标文档,获得用户确认后执行:
```bash
dws doc whiteboard insert --node <DOC_ID> --yes --format json
```
返回的 `blockId` 用于 `doc block delete`,`whiteboardId` 是白板 partId,用于
`dws whiteboard query/update`。两者不可混用。为白板 Vector/SVG 准备资源时:
```bash
dws doc media upload --node <DOC_ID> --file ./icon.svg \
--mime-type image/svg+xml --yes --format json
```
上传与后续白板更新必须使用同一 nodeId;仅使用稳定返回的 `resourceId` 和
`resourceUrl`,禁止使用临时 uploadUrl 或跨文档复用资源。白板内容协议与更新
流程见 `dingtalk-misc` 的 `references/whiteboard.md`。
## 相关产品
- [wiki](../../dingtalk-wiki/references/wiki.md) — 知识库空间级管理(创建/查询/列出/搜索知识库),doc 中的文档存储在 wiki 知识库中
+2 -1
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-misc
description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索。Use when 用户提到上述任一产品,或审批/打卡/排班/OKR/日报周报/单元格读写等相关操作。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。
description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板。Use when 用户提到上述任一产品,或审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写等相关操作。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。
metadata:
cli_version: ">=0.2.14"
category: product
@@ -30,6 +30,7 @@ metadata:
| 日报 / 周报 / 月报 / 写日志 / 收件箱日志 / 发件箱日志 | 日志(日报/周报/月报)查询与按模版提交 | `dws report`(别名 `dws log`) | [report.md](references/report.md) |
| 电子表格 / 工作表 / 单元格读写 / 公式 / 超链接 / 浮动图片 | 电子表格创建/读写/公式/超链接/浮动图片/导出 | `dws sheet` | [sheet.md](references/sheet.md) |
| 开放平台文档 / API文档 / 接口文档 / 接口报错 | 开放平台开发文档搜索 | `dws devdoc` | [devdoc.md](references/devdoc.md) |
| 白板 / 画布 / OpenNodes / 白板节点 | 读取和更新钉钉文档中的内嵌白板 | `dws whiteboard` | [whiteboard.md](references/whiteboard.md) |
## 说明
@@ -0,0 +1,97 @@
# 钉钉文档内嵌白板
`dws whiteboard` 只操作已经存在于钉钉在线文档中的单页内嵌白板。创建白板卡片使用
`dws doc whiteboard insert`;普通文档块仍使用 `dws doc block`。
OpenNodes V1 的完整字段、节点类型、目录枚举和错误语义按需读取
[协议索引](./whiteboard/open-nodes-v1.md);不要根据本页概要猜测节点字段或
`geometry`、`catalogId` 等枚举值。渐变卡片、Frame 分支、SVG/Vector 等完整
工作流见 [常用 Recipes](./whiteboard/recipes.md)。
## 定位白板
每次操作都需要真实的文档 `nodeId` 和白板 `partId`。缺少 `partId` 时先读取文档
JSONML,查找 `cardType=hetu` 且 `metadata.id` 非空的 card;`uuid` 是 blockId,
不能当作 partId。多个候选时必须让用户选择,不能取第一个。
```bash
dws doc read --node <DOC_ID> --content-format jsonml --scope tags --tags card --format json
```
## 读取
```bash
dws whiteboard query --node <DOC_ID> --part-id <PART_ID> --format json
```
CLI 会把服务端 `resultJson` 字符串解析为结构化 JSON。白板命令不支持全局
`--jq` 或 `--fields`。
## 更新
更新文件使用 OpenNodes V1 信封:
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "title",
"type": "text",
"x": 40,
"y": 40,
"width": 240,
"height": 48,
"text": {
"blocks": [
{
"type": "paragraph",
"runs": [{"text": "方案"}]
}
]
}
}
]
}
}
```
- `overwrite=false`:追加,`nodes` 至少一个对象。
- `overwrite=true`:整页重建,允许空数组;执行前必须先 query 保存当前内容。
- 所有更新都是远端写入,必须先获得用户确认,再加 `--yes`。
- Query 返回不能直接作为 update 输入;真实节点 ID 不能用于局部修改。
```bash
dws whiteboard update --node <DOC_ID> --part-id <PART_ID> \
--source ./whiteboard.json --yes --format json
```
常用节点类型包括 `text`、`shape`、`frame`、`group`、`connector`、`vector`。
节点可用请求内临时 `id` 建立 `parentId` 或 connector 引用;服务端负责完整字段、
层级和枚举校验,未知字段会使整次更新失败。
## Vector / SVG 资源
本地 SVG 不能直接写入 OpenNodes。先上传为绑定到同一文档 nodeId 的资源:
```bash
dws doc media upload --node <DOC_ID> --file ./icon.svg \
--mime-type image/svg+xml --yes --format json
```
将返回的 `resourceId` 和 `resourceUrl` 分别映射为 Vector resource 的
`resourceId` 与 `url`。禁止使用临时 uploadUrl、跨 nodeId 复用或传本地路径。
## 创建和删除白板卡片
```bash
dws doc whiteboard insert --node <DOC_ID> --yes --format json
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes --format json
```
insert 返回 `blockId` 和 `whiteboardId`。前者用于块删除,后者就是后续 whiteboard
命令的 partId;两者不可混用。插入成功但回查暂未取到 partId 时,命令会返回
`whiteboardId: null` 并提示稍后按 blockId 回查。
@@ -0,0 +1,45 @@
# OpenNodes V1(DWS 白板协议索引)
本目录承载 `dws whiteboard query/update` 使用的 OpenNodes V1 协议。协议按
调用阶段拆分,Agent 只加载当前任务所需章节,避免一次性读取全部内容。
## DWS 使用规则
- 只通过 `dws whiteboard query/update` 读写白板。
- 每次调用必须提供承载白板的文档 `--node` 和目标白板 `--part-id`。
- DWS 当前只支持单页白板:命令没有 `--page-id`,update 文件禁止包含
`pageId`。
- `query` 不接收请求体;CLI 会把返回的 `resultJson` 解析成对象。
- 白板命令不支持使用全局 `--jq` 或 `--fields` 过滤输出;传入任一参数都会报错,
Agent 直接读取 CLI 返回的结构化 JSON。
- `update --source` 使用 `overwrite + source` 信封;append 和 overwrite 都是
远端写入,获得用户确认后必须通过 `--yes` 显式确认。
- CLI 只预检 JSON、信封、版本和 `nodes` 数组等外层结构;节点字段、枚举、层级、
引用和业务约束由白板服务完整校验。任一层失败都不会保留部分更新。
- DWS 返回以 `success`、`nodeId`、`partId`、`resultJson` 和可选的
`resultSummary` 为准。
## 按任务读取
| 当前任务 | 必读章节 |
|---|---|
| 理解版本、兼容和命令语义 | [01-overview](open-nodes-v1/01-overview.md) |
| 读取或解释 query 结果 | [02-query](open-nodes-v1/02-query.md) |
| 构造 append、overwrite 或清空请求 | [03-update](open-nodes-v1/03-update.md) |
| 写富文本、列表、链接、主题色、渐变或阴影 | [04-text-style](open-nodes-v1/04-text-style.md) |
| 写 shape、便签、frame、group 或 connector | [05-shape-frame-group-connector](open-nodes-v1/05-shape-frame-group-connector.md) |
| 写已上传 Vector、内置 Icon 或自由 Path | [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md) |
| 处理错误、query 转写或判断 writeSupport | [07-examples-errors-write-support](open-nodes-v1/07-examples-errors-write-support.md) |
| 选择合法 geometry 或 icon catalogId | [08-catalogs](open-nodes-v1/08-catalogs.md) |
## 强制读取规则
- 使用 `shape.geometry` 前必须读取 [08-catalogs](open-nodes-v1/08-catalogs.md),
不得猜测 geometry。
- 使用 `icon.catalogId` 前必须读取 [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md)
和 [08-catalogs](open-nodes-v1/08-catalogs.md)。
- 使用 `path` 前必须读取 [06-vector-icon-path](open-nodes-v1/06-vector-icon-path.md),
不得把它当作通用 SVG Path。
- Query 结果不能直接作为 update source;转换前必须读取
[03-update](open-nodes-v1/03-update.md) 和
[07-examples-errors-write-support](open-nodes-v1/07-examples-errors-write-support.md)。
@@ -0,0 +1,57 @@
# DWS OpenNodes V1 协议说明
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
> 协议版本:`schemaVersion = "1.0"`,`catalogVersion = "dml-v1"`。
## 1. 协议用途
OpenNodes 是 DWS 白板命令使用的语义节点协议,提供两类能力:
- `dws whiteboard query`:返回稳定、可理解的页面和节点数据。
- `dws whiteboard update`:接收受约束的节点描述,以 `append` 或
`overwrite` 模式修改白板。
调用方只应依赖本文声明的语义字段和行为:
- `query` 不修改白板。
- `update` 全部成功或全部回滚,不返回中间状态。
- 未声明的存储字段、类型名称和处理过程不属于协议承诺。
OpenNodes V1 支持的节点类型、字段和读写范围见第 7 节。
DWS 负责身份认证和权限校验。Vector 资源准备使用 `dws doc media upload`,
具体流程见白板命令参考。
## 2. 版本与兼容原则
| 字段 | 当前值 | 作用 |
| --- | --- | --- |
| `schemaVersion` | `1.0` | 控制文档结构、节点字段和字段语义。 |
| `catalogVersion` | `dml-v1` | 控制允许写入的 DML 几何、连接线标记和内置 icon 目录。 |
V1 采用严格校验:
- 必填字段缺失会失败。
- 未声明字段会失败,不会被静默忽略。
- query-only 字段出现在 update 中会以 `readOnlyField` 失败。
- 不支持的节点类型、目录值或引用范围会失败。
- `null` 不代表“使用默认值”;除非字段类型明确允许,否则会失败。
本文列出的请求枚举值都是协议字面量,调用方必须按文档中的大小写和拼写原样传入,
不能自行转换或猜测。响应中未来可能增加可选字段,调用方应忽略不认识的响应字段。
调用方必须原样携带当前版本值。新增不兼容结构时应升级
`schemaVersion`;修改 DML、marker 或 icon 目录时应评估并升级
`catalogVersion`。
## 3. DWS 命令一览
| 命令 | 所需权限 | 效果 |
| --- | --- | --- |
| `dws whiteboard query --node ... --part-id ...` | 可查看白板 | 读取单页白板,不修改内容。 |
| `dws whiteboard update --node ... --part-id ... --source ... --yes` | 可编辑白板 | 追加节点或整页重建;所有更新都需先取得用户确认。 |
DWS 当前只支持文字文档中已有的单页内嵌白板。命令不接收 `pageId`,也不提供
创建页面、切换页面或按既有节点 ID 局部修改的能力。
@@ -0,0 +1,288 @@
# OpenNodes V1 — Query 请求、返回结构和节点公共字段
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 4. Query 协议
### 4.1 请求
```bash
dws whiteboard query \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--format json
```
`query` 不接收请求体或 `pageId`。`--node` 和 `--part-id` 的发现规则见
[白板命令参考](../../whiteboard.md)。CLI 会把服务返回的 `resultJson` JSON
字符串解析成对象。
### 4.2 返回结构
```ts
interface OpenNodesDocument {
schemaVersion: "1.0";
catalogVersion: "dml-v1";
pages: OpenPage[];
}
interface OpenPage {
id: string;
nodes: OpenNode[];
}
```
DWS 当前只支持单页白板,因此 `pages` 固定包含一个页面。调用方仍应从返回值读取
页面 `id`,但不能把它作为 `pageId` 传给 DWS 命令。
母版节点不会作为独立页面返回,而会合并到引用它的页面 `nodes` 中,并带有:
```json
{
"source": "master",
"writeSupport": "readOnly",
"unsupportedFeatures": ["node.source.master"]
}
```
### 4.3 节点公共字段
`type` 的完整公开枚举如下。前一组可由 update 创建,后一组仅供 query 返回:
```ts
type WritableOpenNodeType =
| "shape"
| "text"
| "connector"
| "stickyNote"
| "frame"
| "group"
| "vector"
| "icon"
| "path";
type ReadOnlyOpenNodeType =
| "image"
| "pdf"
| "media"
| "webLink"
| "table"
| "chart"
| "uml"
| "swimlane"
| "mind"
| "timer"
| "placeholder"
| "unknown";
type OpenNodeType = WritableOpenNodeType | ReadOnlyOpenNodeType;
```
每个 query 节点都包含以下公共字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `id` | `string` | 白板中真实、稳定的节点 ID。 |
| `type` | `OpenNodeType` | OpenNodes 公开语义类型,取值见上方枚举。 |
| `parentId` | `string?` | group/frame 父节点 ID。无父节点时省略。 |
| `children` | `string[]?` | group/frame 的直接子节点 ID,由服务端推导。 |
| `x`、`y` | `number` | 有父节点时相对父节点;否则相对页面。单位为 px。 |
| `width`、`height` | `number` | 节点包围盒尺寸,单位为 px。连接线允许其中一个为 `0`。 |
| `angle` | `number` | 归一化到 `[0, 360)` 的角度。 |
| `absoluteBounds` | `OpenBounds` | 页面坐标系中的绝对包围盒。 |
| `layer` | `background \| normal \| foreground` | 节点所在层。 |
| `zIndex` | `number` | 同一父节点、同一 layer 内的非负顺序,值越小越靠后。 |
| `hidden` | `boolean` | 节点是否隐藏。 |
| `locked` | `boolean` | 节点是否锁定。 |
| `source` | `page \| master` | 节点来自当前页面还是母版。 |
| `writeSupport` | `readWrite \| readOnly` | 当前节点能否由 V1 update 表达。 |
| `unsupportedFeatures` | `string[]?` | 只读原因;`readWrite` 节点省略。 |
公共结构和各节点分支定义如下;分支中的字段含义与写入限制见第 7 节:
```ts
interface OpenPoint {
x: number;
y: number;
}
interface OpenBounds {
x: number;
y: number;
width: number;
height: number;
angle: number;
}
interface OpenNodeBase {
id: string;
type: OpenNodeType;
parentId?: string;
children?: string[];
x: number;
y: number;
width: number;
height: number;
angle: number;
absoluteBounds: OpenBounds;
layer: "background" | "normal" | "foreground";
zIndex: number;
hidden: boolean;
locked: boolean;
source: "page" | "master";
writeSupport: "readWrite" | "readOnly";
unsupportedFeatures?: string[];
}
interface OpenShapeNode extends OpenNodeBase {
type: "shape";
geometry: `dml:${string}`;
adjustments?: Record<string, number>;
text?: OpenText;
style?: OpenNodeStyle;
}
interface OpenTextNode extends OpenNodeBase {
type: "text";
text: OpenText;
style?: OpenNodeStyle;
}
interface OpenConnectorNode extends OpenNodeBase {
type: "connector";
start: OpenConnectorEndpoint;
end: OpenConnectorEndpoint;
routing: OpenConnectorRouting;
waypoints?: OpenPoint[];
style?: OpenNodeStyle;
resolvedPath: OpenResolvedConnectorPath;
}
interface OpenStickyNoteNode extends OpenNodeBase {
type: "stickyNote";
text?: OpenText;
style?: OpenNodeStyle;
creator?: {
displayName?: string;
hasAvatar?: boolean;
};
tags?: Array<{
id: string;
text: string;
background: OpenPaint;
}>;
}
interface OpenFrameNode extends OpenNodeBase {
type: "frame";
title?: {
text: OpenText;
box: { width: number; height: number };
};
style?: OpenNodeStyle;
presentationOrder?: number;
resizeMode: "free" | "fixedAspectRatio";
}
interface OpenGroupNode extends OpenNodeBase {
type: "group";
children: string[];
}
interface OpenVectorNode extends OpenNodeBase {
type: "vector";
resource: OpenVectorResource;
}
interface OpenIconNode extends OpenNodeBase {
type: "icon";
catalogId: string;
}
interface OpenPathNode extends OpenNodeBase {
type: "path";
path: OpenPathData;
style?: OpenNodeStyle;
}
interface OpenReadOnlyNode extends OpenNodeBase {
type: ReadOnlyOpenNodeType;
writeSupport: "readOnly";
unsupportedFeatures: string[];
}
type OpenNode =
| OpenShapeNode
| OpenTextNode
| OpenConnectorNode
| OpenStickyNoteNode
| OpenFrameNode
| OpenGroupNode
| OpenVectorNode
| OpenIconNode
| OpenPathNode
| OpenReadOnlyNode;
```
query 中 `icon.catalogId` 使用 `string`,是为了让无法映射到当前目录的既有图标仍可
被诊断;update 只能传入 7.10 节 `OpenIconCatalogId` 中列出的值。
### 4.4 Query 示例
```json
{
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"pages": [
{
"id": "page",
"nodes": [
{
"id": "real-node-id",
"type": "text",
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"angle": 0,
"absoluteBounds": {
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"angle": 0
},
"layer": "normal",
"zIndex": 0,
"hidden": false,
"locked": false,
"source": "page",
"writeSupport": "readWrite",
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "left",
"runs": [
{
"text": "Hello OpenNodes",
"marks": {
"fontSize": 16,
"color": "#223344"
}
}
]
}
],
"verticalAlign": "center",
"padding": [2, 4],
"plainText": "Hello OpenNodes",
"writeSupport": "readWrite"
}
}
]
}
]
}
```
@@ -0,0 +1,249 @@
# OpenNodes V1 — Update 信封、Append/Overwrite 和公共写入字段
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 5. Update 协议
### 5.1 请求信封
```ts
interface OpenNodesUpdateRequest {
overwrite?: boolean;
source: {
schemaVersion: "1.0";
catalogVersion: "dml-v1";
nodes: OpenNodeWrite[];
};
}
```
字段含义:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `overwrite` | 否 | `false` 或省略为 append;`true` 为 overwrite。 |
| `source.schemaVersion` | 是 | 必须为 `1.0`。 |
| `source.catalogVersion` | 是 | 必须为 `dml-v1`。 |
| `source.nodes` | 是 | 本次创建的节点数组。append 至少一个;overwrite 允许空数组。 |
`source` 不能直接使用 query 返回的 `OpenNodesDocument`,也不接受 `pages`。
DWS 不接受 `pageId`;传入会在 CLI 本地校验阶段失败。
V1 没有“按真实节点 ID patch 既有节点”的语义。`source.nodes` 中的每一项都会
创建一个新节点,`id` 仅是本次请求内建立父子关系和连接线引用的临时 ID:
append 是新增节点,overwrite 是整页删除后重新创建。
### 5.2 Append 与 Overwrite
> **注意:** `overwrite: true` 是破坏性操作,会删除当前白板页面的全部自有
> 节点。调用前应先 query 并确认影响范围;`nodes: []` 会清空页面。不希望删除
> 既有内容时,应使用 append。
| 行为 | append | overwrite |
| --- | --- | --- |
| `overwrite` | `false` 或省略 | `true` |
| 空 `nodes` | 禁止 | 允许,用于清空当前页面 |
| 当前页面旧节点 | 全部保留 | 删除页面自有节点后创建新节点 |
| 母版节点 | 保留 | 保留 |
| 页面级设置 | 保留 | 保留 |
| 原子性 | 全部成功或全部回滚 | 全部成功或全部回滚 |
overwrite 会替换当前页面的全部自有节点,不要求旧节点本身可由 OpenNodes V1
写入。因此页面中存在 image、PDF、复杂文本等只读节点,不会单独阻止清空页面。
overwrite 会在删除前执行安全预检;以下情况会拒绝执行:
- 节点被锁定:`lockedNode`。
- 节点不允许被删除:`deleteForbidden`。
- 页面包含无法安全保留或清理的关联数据:`unknownMetadataReference`。
- 目标节点未能完整删除:`deleteFailed`。
与被删除节点绑定且有明确清理规则的关联数据会随节点清理;无法安全保留或清理的
关联数据会使 overwrite 失败。
overwrite 只替换当前页面节点,不会重建页面,也不会修改主题等页面级设置。
白板已有的主题会被保留;白板没有有效主题时也不会自动添加。调用方应使用
已配置有效主题的白板,或者显式提供不依赖主题的颜色。
### 5.3 成功结果
```ts
interface DWSWhiteboardUpdateResponse {
success: true;
nodeId: string;
partId: string;
resultJson: {
mode: "append" | "overwrite";
createdNodeIds: string[];
idMap: Record<string, string>;
deletedNodeCount: number;
message: string;
};
}
```
| 字段 | 说明 |
| --- | --- |
| `success` | `true` 表示本次 DWS 调用成功。 |
| `nodeId` | 输入的文档节点 ID。 |
| `partId` | 输入的白板标识。 |
| `resultJson.mode` | 实际执行的模式。 |
| `resultJson.createdNodeIds` | 按请求节点顺序返回真实节点 ID。 |
| `resultJson.idMap` | 请求中显式临时 ID 到真实节点 ID 的映射。 |
| `resultJson.deletedNodeCount` | append 恒为 `0`;overwrite 为删除的页面自有节点数。 |
| `resultJson.message` | 供人阅读的结果摘要,不应作为机器判断依据。 |
响应可能增加其他可选字段;Agent 不应依赖本节未声明的字段。
示例:
```json
{
"success": true,
"nodeId": "DOC_NODE_ID",
"partId": "WHITEBOARD_PART_ID",
"resultJson": {
"mode": "append",
"createdNodeIds": ["generated-title-id", "generated-body-id"],
"idMap": {
"title": "generated-title-id",
"body": "generated-body-id"
},
"deletedNodeCount": 0,
"message": "Created 2 Whiteboard nodes"
}
}
```
## 6. Update 公共节点字段
V1 可写节点公共字段如下:
```ts
interface OpenNodeWriteBase {
id?: string;
layer?: "background" | "normal" | "foreground";
zIndex?: number;
hidden?: boolean;
}
interface OpenChildNodeWriteBase extends OpenNodeWriteBase {
parentId?: string;
}
interface OpenSizedNodeWriteBase extends OpenChildNodeWriteBase {
x: number;
y: number;
width: number;
height: number;
angle?: number;
}
interface OpenShapeNodeWrite extends OpenSizedNodeWriteBase {
type: "shape";
geometry: `dml:${string}`;
text?: OpenTextWrite;
style?: OpenNodeStyleWrite;
}
interface OpenTextNodeWrite extends OpenSizedNodeWriteBase {
type: "text";
text: OpenTextWrite;
style?: OpenNodeStyleWrite;
}
interface OpenConnectorNodeWrite extends OpenNodeWriteBase {
type: "connector";
start: OpenConnectorEndpointWrite;
end: OpenConnectorEndpointWrite;
routing: OpenConnectorRouting;
waypoints?: OpenPoint[];
style?: OpenNodeStyleWrite;
}
interface OpenStickyNoteNodeWrite extends OpenSizedNodeWriteBase {
type: "stickyNote";
text?: OpenTextWrite;
style?: OpenNodeStyleWrite;
}
interface OpenFrameNodeWrite extends OpenNodeWriteBase {
type: "frame";
x: number;
y: number;
width: number;
height: number;
angle?: 0;
title?: {
text: OpenTextWrite;
box?: { width: number; height: number };
};
style?: OpenNodeStyleWrite;
presentationOrder?: number;
resizeMode?: "free" | "fixedAspectRatio";
}
interface OpenGroupNodeWrite extends OpenChildNodeWriteBase {
id: string;
type: "group";
x: number;
y: number;
}
interface OpenVectorNodeWrite extends OpenSizedNodeWriteBase {
type: "vector";
resource: OpenManagedVectorResourceWrite;
}
interface OpenIconNodeWrite extends OpenSizedNodeWriteBase {
type: "icon";
catalogId: OpenIconCatalogId;
}
interface OpenPathNodeWrite extends OpenSizedNodeWriteBase {
type: "path";
path: OpenPathDataWrite;
style?: OpenNodeStyleWrite;
}
type OpenNodeWrite =
| OpenShapeNodeWrite
| OpenTextNodeWrite
| OpenConnectorNodeWrite
| OpenStickyNoteNodeWrite
| OpenFrameNodeWrite
| OpenGroupNodeWrite
| OpenVectorNodeWrite
| OpenIconNodeWrite
| OpenPathNodeWrite;
```
上面的联合类型是 update 的字段白名单。各辅助结构和完整枚举值在第 7 节定义;
未出现在对应分支中的字段不能发送。
| 字段 | 规则 |
| --- | --- |
| `id` | 可选的请求级临时 ID;非空、区分大小写、在请求内唯一。被引用时必须提供。 |
| `type` | 必填,必须是 V1 可写类型。 |
| `parentId` | 可选,引用同一请求中 group/frame 的临时 ID。 |
| `x`、`y` | 除 connector 外必填;有父节点时为父节点相对坐标。 |
| `width`、`height` | shape/text/stickyNote/frame/vector/icon/path 必填且大于 `0`;group/connector 只读。 |
| `angle` | 可选,默认 `0`;frame 只允许 `0`;group/connector 只读。 |
| `layer` | 可选;frame 默认 `background`,其他节点默认 `normal`。 |
| `zIndex` | 可选的非负整数;相同值时按请求顺序稳定排序。 |
| `hidden` | 可选布尔值,默认 `false`。 |
以下 query 字段禁止写回:
`children`、`absoluteBounds`、`locked`、`source`、`writeSupport`、
`unsupportedFeatures`。
关系规则:
- `parentId` 只能引用同一请求中的 group 或 frame。
- frame 和 connector 必须是页面直属节点,不能带 `parentId`。
- group 可以嵌套,也可以放在 frame 中。
- `children` 始终由各子节点的 `parentId` 推导。
- 父子关系不能成环。
- group 必须有临时 `id`、至少两个直接子节点,且不能全部隐藏。
@@ -0,0 +1,395 @@
# OpenNodes V1 — 支持矩阵、富文本和样式
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 7. 节点类型
### 7.1 支持矩阵
| `type` | query | update | 主要字段 |
| --- | --- | --- | --- |
| `shape` | 支持 | 支持 | `geometry`、`text?`、`style?` |
| `text` | 支持 | 支持 | `text`、`style?` |
| `connector` | 支持 | 支持 | `start`、`end`、`routing`、`waypoints?`、`style?` |
| `stickyNote` | 支持 | 支持 | `text?`、`style?` |
| `frame` | 支持 | 支持 | `title?`、`style?`、`presentationOrder?`、`resizeMode?` |
| `group` | 支持 | 支持 | 子关系通过 `parentId` 表达 |
| `image` | 支持 | 只读 | 仅公共字段 |
| `vector` | 支持 | 支持 | `resource` |
| `icon` | 支持 | 支持 | `catalogId` |
| `path` | 支持 | 支持 | `path`、`style?` |
| `pdf` | 支持 | 只读 | 仅公共字段 |
| `media` | 支持 | 只读 | 仅公共字段 |
| `webLink` | 支持 | 只读 | 仅公共字段 |
| `table` | 支持 | 只读 | 仅公共字段 |
| `chart` | 支持 | 只读 | 仅公共字段 |
| `uml` | 支持 | 只读 | 仅公共字段 |
| `swimlane` | 支持 | 只读 | 仅公共字段 |
| `mind` | 支持 | 只读 | 仅公共字段 |
| `timer` | 支持 | 只读 | 仅公共字段 |
| `placeholder` | 支持 | 只读 | 仅公共字段 |
| `unknown` | 支持 | 只读 | 未识别或尚未定义独立语义的节点统一映射到此类型 |
表中标记为只读的类型仍会完整返回公共几何、层级和顺序信息,但 update 提交会以
`nodeTypeUnsupported` 失败。
`timer`、`table`、`webLink` 不支持 V1 update。query 仅返回这些节点的公共几何、
层级、顺序和诊断字段,不承诺完整业务字段。文字 run 中的 `link` 是富文本能力,
不属于 `webLink` 节点,V1 支持读写。
`webLink` 只保留只读查询;OpenNodes V1 不支持创建或重建该节点。
### 7.2 Text
query 和 update 均支持普通段落、无序列表、有序列表、多 block、多 run 和文字
链接:
```ts
interface OpenTextRun {
text: string;
marks?: {
fontFamily?: string;
fontSize?: number;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strike?: boolean;
color?: string;
highlight?: string;
};
link?: { url: string };
}
interface OpenTextBlock {
type: "paragraph" | "bulletList" | "orderedList";
horizontalAlign?: "left" | "center" | "right";
runs: OpenTextRun[];
}
interface OpenTextWrite {
blocks: OpenTextBlock[];
verticalAlign?: "top" | "center" | "bottom";
padding?: number | [number, number];
}
interface OpenText extends OpenTextWrite {
plainText: string;
writeSupport: "readWrite" | "readOnly";
unsupportedFeatures?: string[];
}
```
约束:
- `blocks.length >= 1`,每个 block 的 `type` 必须是 `paragraph`、
`bulletList` 或 `orderedList`。
- 每个 block 都必须满足 `runs.length >= 1`。
- run 的 `text` 不能包含 `\r`、`\n`、U+2028 或 U+2029。换行和列表项使用
独立 block 表达;每一段或每个列表项应写成一个 block,而不是把原始换行符
放进单个 run。
- 每个 `bulletList` / `orderedList` block 表示一个列表项;服务端会把连续且同类的
block 解释为同一个列表中的多个列表项。
- `link.url` 长度必须为 `1..2048`,不能包含控制字符、`<`、`>`。支持无 scheme
的相对/裸链接,以及 `http`、`https`、`mailto`、`tel`、`dingtalk` scheme;
`javascript:`、`data:` 等可执行或未知 scheme 会被拒绝。
- 相邻且 URL 相同的 linked run 会呈现为同一个链接,同时保留各 run 自己的 marks。
- `fontSize > 0`,padding 各项必须大于等于 `0`。
- `plainText`、文本级 `writeSupport` 和 `unsupportedFeatures` 是 query-only。
- text 节点必须提供 `text`;shape 和 stickyNote 的 `text` 可省略。
- 受支持的 paragraph、列表、链接、多 run 都保持
`writeSupport = "readWrite"`;未知 list style、非法链接、未支持的 block 或
run marks 会令文本和所属节点变为 `readOnly`。
下面的文本会显示为两个段落,第一段由两个不同样式的 run 组成:
```json
{
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "left",
"runs": [
{
"text": "OpenNodes ",
"marks": { "fontSize": 18, "bold": true, "color": "#2563EB" }
},
{
"text": "rich text",
"marks": { "fontSize": 18, "italic": true, "color": "#0F172A" }
}
]
},
{
"type": "paragraph",
"horizontalAlign": "right",
"runs": [
{
"text": "第二段",
"marks": { "fontSize": 16, "underline": true, "color": "#047857" }
}
]
}
],
"verticalAlign": "center",
"padding": [4, 8]
}
```
同一 `OpenTextWrite` 结构适用于独立 text 节点、shape 文本、stickyNote 文本和
frame title。
下面三个 block 会生成两个无序列表项,其中第二项包含文字链接:
```json
{
"blocks": [
{
"type": "bulletList",
"runs": [{ "text": "准备输入数据" }]
},
{
"type": "bulletList",
"runs": [
{
"text": "查看钉钉文档",
"marks": { "underline": true },
"link": { "url": "https://alidocs.dingtalk.com" }
}
]
},
{
"type": "orderedList",
"runs": [{ "text": "执行生成" }]
}
]
}
```
颜色接受以下 CSS 形式:3/4/6/8 位十六进制、颜色名,以及
`rgb()`、`rgba()`、`hsl()`、`hsla()`、`oklch()`、`lab()`、`lch()`、
`color()`。字符串最长 128 字符,不能包含控制字符、`<`、`>` 或 `;`。
这里只接受可独立解析的字面量;依赖外部样式上下文的 `var()`、`calc()`、
`color-mix()` 等动态表达式不属于 V1。颜色名必须是标准 CSS named color,
任意字母串不会被当成颜色。
### 7.3 Style
query 可表达:
- `none`、`solid`、`theme`、线性渐变、径向渐变和图片 paint。
- shadow、blur 和 unknown effect。
V1 update 支持 `none`、`solid`、`theme`、线性渐变、九方向径向渐变,以及
单个自定义 shadow。主题明暗参数和渐变色标位置使用 `0~100` 的百分比,
不会归一化成 `0~1`:
```ts
type OpenRadialGradientPosition =
| "topLeft"
| "topCenter"
| "topRight"
| "centerLeft"
| "center"
| "centerRight"
| "bottomLeft"
| "bottomCenter"
| "bottomRight";
interface OpenColorStop {
offset: number;
color: string;
opacity?: number;
}
interface OpenImagePaint {
type: "image";
resource: {
kind: "managed" | "external" | "embedded" | "unresolved";
resourceId?: string;
};
intrinsicWidth: number;
intrinsicHeight: number;
}
type OpenPaint =
| { type: "none" }
| { type: "solid"; color: string; opacity?: number }
| {
type: "theme";
token: string;
lumMod?: number;
lumOff?: number;
resolvedColor?: string;
}
| {
type: "linearGradient";
angle: number;
stops: OpenColorStop[];
}
| {
type: "radialGradient";
position: OpenRadialGradientPosition | "custom";
stops: OpenColorStop[];
}
| OpenImagePaint;
type OpenEffect =
| {
type: "shadow";
offsetX: number;
offsetY: number;
blur: number;
color: string;
opacity: number;
}
| { type: "blur"; blur: number }
| { type: "unknown" };
interface OpenNodeStyle {
opacity?: number;
fill?: OpenPaint;
stroke?: {
paint: OpenPaint;
width?: number;
dash?: number[];
lineCap?: "butt" | "round" | "square";
lineJoin?: "miter" | "round" | "bevel";
};
effects?: OpenEffect[];
writeSupport: "readWrite" | "readOnly";
unsupportedFeatures?: string[];
}
interface OpenColorStopWrite {
offset: number; // [0, 100],百分比
color: string;
opacity?: number; // [0, 1]
}
type OpenPaintWrite =
| { type: "none" }
| { type: "solid"; color: string; opacity?: number }
| {
type: "theme";
token: string;
lumMod?: number; // [0, 100],默认 100
lumOff?: number; // [0, 100],默认 0
}
| {
type: "linearGradient";
angle: number;
stops: OpenColorStopWrite[];
}
| {
type: "radialGradient";
position: OpenRadialGradientPosition;
stops: OpenColorStopWrite[];
};
interface OpenShadowEffectWrite {
type: "shadow";
offsetX: number;
offsetY: number;
blur: number;
color: string;
opacity: number;
}
interface OpenNodeStyleWrite {
opacity?: number;
fill?: OpenPaintWrite;
stroke?: {
paint: OpenPaintWrite;
width?: number;
dash?: number[];
lineCap?: "butt" | "round" | "square";
lineJoin?: "miter" | "round" | "bevel";
};
effects?: OpenShadowEffectWrite[];
}
```
约束:
- opacity 范围为 `[0, 1]`。
- solid paint 的 `color` 与 `opacity` 在 query 后仍保持独立字段,不会合并为
动态 CSS 表达式。
- theme 的 `token` 必须能在当前白板主题中解析;
`lumMod`、`lumOff` 范围均为 `[0, 100]`。token 不存在时在
Request graph 阶段返回 `themeTokenNotFound`,不会写出部分节点。
token 长度为 `1~64`,首尾不能有空白,也不能包含空白、控制字符、
`<`、`>` 或 `;`。
- query 的 theme paint 还会返回按当前白板主题计算出的 query-only
`resolvedColor`。调用方写入时只传 `token/lumMod/lumOff`,不传
`resolvedColor`。
- 渐变必须至少有一个 stop;`offset` 范围为 `[0, 100]`,单位是百分比。
- 线性渐变 `angle` 范围为 `[0, 360]`。
- 径向渐变只接受上述九宫格位置。query 遇到九宫格以外的位置时返回
`position: "custom"` 并将该节点标为只读。
- `effects` 最多包含一个 `shadow`;`offsetX/offsetY` 必须是有限数,
`blur >= 0`,shadow opacity 范围为 `[0, 1]`。
- stroke width 和 dash 各项必须大于等于 `0`。
- 一旦提供 stroke,`stroke.paint` 必填。
- 样式级 `writeSupport` 和 `unsupportedFeatures` 是 query-only。
- 能在当前白板主题中解析且明暗参数合法的 theme paint 可写。无法解析的主题
token、越界的主题明暗参数、image paint、blur、unknown effect、叠加 effect
或自定义径向位置会令节点只读;受支持的 theme、渐变和单个 shadow 本身不会
令节点只读。
主题色示例:
```json
{
"fill": {
"type": "theme",
"token": "ac3",
"lumMod": 20,
"lumOff": 80
},
"stroke": {
"paint": {
"type": "theme",
"token": "sk1",
"lumMod": 80,
"lumOff": 20
},
"width": 2
}
}
```
示例中的 `ac3`、`sk1` 只是主题 token 示例,不是所有白板都可用的全局枚举。
调用方只能使用已确认可由当前白板主题解析的 token;无法确认时应改用
`solid` 颜色。
DWS 不提供新增、修改或切换主题的命令。当前白板没有有效主题时,应改用
`solid`。
示例:
```json
{
"fill": {
"type": "linearGradient",
"angle": 35,
"stops": [
{ "offset": 0, "color": "#1677ff", "opacity": 0.4 },
{ "offset": 100, "color": "#69b1ff" }
]
},
"effects": [
{
"type": "shadow",
"offsetX": 8,
"offsetY": 8,
"blur": 19,
"color": "rgba(93,190,172,1)",
"opacity": 0.5
}
]
}
```
未传 `style` 时使用节点类型的默认样式。调用方如需稳定的视觉结果,应显式传入
`fill` 和 `stroke`。
@@ -0,0 +1,186 @@
# OpenNodes V1 — Shape、Text、Sticky note、Frame、Group 和 Connector
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
### 7.4 Shape
shape 必须提供 `geometry`,格式为 `dml:<name>`:
```json
{
"id": "shape-1",
"type": "shape",
"x": 100,
"y": 80,
"width": 160,
"height": 100,
"geometry": "dml:roundRect",
"style": {
"fill": {
"type": "solid",
"color": "#DCEEFF"
},
"stroke": {
"paint": {
"type": "solid",
"color": "#225588"
},
"width": 2
}
}
}
```
query 可能返回 `adjustments`,但 V1 update 不支持写入;带 adjustments 的
shape 会标为只读。`dml-v1` 的完整 geometry 目录见附录 A。
### 7.5 Text node 与 Sticky note
text node 使用公共几何、必填 `text` 和可选 `style`。
stickyNote 使用公共几何、可选 `text` 和可选 `style`。省略 `text` 时创建空
便签。query 还可能返回:
- `creator`:创建者展示信息。
- `tags`:标签 ID、文本和背景 paint。
这两个字段是 query-only;存在 creator 或 tags 的 stickyNote 会被标为只读。
### 7.6 Frame
frame 的 update 结构见第 6 节 `OpenFrameNodeWrite`。
约束:
- frame 必须是页面直属节点,不能带 `parentId`。
- angle 只允许 `0`。
- `presentationOrder` 是非负整数,并且不能和已有或本次创建的 frame 冲突。
- frame 的子节点通过子节点 `parentId` 引用 frame 临时 ID。
- frame 不能包含 frame 或 connector。
- frame 默认 layer 为 `background`。
### 7.7 Group
group 的写入字段只有公共字段中的 `id`、`parentId?`、`x`、`y`、`layer?`、
`zIndex?` 和 `hidden?`。其 width、height、angle 和 children 都由服务端根据
子节点推导。
group 必须:
- 提供临时 `id`。
- 至少包含两个直接子节点。
- 至少有一个直接子节点可见。
group 的任一子节点为只读时,query 会把 group 一并标为只读。
### 7.8 Connector
connector 的几何由端点和路由推导,因此 update 不能提供 `x`、`y`、
`width`、`height` 或 `angle`。
query 的连接线结构如下:
```ts
type OpenConnectorRouting =
| "straight"
| "polyline"
| "curve"
| "orthogonal";
interface OpenConnectorMarker {
catalogId: string;
}
type OpenConnectorAnchor =
| {
mode: "fixed";
side: "top" | "right" | "bottom" | "left";
position: OpenPoint;
}
| {
mode: "fixed";
side: "custom";
position: OpenPoint;
};
type OpenConnectorEndpoint =
| {
type: "point";
point: OpenPoint;
marker: OpenConnectorMarker;
}
| {
type: "node";
nodeRef: { scope: "document"; id: string };
anchor: OpenConnectorAnchor;
resolvedPoint: OpenPoint;
marker: OpenConnectorMarker;
};
interface OpenBezierSegment {
start: OpenPoint;
control1: OpenPoint;
control2: OpenPoint;
end: OpenPoint;
}
type OpenResolvedConnectorPath =
| { type: "polyline"; points: OpenPoint[] }
| { type: "bezier"; segments: OpenBezierSegment[] };
```
update 端点有两种形式:
```ts
type OpenConnectorEndpointWrite =
| {
type: "point";
point: { x: number; y: number };
marker?: { catalogId: "none" | "arrow.open" | "arrow.filled" };
}
| {
type: "node";
nodeRef: { scope: "request"; id: string };
anchor?:
| { mode: "auto" }
| {
mode: "fixed";
side: "top" | "right" | "bottom" | "left";
};
marker?: { catalogId: "none" | "arrow.open" | "arrow.filled" };
};
```
query 的 marker `catalogId` 使用 `string`,以便返回无法映射的既有 marker;
update 只接受上面列出的 `"none"`、`"arrow.open"`、`"arrow.filled"`。
路由规则:
| `routing` | `waypoints` |
| --- | --- |
| `straight` | 禁止提供,包括空数组。 |
| `polyline` | 必须至少提供一个。 |
| `curve` | 可选。 |
| `orthogonal` | 可选;显式点路径的相邻线段必须水平或垂直。 |
其他约束:
- 所有 point 和 waypoint 都使用页面绝对坐标。
- node 端点只能引用同一请求中的 shape、text、stickyNote、frame、group 或 path。
- node 端点不能引用隐藏节点、connector 或 query 中既有节点。
- update 引用范围固定为 `scope: "request"`;query 返回的节点引用范围为
`scope: "document"`,不能直接回写。
- 同一连接线的两端不能引用同一个节点。
- 零长度或无效路径会被拒绝。
- marker 省略时默认为 `none`。
- anchor 省略时按 `auto` 处理。
query 额外返回服务端解析后的:
- node 端点 `resolvedPoint`。
- fixed anchor 的归一化 `position`。
- `resolvedPath`,类型为 polyline points 或 cubic bezier segments。
- 由真实路径推导的 `absoluteBounds`。
这些解析字段都是 query-only。
@@ -0,0 +1,313 @@
# OpenNodes V1 — Vector、Icon 和 Path
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
### 7.9 Vector(已上传 SVG/矢量资源)
`vector` 表示已经通过 `dws doc media upload` 获得稳定引用的 SVG/矢量图片。
OpenNodes 只接收上传结果中的资源引用,不接收本地路径或原始 SVG/XML 内容。
上传时必须使用与后续白板更新相同的文档 `nodeId`:
```bash
dws doc media upload \
--node <DOC_NODE_ID> \
--file ./icon.svg \
--mime-type image/svg+xml \
--format json
```
将上传结果的 `resourceId` 和 `resourceUrl` 分别写入
`resource.resourceId` 和 `resource.url`。
Update 必须提供完整的托管资源信息:
```ts
interface OpenManagedVectorResourceWrite {
kind: "managed";
resourceId: string;
url: string;
}
```
完整节点结构见第 6 节 `OpenVectorNodeWrite`。
`resource` 字段规则:
| 字段 | 必填 | 规则 |
| --- | --- | --- |
| `kind` | 是 | 当前只允许固定值 `managed`,表示资源已经通过 DWS 上传。 |
| `resourceId` | 是 | 资源稳定 ID,长度 1~256,只允许字母、数字、`.`、`_`、`:`、`-`。 |
| `url` | 是 | 已上传资源地址,最长 4096;必须包含且只能包含一个同值的 `resourceId` 查询参数。 |
`url` 必须直接使用 `dws doc media upload` 返回的 `resourceUrl`,不得自行拼装
或修改。
以下内容会被拒绝:
- 原始 SVG/XML、`data:`、`blob:`、`http:` URL。
- `//host/path` 协议相对地址,以及自行构造或修改的其他相对地址。
- 含空白、控制字符、反斜杠、fragment 或用户凭证的 URL。
- URL 缺少 `resourceId`、重复出现 `resourceId`,或者 URL 中 ID 与显式
`resource.resourceId` 不一致。
- `resource` 缺失、字段不完整、`kind` 不是 `managed`,或包含未知字段。
完整 Append 示例:
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "uploaded-svg-1",
"type": "vector",
"x": 100,
"y": 80,
"width": 240,
"height": 180,
"angle": 0,
"resource": {
"kind": "managed",
"resourceId": "0c1c94e1-f9af-4228-b32f-42bbd1555253",
"url": "https://resources.example.com/assets/opaque-path?resourceId=0c1c94e1-f9af-4228-b32f-42bbd1555253"
}
}
]
},
"overwrite": false
}
```
示例中的 `resources.example.com` 是占位域名,实际调用必须使用
`dws doc media upload` 返回的 `resourceUrl`。
`resourceId` 同时显式出现并包含在 URL 中,用于校验资源身份与地址是否一致。
两者必须来自同一次 `dws doc media upload` 结果。
Query 的 `resource` 可能是:
```ts
type OpenVectorResource =
| { kind: "managed"; resourceId: string; url: string }
| { kind: "external" | "embedded" | "unresolved" };
```
- 能识别为托管资源的引用返回完整 `managed` 信息。
- HTTP(S) 外链但不满足托管资源契约时返回 `external`。
- `data:`/`blob:` 返回 `embedded`。
- 其他缺失或无法识别的地址返回 `unresolved`。
- 非 `managed` 资源会令节点只读,并分别产生
`vector.resource.external`、`vector.resource.embedded` 或
`vector.resource.unresolved`。
V1 vector update 不接受 `style`。既有节点包含 V1 无法表达的 fill、stroke、
opacity、effect 或 adjustments 时,query 仍可读取资源和几何,但节点会标为只读。
不影响资源内容的兼容性装饰不会单独令节点变为只读。
DWS 会校验资源引用。上传与 `whiteboard update` 必须使用同一个文档
`nodeId`;不要跨文档复用资源,也不要使用临时 `uploadUrl`。
### 7.10 Icon(内置图标)
`icon` 表示内置图标。OpenNodes 使用版本化的 `catalogId` 作为稳定标识,
允许值见下列类型定义和附录 B。
Update 结构:
```ts
type OpenIconCatalogId =
| `emoji/${
| "happy"
| "smile"
| "laugh"
| "fighting"
| "like"
| "ok"
| "please"
| "face-plam"
| "tears-of-joy"
| "cry"
| "question"
| "face-with-sweat"
| "bloody-nose"
| "doggy"}`
| `tools/${
| "pad"
| "blue-note"
| "yellow-notes"
| "chart"
| "chart-2"
| "pencil"
| "pen"
| "bag"
| "rocket"
| "fire"
| "gold"
| "light"
| "pin"
| "red-flag"
| "tea"
| "island"
| "ball"
| "lucky-fish"
| "coffee"
| "milky-tea"
| "pan"}`
| `priority/priority-${1 | 2 | 3 | 4 | 5 | 6 | 7}`
| `task/${
| "task-start"
| "task-oct"
| "task-3oct"
| "task-half"
| "task-5oct"
| "task-7oct"
| "task-done"}`;
```
完整节点结构见第 6 节 `OpenIconNodeWrite`。
完整 Append 示例:
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "pencil-icon",
"type": "icon",
"x": 100,
"y": 80,
"width": 48,
"height": 48,
"catalogId": "tools/pencil"
}
]
},
"overwrite": false
}
```
规则:
- `catalogId` 必填,格式为 `<group>/<name>`,并且必须精确命中附录 B 的
`dml-v1` allowlist;大小写、连字符和历史拼写都不能自行修正。
- 当前目录有 `emoji`、`tools`、`priority`、`task` 四组,共 49 项。
- update 只接受 `catalogId`,不接受 `group`、`name`、`style` 或 `resource`。
- 内置 icon 不需要调用方上传资源,也不需要 `resourceId`。
- 任意已上传 SVG 或自定义图标应使用 `vector`,并按 7.9 节提供完整
`resource` 信息,不能伪造一个 icon `catalogId`。
- icon 可以作为 group/frame 子节点;V1 connector 的 node 端点当前仍不接受
icon 作为目标。
Query 返回相同的 `catalogId`。既有 icon 无法映射到当前目录时,节点仍会以
`type: "icon"` 返回以便诊断,但 `writeSupport` 为 `readOnly`,原因包含
`icon.catalogId`。非默认 opacity、filter/effect、text 或 adjustments 同样会令节点
只读。
### 7.11 Path(自由画笔)
`path` 表示自由画笔轨迹。OpenNodes 保留两组互相独立的尺寸:
- 节点公共 `width` / `height` 是画布上的实际渲染尺寸,缩放节点时会变化。
- `path.intrinsicWidth` / `path.intrinsicHeight` 是 SVG path 自身的坐标空间尺寸,
表示 `path.data` 使用的内部坐标空间。
```ts
interface OpenPathData {
data: string;
intrinsicWidth: number;
intrinsicHeight: number;
}
type OpenPathDataWrite = OpenPathData;
```
完整 update 节点结构见第 6 节 `OpenPathNodeWrite`。query 和 update 的 `path`
字段结构相同,但 update 仍必须满足下方命令子集和大小限制。
```json
{
"id": "freehand-stroke",
"type": "path",
"x": 120,
"y": 100,
"width": 500,
"height": 150,
"path": {
"data": "M0,75 Q50,0 100,75 Q150,150 200,75 Q250,0 300,75 Q350,150 400,75 Q450,0 500,75",
"intrinsicWidth": 500,
"intrinsicHeight": 150
},
"style": {
"fill": { "type": "none" },
"stroke": {
"paint": { "type": "solid", "color": "#7C3AED" },
"width": 10,
"lineCap": "round",
"lineJoin": "round"
}
}
}
```
下面是一个仍然只使用 V1 命令子集、但包含 12 段二次贝塞尔曲线的蝴蝶轮廓。
它显式回到起点,因此不需要使用尚未支持的 `Z`:
```json
{
"id": "complex-butterfly-path",
"type": "path",
"x": 1500,
"y": 1215,
"width": 500,
"height": 420,
"path": {
"data": "M250,180 Q210,105 145,70 Q55,25 35,110 Q10,195 125,220 Q35,275 80,355 Q125,415 210,315 Q235,285 250,250 Q265,285 290,315 Q375,415 420,355 Q465,275 375,220 Q490,195 465,110 Q445,25 355,70 Q290,105 250,180",
"intrinsicWidth": 500,
"intrinsicHeight": 420
},
"style": {
"opacity": 0.96,
"fill": {
"type": "solid",
"color": "#EDE9FE",
"opacity": 0.72
},
"stroke": {
"paint": {
"type": "solid",
"color": "#6D28D9"
},
"width": 8,
"lineCap": "round",
"lineJoin": "round"
}
}
}
```
V1 path 写入约束如下:
- `data` 必须是一个绝对 `M`,后跟至少一个显式写出的绝对 `Q`;不接受相对命令,
也不接受 `L`、`C`、`A`、`Z` 等通用 SVG 命令。
- 所有命令参数必须完整且为有限数值;单节点 `data` 最长 1 MiB,最多 50,000 个
命令。
- `intrinsicWidth` 和 `intrinsicHeight` 必须为有限正数;它们不要求等于节点的
`width` 和 `height`。
- 未传 `style` 时,默认使用透明填充、`#222222` 描边、宽度 5,以及 round
line cap/join。需要稳定视觉结果时仍应显式传入 `style`。
- path 可以作为 group/frame 的子节点,也可以作为同一 update 请求中 connector
的 node 端点。
query 会把其他能够解析的 SVG path 保留为 `type: "path"`,但标记为
`readOnly`,原因包含 `path.commands`;超出上述 V1 限制时使用
`path.data.size` 或 `path.commands.limit`。既有 path 带 text、adjustments、非
`nonzero` fillRule 或 V1 无法表达的样式时也会只读。仅包含不影响画笔几何的
兼容性信息时,不会因此变为只读。theme stroke 遵循通用 style 契约:query 会
保留 token 和明暗参数;只要 token 能在当前白板主题中解析,就可以按 theme
paint 回写。
@@ -0,0 +1,270 @@
# OpenNodes V1 — Update 示例、回写规则、错误模型和 writeSupport
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 8. Update 示例
### 8.1 Append 一个文本节点
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "title",
"type": "text",
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "left",
"runs": [
{
"text": "Hello OpenNodes",
"marks": {
"fontSize": 16,
"color": "#223344"
}
}
]
}
],
"verticalAlign": "center",
"padding": [2, 4]
}
}
]
}
}
```
### 8.2 Append 两个形状和一条引用连接线
```json
{
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "left",
"type": "shape",
"x": 80,
"y": 100,
"width": 120,
"height": 80,
"geometry": "dml:roundRect"
},
{
"id": "right",
"type": "shape",
"x": 360,
"y": 100,
"width": 120,
"height": 80,
"geometry": "dml:roundRect"
},
{
"id": "line",
"type": "connector",
"start": {
"type": "node",
"nodeRef": {
"scope": "request",
"id": "left"
},
"anchor": {
"mode": "fixed",
"side": "right"
}
},
"end": {
"type": "node",
"nodeRef": {
"scope": "request",
"id": "right"
},
"anchor": {
"mode": "fixed",
"side": "left"
},
"marker": {
"catalogId": "arrow.filled"
}
},
"routing": "straight"
}
]
}
}
```
### 8.3 Overwrite 整页
```json
{
"overwrite": true,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "replacement",
"type": "text",
"x": 120,
"y": 80,
"width": 240,
"height": 48,
"text": {
"blocks": [
{
"type": "paragraph",
"runs": [
{
"text": "Replacement content"
}
]
}
]
}
}
]
}
}
```
### 8.4 清空当前页面
```json
{
"overwrite": true,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": []
}
}
```
## 9. Query 数据不能直接回写
query 是完整可读投影,update 是受约束的创建协议,两者不是对称 JSON:
| Query 字段/能力 | Update 处理方式 |
| --- | --- |
| 真实 `id` | 只能作为请求级临时 ID;不能引用既有 document 节点。 |
| `children` | 删除,通过子节点 `parentId` 重建。 |
| `absoluteBounds` | 删除;普通节点使用 `x/y/width/height`,connector 使用端点和路由字段。 |
| `locked`、`source`、`writeSupport`、`unsupportedFeatures` | 删除,均为 query-only。 |
| 文本 `plainText` | 删除,由服务端根据 paragraph 和 run 重新计算。 |
| 多 paragraph、列表、多 run、文字链接 | 可以保留;每个 block 必须是受支持类型,且 run 内不能包含原始换行符。 |
| 未知 list style、非法链接或未支持的 block/marks | 需要移除或降级为受支持的 block/run。 |
| theme paint | 保留 `token/lumMod/lumOff`,删除 query-only `resolvedColor`;token 必须能在当前白板主题中解析。 |
| image paint | 需要降级成受支持的 paint,或不更新该节点。 |
| 受支持的 linear/radial gradient、单个 shadow | 可以保留;gradient offset 使用 `0~100`,radial `custom` 不能回写。 |
| blur、unknown 或叠加 effects | 需要删除、降级成单个 shadow,或不更新该节点。 |
| connector `scope: "document"` | 不能回写;改为引用同一请求节点的 `scope: "request"`。 |
| connector `resolvedPoint`、`position`、`resolvedPath` | 删除,均由服务端重新计算。 |
| shape `adjustments` | V1 不支持写入。 |
| stickyNote `creator`、`tags` | V1 不支持写入。 |
即使 query 节点显示 `writeSupport = "readWrite"`,update 仍会对版本、目录、
字段和请求关系做完整校验。调用方不应跳过 update 错误处理。
## 10. 错误模型
### 10.1 顶层错误码
| 错误码 | 含义 |
| --- | --- |
| `invalidRequest.whiteboard.schemaInvalid` | JSON、字段或节点 schema 不合法。 |
| `invalidRequest.whiteboard.catalogVersionUnsupported` | catalogVersion 不受支持。 |
| `invalidRequest.whiteboard.validationFailed` | 节点间引用、父子关系或路径关系不合法。 |
| `invalidRequest.whiteboard.emptySource` | append 的 nodes 为空。 |
| `invalidRequest.whiteboard.overwriteUnsafe` | overwrite 安全预检失败。 |
### 10.2 DWS 错误输出
远端校验失败时,DWS 以统一 CLI 错误结构返回:
```json
{
"error": {
"category": "api",
"reason": "business_error",
"server_key": "whiteboard",
"server_error_code": "invalidRequest.whiteboard.validationFailed",
"message": "Whiteboard request graph is invalid",
"trace_id": "TRACE_ID"
}
}
```
部分服务错误会使用更宽泛的 `invalidRequest.inputArgs.invalid`。Agent 应结合
`server_error_code` 和 `message` 修正输入;需要排障时保留 `trace_id`。JSON 或
信封级错误可能由 CLI 本地返回,不一定包含 `server_key` 和 `trace_id`。
校验类错误不可通过原样重试恢复。常见原因包括:
- Schema:缺少字段、未知字段、类型或枚举值错误、提交 query-only 字段、
节点类型不支持。
- 请求关系:临时 ID 重复、引用不存在、父子关系非法、连接线目标不支持、
路径退化或主题 token 不存在。
- Overwrite 预检:锁定节点、禁止删除、关联数据无法安全处理或删除失败。
任一阶段失败都不会保留部分更新。
## 11. writeSupport 的含义
`writeSupport` 表示当前 query 节点是否能由 V1 update 无损表达,不代表用户
权限,也不代表 overwrite 是否允许移除该既有节点。
以下 `unsupportedFeatures` 均为对外返回的诊断枚举值:
- `node.source.master`、`node.locked`、`node.role`、`node.placeholder`、
`node.extras`、`node.ability`。
- `node.type.image`、`node.type.pdf`、`node.type.media`、
`node.type.webLink`、`node.type.table`、`node.type.chart`、
`node.type.uml`、`node.type.swimlane`、`node.type.mind`、
`node.type.timer`、`node.type.placeholder`、`node.type.unknown`。
- `text.list.unsupported`、`text.link.unsupported`、`text.block.unsupported`、
`text.lineBreak.unsupported`、`text.marks.unsupported`、
`text.color.unsupported`、`text.highlight.unsupported`。
- `style.fill.color`、`style.fill.opacity`、`style.fill.theme.token`、
`style.fill.theme.unresolved`、`style.fill.theme.modifier`、
`style.fill.theme.opacity`、
`style.fill.gradient.angle`、`style.fill.gradient.offset`、
`style.fill.gradient.color`、`style.fill.gradient.opacity`、
`style.fill.gradient.position`、`style.fill.image`。
- `style.stroke.color`、`style.stroke.opacity`、`style.stroke.theme.token`、
`style.stroke.theme.unresolved`、
`style.stroke.theme.modifier`、`style.stroke.theme.opacity`、
`style.stroke.gradient.angle`、`style.stroke.gradient.offset`、
`style.stroke.gradient.color`、`style.stroke.gradient.opacity`、
`style.stroke.gradient.position`、`style.stroke.image`。
- `style.effects`。
- `shape.adjustments`、`stickyNote.creator`、`stickyNote.tags`。
- `vector.resource.external`、`vector.resource.embedded`、
`vector.resource.unresolved`、`vector.fill`、`vector.stroke`、
`vector.opacity`、`vector.effect`、`vector.adjustments`。
- `icon.catalogId`、`icon.opacity`、`icon.effect`、`icon.text`、
`icon.adjustments`。
- `path.commands`、`path.data.size`、`path.commands.limit`、`path.text`、
`path.fillRule`、`path.adjustments`。
- `connector.parent`、`connector.marker.unsupported`、
`connector.target.unexposed`、`connector.target.unsupported`、
`connector.anchor.unresolved`、`connector.anchor.custom`、
`connector.selfLoop`。
- `group.angle`、`group.children.minimum`、`group.children.hidden`、
`group.child.readOnly`。
- `frame.angle`、`frame.child.frame`、`frame.child.readOnly`。
调用方应把 `unsupportedFeatures` 当作诊断信息,不应把当前枚举穷举写死为
业务逻辑。真正可写与否以 `writeSupport` 和 update 校验结果为准。
@@ -0,0 +1,61 @@
# OpenNodes V1 — dml-v1 Geometry 和 Icon 完整目录
> 本文件是 DWS OpenNodes V1 协议的拆分章节。按需读取入口见
> [协议索引](../open-nodes-v1.md)。
## 附录 A:dml-v1 geometry 目录
写入时在以下名称前加 `dml:`,例如 `rect` 写成 `dml:rect`。当前目录共
183 项,目录版本由 `catalogVersion = "dml-v1"` 标识。
```text
accentBorderCallout1 accentBorderCallout2 accentBorderCallout3
accentCallout1 accentCallout2 accentCallout3 actionButtonBackPrevious
actionButtonBeginning actionButtonBlank actionButtonDocument actionButtonEnd
actionButtonForwardNext actionButtonHelp actionButtonHome
actionButtonInformation actionButtonMovie actionButtonReturn actionButtonSound
active allGeneralization arc attribute bentArrow bentUpArrow bevel bind blockArc
borderCallout1 borderCallout2 borderCallout3 bracePair bracketPair callout1
callout2 callout3 can chevron chord circularArrow cloud cloudCallout comment
component control convert corner cube curvedDownArrow curvedLeftArrow
curvedRightArrow curvedUpArrow dataStorage decagon delete diagStripe diamond
dodecagon donut doubleWave downArrow downArrowCallout ellipse ellipseRibbon
ellipseRibbon2 entity entitySet flowChartAlternateProcess flowChartCollate
flowChartConnector flowChartDecision flowChartDelay flowChartDisplay
flowChartDocument flowChartExtract flowChartInputOutput flowChartInternalStorage
flowChartMagneticDisk flowChartMagneticDrum flowChartMagneticTape
flowChartManualInput flowChartManualOperation flowChartMerge
flowChartMultidocument flowChartOffpageConnector flowChartOnlineStorage
flowChartOr flowChartPredefinedProcess flowChartPreparation
flowChartPunchedCard flowChartPunchedTape flowChartSort
flowChartSummingJunction flowChartTerminator foldedCorner frame generalization
halfFrame heart heptagon hexagon history homePlate horizontalDivCircle
horizontalScroll irregularSeal1 irregularSeal2 leftArrow leftArrowCallout
leftBrace leftBracket leftRightArrow leftRightArrowCallout leftRightUpArrow
leftUpArrow lightningBolt mathDivide mathEqual mathMinus mathMultiply
mathNotEqual mathPlus moon multiClass multiValuedAttribute noSmoking node
nonIsoscelesTrapezoid notchedRightArrow octagon parallelogram pentagon person
pie plaque plus quadArrow quadArrowCallout receiveSignal rect relationship
ribbon ribbon2 rightArrow rightArrowCallout rightBrace rightBracket round1Rect
round2DiagRect round2SameRect roundRect rtTriangle smileyFace snip1Rect
snip2DiagRect snip2SameRect snipRoundRect star10 star12 star16 star24 star32
star4 star5 star6 star7 star8 stripedRightArrow sun teardrop triangle upArrow
upArrowCallout upDownArrow user uturnArrow verticalDivCircle verticalScroll
wave weakEntitySet weakRelationship wedgeEllipseCallout wedgeRectCallout
wedgeRoundRectCallout
```
## 附录 B:dml-v1 icon 目录
写入时必须使用完整的 `<group>/<name>`。当前共 4 组 49 项,目录版本由
`catalogVersion = "dml-v1"` 标识。
| group | 数量 | name(组成 `group/name`) |
| --- | ---: | --- |
| `emoji` | 14 | `happy`、`smile`、`laugh`、`fighting`、`like`、`ok`、`please`、`face-plam`、`tears-of-joy`、`cry`、`question`、`face-with-sweat`、`bloody-nose`、`doggy` |
| `tools` | 21 | `pad`、`blue-note`、`yellow-notes`、`chart`、`chart-2`、`pencil`、`pen`、`bag`、`rocket`、`fire`、`gold`、`light`、`pin`、`red-flag`、`tea`、`island`、`ball`、`lucky-fish`、`coffee`、`milky-tea`、`pan` |
| `priority` | 7 | `priority-1`、`priority-2`、`priority-3`、`priority-4`、`priority-5`、`priority-6`、`priority-7` |
| `task` | 7 | `task-start`、`task-oct`、`task-3oct`、`task-half`、`task-5oct`、`task-7oct`、`task-done` |
注意:`emoji/face-plam` 是 V1 保留的历史兼容枚举值,拼写虽然异常但属于协议值;
传入 `emoji/face-palm` 会被拒绝。
@@ -0,0 +1,308 @@
# 钉钉白板常用 Recipes
以下各 Recipe 的 JSON 都写入本地文件,再通过 `dws whiteboard update --source <FILE.json>`
传给白板。执行任何远端写入前,必须先向用户展示影响并取得明确确认;确认后
才可添加 `--yes`:
```bash
dws whiteboard update \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--source <FILE.json> \
--yes \
--format json
```
每次更新后都要再次执行 `dws whiteboard query ... --format json` 回读验证。
## 1. 追加两个流程节点和一条箭头
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "start",
"type": "shape",
"x": 80,
"y": 100,
"width": 160,
"height": 72,
"geometry": "dml:roundRect",
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "center",
"runs": [{ "text": "读取需求", "marks": { "bold": true } }]
}
],
"verticalAlign": "center"
}
},
{
"id": "finish",
"type": "shape",
"x": 360,
"y": 100,
"width": 160,
"height": 72,
"geometry": "dml:roundRect",
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "center",
"runs": [{ "text": "输出结果", "marks": { "bold": true } }]
}
],
"verticalAlign": "center"
}
},
{
"type": "connector",
"start": {
"type": "node",
"nodeRef": { "scope": "request", "id": "start" },
"anchor": { "mode": "fixed", "side": "right" }
},
"end": {
"type": "node",
"nodeRef": { "scope": "request", "id": "finish" },
"anchor": { "mode": "fixed", "side": "left" },
"marker": { "catalogId": "arrow.filled" }
},
"routing": "straight"
}
]
}
}
```
## 2. 追加带渐变和阴影的卡片
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "styled-card",
"type": "shape",
"x": 80,
"y": 260,
"width": 260,
"height": 120,
"geometry": "dml:roundRect",
"style": {
"fill": {
"type": "linearGradient",
"angle": 35,
"stops": [
{ "offset": 0, "color": "#DBEAFE" },
{ "offset": 100, "color": "#A7F3D0" }
]
},
"stroke": {
"paint": { "type": "solid", "color": "#2563EB" },
"width": 2
},
"effects": [
{
"type": "shadow",
"offsetX": 5,
"offsetY": 7,
"blur": 18,
"color": "#0F172A",
"opacity": 0.22
}
]
},
"text": {
"blocks": [
{
"type": "paragraph",
"horizontalAlign": "center",
"runs": [
{
"text": "复杂样式",
"marks": { "fontSize": 20, "bold": true, "color": "#0F172A" }
}
]
}
],
"verticalAlign": "center"
}
}
]
}
}
```
## 3. Frame 中放置分支流程
先创建 frame,再让子节点通过 `parentId` 引用 frame 的临时 ID。子节点坐标相对
frame 左上角:
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "pipeline",
"type": "frame",
"x": 60,
"y": 440,
"width": 720,
"height": 300,
"title": {
"text": {
"blocks": [
{
"type": "paragraph",
"runs": [{ "text": "生成流水线", "marks": { "bold": true } }]
}
]
}
}
},
{
"id": "branch-a",
"type": "shape",
"parentId": "pipeline",
"x": 60,
"y": 80,
"width": 180,
"height": 72,
"geometry": "dml:rect"
},
{
"id": "branch-b",
"type": "shape",
"parentId": "pipeline",
"x": 420,
"y": 80,
"width": 180,
"height": 72,
"geometry": "dml:rect"
}
]
}
}
```
## 4. 上传 SVG 并追加 Vector
Vector 固定使用“上传 → 字段映射 → update → query”流程,并且所有命令使用同一个
`DOC_NODE_ID`。
先上传 SVG。该命令只准备资源,不会插入文档正文:
```bash
dws doc media upload \
--node <DOC_NODE_ID> \
--file ./icon.svg \
--mime-type image/svg+xml \
--yes \
--format json
```
从成功输出取 `resourceId` 和 `resourceUrl`:
```json
{
"nodeId": "<DOC_NODE_ID>",
"resourceId": "resource-stable-id",
"resourceUrl": "https://resource.example/resource-stable-id?resourceId=resource-stable-id",
"fileName": "icon.svg",
"mimeType": "image/svg+xml",
"size": 1024
}
```
写入 `whiteboard-vector.json`,其中 `resourceId` 原样映射到
`resource.resourceId`,`resourceUrl` 映射到 `resource.url`:
```json
{
"overwrite": false,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": [
{
"id": "uploaded-vector",
"type": "vector",
"x": 80,
"y": 80,
"width": 160,
"height": 160,
"resource": {
"kind": "managed",
"resourceId": "resource-stable-id",
"url": "https://resource.example/resource-stable-id?resourceId=resource-stable-id"
}
}
]
}
}
```
执行更新:
```bash
dws whiteboard update \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--source ./whiteboard-vector.json \
--yes \
--format json
```
最后独立回读,不以 update 的成功响应替代验证:
```bash
dws whiteboard query \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--format json
```
禁止跨 nodeId 复用资源,也不要把本地 SVG 路径、独立文件节点 URL 或临时
`uploadUrl` 写入 `resource.url`。
## 5. 整页替换或清空
把文件设为 `overwrite: true` 后,命令必须加 `--yes`:
```bash
dws whiteboard update \
--node <DOC_NODE_ID> \
--part-id <WHITEBOARD_PART_ID> \
--source ./overwrite.json \
--yes \
--format json
```
清空整页:
```json
{
"overwrite": true,
"source": {
"schemaVersion": "1.0",
"catalogVersion": "dml-v1",
"nodes": []
}
}
```
仅当用户明确要求清空时使用。执行前必须 query、展示影响摘要并获得确认。
+35 -3
View File
@@ -1,7 +1,7 @@
[root]
runnable: true
hidden: false
commands: agoal, aisearch, aitable, api, attendance, audit, auth, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, report, schema, sheet, skill, todo, upgrade, version, wiki
commands: agoal, aisearch, aitable, api, attendance, audit, auth, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, report, schema, sheet, skill, todo, upgrade, version, whiteboard, wiki
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=persistent, --client-secret:string|required=false|hidden=false|no-opt=""|scope=persistent, --debug:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --fields:string|required=false|hidden=false|no-opt=""|scope=persistent, -f/--format:string|required=false|hidden=false|no-opt=""|scope=persistent, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=persistent, --mock:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -o/--output:string|required=false|hidden=true|no-opt=""|scope=persistent, --profile:string|required=false|hidden=false|no-opt=""|scope=persistent, --timeout:int|required=false|hidden=false|no-opt=""|scope=persistent, --token:string|required=false|hidden=true|no-opt=""|scope=persistent, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=persistent
[agoal]
@@ -3247,7 +3247,7 @@
[doc]
runnable: true
hidden: false
commands: +comment-create, +comment-list, +comment-reply, +copy, +doc-append, +export-get, +export-submit, +find-doc, +list, +move, +search, +share-doc, +template-list, +template-search, +version-list, +version-revert, +version-save, block, comment, create, export, file, import, info, media, read, template, update, version
commands: +comment-create, +comment-list, +comment-reply, +copy, +doc-append, +export-get, +export-submit, +find-doc, +list, +move, +search, +share-doc, +template-list, +template-search, +version-list, +version-revert, +version-save, block, comment, create, export, file, import, info, media, read, template, update, version, whiteboard
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[doc.+comment-create]
@@ -3443,7 +3443,7 @@
[doc.media]
runnable: true
hidden: false
commands: download, insert
commands: download, insert, upload
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[doc.media.download]
@@ -3456,6 +3456,11 @@
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --doc-id:string|required=false|hidden=true|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, --file:string|required=false|hidden=false|no-opt=""|scope=local, --file-id:string|required=false|hidden=true|no-opt=""|scope=local, --file-path:string|required=false|hidden=true|no-opt=""|scope=local, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --index:int|required=false|hidden=false|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mime-type:string|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --name:string|required=false|hidden=false|no-opt=""|scope=local, --node:string|required=false|hidden=false|no-opt=""|scope=local, --node-id:string|required=false|hidden=true|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --ref-block:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --url:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --where:string|required=false|hidden=false|no-opt=""|scope=local, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[doc.media.upload]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --doc-id:string|required=false|hidden=true|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, --file:string|required=false|hidden=false|no-opt=""|scope=local, --file-id:string|required=false|hidden=true|no-opt=""|scope=local, --file-path:string|required=false|hidden=true|no-opt=""|scope=local, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mime-type:string|required=false|hidden=false|no-opt=""|scope=local, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --name:string|required=false|hidden=false|no-opt=""|scope=local, --node:string|required=false|hidden=false|no-opt=""|scope=local, --node-id:string|required=false|hidden=true|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --url:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --yes:bool|required=false|hidden=false|no-opt="true"|scope=local
[doc.read]
runnable: true
hidden: false
@@ -3509,6 +3514,17 @@
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --doc-id:string|required=false|hidden=true|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, --file-id:string|required=false|hidden=true|no-opt=""|scope=local, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --node:string|required=false|hidden=false|no-opt=""|scope=local, --node-id:string|required=false|hidden=true|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --url:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[doc.whiteboard]
runnable: true
hidden: false
commands: insert
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[doc.whiteboard.insert]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --doc-id:string|required=false|hidden=true|no-opt=""|scope=local, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, --file-id:string|required=false|hidden=true|no-opt=""|scope=local, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --id:string|required=false|hidden=true|no-opt=""|scope=local, --index:int|required=false|hidden=false|no-opt=""|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --node:string|required=false|hidden=false|no-opt=""|scope=local, --node-id:string|required=false|hidden=true|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --parent-block:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --ref-block:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, --url:string|required=false|hidden=true|no-opt=""|scope=local, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --where:string|required=false|hidden=false|no-opt=""|scope=local, --yes:bool|required=false|hidden=false|no-opt="true"|scope=local
[doctor]
runnable: true
hidden: false
@@ -5678,6 +5694,22 @@
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[whiteboard]
runnable: true
hidden: false
commands: query, update
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[whiteboard.query]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --node:string|required=false|hidden=false|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --part-id:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=inherited
[whiteboard.update]
runnable: true
hidden: false
flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=inherited, --client-secret:string|required=false|hidden=false|no-opt=""|scope=inherited, --debug:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --fields:string|required=false|hidden=false|no-opt=""|scope=inherited, -f/--format:string|required=false|hidden=false|no-opt=""|scope=inherited, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=inherited, --mock:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --node:string|required=false|hidden=false|no-opt=""|scope=local, -o/--output:string|required=false|hidden=true|no-opt=""|scope=inherited, --part-id:string|required=false|hidden=false|no-opt=""|scope=local, --profile:string|required=false|hidden=false|no-opt=""|scope=inherited, --source:string|required=false|hidden=false|no-opt=""|scope=local, --timeout:int|required=false|hidden=false|no-opt=""|scope=inherited, --token:string|required=false|hidden=true|no-opt=""|scope=inherited, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=inherited, --yes:bool|required=false|hidden=false|no-opt="true"|scope=local
[wiki]
runnable: true
hidden: false
+127
View File
@@ -0,0 +1,127 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package unit_test
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestWhiteboardQuickExamplesUseWritableTextNodes(t *testing.T) {
paths := []string{
"../../skills/mono/references/products/whiteboard.md",
"../../skills/multi/dingtalk-misc/references/whiteboard.md",
}
for _, path := range paths {
path := path
t.Run(filepath.Base(filepath.Dir(path))+"/"+filepath.Base(path), func(t *testing.T) {
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
payload := firstJSONFence(t, string(data))
source, _ := payload["source"].(map[string]any)
nodes, _ := source["nodes"].([]any)
if len(nodes) == 0 {
t.Fatalf("quick example has no source.nodes: %#v", payload)
}
node, _ := nodes[0].(map[string]any)
if node["type"] != "text" {
t.Fatalf("quick example first node type = %#v, want text", node["type"])
}
for _, dimension := range []string{"width", "height"} {
value, ok := node[dimension].(float64)
if !ok || value <= 0 {
t.Errorf("quick example %s = %#v, want positive number", dimension, node[dimension])
}
}
text, ok := node["text"].(map[string]any)
if !ok {
t.Fatalf("quick example text = %#v, want OpenNodes text object", node["text"])
}
blocks, _ := text["blocks"].([]any)
if len(blocks) == 0 {
t.Fatalf("quick example text.blocks = %#v, want non-empty array", text["blocks"])
}
})
}
}
func TestWhiteboardRecipesAreDeliveredToBothSkillSurfaces(t *testing.T) {
monoPath := "../../skills/mono/references/products/whiteboard/recipes.md"
multiPath := "../../skills/multi/dingtalk-misc/references/whiteboard/recipes.md"
mono, err := os.ReadFile(monoPath)
if err != nil {
t.Fatal(err)
}
multi, err := os.ReadFile(multiPath)
if err != nil {
t.Fatal(err)
}
if string(mono) != string(multi) {
t.Fatal("mono and multi whiteboard recipes differ")
}
content := string(mono)
for _, required := range []string{
"## 1. 追加两个流程节点和一条箭头",
"## 2. 追加带渐变和阴影的卡片",
"## 3. Frame 中放置分支流程",
"## 4. 上传 SVG 并追加 Vector",
"## 5. 整页替换或清空",
"执行任何远端写入前,必须先向用户展示影响并取得明确确认",
`"resourceUrl": "https://resource.example/resource-stable-id?resourceId=resource-stable-id"`,
`"url": "https://resource.example/resource-stable-id?resourceId=resource-stable-id"`,
} {
if !strings.Contains(content, required) {
t.Errorf("whiteboard recipes missing %q", required)
}
}
for _, fence := range bashFences(content) {
if strings.Contains(fence, "dws whiteboard update") || strings.Contains(fence, "dws doc media upload") {
if !strings.Contains(fence, "--yes") {
t.Errorf("whiteboard write example lacks --yes:\n%s", fence)
}
}
}
}
func bashFences(markdown string) []string {
const marker = "```bash\n"
var fences []string
for {
start := strings.Index(markdown, marker)
if start < 0 {
return fences
}
markdown = markdown[start+len(marker):]
end := strings.Index(markdown, "\n```")
if end < 0 {
return fences
}
fences = append(fences, markdown[:end])
markdown = markdown[end+len("\n```"):]
}
}
func firstJSONFence(t *testing.T, markdown string) map[string]any {
t.Helper()
const marker = "```json\n"
start := strings.Index(markdown, marker)
if start < 0 {
t.Fatal("markdown has no JSON fence")
}
start += len(marker)
end := strings.Index(markdown[start:], "\n```")
if end < 0 {
t.Fatal("JSON fence is not closed")
}
var payload map[string]any
if err := json.Unmarshal([]byte(markdown[start:start+end]), &payload); err != nil {
t.Fatalf("decode first JSON fence: %v", err)
}
return payload
}