Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ea5acc2a3 | ||
|
|
5e1bac51e5 | ||
|
|
65ce71b8d4 | ||
|
|
6bfcac4d54 | ||
|
|
f819566c63 | ||
|
|
10d9aa3058 | ||
|
|
a0ca1fdb28 | ||
|
|
5a93f80daa | ||
|
|
8260cf7f53 | ||
|
|
58dfbc5b6e |
@@ -6,42 +6,6 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.57] - 2026-08-06
|
||||
|
||||
This stable release promotes the fully delivered `v1.0.57-beta.3` baseline.
|
||||
It includes reviewed document shortcuts and chat reply mentions, together with
|
||||
the v1.0.57 beta-line command-contract, document, chat, OA, Wiki, compatibility,
|
||||
and CI reliability improvements validated through the prerelease channel.
|
||||
|
||||
- **Promote v1.0.57-beta.3** — publishes the validated prerelease baseline as
|
||||
the stable `v1.0.57` release without adding post-beta product changes.
|
||||
|
||||
## [1.0.57-beta.3] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Reviewed document shortcuts** (#880) — adds public document shortcuts for
|
||||
safe local downloads, content and history, review, media and style, and
|
||||
document access/sharing workflows, while retaining reviewed compatibility
|
||||
identities and confirmation safeguards for writes.
|
||||
- **Mentions in chat replies** (#881) — `chat message reply` now supports
|
||||
`--at-open-dingtalk-ids` and `--at-all`, forwarding reply mention fields and
|
||||
adding any required mention placeholders without changing existing send
|
||||
behavior.
|
||||
|
||||
## [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
|
||||
@@ -95,7 +59,6 @@ and compatibility and CI reliability fixes.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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.57-beta.3"
|
||||
version "1.0.56-beta.4"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "b1ea300a76654751ea33540d8a244b0c81b5df3947786980f95a5a19362a097a"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "68b5f6e38bec994fa4db4bef5db1629c7bce799bb9837c447008c3325fc886e3"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "350e74f1a2611975e476e113e50264975a98185c11ee889a83d9480c0f10181b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "2c27a9a884650a7a60545d9447f1b966667216e94c333ebf9d69f9b2ead96e04"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.3/dws-skills.zip"
|
||||
sha256 "abaa8feaa3c61fff048cfd1139e1fc5b91c329eb666d2eb852797a3f5c4c0cac"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.4/dws-skills.zip"
|
||||
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-05T22:43:52.497190",
|
||||
"count": 294,
|
||||
"generated_at": "2026-07-29T00:06:19.285348",
|
||||
"count": 265,
|
||||
"results": [
|
||||
{
|
||||
"suite": "read",
|
||||
@@ -485,7 +485,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性;可选对资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -658,16 +658,6 @@
|
||||
"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",
|
||||
@@ -725,7 +715,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一群聊与两类单聊目标;省略时间时自动以当前时间向前读取最近消息,并输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文;可选对列表内资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1225,7 +1215,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 统一下载 mediaId 与 fileId,复用受信任下载域、相对路径、无覆盖和原子落盘防护,对重复资源去重并逐资源返回下载失败 ledger;安全本地下载沿用 read/not_required 契约,不产生非交互确认盲区。",
|
||||
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 复用 HTTPS、相对路径、无覆盖和原子落盘防护,逐资源返回下载失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1305,7 +1295,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一承接消息 mediaId 与钉盘 fileId:分别复用 IM 临时资源 URL 和 drive.download_file,只允许钉钉/OSS HTTPS 下载域且重定向复验并隔离跨域凭据,再通过工作目录内安全路径、默认不覆盖、临时文件下载和原子发布输出结构化结果。",
|
||||
"semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1325,7 +1315,7 @@
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 发送;current-user 支持文本、Markdown、mediaId 图片、安全相对路径本地文件上传、userId 姓名解析与幂等键,bot/webhook 仍只暴露下层真实支持的文本/Markdown 能力。",
|
||||
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 文本/Markdown 发送;按身份校验目标与凭据,幂等键只在真实支持的 user 分支开放,媒体上传仍诚实留在 native leaf。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1354,8 +1344,8 @@
|
||||
"command": "+messages-send-card",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "既可只创建流式卡片,也可在一次调用中创建、提取 bizId、写入内容并设置流式状态;dry-run 输出两步执行计划,更新失败时保留已创建的 bizId。",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1425,7 +1415,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;展开下层按会话分组的 conversationMessagesList,支持精确时间窗、page-all、50 条一组 mget 富化,可选安全下载命中消息资源,并以 failure ledger 显式报告截断、富化或下载失败。",
|
||||
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1445,7 +1435,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1718,454 +1708,123 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"suite": "write",
|
||||
"service": "doc",
|
||||
"command": "+comment-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "无 selection 创建全文评论,有 selection 时定位文本并创建划词评论。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "永久删除指定评论,并由静态安全契约强制确认。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "doc",
|
||||
"command": "+comment-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一评论类型、解决状态与分页过滤。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "write",
|
||||
"service": "doc",
|
||||
"command": "+comment-reply",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一评论回复、表情回复和 mention 参数。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "更新指定评论正文与 mention。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "write",
|
||||
"service": "doc",
|
||||
"command": "+copy",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "旧 Doc 复制入口,仅为兼容保留;新的文件复制应使用 Drive 命令。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"suite": "write",
|
||||
"service": "doc",
|
||||
"command": "+doc-append",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+export",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "doc",
|
||||
"command": "+export-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "doc",
|
||||
"command": "+export-submit",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "导出中断恢复所需的专家入口;常规场景使用一体化 +export。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"suite": "read",
|
||||
"service": "doc",
|
||||
"command": "+find-doc",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"suite": "read",
|
||||
"service": "doc",
|
||||
"command": "+list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"suite": "write",
|
||||
"service": "doc",
|
||||
"command": "+move",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"suite": "read",
|
||||
"service": "doc",
|
||||
"command": "+search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一关键词、最近访问、过滤、分页和稳定精简投影,作为文档定位的 canonical 入口。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+share",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析唯一用户后发送文档链接,不改变文档权限。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "write",
|
||||
"service": "doc",
|
||||
"command": "+share-doc",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "doc",
|
||||
"command": "+template-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 MY/PUBLIC 模板浏览和分页参数。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "doc",
|
||||
"command": "+template-search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称检索模板并返回可继续创建的 templateId。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "doc",
|
||||
"command": "+version-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "write",
|
||||
"service": "doc",
|
||||
"command": "+version-revert",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "write",
|
||||
"service": "doc",
|
||||
"command": "+version-save",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
|
||||
@@ -72,39 +72,6 @@ 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 {
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 294
|
||||
publicShortcutCount = 266
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||
schemaPublishedShortcutCount = 295
|
||||
schemaPublishedShortcutCount = 267
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 294
|
||||
publiclyDeliveredShortcutCount = 266
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -194,9 +194,17 @@ func assertDeliveryShortcutSafetyAndInterface(
|
||||
canonical string,
|
||||
) {
|
||||
t.Helper()
|
||||
safety := shortcut.EffectiveSafety(declared)
|
||||
wantEffect, wantRisk := safety.Effect, safety.Risk
|
||||
wantConfirmation, wantIdempotency := safety.Confirmation, safety.Idempotency
|
||||
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"
|
||||
}
|
||||
for field, want := range map[string]string{
|
||||
"effect": wantEffect,
|
||||
"risk": wantRisk,
|
||||
@@ -226,15 +234,6 @@ 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 {
|
||||
@@ -300,13 +299,6 @@ 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 {
|
||||
|
||||
@@ -448,6 +448,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"drive.download_file --output": "local output path",
|
||||
"drive.download_file --parallel": "local multipart download control; never sent to download_file",
|
||||
"drive.download_file --part-size": "local multipart download control; never sent to download_file",
|
||||
"drive.download_file --version": "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; not a download_file interface property",
|
||||
"drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI-local multipart download control and does not publish a direct interface property.",
|
||||
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -655,7 +656,6 @@ var reviewedSchemaParameterBindingRemovals = map[string]schemaParameterBindingRe
|
||||
"contact.get_dept_info_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"contact.get_dept_members_by_deptId --ids": {Reason: "The public flag was renamed from --ids to the unambiguous --depts spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"contact.get_sub_depts_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"drive.download_file --version": {Reason: "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; the version property belongs to download_file_version metadata, not download_file.", Reviewed: true},
|
||||
"minutes.query_user_tag_list --limit": {Reason: "The current helper and pinned interface have no pagination input.", Reviewed: true},
|
||||
"oa.list_pending_approvals --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"oa.list_user_visible_process --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
|
||||
@@ -230,7 +230,7 @@ func newCalendarCommand() *cobra.Command {
|
||||
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
|
||||
|
||||
命令结构:
|
||||
dws calendar event [list|get|create|update|delete|suggest|respond] 日程管理
|
||||
dws calendar event [list|get|create|update|delete|suggest|respond|instances] 日程管理
|
||||
dws calendar attendee [list|add|delete] 参会人管理
|
||||
dws calendar room [search|add|delete|list-groups] 会议室管理
|
||||
dws calendar busy search 闲忙查询 (可查人、查会议室)
|
||||
@@ -2082,7 +2082,184 @@ func newCalendarCommand() *cobra.Command {
|
||||
eventSuggestCmd.Flags().String("members", "", "")
|
||||
_ = eventSuggestCmd.Flags().MarkHidden("members")
|
||||
eventSuggestCmd.Flags().String("duration", "", "日程持续时间 (分钟,默认30)")
|
||||
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd)
|
||||
|
||||
eventInstancesCmd := &cobra.Command{
|
||||
Use: "instances",
|
||||
Short: "查询循环日程的实例列表",
|
||||
Long: `查询指定重复性日程(SeriesMaster)在指定时间范围内的所有实例。
|
||||
**注意**:此接口只能查询重复性日程的实例;若传入的是普通非循环日程,将查不到任何实例信息。
|
||||
必须传入 --id 指定重复性日程的 eventId(即 SeriesMaster 的 eventId,可通过 event list 获取)。
|
||||
不传 --start/--end 时,默认查询今天(00:00:00 ~ 23:59:59)的实例。`,
|
||||
Example: ` dws calendar event instances --id EVENT_ID
|
||||
dws calendar event instances --id EVENT_ID --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00"
|
||||
dws calendar event instances --id EVENT_ID --limit 50
|
||||
dws calendar event instances --id EVENT_ID --cursor "<nextCursor>"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
eventID, err := mustFlagOrFallback(cmd, "id", "event", "event-id", "eventId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"eventId": eventID}
|
||||
var startTime, endTime int64
|
||||
var now time.Time
|
||||
if v := flagOrFallback(cmd, "start", "time-min", "min-time", "start-time", "startTime", "start_time", "start-date", "startDate"); v != "" {
|
||||
startTime, err = parseISOTimeToMillis("start", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["startTime"] = startTime
|
||||
} else {
|
||||
now = time.Now()
|
||||
startTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
|
||||
toolArgs["startTime"] = startTime
|
||||
}
|
||||
if v := flagOrFallback(cmd, "end", "time-max", "max-time", "end-time", "endTime", "end_time", "end-date", "endDate"); v != "" {
|
||||
endTime, err = parseISOTimeToMillis("end", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["endTime"] = endTime
|
||||
} else {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
endTime = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
|
||||
toolArgs["endTime"] = endTime
|
||||
}
|
||||
if err := validateTimeRange(startTime, endTime); err != nil {
|
||||
return err
|
||||
}
|
||||
if v := flagOrFallback(cmd, "calendar-id", "calendarId", "calendar"); v != "" {
|
||||
toolArgs["calendarId"] = v
|
||||
}
|
||||
if v := flagOrFallback(cmd, "cursor", "next-cursor", "nextCursor", "page-token", "pageToken", "next-token"); v != "" {
|
||||
toolArgs["cursor"] = v
|
||||
}
|
||||
if lim, _ := cmd.Flags().GetInt("limit"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("max-results"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("maxResults"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("page-size"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("size"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("count"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
}
|
||||
return callSortedCalendarEvents(cmd, "list_event_instances", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(eventInstancesCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "calendar",
|
||||
Name: "list_event_instances",
|
||||
CanonicalPath: "calendar.list_event_instances",
|
||||
CLIPath: "calendar event instances",
|
||||
PrimaryCLIPath: "calendar event instances",
|
||||
},
|
||||
Description: "查询循环日程的实例列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "calendar", RPCName: "list_event_instances"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询循环日程在时间范围内展开的实例",
|
||||
UseWhen: []string{"已知循环日程 eventId(SeriesMaster),需要列出某时间窗内的实例时"},
|
||||
AvoidWhen: []string{
|
||||
"普通非循环日程请用 dws calendar event get / list",
|
||||
"未知 eventId 时先 dws calendar event list",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws calendar event instances --id <EVENT_ID>",
|
||||
"dws calendar event instances --id <EVENT_ID> --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-31T23:59:59+08:00\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "id", Property: "eventId", Required: boolPtr(true)},
|
||||
{Name: "start", Property: "startTime"},
|
||||
{Name: "end", Property: "endTime"},
|
||||
{Name: "calendar-id", Property: "calendarId"},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
{Name: "limit", Property: "limit", InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// InstancesEvent flags (aligned with event list aliases)
|
||||
eventInstancesCmd.Flags().String("id", "", "日程 ID (必填)")
|
||||
eventInstancesCmd.Flags().String("event", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("event")
|
||||
eventInstancesCmd.Flags().String("event-id", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("event-id")
|
||||
eventInstancesCmd.Flags().String("eventId", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("eventId")
|
||||
eventInstancesCmd.Flags().String("start", "", "开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00)")
|
||||
eventInstancesCmd.Flags().String("time-min", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("time-min")
|
||||
eventInstancesCmd.Flags().String("min-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("min-time")
|
||||
eventInstancesCmd.Flags().String("start-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("start-time")
|
||||
eventInstancesCmd.Flags().String("startTime", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("startTime")
|
||||
eventInstancesCmd.Flags().String("start_time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("start_time")
|
||||
eventInstancesCmd.Flags().String("start-date", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("start-date")
|
||||
eventInstancesCmd.Flags().String("startDate", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("startDate")
|
||||
eventInstancesCmd.Flags().String("end", "", "结束时间 ISO-8601 (例如 2026-03-31T23:59:59+08:00)")
|
||||
eventInstancesCmd.Flags().String("time-max", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("time-max")
|
||||
eventInstancesCmd.Flags().String("max-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("max-time")
|
||||
eventInstancesCmd.Flags().String("end-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("end-time")
|
||||
eventInstancesCmd.Flags().String("endTime", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("endTime")
|
||||
eventInstancesCmd.Flags().String("end_time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("end_time")
|
||||
eventInstancesCmd.Flags().String("end-date", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("end-date")
|
||||
eventInstancesCmd.Flags().String("endDate", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("endDate")
|
||||
eventInstancesCmd.Flags().String("calendar-id", "", "日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 book list 获取)")
|
||||
eventInstancesCmd.Flags().String("calendarId", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("calendarId")
|
||||
eventInstancesCmd.Flags().String("calendar", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("calendar")
|
||||
eventInstancesCmd.Flags().String("cursor", "", "分页游标 (首次查询无需传入,仅翻页时传入上一次返回的 nextCursor)")
|
||||
eventInstancesCmd.Flags().String("next-cursor", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("next-cursor")
|
||||
eventInstancesCmd.Flags().String("nextCursor", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("nextCursor")
|
||||
eventInstancesCmd.Flags().String("page-token", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("page-token")
|
||||
eventInstancesCmd.Flags().String("pageToken", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("pageToken")
|
||||
eventInstancesCmd.Flags().String("next-token", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("next-token")
|
||||
eventInstancesCmd.Flags().Int("limit", 0, "每页返回条数 (默认 100,最大 100)")
|
||||
eventInstancesCmd.Flags().Int("max-results", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("max-results")
|
||||
eventInstancesCmd.Flags().Int("maxResults", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("maxResults")
|
||||
eventInstancesCmd.Flags().Int("page-size", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("page-size")
|
||||
eventInstancesCmd.Flags().Int("size", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("size")
|
||||
eventInstancesCmd.Flags().Int("count", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("count")
|
||||
|
||||
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd)
|
||||
|
||||
// participant
|
||||
participantCmd.PersistentFlags().String("event", "", "日程 ID (必填)")
|
||||
|
||||
+27
-85
@@ -54,14 +54,6 @@ 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) {
|
||||
@@ -404,50 +396,6 @@ 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)
|
||||
@@ -2081,15 +2029,29 @@ 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,
|
||||
}
|
||||
text = applyCurrentUserGroupMentions(newParams, text, atOpenIdsStr, atAll)
|
||||
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
|
||||
newParams["content"] = string(contentJSON)
|
||||
if atAll {
|
||||
newParams["atAll"] = true
|
||||
}
|
||||
if len(atOpenIds) > 0 {
|
||||
newParams["atOpenDingTalkIds"] = atOpenIds
|
||||
}
|
||||
if msgUuid != "" {
|
||||
newParams["uuid"] = msgUuid
|
||||
}
|
||||
@@ -5406,14 +5368,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
chatMessageReplyCmd := &cobra.Command{
|
||||
Use: "reply",
|
||||
Short: "引用回复消息(支持单聊/群聊)",
|
||||
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。群聊回复可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人;正文中的裸 @openDingTalkId 会自动规范化为 <@openDingTalkId>,缺少对应成员或 <@all> 占位符时会自动补齐。
|
||||
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。
|
||||
|
||||
如何获取 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 "收到,马上处理"
|
||||
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
|
||||
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
|
||||
return err
|
||||
@@ -5426,6 +5387,13 @@ 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 {
|
||||
@@ -5434,25 +5402,9 @@ 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
|
||||
}
|
||||
@@ -5486,8 +5438,6 @@ 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"},
|
||||
},
|
||||
},
|
||||
@@ -5502,8 +5452,6 @@ 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: 转发单条消息 ────────────────────────
|
||||
@@ -8218,11 +8166,5 @@ 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
|
||||
}
|
||||
|
||||
@@ -81,33 +81,24 @@ func TestCrossPlatformCoverageEvaluationRegressionChatSearchSpellingsAndNaturalB
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
func TestCrossPlatformCoverageChatMisroutedPathsRemainUnknownSubcommands(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
args []string
|
||||
hint string
|
||||
flag string
|
||||
}{
|
||||
{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>"},
|
||||
{path: "send", flag: "--group"},
|
||||
{path: "history", flag: "--group"},
|
||||
} {
|
||||
command, remaining, err := root.Find([]string{tc.path})
|
||||
if err != nil {
|
||||
t.Fatalf("find chat %s: %v", tc.path, err)
|
||||
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)
|
||||
}
|
||||
if len(remaining) != 0 || command.Name() != tc.path {
|
||||
t.Fatalf("find chat %s = command %q, remaining %v", tc.path, command.Name(), remaining)
|
||||
if strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Fatalf("chat %s was misreported as a flag error: %v", tc.path, err)
|
||||
}
|
||||
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)
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("chat %s tool calls = %d, want 0", tc.path, caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -286,150 +285,6 @@ 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")
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// Copyright 2022 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file is adapted from Go standard library's internal/diff package.
|
||||
// The original source can be found at /usr/local/go/src/internal/diff/diff.go.
|
||||
//
|
||||
// Modifications:
|
||||
// - Package changed from "diff" to "products"
|
||||
// - Diff() renamed to UnifiedDiff() and accepts a contextLines parameter
|
||||
// - const C replaced with the contextLines parameter
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A pair is a pair of values tracked for both the x and y side of a diff.
|
||||
// It is typically a pair of line indexes.
|
||||
type diffPair struct{ x, y int }
|
||||
|
||||
func nonNeg(v int) int {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// UnifiedDiff returns an anchored diff of the two texts old and new
|
||||
// in the "unified diff" format. If old and new are identical,
|
||||
// UnifiedDiff returns a nil slice (no output).
|
||||
//
|
||||
// Unix diff implementations typically look for a diff with
|
||||
// the smallest number of lines inserted and removed,
|
||||
// which can in the worst case take time quadratic in the
|
||||
// number of lines in the texts. As a result, many implementations
|
||||
// either can be made to run for a long time or cut off the search
|
||||
// after a predetermined amount of work.
|
||||
//
|
||||
// In contrast, this implementation looks for a diff with the
|
||||
// smallest number of "unique" lines inserted and removed,
|
||||
// where unique means a line that appears just once in both old and new.
|
||||
// We call this an "anchored diff" because the unique lines anchor
|
||||
// the chosen matching regions. An anchored diff is usually clearer
|
||||
// than a standard diff, because the algorithm does not try to
|
||||
// reuse unrelated blank lines or closing braces.
|
||||
// The algorithm also guarantees to run in O(n log n) time
|
||||
// instead of the standard O(n²) time.
|
||||
//
|
||||
// Some systems call this approach a "patience diff," named for
|
||||
// the "patience sorting" algorithm, itself named for a solitaire card game.
|
||||
// We avoid that name for two reasons. First, the name has been used
|
||||
// for a few different variants of the algorithm, so it is imprecise.
|
||||
// Second, the name is frequently interpreted as meaning that you have
|
||||
// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm,
|
||||
// when in fact the algorithm is faster than the standard one.
|
||||
func UnifiedDiff(oldName string, old []byte, newName string, new []byte, contextLines int) []byte {
|
||||
if bytes.Equal(old, new) {
|
||||
return nil
|
||||
}
|
||||
x := diffLines(old)
|
||||
y := diffLines(new)
|
||||
|
||||
// Print diff header.
|
||||
var out bytes.Buffer
|
||||
fmt.Fprintf(&out, "diff %s %s\n", oldName, newName)
|
||||
fmt.Fprintf(&out, "--- %s\n", oldName)
|
||||
fmt.Fprintf(&out, "+++ %s\n", newName)
|
||||
|
||||
// Loop over matches to consider,
|
||||
// expanding each match to include surrounding lines,
|
||||
// and then printing diff chunks.
|
||||
// To avoid setup/teardown cases outside the loop,
|
||||
// tgs returns a leading {0,0} and trailing {len(x), len(y)} pair
|
||||
// in the sequence of matches.
|
||||
var (
|
||||
done diffPair // printed up to x[:done.x] and y[:done.y]
|
||||
chunk diffPair // start lines of current chunk
|
||||
count diffPair // number of lines from each side in current chunk
|
||||
ctext []string // lines for current chunk
|
||||
)
|
||||
for _, m := range diffTgs(x, y) {
|
||||
if m.x < done.x {
|
||||
// Already handled scanning forward from earlier match.
|
||||
continue
|
||||
}
|
||||
|
||||
// Expand matching lines as far as possible,
|
||||
// establishing that x[start.x:end.x] == y[start.y:end.y].
|
||||
// Note that on the first (or last) iteration we may (or definitely do)
|
||||
// have an empty match: start.x==end.x and start.y==end.y.
|
||||
start := m
|
||||
for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] {
|
||||
start.x--
|
||||
start.y--
|
||||
}
|
||||
end := m
|
||||
for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] {
|
||||
end.x++
|
||||
end.y++
|
||||
}
|
||||
|
||||
// Emit the mismatched lines before start into this chunk.
|
||||
// (No effect on first sentinel iteration, when start = {0,0}.)
|
||||
for _, s := range x[done.x:start.x] {
|
||||
ctext = append(ctext, "-"+s)
|
||||
count.x++
|
||||
}
|
||||
for _, s := range y[done.y:start.y] {
|
||||
ctext = append(ctext, "+"+s)
|
||||
count.y++
|
||||
}
|
||||
|
||||
// If we're not at EOF and have too few common lines,
|
||||
// the chunk includes all the common lines and continues.
|
||||
C := contextLines
|
||||
if C < 0 {
|
||||
// 防御性兼容:负值会破坏下方区间判定,按无上下文处理;0 是合法值,直接生效
|
||||
C = 0
|
||||
}
|
||||
if (end.x < len(x) || end.y < len(y)) &&
|
||||
(end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) {
|
||||
for _, s := range x[start.x:end.x] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = end
|
||||
continue
|
||||
}
|
||||
|
||||
// End chunk with common lines for context.
|
||||
if len(ctext) > 0 {
|
||||
n := end.x - start.x
|
||||
if n > C {
|
||||
n = C
|
||||
}
|
||||
for _, s := range x[start.x : start.x+n] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = diffPair{start.x + n, start.y + n}
|
||||
|
||||
// Format and emit chunk.
|
||||
// Convert line numbers to 1-indexed.
|
||||
// Special case: empty file shows up as 0,0 not 1,0.
|
||||
if count.x > 0 {
|
||||
chunk.x++
|
||||
}
|
||||
if count.y > 0 {
|
||||
chunk.y++
|
||||
}
|
||||
fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y)
|
||||
for _, s := range ctext {
|
||||
out.WriteString(s)
|
||||
}
|
||||
count.x = 0
|
||||
count.y = 0
|
||||
ctext = ctext[:0]
|
||||
}
|
||||
|
||||
// If we reached EOF, we're done.
|
||||
if end.x >= len(x) && end.y >= len(y) {
|
||||
break
|
||||
}
|
||||
|
||||
// Otherwise start a new chunk.
|
||||
// C is clamped to >= 0 above; nonNeg saturates end-C when the next
|
||||
// hunk would start before line 0 (defensive; exercised via nonNeg tests).
|
||||
chunk = diffPair{nonNeg(end.x - C), nonNeg(end.y - C)}
|
||||
for _, s := range x[chunk.x:end.x] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = end
|
||||
}
|
||||
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// diffLines returns the lines in the file x, including newlines.
|
||||
// If the file does not end in a newline, one is supplied
|
||||
// along with a warning about the missing newline.
|
||||
func diffLines(x []byte) []string {
|
||||
l := strings.SplitAfter(string(x), "\n")
|
||||
if l[len(l)-1] == "" {
|
||||
l = l[:len(l)-1]
|
||||
} else {
|
||||
// Treat last line as having a message about the missing newline attached,
|
||||
// using the same text as BSD/GNU diff (including the leading backslash).
|
||||
l[len(l)-1] += "\n\\ No newline at end of file\n"
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// diffTgs returns the pairs of indexes of the longest common subsequence
|
||||
// of unique lines in x and y, where a unique line is one that appears
|
||||
// once in x and once in y.
|
||||
//
|
||||
// The longest common subsequence algorithm is as described in
|
||||
// Thomas G. Szymanski, "A Special Case of the Maximal Common
|
||||
// Subsequence Problem," Princeton TR #170 (January 1975),
|
||||
// available at https://research.swtch.com/tgs170.pdf.
|
||||
func diffTgs(x, y []string) []diffPair {
|
||||
// Count the number of times each string appears in a and b.
|
||||
// We only care about 0, 1, many, counted as 0, -1, -2
|
||||
// for the x side and 0, -4, -8 for the y side.
|
||||
// Using negative numbers now lets us distinguish positive line numbers later.
|
||||
m := make(map[string]int)
|
||||
for _, s := range x {
|
||||
if c := m[s]; c > -2 {
|
||||
m[s] = c - 1
|
||||
}
|
||||
}
|
||||
for _, s := range y {
|
||||
if c := m[s]; c > -8 {
|
||||
m[s] = c - 4
|
||||
}
|
||||
}
|
||||
|
||||
// Now unique strings can be identified by m[s] = -1+-4.
|
||||
//
|
||||
// Gather the indexes of those strings in x and y, building:
|
||||
// xi[i] = increasing indexes of unique strings in x.
|
||||
// yi[i] = increasing indexes of unique strings in y.
|
||||
// inv[i] = index j such that x[xi[i]] = y[yi[j]].
|
||||
var xi, yi, inv []int
|
||||
for i, s := range y {
|
||||
if m[s] == -1+-4 {
|
||||
m[s] = len(yi)
|
||||
yi = append(yi, i)
|
||||
}
|
||||
}
|
||||
for i, s := range x {
|
||||
if j, ok := m[s]; ok && j >= 0 {
|
||||
xi = append(xi, i)
|
||||
inv = append(inv, j)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Algorithm A from Szymanski's paper.
|
||||
// In those terms, A = J = inv and B = [0, n).
|
||||
// We add sentinel pairs {0,0}, and {len(x),len(y)}
|
||||
// to the returned sequence, to help the processing loop.
|
||||
J := inv
|
||||
n := len(xi)
|
||||
T := make([]int, n)
|
||||
L := make([]int, n)
|
||||
for i := range T {
|
||||
T[i] = n + 1
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
k := sort.Search(n, func(k int) bool {
|
||||
return T[k] >= J[i]
|
||||
})
|
||||
T[k] = J[i]
|
||||
L[i] = k + 1
|
||||
}
|
||||
k := 0
|
||||
for _, v := range L {
|
||||
if k < v {
|
||||
k = v
|
||||
}
|
||||
}
|
||||
seq := make([]diffPair, 2+k)
|
||||
seq[1+k] = diffPair{len(x), len(y)} // sentinel at end
|
||||
lastj := n
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if L[i] == k && J[i] < lastj {
|
||||
seq[k] = diffPair{xi[i], yi[J[i]]}
|
||||
k--
|
||||
}
|
||||
}
|
||||
seq[0] = diffPair{0, 0} // sentinel at start
|
||||
return seq
|
||||
}
|
||||
+11
-4
@@ -3657,11 +3657,13 @@ CLI 内部自动完成全部流程:
|
||||
PROCESSING 处理中
|
||||
SUCCESS 导出成功,返回 downloadUrl
|
||||
FAILED 导出失败`,
|
||||
Example: ` dws doc export get --job-id <JOB_ID>`,
|
||||
Example: ` dws doc export get --job-id <JOB_ID>
|
||||
dws doc export get --task-id <TASK_ID>`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
jobID := mustGetFlag(cmd, "job-id")
|
||||
if jobID == "" {
|
||||
return fmt.Errorf("flag --job-id is required")
|
||||
// Keep --job-id as the visible primary; --task-id is an add-only synonym.
|
||||
jobID, err := mustFlagOrFallback(cmd, "job-id", "task-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
@@ -3728,9 +3730,14 @@ CLI 内部自动完成全部流程:
|
||||
AvoidWhen: []string{"常规导出请直接 dws doc export(一体化提交+轮询+下载),不要先查 job"},
|
||||
Examples: []string{"dws doc export get --job-id <JOB_ID> --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "job-id", Property: "jobId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
exportGetCmd.Flags().String("job-id", "", "导出任务 ID (必填)")
|
||||
exportGetCmd.Flags().String("task-id", "", "--job-id 的别名")
|
||||
_ = exportGetCmd.Flags().MarkHidden("task-id")
|
||||
|
||||
// --node 的隐藏别名(与 doc 下其他命令保持一致)
|
||||
exportCmd.Flags().String("url", "", "--node 的别名")
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -316,12 +316,24 @@ func newDriveCommand() *cobra.Command {
|
||||
Example: ` dws drive list --limit 20
|
||||
dws drive list --folder <dentryUuid> --order-by name --order asc
|
||||
dws drive list --workspace <workspaceId>
|
||||
dws drive list --workspace <workspaceId> --folder <folderId>`,
|
||||
dws drive list --workspace <workspaceId> --folder <folderId>
|
||||
dws drive list --latest 5
|
||||
dws drive list --folder <dentryUuid> --latest 3 --pattern "*.docx"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pattern, _ := cmd.Flags().GetString("pattern")
|
||||
|
||||
depth, _ := cmd.Flags().GetInt("depth")
|
||||
|
||||
latest, _ := cmd.Flags().GetInt("latest")
|
||||
if cmd.Flags().Changed("latest") {
|
||||
if err := validateDriveListLatest(cmd, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Flags().Changed("versions") {
|
||||
return &CLIError{Code: CodeInvalidParam, Message: "--latest 不能与 --versions 同时使用"}
|
||||
}
|
||||
}
|
||||
|
||||
// --versions 模式:列出文件历史版本(仅普通文件)
|
||||
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
|
||||
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
|
||||
@@ -363,7 +375,7 @@ func newDriveCommand() *cobra.Command {
|
||||
if workspaceID != "" {
|
||||
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
|
||||
// 知识库无对应参数,静默忽略。
|
||||
if depth > 1 {
|
||||
if depth > 1 || latest > 0 {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{"workspaceId": workspaceID}
|
||||
rootFolder := docFolderFlag(cmd, "node", "file-id")
|
||||
@@ -372,7 +384,7 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
|
||||
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
|
||||
}
|
||||
if pattern != "" {
|
||||
return &CLIError{
|
||||
@@ -417,10 +429,27 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
|
||||
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
|
||||
}
|
||||
|
||||
// 默认路由:钉盘文件列表
|
||||
if latest > 0 {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{}
|
||||
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
|
||||
baseArgs["spaceId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
|
||||
baseArgs["withThumbnail"] = true
|
||||
}
|
||||
rootFolder := flagOrFallback(cmd, "folder", "parent-id")
|
||||
if rootFolder != "" {
|
||||
if err := validateDriveParentID(rootFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListLatest(cmd, baseArgs, rootFolder, latest, pattern, quiet)
|
||||
}
|
||||
maxResults, _ := cmd.Flags().GetInt("limit")
|
||||
if !cmd.Flags().Changed("limit") {
|
||||
if v, _ := cmd.Flags().GetInt("max"); v > 0 {
|
||||
@@ -491,7 +520,7 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
Examples: []string{
|
||||
"dws drive list --limit 20 --format json",
|
||||
"dws drive list --folder <dentryUuid> --limit 20 --format json",
|
||||
"dws drive list --latest 5 --format json",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -701,6 +730,8 @@ func newDriveCommand() *cobra.Command {
|
||||
{Name: "part-size", Description: "分片下载的分片大小(如 8MB/16MB/1GB)"},
|
||||
{Name: "parallel", Description: "分片下载并发数(1-8)"},
|
||||
{Name: "no-resume", Description: "关闭断点续传"},
|
||||
// Wukong compat alias: routes to download-version; not a download_file property.
|
||||
{Name: "version", Description: "下载指定历史版本号(兼容别名,等价 download-version)"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1052,7 +1083,8 @@ func newDriveCommand() *cobra.Command {
|
||||
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
|
||||
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
|
||||
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
|
||||
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (仅 --depth>1 时有效) (可选)")
|
||||
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
|
||||
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
|
||||
|
||||
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
|
||||
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
|
||||
@@ -1060,6 +1092,7 @@ func newDriveCommand() *cobra.Command {
|
||||
driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
|
||||
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
|
||||
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
|
||||
driveDownloadCmd.Flags().Int("version", 0, "下载指定历史版本号(兼容别名,等价 download-version)")
|
||||
driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
|
||||
driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
|
||||
driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
|
||||
@@ -1074,6 +1107,14 @@ func newDriveCommand() *cobra.Command {
|
||||
driveDownloadVersionCmd.Flags().String(alias, "", "")
|
||||
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
// Wukong compat: `drive download --version N` routes to download-version.
|
||||
origDriveDownloadRunE := driveDownloadCmd.RunE
|
||||
driveDownloadCmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if cmd.Flags().Changed("version") {
|
||||
return driveDownloadVersionCmd.RunE(cmd, args)
|
||||
}
|
||||
return origDriveDownloadRunE(cmd, args)
|
||||
}
|
||||
|
||||
driveMkdirCmd.Flags().String("name", "", "文件夹名称,最长 50 字符 (必填)")
|
||||
driveMkdirCmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
|
||||
@@ -2019,6 +2060,8 @@ func newDriveCommand() *cobra.Command {
|
||||
limit := 0
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ = cmd.Flags().GetInt("limit")
|
||||
} else if cmd.Flags().Changed("max-results") {
|
||||
limit, _ = cmd.Flags().GetInt("max-results")
|
||||
}
|
||||
if limit > 0 {
|
||||
toolArgs["maxResults"] = limit
|
||||
@@ -2070,6 +2113,8 @@ func newDriveCommand() *cobra.Command {
|
||||
})
|
||||
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||||
drivePermListCmd.Flags().Int("max-results", 0, "")
|
||||
_ = drivePermListCmd.Flags().MarkHidden("max-results")
|
||||
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||||
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
|
||||
@@ -2733,7 +2778,7 @@ func newDriveCommand() *cobra.Command {
|
||||
|
||||
// ── cross-product hidden aliases ──
|
||||
for _, cmd := range []*cobra.Command{
|
||||
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd,
|
||||
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd, driveDownloadVersionCmd,
|
||||
driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd,
|
||||
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
|
||||
driveFolderCreateCmd,
|
||||
@@ -3035,6 +3080,11 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
driveCoverCmd.Flags().String("node", "", "节点 ID (dentryUuid) 或文档 URL (必填)")
|
||||
for _, alias := range []string{"url", "id"} {
|
||||
driveCoverCmd.Flags().String(alias, "", "--node 的别名")
|
||||
_ = driveCoverCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
RegisterCrossProductAliases(driveCoverCmd)
|
||||
|
||||
// ── drive revert (回滚文件到指定历史版本) ──
|
||||
driveRevertCmd := &cobra.Command{
|
||||
@@ -3087,6 +3137,21 @@ func newDriveCommand() *cobra.Command {
|
||||
})
|
||||
driveRevertCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
|
||||
driveRevertCmd.Flags().Int("version", 0, "要回滚到的历史版本号 (必填,正整数)")
|
||||
for _, alias := range []string{"url", "id"} {
|
||||
driveRevertCmd.Flags().String(alias, "", "--node 的别名")
|
||||
_ = driveRevertCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
RegisterCrossProductAliases(driveRevertCmd)
|
||||
|
||||
for _, child := range driveStarCmd.Commands() {
|
||||
for _, alias := range []string{"url", "id"} {
|
||||
if child.Flags().Lookup(alias) == nil {
|
||||
child.Flags().String(alias, "", "--node 的别名")
|
||||
_ = child.Flags().MarkHidden(alias)
|
||||
}
|
||||
}
|
||||
RegisterCrossProductAliases(child)
|
||||
}
|
||||
|
||||
driveCmd.AddCommand(
|
||||
driveListCmd,
|
||||
|
||||
@@ -178,7 +178,7 @@ func newDocDepthRoute() driveDepthRoute {
|
||||
}
|
||||
|
||||
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
|
||||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool) error {
|
||||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return printDriveDepthDryRun(route, baseArgs, maxDepth)
|
||||
}
|
||||
@@ -209,7 +209,7 @@ func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[s
|
||||
bfs:
|
||||
for len(queue) > 0 {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
}
|
||||
folder := queue[0]
|
||||
queue = queue[1:]
|
||||
@@ -220,7 +220,7 @@ bfs:
|
||||
pages := 0
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
}
|
||||
pages++
|
||||
if pages > maxPagesPerFolder {
|
||||
@@ -233,7 +233,7 @@ bfs:
|
||||
args := route.buildArgs(baseArgs, folder.id, pageToken)
|
||||
text, err := route.fetchPage(ctx, args)
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
}
|
||||
if err != nil {
|
||||
folderErr = err
|
||||
@@ -249,6 +249,12 @@ bfs:
|
||||
item["depth"] = folder.depth + 1
|
||||
item["parentId"] = folder.id // 根级为空串
|
||||
item["rel_path"] = rel // 不保证唯一,组树以 parentId 为准
|
||||
// 时间戳归一:钉盘 modifyTime / 知识库 updateTime 统一为 sortTime(毫秒 int64)
|
||||
if ms, ok := driveItemModifiedMillis(item); ok {
|
||||
item["sortTime"] = ms
|
||||
} else {
|
||||
item["sortTime"] = int64(0)
|
||||
}
|
||||
collected = append(collected, item)
|
||||
if len(collected) >= driveDepthMaxItems {
|
||||
// 未访问目录不记 errors[](没失败只是没扫),避免 errors 数组被淹没
|
||||
@@ -291,7 +297,7 @@ bfs:
|
||||
if driveDepthUnrecoverable(folderErr) {
|
||||
// partial 照吐 stdout,错误详情走 stderr,非零退出
|
||||
errs = append(errs, newDriveDepthError(folder, folderErr))
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern); emitErr != nil {
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil {
|
||||
return emitErr
|
||||
}
|
||||
return folderErr
|
||||
@@ -324,18 +330,26 @@ bfs:
|
||||
}
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern)
|
||||
if truncated && latest > 0 {
|
||||
return &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
|
||||
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
|
||||
}
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
|
||||
}
|
||||
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern); err != nil {
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth); err != nil {
|
||||
return err
|
||||
}
|
||||
return &driveDepthCancelledError{}
|
||||
}
|
||||
|
||||
// depth>1 不输出 nextToken。
|
||||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string) error {
|
||||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int) error {
|
||||
if pattern != "" {
|
||||
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
|
||||
filtered := make([]map[string]any, 0, len(items))
|
||||
@@ -350,21 +364,28 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
ri, _ := items[i]["rel_path"].(string)
|
||||
rj, _ := items[j]["rel_path"].(string)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
|
||||
})
|
||||
if latest > 0 {
|
||||
items = applyDriveListLatest(items, latest)
|
||||
} else {
|
||||
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
ri, _ := items[i]["rel_path"].(string)
|
||||
rj, _ := items[j]["rel_path"].(string)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
|
||||
})
|
||||
}
|
||||
maxDepth := 0
|
||||
for _, item := range items {
|
||||
if d, ok := item["depth"].(int); ok && d > maxDepth {
|
||||
maxDepth = d
|
||||
}
|
||||
}
|
||||
if latest > 0 && reqDepth == 1 {
|
||||
stripDriveDepthDecorations(items)
|
||||
}
|
||||
if items == nil {
|
||||
items = []map[string]any{}
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
{"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"},
|
||||
{"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"},
|
||||
}
|
||||
if err := emitDriveDepthResult(items, nil, false, "*.xlsx"); err != nil {
|
||||
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
@@ -325,7 +325,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(nil, nil, false, ""); err != nil {
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result = decodeDepthResult(t, out)
|
||||
@@ -338,7 +338,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
|
||||
}
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(samePath, nil, false, ""); err != nil {
|
||||
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = decodeDepthResult(t, out)["items"].([]any)
|
||||
@@ -347,7 +347,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
}
|
||||
|
||||
deps.Out.w = failingWriter{}
|
||||
if err := emitDriveDepthResult(nil, nil, false, ""); err == nil {
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err == nil {
|
||||
t.Fatal("failing writer returned nil")
|
||||
}
|
||||
}
|
||||
@@ -382,7 +382,7 @@ func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute
|
||||
t.Helper()
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0)
|
||||
return decodeDepthResult(t, out), err
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ func TestCrossPlatformCoverageRunDriveListDepthDryRun(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
@@ -413,7 +413,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) {
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
@@ -548,7 +548,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t
|
||||
deps.Out.w = out
|
||||
deps.Out.errW = io.Discard
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 3 {
|
||||
@@ -593,7 +593,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) {
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil {
|
||||
t.Fatal("root failure returned nil")
|
||||
}
|
||||
@@ -610,7 +610,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) {
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil {
|
||||
t.Fatal("unrecoverable returned nil")
|
||||
}
|
||||
@@ -635,7 +635,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testi
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -648,7 +648,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) {
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") {
|
||||
t.Fatalf("err = %v, want pagination anomaly", err)
|
||||
}
|
||||
@@ -690,7 +690,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) {
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
@@ -710,7 +710,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -724,7 +724,7 @@ func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) {
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -772,7 +772,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *test
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
@@ -801,7 +801,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T)
|
||||
deps.Out.errW = io.Discard
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
// --latest 上限与 --limit 服务端每页硬上限 50 对齐。
|
||||
driveLatestMax = 50
|
||||
// 钉盘单层 latest 扫描上限(50×20 页)。
|
||||
driveLatestScanMax = 1000
|
||||
)
|
||||
|
||||
// validateDriveListLatest --latest 边界与互斥校验。
|
||||
func validateDriveListLatest(cmd *cobra.Command, latest int) error {
|
||||
if latest < 1 || latest > driveLatestMax {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--latest 必须为 1~%d 的整数,当前: %d", driveLatestMax, latest),
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"order-by", "order"} {
|
||||
if cmd.Flags().Changed(f) {
|
||||
return driveLatestExclusiveError(f, latest)
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
|
||||
return driveLatestExclusiveError("limit", latest)
|
||||
}
|
||||
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
|
||||
return driveLatestExclusiveError("cursor", latest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func driveLatestExclusiveError(flag string, latest int) error {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--latest 不能与 --%s 同时使用:Top-N 排序语义由 latest 独占;如需自定义排序请改用 --order-by modifyTime --order desc --limit %d", flag, latest),
|
||||
}
|
||||
}
|
||||
|
||||
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
|
||||
files := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
|
||||
continue
|
||||
}
|
||||
files = append(files, item)
|
||||
}
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
ti, _ := files[i]["sortTime"].(int64)
|
||||
tj, _ := files[j]["sortTime"].(int64)
|
||||
if ti != tj {
|
||||
return ti > tj
|
||||
}
|
||||
ri, _ := files[i]["rel_path"].(string)
|
||||
rj, _ := files[j]["rel_path"].(string)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return driveDepthItemID(files[i]) < driveDepthItemID(files[j])
|
||||
})
|
||||
if len(files) > latest {
|
||||
files = files[:latest]
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func stripDriveDepthDecorations(items []map[string]any) {
|
||||
for _, item := range items {
|
||||
delete(item, "depth")
|
||||
delete(item, "parentId")
|
||||
delete(item, "rel_path")
|
||||
delete(item, "sortTime")
|
||||
}
|
||||
}
|
||||
|
||||
func driveItemModifiedMillis(item map[string]any) (int64, bool) {
|
||||
for _, k := range []string{"modifiedTime", "modifyTime", "modified_time", "gmtModified", "lastModifiedTime", "updateTime"} {
|
||||
if v, ok := item[k]; ok {
|
||||
if ms, ok := toMillis(v); ok {
|
||||
return ms, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func toMillis(v any) (int64, bool) {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
if t <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return int64(t), true
|
||||
case json.Number:
|
||||
if n, err := t.Int64(); err == nil && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
if tm, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return tm.UnixMilli(), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// runDriveListLatest 钉盘单层 --latest 扫描。
|
||||
func runDriveListLatest(cmd *cobra.Command, baseArgs map[string]any, rootFolder string, latest int, pattern string, quiet bool) error {
|
||||
buildArgs := func(pageToken string) map[string]any {
|
||||
args := map[string]any{
|
||||
"maxResults": float64(driveDepthPageSize),
|
||||
"orderBy": "modifyTime",
|
||||
"order": "desc",
|
||||
}
|
||||
for k, v := range baseArgs {
|
||||
args[k] = v
|
||||
}
|
||||
if rootFolder != "" {
|
||||
args["parentId"] = rootFolder
|
||||
}
|
||||
if pageToken != "" {
|
||||
args["nextToken"] = pageToken
|
||||
}
|
||||
return args
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"tool": "list_files",
|
||||
"args": buildArgs(""),
|
||||
"latest": latest,
|
||||
"note": "dry-run:latest 为客户端能力,凑够 N 条即停,最多扫描 1000 条",
|
||||
})
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
collected := make([]map[string]any, 0, latest)
|
||||
scanned := 0
|
||||
pageToken := ""
|
||||
maxPages := driveLatestScanMax/driveDepthPageSize + 1
|
||||
for pages := 0; pages < maxPages; pages++ {
|
||||
text, err := callMCPToolReturnText(ctx, "list_files", buildArgs(pageToken))
|
||||
if err != nil {
|
||||
return fmt.Errorf("latest 扫描第 %d 页失败: %w", pages+1, err)
|
||||
}
|
||||
items, next, _ := parseDriveDepthPage(text)
|
||||
for _, item := range items {
|
||||
scanned++
|
||||
if isDriveDepthFolder(item) {
|
||||
continue
|
||||
}
|
||||
name, _ := item["name"].(string)
|
||||
if name == "" {
|
||||
name, _ = item["fileName"].(string)
|
||||
}
|
||||
if pattern != "" && !matchDriveNamePattern(name, pattern) {
|
||||
continue
|
||||
}
|
||||
collected = append(collected, item)
|
||||
if len(collected) >= latest {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(collected) >= latest || next == "" || scanned >= driveLatestScanMax {
|
||||
break
|
||||
}
|
||||
pageToken = next
|
||||
if !quiet {
|
||||
fmt.Fprintf(os.Stderr, "[drive-list] latest 扫描中: 已扫 %d 条,命中 %d/%d\n", scanned, len(collected), latest)
|
||||
}
|
||||
}
|
||||
if len(collected) < latest {
|
||||
hint := fmt.Sprintf("dws drive list --folder <子目录ID> --latest %d", latest)
|
||||
if pattern != "" {
|
||||
hint = fmt.Sprintf("dws drive list --folder <子目录ID> --pattern %q --latest %d", pattern, latest)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[drive-list] 已扫描 %d 条,找到 %d/%d 条;建议缩小范围:%s\n", scanned, len(collected), latest, hint)
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{"items": collected})
|
||||
}
|
||||
@@ -66,8 +66,6 @@ var (
|
||||
driveFileStat = (*os.File).Stat
|
||||
)
|
||||
|
||||
var driveWorkerContextErr = func(ctx context.Context) error { return ctx.Err() }
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// HTTP 状态错误
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -631,7 +629,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for part := range jobs {
|
||||
if driveWorkerContextErr(runCtx) != nil {
|
||||
if runCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
|
||||
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -2870,32 +2868,48 @@ func TestCrossPlatformCoverageDriveDownloadVersionCancelNoResume(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageDriveTransferWorkerCtxCancelBeforeProcess(t *testing.T) {
|
||||
// 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。
|
||||
// 通过结构化 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) {
|
||||
requests.Add(1)
|
||||
return nil, errors.New("worker context guard did not stop the request")
|
||||
}),
|
||||
})
|
||||
// 策略:让 workers 正常处理分片,通过 context timeout 在处理过程中过期。
|
||||
// 当 worker 完成某个分片后循环回来收到新 job 时,发现 runCtx 已取消。
|
||||
// transport 每次请求加 50μs 延迟,使总处理时间接近 timeout,最大化命中率。
|
||||
|
||||
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)
|
||||
totalSize := int64(200)
|
||||
content := makeTestContent(int(totalSize))
|
||||
|
||||
origClient := driveRangeClient
|
||||
t.Cleanup(func() { driveRangeClient = origClient })
|
||||
|
||||
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
|
||||
}),
|
||||
}
|
||||
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())
|
||||
|
||||
// 多次尝试以确保覆盖(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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+489
-3
@@ -195,7 +195,72 @@ func newMailCommand() *cobra.Command {
|
||||
|
||||
mailboxProfileCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
|
||||
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd)
|
||||
mailboxSharedWithMeCmd := &cobra.Command{
|
||||
Use: "shared-with-me",
|
||||
Short: "查询共享给我的邮箱",
|
||||
Long: `查询他人共享给当前用户的邮箱账号列表,包含共享关系类型。
|
||||
|
||||
共享关系(relationships)取值:
|
||||
LOGIN 登录(可登录该共享邮箱)
|
||||
SEND_AS 代发(以该邮箱身份发送邮件)
|
||||
SEND_ON_BEHALF 代表发送(代表该邮箱发送邮件)
|
||||
|
||||
返回字段:
|
||||
total 可访问的共享账号总数
|
||||
targets 可访问的共享账号列表`,
|
||||
Example: ` dws mail mailbox shared-with-me
|
||||
dws mail mailbox shared-with-me --limit 20 --offset 0`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
toolArgs["limit"] = limit
|
||||
}
|
||||
if cmd.Flags().Changed("offset") {
|
||||
offset, _ := cmd.Flags().GetInt("offset")
|
||||
toolArgs["offset"] = offset
|
||||
}
|
||||
if len(toolArgs) == 0 {
|
||||
return callMCPTool("list_shared_with_me", nil)
|
||||
}
|
||||
return callMCPTool("list_shared_with_me", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(mailboxSharedWithMeCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "list_shared_with_me",
|
||||
CanonicalPath: "mail.list_shared_with_me",
|
||||
CLIPath: "mail mailbox shared-with-me",
|
||||
PrimaryCLIPath: "mail mailbox shared-with-me",
|
||||
},
|
||||
Description: "查询共享给我的邮箱",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_shared_with_me"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询他人共享给当前用户的邮箱",
|
||||
UseWhen: []string{"需要列出登录/代发/代表发送权限的共享邮箱时"},
|
||||
AvoidWhen: []string{"列出自己邮箱用 mail mailbox list"},
|
||||
Examples: []string{"dws mail mailbox shared-with-me"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "limit", Property: "limit", InterfaceType: "integer"},
|
||||
{Name: "offset", Property: "offset", InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
mailboxSharedWithMeCmd.Flags().Int("limit", 0, "返回数量上限 (可选)")
|
||||
mailboxSharedWithMeCmd.Flags().Int("offset", 0, "偏移量 (可选)")
|
||||
|
||||
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd, mailboxSharedWithMeCmd)
|
||||
|
||||
messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}
|
||||
|
||||
@@ -2126,9 +2191,251 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
||||
messageVerifyCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)")
|
||||
messageVerifyCmd.Flags().String("internet-message-id", "", "邮件的 internetMessageId (必填),取自发送类命令返回值")
|
||||
|
||||
messageExportCmd := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "导出/备份邮件(EML格式)",
|
||||
Long: `导出指定邮件为 EML 格式文件并保存到本地。
|
||||
|
||||
不指定 --filename 时,默认以邮件主题作为文件名。
|
||||
文件保存在当前工作目录下,扩展名为 .eml。
|
||||
默认不覆盖同名文件,使用 --overwrite 强制覆盖。
|
||||
|
||||
注意:目前仅支持 100KB 以内的邮件导出。
|
||||
|
||||
编排流程:
|
||||
1. 调用 get_email_by_message_id 获取邮件主题(用作默认文件名)
|
||||
2. 调用 export_message_mime 获取 EML 内容
|
||||
3. 将 EML 内容原子写入本地文件`,
|
||||
Example: ` dws mail message export --email user@company.com --id <messageId>
|
||||
dws mail message export --email user@company.com --id <messageId> --filename my-mail`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
email := mustGetFlag(cmd, "email")
|
||||
messageID := mustGetFlag(cmd, "id")
|
||||
filename := mustGetFlag(cmd, "filename")
|
||||
overwrite, _ := cmd.Flags().GetBool("overwrite")
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
|
||||
// evidence classifies "操作:" + audited DryRun() as plan.
|
||||
deps.Out.PrintKeyValue("操作", "导出邮件为 EML 文件")
|
||||
deps.Out.PrintKeyValue("email", email)
|
||||
deps.Out.PrintKeyValue("messageId", messageID)
|
||||
if filename != "" {
|
||||
deps.Out.PrintKeyValue("filename", filename)
|
||||
}
|
||||
deps.Out.PrintKeyValue("overwrite", fmt.Sprintf("%v", overwrite))
|
||||
deps.Out.PrintKeyValue("编排", "get_email_by_message_id → export_message_mime → 写入本地 .eml 文件")
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
if filename == "" {
|
||||
msgText, err := callMCPToolReturnText(ctx, "get_email_by_message_id", map[string]any{
|
||||
"email": email,
|
||||
"messageId": messageID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取邮件信息失败: %w", err)
|
||||
}
|
||||
var msgData map[string]any
|
||||
if err := json.Unmarshal([]byte(msgText), &msgData); err == nil {
|
||||
data := msgData
|
||||
if result, ok := data["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
if msg, ok := data["message"].(map[string]any); ok {
|
||||
data = msg
|
||||
}
|
||||
if subj, ok := data["subject"].(string); ok && subj != "" {
|
||||
filename = subj
|
||||
}
|
||||
}
|
||||
if filename == "" {
|
||||
filename = messageID
|
||||
}
|
||||
}
|
||||
filename = sanitizeMailFilename(filename)
|
||||
|
||||
exportText, err := callMCPToolReturnText(ctx, "export_message_mime", map[string]any{
|
||||
"email": email,
|
||||
"id": messageID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("导出邮件失败: %w", err)
|
||||
}
|
||||
var exportData map[string]any
|
||||
if err := json.Unmarshal([]byte(exportText), &exportData); err != nil {
|
||||
return fmt.Errorf("解析导出结果失败: %w", err)
|
||||
}
|
||||
if result, ok := exportData["result"].(map[string]any); ok {
|
||||
exportData = result
|
||||
}
|
||||
emlContent, _ := exportData["emlContent"].(string)
|
||||
if emlContent == "" {
|
||||
return fmt.Errorf("导出结果为空: %s", exportText)
|
||||
}
|
||||
destPath := filename + ".eml"
|
||||
if err := atomicWriteFile(destPath, []byte(emlContent), 0600, overwrite); err != nil {
|
||||
if os.IsExist(err) {
|
||||
return fmt.Errorf("文件 %s 已存在,使用 --overwrite 覆盖", destPath)
|
||||
}
|
||||
return fmt.Errorf("保存文件失败: %w", err)
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("邮件已导出到: %s", destPath))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(messageExportCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "export_message_mime",
|
||||
CanonicalPath: "mail.export_message_mime",
|
||||
CLIPath: "mail message export",
|
||||
PrimaryCLIPath: "mail message export",
|
||||
},
|
||||
Description: "导出/备份邮件为本地 EML 文件",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Orchestrates get_email_by_message_id + export_message_mime + local file write.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "导出邮件为本地 EML 备份",
|
||||
UseWhen: []string{"需要把单封邮件备份为本地 .eml 文件时"},
|
||||
AvoidWhen: []string{"仅查看正文用 mail message get;分享到 IM 用 mail message share-to-chat"},
|
||||
Examples: []string{"dws mail message export --email user@company.com --id <messageId>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
{Name: "id", Property: "id", Required: boolPtr(true)},
|
||||
{Name: "filename", Property: "filename"},
|
||||
{Name: "overwrite", Property: "overwrite", InterfaceType: "boolean"},
|
||||
},
|
||||
},
|
||||
})
|
||||
messageExportCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
messageExportCmd.Flags().String("id", "", "邮件ID (必填)")
|
||||
messageExportCmd.Flags().String("filename", "", "导出文件名(不含扩展名),默认使用邮件主题")
|
||||
messageExportCmd.Flags().Bool("overwrite", false, "是否覆盖同名文件,默认 false")
|
||||
|
||||
messageShareToChatCmd := &cobra.Command{
|
||||
Use: "share-to-chat",
|
||||
Short: "[危险] 分享邮件至IM聊天",
|
||||
Long: `将指定邮件分享到钉钉单聊。
|
||||
|
||||
参数说明:
|
||||
--users 目标用户UID列表,逗号分隔(规范名),兼容 --uids
|
||||
--yes 跳过二次确认,直接执行分享
|
||||
|
||||
服务端可能返回风险提示(riskMessage)和 sign,此时需要用户确认后
|
||||
携带 sign 重新请求。默认会展示风险提示并中止,传入 --yes 可跳过确认。`,
|
||||
Example: ` dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1,uid2
|
||||
dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
mcpArgs := map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"id": mustGetFlag(cmd, "id"),
|
||||
}
|
||||
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
||||
mcpArgs["uids"] = parseRecipients(users)
|
||||
}
|
||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
||||
if deps.Caller.DryRun() {
|
||||
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
|
||||
// evidence classifies "操作:" + audited DryRun() as plan.
|
||||
deps.Out.PrintKeyValue("操作", "分享邮件至 IM 聊天")
|
||||
deps.Out.PrintKeyValue("email", mustGetFlag(cmd, "email"))
|
||||
deps.Out.PrintKeyValue("messageId", mustGetFlag(cmd, "id"))
|
||||
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
||||
deps.Out.PrintKeyValue("users", users)
|
||||
}
|
||||
deps.Out.PrintKeyValue("yes", fmt.Sprintf("%v", skipConfirm))
|
||||
deps.Out.PrintKeyValue("说明", "仅预览分享计划,不发起真实分享请求")
|
||||
return nil
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
firstText, err := callMCPToolReturnText(ctx, "share_message_to_chat", mcpArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("分享邮件失败: %w", err)
|
||||
}
|
||||
var firstResult map[string]any
|
||||
if err := json.Unmarshal([]byte(firstText), &firstResult); err != nil {
|
||||
return fmt.Errorf("解析分享结果失败: %w", err)
|
||||
}
|
||||
if result, ok := firstResult["result"].(map[string]any); ok {
|
||||
firstResult = result
|
||||
}
|
||||
if sign, ok := firstResult["sign"].(string); ok && sign != "" {
|
||||
riskMsg, _ := firstResult["riskMessage"].(string)
|
||||
if !skipConfirm {
|
||||
if riskMsg != "" {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[风险提示] %s", riskMsg))
|
||||
}
|
||||
return fmt.Errorf("服务端要求二次确认,请添加 --yes 参数确认后重新执行")
|
||||
}
|
||||
mcpArgs["sign"] = sign
|
||||
return callMCPTool("share_message_to_chat", mcpArgs)
|
||||
}
|
||||
// firstResult is already a parsed object (possibly unwrapped from result).
|
||||
return deps.Out.PrintJSON(firstResult)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(messageShareToChatCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "share_message_to_chat",
|
||||
CanonicalPath: "mail.share_message_to_chat",
|
||||
CLIPath: "mail message share-to-chat",
|
||||
PrimaryCLIPath: "mail message share-to-chat",
|
||||
},
|
||||
Description: "分享邮件至 IM 单聊",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "share_message_to_chat"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "把邮件分享到钉钉单聊",
|
||||
UseWhen: []string{"需要将指定邮件分享给钉钉用户(单聊)时"},
|
||||
AvoidWhen: []string{"仅导出本地备份用 mail message export;群聊发消息用 chat message send"},
|
||||
Examples: []string{"dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
{Name: "id", Property: "id", Required: boolPtr(true)},
|
||||
{Name: "users", Property: "uids"},
|
||||
{Name: "yes", Property: "yes", InterfaceType: "boolean"},
|
||||
},
|
||||
},
|
||||
})
|
||||
messageShareToChatCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
messageShareToChatCmd.Flags().String("id", "", "邮件ID (必填)")
|
||||
messageShareToChatCmd.Flags().String("users", "", "目标用户UID列表,逗号分隔")
|
||||
messageShareToChatCmd.Flags().String("uids", "", "--users 的别名")
|
||||
_ = messageShareToChatCmd.Flags().MarkHidden("uids")
|
||||
messageShareToChatCmd.Flags().Bool("yes", false, "跳过二次确认,直接执行分享")
|
||||
|
||||
messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd,
|
||||
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
|
||||
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd)
|
||||
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd, messageExportCmd, messageShareToChatCmd)
|
||||
|
||||
sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}
|
||||
|
||||
@@ -3400,7 +3707,129 @@ object 与 operation 合法组合:
|
||||
blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
|
||||
blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd)
|
||||
|
||||
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd)
|
||||
calendarCmd := &cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE}
|
||||
calendarListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出用户可访问的日历列表",
|
||||
Long: `列出用户可访问的日历列表,包括用户自己创建以及接受共享后生成的日历。
|
||||
返回的 id 可作为 calendar-event list 的 --id / --folder-id 使用。`,
|
||||
Example: ` dws mail calendar list --email user@company.com`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("list_mailbox_calendars", map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(calendarListCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "list_mailbox_calendars",
|
||||
CanonicalPath: "mail.list_mailbox_calendars",
|
||||
CLIPath: "mail calendar list",
|
||||
PrimaryCLIPath: "mail calendar list",
|
||||
},
|
||||
Description: "列出邮箱日历文件夹",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendars"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出邮箱可访问的日历文件夹",
|
||||
UseWhen: []string{"查询邮箱日历文件夹 id,以便继续查日程时"},
|
||||
AvoidWhen: []string{"钉钉主日历日程请用 dws calendar event list"},
|
||||
Examples: []string{"dws mail calendar list --email user@company.com"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
calendarListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
calendarCmd.AddCommand(calendarListCmd)
|
||||
|
||||
calendarEventCmd := &cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE}
|
||||
calendarEventListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询指定日历时间范围内的日程",
|
||||
Long: `查询指定邮箱日历文件夹在 UTC 时间区间 [startTime, endTime) 内出现的日程,支持 cursor 分页。循环日程会展开为该时间范围内的单次日程。`,
|
||||
Example: ` dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z"
|
||||
dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z" --cursor <cursor>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "start", "end"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlagWithAliases(cmd, "id", "folder-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"id": flagOrFallback(cmd, "id", "folder-id"),
|
||||
"startTime": flagOrFallback(cmd, "start", "start-time"),
|
||||
"endTime": flagOrFallback(cmd, "end", "end-time"),
|
||||
}
|
||||
if cursor := mustGetFlag(cmd, "cursor"); cursor != "" {
|
||||
toolArgs["cursor"] = cursor
|
||||
}
|
||||
return callMCPTool("list_mailbox_calendar_events", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(calendarEventListCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "list_mailbox_calendar_events",
|
||||
CanonicalPath: "mail.list_mailbox_calendar_events",
|
||||
CLIPath: "mail calendar-event list",
|
||||
PrimaryCLIPath: "mail calendar-event list",
|
||||
},
|
||||
Description: "查询邮箱日历文件夹时间范围内的日程",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendar_events"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询邮箱日历日程列表",
|
||||
UseWhen: []string{"已知邮箱日历文件夹 id,需要按 UTC 时间窗列出日程时"},
|
||||
AvoidWhen: []string{"钉钉主日历请用 dws calendar event list;未知文件夹 id 时先 mail calendar list"},
|
||||
Examples: []string{"dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start \"2026-07-01T00:00:00Z\" --end \"2026-07-31T23:59:59Z\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
{Name: "id", Property: "id", Required: boolPtr(true)},
|
||||
{Name: "start", Property: "startTime", Required: boolPtr(true)},
|
||||
{Name: "end", Property: "endTime", Required: boolPtr(true)},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
},
|
||||
},
|
||||
})
|
||||
calendarEventListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
calendarEventListCmd.Flags().String("id", "", "日历文件夹ID (必填)")
|
||||
calendarEventListCmd.Flags().String("folder-id", "", "--id 的别名")
|
||||
_ = calendarEventListCmd.Flags().MarkHidden("folder-id")
|
||||
calendarEventListCmd.Flags().String("start", "", "视图开始UTC时间 (必填)")
|
||||
calendarEventListCmd.Flags().String("start-time", "", "--start 的别名")
|
||||
_ = calendarEventListCmd.Flags().MarkHidden("start-time")
|
||||
calendarEventListCmd.Flags().String("end", "", "视图结束UTC时间 (必填)")
|
||||
calendarEventListCmd.Flags().String("end-time", "", "--end 的别名")
|
||||
_ = calendarEventListCmd.Flags().MarkHidden("end-time")
|
||||
calendarEventListCmd.Flags().String("cursor", "", "分页光标 (可选)")
|
||||
calendarEventCmd.AddCommand(calendarEventListCmd)
|
||||
|
||||
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd, calendarCmd, calendarEventCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
@@ -3935,3 +4364,60 @@ func runMailAttachmentDownload(cmd *cobra.Command) error {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("附件已保存到: %s", destPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeMailFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.ReplaceAll(name, "/", "_")
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
name = strings.ReplaceAll(name, "\x00", "")
|
||||
if name == "" {
|
||||
return "mail"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// mailAtomicLink is the no-clobber commit for atomicWriteFile (test-injectable).
|
||||
var mailAtomicLink = os.Link
|
||||
|
||||
// atomicWriteFile 原子写入文件:先写同目录临时文件,成功后提交到目标路径。
|
||||
// overwrite=false 使用 link(2) 实现存在即失败;overwrite=true 使用 rename 覆盖。
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode, overwrite bool) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := atomicCreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建临时文件失败: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
_ = tmp.Close()
|
||||
_ = atomicRemove(tmpName)
|
||||
}
|
||||
}()
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
return fmt.Errorf("设置文件权限失败: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
return fmt.Errorf("写入数据失败: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return fmt.Errorf("同步磁盘失败: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("关闭临时文件失败: %w", err)
|
||||
}
|
||||
if overwrite {
|
||||
if err := atomicRename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("重命名文件失败: %w", err)
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
if err := mailAtomicLink(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = atomicRemove(tmpName)
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -49,12 +49,13 @@ func newMarkdownCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "markdown",
|
||||
Short: "Markdown 文件处理",
|
||||
Long: "创建、覆盖、修补和获取钉盘或文档空间中的原生 Markdown 文件。",
|
||||
Long: "创建、覆盖、修补、对比和获取钉盘或文档空间中的原生 Markdown 文件。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
root.AddCommand(
|
||||
newMarkdownFetchCmd(),
|
||||
newMarkdownCreateCmd(),
|
||||
newMarkdownDiffCmd(),
|
||||
newMarkdownOverwriteCmd(),
|
||||
newMarkdownPatchCmd(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws markdown diff
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// diffResult 是 diff 命令的输出结构。
|
||||
type diffResult struct {
|
||||
Mode string `json:"mode"`
|
||||
Changed bool `json:"changed"`
|
||||
AddedLines int `json:"added_lines"`
|
||||
DeletedLines int `json:"deleted_lines"`
|
||||
Hunks int `json:"hunks"`
|
||||
Diff string `json:"diff"`
|
||||
}
|
||||
|
||||
// diff 命令的限制(包级变量以便覆盖测试注入更小阈值)。
|
||||
var (
|
||||
maxDiffFileSize int64 = 10 * 1024 * 1024 // 单侧文件大小上限 10MB
|
||||
diffDownloadTimeout = 10 * time.Minute // 下载远端内容超时(与项目其他下载命令一致)
|
||||
diffComputeTimeout = 30 * time.Second // 本地 diff 计算超时
|
||||
diffJSONMarshalIndent = json.MarshalIndent
|
||||
runMarkdownUnifiedDiff = computeUnifiedDiff
|
||||
)
|
||||
|
||||
// formatFileSize 返回人类可读的文件大小。
|
||||
func formatFileSize(size int64) string {
|
||||
if size >= 1024*1024 {
|
||||
return fmt.Sprintf("%.1f MB", float64(size)/float64(1024*1024))
|
||||
}
|
||||
if size >= 1024 {
|
||||
return fmt.Sprintf("%.1f KB", float64(size)/float64(1024))
|
||||
}
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
|
||||
// checkFileSize 校验文件大小是否超过限制。
|
||||
func checkFileSize(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() > maxDiffFileSize {
|
||||
return fmt.Errorf("文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(info.Size()), formatFileSize(maxDiffFileSize))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadRemoteContent 下载远端文件内容并返回其文本。
|
||||
// versionNum <= 0 时走 download_file(用 fileId),> 0 时走 download_file_version(用 nodeId + version)。
|
||||
func downloadRemoteContent(ctx context.Context, fileID string, versionNum int) (string, error) {
|
||||
var text string
|
||||
var err error
|
||||
if versionNum > 0 {
|
||||
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
|
||||
"nodeId": fileID,
|
||||
"version": versionNum,
|
||||
})
|
||||
} else {
|
||||
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file", map[string]any{
|
||||
"fileId": fileID,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resourceURL, dlHeaders, err := parseDownloadInfo(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 下载时即限制大小,避免完整下载超大文件后才拦截(约束网络流量与内存占用)
|
||||
content, err := diffDownloadLimited(ctx, resourceURL, dlHeaders)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// diffDownloadLimited 下载远端内容,并在下载过程中强制执行 maxDiffFileSize 上限。
|
||||
// 包级变量以便测试注入。
|
||||
var diffDownloadLimited = defaultDiffDownloadLimited
|
||||
|
||||
// defaultDiffDownloadLimited 通过 HTTP GET 下载内容:先用 Content-Length 预检,
|
||||
// 再用 io.LimitReader 将实际读取量限制为 maxDiffFileSize+1 字节,超限即报错,
|
||||
// 从而约束网络流量与内存占用,而非在完整下载后才校验。
|
||||
func defaultDiffDownloadLimited(ctx context.Context, url string, headers map[string]string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: diffDownloadTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Content-Length 预检:可在读取 body 前提前拦截超大文件
|
||||
if resp.ContentLength > maxDiffFileSize {
|
||||
return nil, fmt.Errorf("远端文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(resp.ContentLength), formatFileSize(maxDiffFileSize))
|
||||
}
|
||||
|
||||
// 实际读取限制为 maxDiffFileSize+1 字节,读满即判定超限(防止 Content-Length 缺失或造假)
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxDiffFileSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > maxDiffFileSize {
|
||||
return nil, fmt.Errorf("远端文件大小超过限制 %s,请使用更小的文件", formatFileSize(maxDiffFileSize))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// computeUnifiedDiff 使用 Go stdlib patience diff 算法计算 unified diff,并统计变更行数。
|
||||
func computeUnifiedDiff(left, right string, contextLines int) (string, int, int, int, bool) {
|
||||
out := UnifiedDiff("left", []byte(left), "right", []byte(right), contextLines)
|
||||
if len(out) == 0 {
|
||||
return "", 0, 0, 0, false
|
||||
}
|
||||
|
||||
text := string(out)
|
||||
added, deleted, hunks := 0, 0, 0
|
||||
// 只统计首个 @@ 之后的 hunk 区行:头部三行(diff/---/+++)不参与计数,
|
||||
// hunk 区内每行必带单字符前缀,内容行以 --/++ 开头也不会被误判为文件头而漏计
|
||||
inHunk := false
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
hunks++
|
||||
inHunk = true
|
||||
continue
|
||||
}
|
||||
if !inHunk {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "+") {
|
||||
added++
|
||||
} else if strings.HasPrefix(line, "-") {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
changed := added > 0 || deleted > 0
|
||||
return text, added, deleted, hunks, changed
|
||||
}
|
||||
|
||||
// ensureMarkdownDiffType 校验 markdown diff 的目标文件类型。
|
||||
// markdown 产品域面向 .md 文件,非 md 文件拦截并回引到对应产品命令。
|
||||
// 类型探测复用 fetchFileInfo(显式路由 drive server 的 get_file_info),
|
||||
// 探测失败或类型未知时不阻断,让后续 MCP 工具自行报错。
|
||||
func ensureMarkdownDiffType(ctx context.Context, nodeID string) error {
|
||||
info := fetchFileInfo(ctx, nodeID)
|
||||
switch info.extension {
|
||||
case "", "md", "markdown":
|
||||
return nil
|
||||
case "adoc":
|
||||
return fmt.Errorf("该文件为钉钉在线文档 (adoc),不支持 markdown diff\n请使用 dws doc 对应命令(如 dws doc version list / dws doc export)")
|
||||
case "axls":
|
||||
return fmt.Errorf("该文件为钉钉在线表格 (axls),不支持 markdown diff\n请使用 dws sheet 对应命令")
|
||||
case "amind", "adraw":
|
||||
return fmt.Errorf("该文件为钉钉在线%s (%s),暂不支持历史版本管理\nmarkdown diff 与 dws drive list --versions / dws drive download --version 均不支持该类型", describeDingTalkDocType(info.extension), info.extension)
|
||||
default:
|
||||
return fmt.Errorf("该文件为 %s 文件,markdown diff 仅支持 .md 文件\n普通文件的历史版本请使用 dws drive list --versions / dws drive download --version / dws drive revert", info.extension)
|
||||
}
|
||||
}
|
||||
|
||||
// newMarkdownDiffCmd 创建 markdown diff 子命令。
|
||||
func newMarkdownDiffCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "diff",
|
||||
Short: "比较 Markdown 内容差异",
|
||||
Long: `比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff。
|
||||
|
||||
模式:
|
||||
remote_vs_remote: --version V1 --version2 V2 (两个历史版本)
|
||||
remote_vs_remote: --version V1 (历史版本 vs 最新)
|
||||
remote_vs_local: --file ./local.md (最新 vs 本地)
|
||||
remote_vs_local: --version V1 --file ./local.md (历史版本 vs 本地)
|
||||
|
||||
历史版本号通过 dws drive list --versions 获取。
|
||||
--file 与 --version2 不能同时使用。
|
||||
|
||||
限制:
|
||||
- 仅支持 .md 文件(在线文档/表格请使用 doc/sheet 命令)
|
||||
- 单侧文件大小上限: 10 MB
|
||||
- 下载超时: 10 分钟
|
||||
- diff 计算超时: 30 秒`,
|
||||
Example: ` # 比较两个历史版本
|
||||
dws markdown diff --node <dentryUuid> --version 3 --version2 5
|
||||
|
||||
# 历史版本 vs 最新版本
|
||||
dws markdown diff --node <dentryUuid> --version 3
|
||||
|
||||
# 最新版本 vs 本地文件
|
||||
dws markdown diff --node <dentryUuid> --file ./draft.md
|
||||
|
||||
# 历史版本 vs 本地文件
|
||||
dws markdown diff --node <dentryUuid> --version 3 --file ./draft.md`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version1, _ := cmd.Flags().GetInt("version")
|
||||
version2, _ := cmd.Flags().GetInt("version2")
|
||||
localFile, _ := cmd.Flags().GetString("file")
|
||||
contextLines, _ := cmd.Flags().GetInt("context")
|
||||
|
||||
// fail-fast 参数校验(置于 dry-run 之前):显式传了版本号但非正整数时立即报错,
|
||||
// 避免静默降级为“最新版本”;--context 允许 0(无上下文),仅拒绝负值
|
||||
if cmd.Flags().Changed("version") && version1 <= 0 {
|
||||
return fmt.Errorf("--version 必须为正整数,当前值: %d", version1)
|
||||
}
|
||||
if cmd.Flags().Changed("version2") && version2 <= 0 {
|
||||
return fmt.Errorf("--version2 必须为正整数,当前值: %d", version2)
|
||||
}
|
||||
if contextLines < 0 {
|
||||
return fmt.Errorf("--context 不能为负数,当前值: %d", contextLines)
|
||||
}
|
||||
|
||||
// 互斥校验:--file 与 --version2 不能同时使用
|
||||
if localFile != "" && version2 > 0 {
|
||||
return fmt.Errorf("--file 与 --version2 不能同时使用")
|
||||
}
|
||||
|
||||
// 模式判定
|
||||
var mode string
|
||||
if localFile != "" {
|
||||
mode = "remote_vs_local"
|
||||
} else {
|
||||
mode = "remote_vs_remote"
|
||||
}
|
||||
|
||||
// remote_vs_remote 模式至少需要一个版本号,否则两侧均取最新版本,diff 必为空
|
||||
if localFile == "" && version1 == 0 && version2 == 0 {
|
||||
return fmt.Errorf("remote_vs_remote 模式至少需要指定 --version 或 --version2 之一")
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "Markdown 内容 Diff")
|
||||
deps.Out.PrintKeyValue("模式", mode)
|
||||
deps.Out.PrintKeyValue("节点ID", nodeID)
|
||||
if version1 > 0 {
|
||||
deps.Out.PrintKeyValue("左侧版本", fmt.Sprintf("%d", version1))
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("左侧版本", "最新")
|
||||
}
|
||||
if localFile != "" {
|
||||
deps.Out.PrintKeyValue("右侧", localFile)
|
||||
} else if version2 > 0 {
|
||||
deps.Out.PrintKeyValue("右侧版本", fmt.Sprintf("%d", version2))
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("右侧版本", "最新")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 下载超时 context(与项目其他下载命令一致:10 分钟)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), diffDownloadTimeout)
|
||||
defer cancel()
|
||||
|
||||
// 类型守卫置于 dry-run 之后,确保 dry-run 不产生任何服务端调用
|
||||
if err := ensureMarkdownDiffType(ctx, nodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remote_vs_local 模式:先校验本地文件大小,避免下载后才发现过大
|
||||
if localFile != "" {
|
||||
if err := checkFileSize(localFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 输出格式读全局 --format(默认 json),json 时输出结构化结果,其余输出文本摘要
|
||||
isJSON := deps.Caller.Format() == "json"
|
||||
// 进度属带外诊断信息,统一写入 stderr,保证 stdout 在两种模式下都是纯净输出
|
||||
progress := func(msg string) { fmt.Fprintln(os.Stderr, msg) }
|
||||
|
||||
// 下载左侧内容
|
||||
progress("[1/3] 获取左侧内容...")
|
||||
leftContent, err := downloadRemoteContent(ctx, nodeID, version1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取右侧内容
|
||||
var rightContent string
|
||||
if localFile != "" {
|
||||
// remote_vs_local: 读取本地文件(大小已校验)
|
||||
progress("[2/3] 读取本地文件...")
|
||||
data, err := os.ReadFile(localFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取本地文件失败: %w", err)
|
||||
}
|
||||
rightContent = string(data)
|
||||
} else {
|
||||
// remote_vs_remote: 下载右侧远端内容
|
||||
progress("[2/3] 获取右侧内容...")
|
||||
rightContent, err = downloadRemoteContent(ctx, nodeID, version2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 diff(带超时保护)
|
||||
progress("[3/3] 计算差异...")
|
||||
type diffOutput struct {
|
||||
text string
|
||||
added int
|
||||
deleted int
|
||||
hunks int
|
||||
changed bool
|
||||
}
|
||||
resultCh := make(chan diffOutput, 1)
|
||||
// Capture seams before the goroutine so test restorers cannot race
|
||||
// against a still-running compute after the timeout path returns.
|
||||
computeDiff := runMarkdownUnifiedDiff
|
||||
marshalIndent := diffJSONMarshalIndent
|
||||
go func() {
|
||||
text, added, deleted, hunks, changed := computeDiff(leftContent, rightContent, contextLines)
|
||||
resultCh <- diffOutput{text, added, deleted, hunks, changed}
|
||||
}()
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
diffText, added, deleted, hunks, changed := res.text, res.added, res.deleted, res.hunks, res.changed
|
||||
result := diffResult{
|
||||
Mode: mode,
|
||||
Changed: changed,
|
||||
AddedLines: added,
|
||||
DeletedLines: deleted,
|
||||
Hunks: hunks,
|
||||
Diff: diffText,
|
||||
}
|
||||
|
||||
// 输出
|
||||
if isJSON {
|
||||
data, err := marshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("JSON 序列化失败: %w", err)
|
||||
}
|
||||
deps.Out.PrintRaw(string(data))
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("模式", result.Mode)
|
||||
if result.Changed {
|
||||
deps.Out.PrintKeyValue("是否有变更", "是")
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("是否有变更", "否")
|
||||
}
|
||||
deps.Out.PrintKeyValue("新增行数", fmt.Sprintf("%d", result.AddedLines))
|
||||
deps.Out.PrintKeyValue("删除行数", fmt.Sprintf("%d", result.DeletedLines))
|
||||
deps.Out.PrintKeyValue("差异块数", fmt.Sprintf("%d", result.Hunks))
|
||||
if result.Changed {
|
||||
deps.Out.PrintRaw("")
|
||||
deps.Out.PrintRaw(result.Diff)
|
||||
}
|
||||
}
|
||||
case <-time.After(diffComputeTimeout):
|
||||
return fmt.Errorf("diff 计算超时(%s),文件可能过大,请尝试减小 --context 或使用更小的文件", diffComputeTimeout)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
|
||||
cmd.Flags().Int("version", 0, "左侧历史版本号 (可选,不传=最新版本)")
|
||||
cmd.Flags().Int("version2", 0, "右侧历史版本号 (可选,不传=最新版本;不能与 --file 同时使用)")
|
||||
cmd.Flags().String("file", "", "本地 .md 文件路径 (可选,指定后进入 remote_vs_local 模式)")
|
||||
cmd.Flags().Int("context", 3, "diff 上下文行数 (默认 3)")
|
||||
|
||||
// --node 隐藏别名(与 version/fetch 子命令一致)
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("id", "", "")
|
||||
cmd.Flags().String("node-id", "", "")
|
||||
cmd.Flags().String("doc-id", "", "")
|
||||
cmd.Flags().String("file-id", "", "")
|
||||
_ = cmd.Flags().MarkHidden("url")
|
||||
_ = cmd.Flags().MarkHidden("id")
|
||||
_ = cmd.Flags().MarkHidden("node-id")
|
||||
_ = cmd.Flags().MarkHidden("doc-id")
|
||||
_ = cmd.Flags().MarkHidden("file-id")
|
||||
|
||||
RegisterCrossProductAliases(cmd)
|
||||
|
||||
cli.AnnotateRuntimeRequiredFlags(cmd, "node")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "markdown",
|
||||
Name: "diff",
|
||||
CanonicalPath: "markdown.diff",
|
||||
CLIPath: "markdown diff",
|
||||
PrimaryCLIPath: "markdown diff",
|
||||
},
|
||||
Description: "比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Local diff workflow: download remote version(s) and/or read a local file, then compute unified diff client-side.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "比较 Markdown 文件版本或本地草稿差异",
|
||||
UseWhen: []string{"需要对比远端 .md 历史版本,或远端最新/历史版本与本地草稿的差异时"},
|
||||
AvoidWhen: []string{"在线文档/表格差异请用 doc/sheet;普通二进制文件版本请用 drive list --versions / drive download --version"},
|
||||
Examples: []string{"dws markdown diff --node <nodeId> --version 3 --version2 5"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||
{Name: "version", Property: "version", InterfaceType: "integer"},
|
||||
{Name: "version2", Property: "version2", InterfaceType: "integer"},
|
||||
{Name: "file", Property: "file"},
|
||||
{Name: "context", Property: "context", InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// fetchFileInfo 通过 get_file_info 获取扩展名(markdown diff 类型守卫用)。
|
||||
// 探测失败返回零值,由调用方决定是否阻断。
|
||||
type markdownFileInfo struct {
|
||||
name string
|
||||
extension string
|
||||
}
|
||||
|
||||
func fetchFileInfo(ctx context.Context, nodeID string) (info markdownFileInfo) {
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_file_info", map[string]any{"fileId": nodeID})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &resp); err != nil {
|
||||
return
|
||||
}
|
||||
data := resp
|
||||
if result, ok := resp["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
if name, ok := data["name"].(string); ok {
|
||||
info.name = name
|
||||
}
|
||||
if ext, ok := data["extension"].(string); ok {
|
||||
info.extension = strings.ToLower(strings.TrimPrefix(ext, "."))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func describeDingTalkDocType(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case "adoc":
|
||||
return "文档"
|
||||
case "axls":
|
||||
return "表格"
|
||||
case "amind":
|
||||
return "脑图"
|
||||
case "adraw":
|
||||
return "画图"
|
||||
default:
|
||||
return "文件"
|
||||
}
|
||||
}
|
||||
+260
-10
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -18,7 +19,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "minutes",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结及文件上传会话。",
|
||||
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结、语音备忘及文件上传会话。",
|
||||
UseWhen: []string{
|
||||
"用户要查找、读取、编辑或管理钉钉听记及其录音、转写、摘要和衍生内容。",
|
||||
},
|
||||
@@ -1282,7 +1283,10 @@ func newMinutesCommand() *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。",
|
||||
UseWhen: []string{"需要添加听记个人热词以优化专有名词/人名识别时(单词不超过约10汉字)"},
|
||||
AvoidWhen: []string{"要查看已有热词时改用 dws minutes hot-word list"},
|
||||
AvoidWhen: []string{
|
||||
"要查看已有热词时改用 dws minutes hot-word list",
|
||||
"要删除热词时改用 dws minutes hot-word delete",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes hot-word add --words \"钉钉\"",
|
||||
"dws minutes hot-word add --words \"OKR,钉钉,Copilot\"",
|
||||
@@ -1328,13 +1332,70 @@ func newMinutesCommand() *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询当前用户配置的所有听记热词列表。",
|
||||
UseWhen: []string{"需要查看当前用户已配置的听记个人热词列表时"},
|
||||
AvoidWhen: []string{"要添加热词时改用 hot-word add"},
|
||||
Examples: []string{"dws minutes hot-word list"},
|
||||
AvoidWhen: []string{
|
||||
"要添加热词时改用 hot-word add",
|
||||
"要删除热词时改用 hot-word delete",
|
||||
},
|
||||
Examples: []string{"dws minutes hot-word list"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd)
|
||||
hotWordDeleteCmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "批量删除个人热词",
|
||||
Long: `批量删除听记个人热词。
|
||||
支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`,
|
||||
Example: ` dws minutes hot-word delete --words "钉钉"
|
||||
dws minutes hot-word delete --words "OKR,钉钉,Copilot"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "words"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("delete_personal_hotword", map[string]any{
|
||||
"hotWordList": parseCSVValues(mustGetFlag(cmd, "words")),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(hotWordDeleteCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: "delete_personal_hotword",
|
||||
CanonicalPath: "minutes.delete_personal_hotword",
|
||||
CLIPath: "minutes hot-word delete",
|
||||
PrimaryCLIPath: "minutes hot-word delete",
|
||||
},
|
||||
Description: "批量删除听记个人热词。删除后对应热词不再参与后续语音识别优化。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "delete_personal_hotword"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "批量删除听记个人热词,清理误加或过时热词。",
|
||||
UseWhen: []string{"用户要删除/移除已配置的听记个人热词时"},
|
||||
AvoidWhen: []string{
|
||||
"要添加热词时改用 hot-word add",
|
||||
"不确定现有热词时先用 hot-word list",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes hot-word delete --words \"钉钉\"",
|
||||
"dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "words", Property: "hotWordList"},
|
||||
},
|
||||
},
|
||||
})
|
||||
hotWordDeleteCmd.Flags().String("words", "", "要删除的热词,多个用逗号分隔 (必填)")
|
||||
|
||||
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd, hotWordDeleteCmd)
|
||||
|
||||
// ── replace-text 命令 ───────────────────────────────────────
|
||||
replaceTextCmd := &cobra.Command{
|
||||
@@ -1618,8 +1679,8 @@ func newMinutesCommand() *cobra.Command {
|
||||
uploadCmd.AddCommand(uploadCreateCmd, uploadCompleteCmd, uploadCancelCmd)
|
||||
|
||||
// ── permission 子组 ─────────────────────────────────────────
|
||||
// 听记成员权限管理:批量添加/移除成员及其权限。
|
||||
// 对应 MCP 工具 add_member_permission / remove_member_permission。
|
||||
// 听记成员权限管理:批量添加/移除成员及其权限、为当前用户申请权限。
|
||||
// 对应 MCP 工具 add_member_permission / remove_member_permission / apply_minutes_permission。
|
||||
permissionCmd := &cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE}
|
||||
|
||||
// permission add — 对应 MCP 工具 add_member_permission
|
||||
@@ -1709,6 +1770,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
UseWhen: []string{"已知听记 uuid,需要批量给听记增加成员并设置权限(policy 0管理员/1所有者/2可编辑/3可查看下载/4仅查看)时"},
|
||||
AvoidWhen: []string{
|
||||
"要移除成员权限时改用 dws minutes permission remove",
|
||||
"当前用户自己申请访问权限时改用 dws minutes permission apply",
|
||||
"成员、权限策略或听记 id 未确认时不要添加",
|
||||
},
|
||||
Examples: []string{
|
||||
@@ -1784,6 +1846,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
UseWhen: []string{"用户明确要求批量移除听记成员权限,使其失去访问时"},
|
||||
AvoidWhen: []string{
|
||||
"要添加权限时改用 permission add",
|
||||
"当前用户自己申请访问权限时改用 permission apply",
|
||||
"成员或听记 id 未确认时不要移除",
|
||||
},
|
||||
Examples: []string{
|
||||
@@ -1803,7 +1866,85 @@ func newMinutesCommand() *cobra.Command {
|
||||
_ = permissionRemoveCmd.Flags().MarkHidden("task-uuids")
|
||||
permissionRemoveCmd.Flags().String("member-uids", "", "成员钉钉 UID 列表,逗号分隔 (必填)")
|
||||
|
||||
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd)
|
||||
// permission apply — 对应 MCP 工具 apply_minutes_permission
|
||||
permissionApplyCmd := &cobra.Command{
|
||||
Use: "apply",
|
||||
Short: "为当前用户申请听记权限",
|
||||
Long: `为当前登录用户申请指定听记的权限。
|
||||
适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。
|
||||
|
||||
权限类型 (--policy):
|
||||
2 = 可编辑
|
||||
3 = 可查看/下载
|
||||
4 = 仅查看`,
|
||||
Example: ` dws minutes permission apply --id <taskUuid> --policy 4
|
||||
dws minutes permission apply --id <taskUuid> --policy 2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "policy"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64)
|
||||
if err != nil || policyID < 2 || policyID > 4 {
|
||||
return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)")
|
||||
}
|
||||
|
||||
return callMCPTool("apply_minutes_permission", map[string]any{
|
||||
"taskUuid": flagOrFallback(cmd, "id", "url", "task-uuid", "uuid"),
|
||||
"policyId": float64(policyID),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(permissionApplyCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: "apply_minutes_permission",
|
||||
CanonicalPath: "minutes.apply_minutes_permission",
|
||||
CLIPath: "minutes permission apply",
|
||||
PrimaryCLIPath: "minutes permission apply",
|
||||
},
|
||||
Description: "为当前登录用户申请指定听记的权限。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "apply_minutes_permission"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为当前登录用户申请指定听记的访问权限(可编辑/可查看下载/仅查看)。",
|
||||
UseWhen: []string{"当前用户对某听记无权限,需要向所有者申请访问(policy 2/3/4)时"},
|
||||
AvoidWhen: []string{
|
||||
"所有者批量给他人加权限时改用 permission add",
|
||||
"要移除他人权限时改用 permission remove",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes permission apply --id <taskUuid> --policy 4",
|
||||
"dws minutes permission apply --id <taskUuid> --policy 2",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "id", Property: "taskUuid"},
|
||||
{Name: "policy", Property: "policyId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
permissionApplyCmd.Flags().String("id", "", "听记 taskUuid (必填)")
|
||||
permissionApplyCmd.Flags().String("url", "", "--id 的别名")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("url")
|
||||
permissionApplyCmd.Flags().String("task-uuid", "", "--id 的别名 (兼容 OpenAPI 字段名)")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("task-uuid")
|
||||
permissionApplyCmd.Flags().String("uuid", "", "--id 的别名")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("uuid")
|
||||
permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
|
||||
|
||||
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd)
|
||||
|
||||
// ── tag 子组 ────────────────────────────────────────────────
|
||||
// 听记标签/分组管理:查询用户标签列表、按标签查询听记。
|
||||
@@ -1928,13 +2069,122 @@ func newMinutesCommand() *cobra.Command {
|
||||
|
||||
tagCmd.AddCommand(tagListCmd, tagQueryCmd)
|
||||
|
||||
// ── audio-memo 子组 ────────────────────────────
|
||||
// 语音备忘查询:对应 MCP 工具 list_audio_memos。
|
||||
// 用户身份由网关按登录态注入 uid,agent/CLI 无需传入。
|
||||
// 返回值 items[].audioUrl 为带签名的音频 URL(含 &),因此使用
|
||||
// callMCPToolUnescaped 输出,避免 & 被转义为 \u0026(与 upload 一致)。
|
||||
audioMemoCmd := &cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE}
|
||||
|
||||
audioMemoListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询语音备忘列表",
|
||||
Long: `查询当前用户的语音备忘列表,支持分页和时间范围筛选。
|
||||
分页:首页 --cursor 留空(或 0),后续把上一页返回的 nextCursor 回填到 --cursor。
|
||||
时间范围:--start/--end 为 ISO-8601(可选),不传默认查询近一年。`,
|
||||
Example: ` dws minutes audio-memo list
|
||||
dws minutes audio-memo list --max 500
|
||||
dws minutes audio-memo list --start "2026-01-01T00:00:00+08:00" --end "2026-07-21T23:59:59+08:00"
|
||||
dws minutes audio-memo list --cursor 1740000000000`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
|
||||
max, _ := cmd.Flags().GetFloat64("max")
|
||||
if max <= 0 || max > 1000 {
|
||||
return fmt.Errorf("flag --max must be between 1 and 1000")
|
||||
}
|
||||
toolArgs["pageSize"] = max
|
||||
|
||||
if cmd.Flags().Changed("cursor") {
|
||||
cursor, _ := cmd.Flags().GetInt64("cursor")
|
||||
if cursor < 0 {
|
||||
return fmt.Errorf("flag --cursor must be >= 0")
|
||||
}
|
||||
toolArgs["cursor"] = float64(cursor)
|
||||
}
|
||||
|
||||
startStr, _ := cmd.Flags().GetString("start")
|
||||
endStr, _ := cmd.Flags().GetString("end")
|
||||
// China Standard Time has no DST; FixedZone avoids zoneinfo nil-fallback branches.
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
var startMs, endMs int64
|
||||
if startStr != "" {
|
||||
var err error
|
||||
startMs, err = parseISOTimeToMillis("start", startStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["startTime"] = time.UnixMilli(startMs).In(loc).Format(time.RFC3339)
|
||||
}
|
||||
if endStr != "" {
|
||||
var err error
|
||||
endMs, err = parseISOTimeToMillis("end", endStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["endTime"] = time.UnixMilli(endMs).In(loc).Format(time.RFC3339)
|
||||
}
|
||||
if startStr != "" && endStr != "" {
|
||||
if err := validateTimeRange(startMs, endMs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return callMCPToolUnescaped("list_audio_memos", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(audioMemoListCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: "list_audio_memos",
|
||||
CanonicalPath: "minutes.list_audio_memos",
|
||||
CLIPath: "minutes audio-memo list",
|
||||
PrimaryCLIPath: "minutes audio-memo list",
|
||||
},
|
||||
Description: "查询当前用户的语音备忘列表,支持分页和时间范围筛选。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "list_audio_memos"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询当前用户的语音备忘列表(独立于听记列表与 get audio)。",
|
||||
UseWhen: []string{"用户要查看语音备忘/录音备忘列表时(可带时间范围或翻页)"},
|
||||
AvoidWhen: []string{
|
||||
"要查听记列表改用 minutes list",
|
||||
"只要某篇听记的音频地址改用 minutes get audio",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes audio-memo list",
|
||||
"dws minutes audio-memo list --start \"2026-01-01T00:00:00+08:00\" --end \"2026-07-21T23:59:59+08:00\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "max", Property: "pageSize"},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
{Name: "start", Property: "startTime"},
|
||||
{Name: "end", Property: "endTime"},
|
||||
},
|
||||
},
|
||||
})
|
||||
audioMemoListCmd.Flags().Float64("max", 200, "每页数据条数 (默认 200,上限 1000)")
|
||||
audioMemoListCmd.Flags().Int64("cursor", 0, "翻页游标,回填上一页返回的 nextCursor (首页留空)")
|
||||
audioMemoListCmd.Flags().String("start", "", "开始时间 ISO-8601 (可选,默认近一年)")
|
||||
audioMemoListCmd.Flags().String("end", "", "结束时间 ISO-8601 (可选)")
|
||||
audioMemoCmd.AddCommand(audioMemoListCmd)
|
||||
|
||||
minutesCmd := &cobra.Command{
|
||||
Use: "minutes",
|
||||
Short: "AI 听记 / 会议纪要",
|
||||
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理,以及修改标题和纪要内容。`,
|
||||
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理、语音备忘查询,以及修改标题和纪要内容。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, uploadCmd, permissionCmd, tagCmd)
|
||||
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, audioMemoCmd, uploadCmd, permissionCmd, tagCmd)
|
||||
return minutesCmd
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executeMarkdownDiff(t *testing.T, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = append([]string{"dws", "markdown"}, args...)
|
||||
root := newMarkdownCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownDiffCommand(t *testing.T) {
|
||||
t.Run("oversized local file", func(t *testing.T) {
|
||||
testseam.Swap(t, &maxDiffFileSize, int64(8))
|
||||
big := filepath.Join(t.TempDir(), "big.md")
|
||||
if err := os.WriteFile(big, []byte("0123456789"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkFileSize(big); err == nil {
|
||||
t.Fatal("expected oversized local file")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download helpers and ensure type", func(t *testing.T) {
|
||||
testseam.Protect(t, &diffDownloadLimited)
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("left\n"), nil
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://example.test/a","headers":{"X":"1"}}`},
|
||||
}})
|
||||
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err != nil {
|
||||
t.Fatalf("download latest: %v", err)
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://example.test/a"}`},
|
||||
}})
|
||||
if _, err := downloadRemoteContent(context.Background(), "nid", 3); err != nil {
|
||||
t.Fatalf("download version: %v", err)
|
||||
}
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return nil, errors.New("dl boom")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://example.test/a"}`},
|
||||
}})
|
||||
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err == nil {
|
||||
t.Fatal("expected download limited error")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
ext string
|
||||
ok bool
|
||||
}{
|
||||
{"md", true}, {"markdown", true}, {"", true},
|
||||
{"adoc", false}, {"axls", false}, {"amind", false}, {"adraw", false}, {"pdf", false},
|
||||
} {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: fmt.Sprintf(`{"result":{"name":"x","extension":%q}}`, tc.ext)},
|
||||
}})
|
||||
err := ensureMarkdownDiffType(context.Background(), "n1")
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("ext %q: %v", tc.ext, err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatalf("ext %q: expected type error", tc.ext)
|
||||
}
|
||||
}
|
||||
for _, ext := range []string{"adoc", "axls", "amind", "adraw", "other"} {
|
||||
if describeDingTalkDocType(ext) == "" {
|
||||
t.Fatalf("describe %q empty", ext)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaultDiffDownloadLimited http paths", func(t *testing.T) {
|
||||
okSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", "4")
|
||||
_, _ = w.Write([]byte("data"))
|
||||
}))
|
||||
t.Cleanup(okSrv.Close)
|
||||
got, err := defaultDiffDownloadLimited(context.Background(), okSrv.URL, map[string]string{"X-Test": "1"})
|
||||
if err != nil || string(got) != "data" {
|
||||
t.Fatalf("ok download: %v %q", err, got)
|
||||
}
|
||||
|
||||
badStatus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("nope"))
|
||||
}))
|
||||
t.Cleanup(badStatus.Close)
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), badStatus.URL, nil); err == nil {
|
||||
t.Fatal("expected non-200")
|
||||
}
|
||||
|
||||
testseam.Swap(t, &maxDiffFileSize, int64(3))
|
||||
tooBigHeader := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Length", "100")
|
||||
_, _ = w.Write([]byte("xxxx"))
|
||||
}))
|
||||
t.Cleanup(tooBigHeader.Close)
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), tooBigHeader.URL, nil); err == nil {
|
||||
t.Fatal("expected content-length guard")
|
||||
}
|
||||
|
||||
chunkedBig := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
// No Content-Length: force LimitReader path to observe oversize body.
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "no hijack", 500)
|
||||
return
|
||||
}
|
||||
conn, bufrw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
payload := strings.Repeat("x", 16)
|
||||
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
|
||||
_, _ = bufrw.WriteString(fmt.Sprintf("%x\r\n%s\r\n0\r\n\r\n", len(payload), payload))
|
||||
_ = bufrw.Flush()
|
||||
}))
|
||||
t.Cleanup(chunkedBig.Close)
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), chunkedBig.URL, nil); err == nil {
|
||||
t.Fatal("expected body size guard")
|
||||
}
|
||||
|
||||
readFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
conn, bufrw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n")
|
||||
_ = bufrw.Flush()
|
||||
_ = conn.Close() // truncate body → ReadAll error
|
||||
}))
|
||||
t.Cleanup(readFail.Close)
|
||||
maxDiffFileSize = 1000
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), readFail.URL, nil); err == nil {
|
||||
t.Fatal("expected read error")
|
||||
}
|
||||
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), "http://%\x00", nil); err == nil {
|
||||
t.Fatal("expected bad url")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := defaultDiffDownloadLimited(ctx, okSrv.URL, nil); err == nil {
|
||||
t.Fatal("expected canceled ctx")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("computeUnifiedDiff deletes", func(t *testing.T) {
|
||||
text, add, del, hunks, changed := computeUnifiedDiff("a\nb\n", "a\n", 2)
|
||||
if !changed || del < 1 || hunks < 1 || add != 0 || text == "" {
|
||||
t.Fatalf("delete-only diff: changed=%v add=%d del=%d hunks=%d", changed, add, del, hunks)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validation and dry-run modes", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executeMarkdownDiff(t, "diff"); err == nil {
|
||||
t.Fatal("expected missing node")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "0"); err == nil {
|
||||
t.Fatal("expected version>0")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "0"); err == nil {
|
||||
t.Fatal("expected version2>0")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--context", "-1"); err == nil {
|
||||
t.Fatal("expected context>=0")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", "a.md", "--version2", "2"); err == nil {
|
||||
t.Fatal("expected file/version2 mutex")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1"); err == nil {
|
||||
t.Fatal("expected remote needs version")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2"); err != nil {
|
||||
t.Fatalf("dry-run remote: %v", err)
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--url", "n1", "--file", "local.md"); err != nil {
|
||||
t.Fatalf("dry-run local latest: %v", err)
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2", "--file", "local.md"); err != nil {
|
||||
t.Fatalf("dry-run local version: %v", err)
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "3"); err != nil {
|
||||
t.Fatalf("dry-run version2-only: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remote vs local and remote vs remote execute", func(t *testing.T) {
|
||||
testseam.Protect(t, &diffDownloadLimited)
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("alpha\n"), nil
|
||||
}
|
||||
local := filepath.Join(t.TempDir(), "right.md")
|
||||
if err := os.WriteFile(local, []byte("beta\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err != nil {
|
||||
t.Fatalf("local json: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
diffDownloadLimited = func(_ context.Context, url string, _ map[string]string) ([]byte, error) {
|
||||
if strings.Contains(url, "/r") {
|
||||
return []byte("alpha\n"), nil
|
||||
}
|
||||
return []byte("gamma\n"), nil
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
|
||||
t.Fatalf("remote text changed: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("same\n"), nil
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
|
||||
t.Fatalf("remote text unchanged: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"adoc"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1"); err == nil {
|
||||
t.Fatal("expected type guard")
|
||||
}
|
||||
|
||||
testseam.Protect(t, &maxDiffFileSize)
|
||||
maxDiffFileSize = 2
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err == nil {
|
||||
t.Fatal("expected local size fail")
|
||||
}
|
||||
maxDiffFileSize = 10 * 1024 * 1024
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
}})
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("x\n"), nil
|
||||
}
|
||||
missingLocal := filepath.Join(t.TempDir(), "missing.md")
|
||||
// pass size check by writing then removing after checkFileSize... actually RunE checks size first then reads.
|
||||
// Create file for size check, then make ReadFile fail via directory path.
|
||||
dirAsFile := t.TempDir()
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", dirAsFile); err == nil {
|
||||
t.Fatal("expected read local dir failure")
|
||||
}
|
||||
_ = missingLocal
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{err: errors.New("right fail")},
|
||||
}})
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("x\n"), nil
|
||||
}
|
||||
// second download uses version tool — make parse fail on second call via empty resource
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil {
|
||||
t.Fatal("expected right download parse fail")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json marshal and compute timeout", func(t *testing.T) {
|
||||
testseam.Protect(t, &diffDownloadLimited)
|
||||
testseam.Protect(t, &diffJSONMarshalIndent)
|
||||
testseam.Protect(t, &runMarkdownUnifiedDiff)
|
||||
testseam.Protect(t, &diffComputeTimeout)
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("a\n"), nil
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
diffJSONMarshalIndent = func(any, string, string) ([]byte, error) {
|
||||
return nil, errors.New("marshal boom")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "JSON") {
|
||||
t.Fatalf("expected marshal err, got %v", err)
|
||||
}
|
||||
|
||||
diffJSONMarshalIndent = json.MarshalIndent
|
||||
runMarkdownUnifiedDiff = func(string, string, int) (string, int, int, int, bool) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return "", 0, 0, 0, false
|
||||
}
|
||||
diffComputeTimeout = time.Millisecond
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "超时") {
|
||||
t.Fatalf("expected timeout, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fetchFileInfo extension field", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":".MD","name":"x.md"}`},
|
||||
}})
|
||||
info := fetchFileInfo(context.Background(), "n")
|
||||
if info.extension != "md" || info.name != "x.md" {
|
||||
t.Fatalf("info=%+v", info)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailExportShareAndAtomicWrite(t *testing.T) {
|
||||
t.Run("message export dry-run and execute", func(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "named"); err != nil {
|
||||
t.Fatalf("export dry-run: %v", err)
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
|
||||
t.Fatalf("export dry-run default name: %v", err)
|
||||
}
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"message":{"subject":"Hello/World"}}}`},
|
||||
{text: `{"result":{"emlContent":"From: a\r\n\r\nbody"}}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
|
||||
t.Fatalf("export: %v", err)
|
||||
}
|
||||
if _, err := os.Stat("Hello_World.eml"); err != nil {
|
||||
t.Fatalf("missing eml: %v", err)
|
||||
}
|
||||
|
||||
// exist without overwrite
|
||||
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"emlContent":"x"}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller2)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World"); err == nil {
|
||||
t.Fatal("expected exist error")
|
||||
}
|
||||
|
||||
caller3 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"emlContent":"y"}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller3)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World", "--overwrite"); err != nil {
|
||||
t.Fatalf("overwrite: %v", err)
|
||||
}
|
||||
|
||||
// subject fallback to message id when missing
|
||||
caller4 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{}}`},
|
||||
{text: `{"emlContent":"z"}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller4)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "msg-fallback"); err != nil {
|
||||
t.Fatalf("fallback name: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("share-to-chat paths", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err != nil {
|
||||
t.Fatalf("share dry-run: %v", err)
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1"); err != nil {
|
||||
t.Fatalf("share dry-run no users: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
|
||||
// Stdin "yes" satisfies deferred ConfirmSafety without leaf --yes,
|
||||
// so skipConfirm=false and the server-sign reconfirm branch runs.
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"sign":"sig","riskMessage":"careful"}}`},
|
||||
}})
|
||||
if err := executeMailShare(t, strings.NewReader("yes\n"),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("expected server reconfirm, got %v", err)
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"sign":"sig"}`},
|
||||
}})
|
||||
if err := executeMailShare(t, strings.NewReader("yes\n"),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("expected reconfirm without riskMessage, got %v", err)
|
||||
}
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"sign":"sig","riskMessage":"careful"}`},
|
||||
{text: `{"ok":true}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
|
||||
t.Fatalf("share with yes: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"ok":true}`}}})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
|
||||
t.Fatalf("json success path: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("calendar-event missing folder id", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"calendar-event", "list",
|
||||
"--email", "u@c.com",
|
||||
"--start", "2026-07-01T00:00:00Z",
|
||||
"--end", "2026-07-31T23:59:59Z",
|
||||
); err == nil {
|
||||
t.Fatal("expected missing id/folder-id")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sanitize and atomic write", func(t *testing.T) {
|
||||
if got := sanitizeMailFilename(" a/b\\c\x00 "); got != "a_b_c" {
|
||||
t.Fatalf("sanitize=%q", got)
|
||||
}
|
||||
if got := sanitizeMailFilename(" "); got != "mail" {
|
||||
t.Fatalf("empty sanitize=%q", got)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "out.eml")
|
||||
if err := atomicWriteFile(path, []byte("one"), 0o600, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := atomicWriteFile(path, []byte("two"), 0o600, false); err == nil {
|
||||
t.Fatal("expected exist without overwrite")
|
||||
}
|
||||
if err := atomicWriteFile(path, []byte("two"), 0o600, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "no-such", "x.eml"), []byte("x"), 0o600, false); err == nil {
|
||||
t.Fatal("expected create temp fail")
|
||||
}
|
||||
|
||||
testseam.Protect(t, &atomicCreateTemp)
|
||||
testseam.Protect(t, &atomicRemove)
|
||||
testseam.Protect(t, &atomicRename)
|
||||
atomicRemove = func(string) error { return nil }
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{chmodErr: errors.New("chmod boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "c.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "权限") {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{writeErr: errors.New("write boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "w.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "写入") {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{syncErr: errors.New("sync boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "s.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "同步") {
|
||||
t.Fatalf("sync: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{closeErr: errors.New("close boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "cl.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "关闭") {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{}, nil
|
||||
}
|
||||
atomicRename = func(string, string) error { return errors.New("rename boom") }
|
||||
if err := atomicWriteFile(filepath.Join(dir, "r.eml"), []byte("x"), 0o600, true); err == nil || !strings.Contains(err.Error(), "重命名") {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("export save non-exist failure", func(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
testseam.Swap(t, &atomicCreateTemp, func(string, string) (atomicTempFile, error) {
|
||||
return nil, errors.New("nospc")
|
||||
})
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"emlContent":"body"}`},
|
||||
}})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "x"); err == nil || !strings.Contains(err.Error(), "保存文件失败") {
|
||||
t.Fatalf("expected save failure, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func executeMailShare(t *testing.T, in io.Reader, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = append([]string{"dws", "mail"}, args...)
|
||||
root := newMailCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetIn(in)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDiffEngineEdges(t *testing.T) {
|
||||
// Expand matches backward + negative context clamp + missing newline marker.
|
||||
old := []byte("a\nb\nc\nd\ne")
|
||||
neu := []byte("a\nb\nC\nd\ne\n")
|
||||
diff := UnifiedDiff("o", old, "n", neu, -3)
|
||||
if len(diff) == 0 {
|
||||
t.Fatal("expected diff with negative context")
|
||||
}
|
||||
diff2 := UnifiedDiff("o", []byte("same"), "n", []byte("same\nextra\n"), 2)
|
||||
if !strings.Contains(string(diff2), "No newline at end of file") && !strings.Contains(string(diff2), "+extra") {
|
||||
t.Fatalf("unexpected diff2=%q", diff2)
|
||||
}
|
||||
_ = UnifiedDiff("o", []byte("1\n2\n3\n4\n5\n6\n7\n"), "n", []byte("1\n2\n3\nX\n5\n6\n7\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("only-old\n"), "n", []byte("only-new\n"), 0)
|
||||
|
||||
// Non-unique "common" lines before a unique anchor → backward expand (L90).
|
||||
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\n"), 1)
|
||||
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\ntail\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\ntail\nextra\n"), 2)
|
||||
// Large context after an early emitted chunk → chunk.x/y clamps + new-chunk prefix.
|
||||
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("U\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("U\nZ\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("A\n\n\nU\nrest\n"), "n", []byte("B\n\n\nU\nrest\nmore\n"), 5)
|
||||
if nonNeg(-3) != 0 || nonNeg(0) != 0 || nonNeg(4) != 4 {
|
||||
t.Fatalf("nonNeg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveLatestRemaining(t *testing.T) {
|
||||
items := []map[string]any{
|
||||
{"name": "a", "sortTime": int64(5), "rel_path": "same", "fileId": "2", "type": "file"},
|
||||
{"name": "b", "sortTime": int64(5), "rel_path": "same", "fileId": "1", "type": "file"},
|
||||
{"name": "c", "sortTime": int64(5), "rel_path": "z", "fileId": "3", "type": "file"},
|
||||
{"nodeType": "Folder", "name": "folder"},
|
||||
}
|
||||
got := applyDriveListLatest(items, 10)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len=%d", len(got))
|
||||
}
|
||||
if n, err := json.Number("99").Int64(); err != nil || n != 99 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ms, ok := toMillis(json.Number("99")); !ok || ms != 99 {
|
||||
t.Fatalf("json.Number millis=%v %v", ms, ok)
|
||||
}
|
||||
if _, ok := toMillis(json.Number("-1")); ok {
|
||||
t.Fatal("negative json.Number")
|
||||
}
|
||||
if _, ok := toMillis(struct{}{}); ok {
|
||||
t.Fatal("unknown type")
|
||||
}
|
||||
|
||||
// pagination + quiet=false progress + shortfall hint with pattern
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"x.bin","fileId":"2","type":"file"},{"fileId":"3","type":"file"}],"nextToken":"n1"}`},
|
||||
{text: `{"items":[{"name":"b.md","fileName":"b.md","fileId":"4","type":"file"}],"nextToken":""}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "drive", "list"}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(context.Background())
|
||||
if err := runDriveListLatest(cmd, map[string]any{"spaceId": "s"}, "folder", 5, "*.md", false); err != nil {
|
||||
t.Fatalf("latest paginate: %v", err)
|
||||
}
|
||||
|
||||
// nil context uses Background
|
||||
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"}],"nextToken":""}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller2)
|
||||
cmd2 := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListLatest(cmd2, nil, "", 1, "", true); err != nil {
|
||||
t.Fatalf("nil ctx: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveListLatestBadFolder(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "list", "--latest", "2", "--folder", "12345"); err == nil {
|
||||
t.Fatal("expected numeric folder rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"items":[`)
|
||||
for i := 0; i < driveDepthMaxItems; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifiedTime":%d}`, i, i, 1000+i)
|
||||
}
|
||||
sb.WriteString(`]}`)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 2)
|
||||
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("err=%v, want LATEST_SCAN_TRUNCATED", err)
|
||||
}
|
||||
_ = out
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWhiteboardSeams(t *testing.T) {
|
||||
testseam.Swap(t, &whiteboardJSONMarshal, func(any) ([]byte, error) { return nil, errors.New("boom") })
|
||||
if buildWhiteboardCardJSONML("b", "w") != "" {
|
||||
t.Fatal("expected empty on marshal fail")
|
||||
}
|
||||
|
||||
testseam.Swap(t, &prepareWhiteboardCard, func(*cobra.Command, string) (string, error) {
|
||||
return "", errors.New("bad template")
|
||||
})
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err == nil || !strings.Contains(err.Error(), "白板卡片模板") {
|
||||
t.Fatalf("expected prepare fail, got %v", err)
|
||||
}
|
||||
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "doc", "whiteboard"}
|
||||
|
||||
// nil entry in blocks list
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"blocks":[null,{"blockId":"b","jsonml":"[\"card\",{\"metadata\":{\"id\":\"w\"}}]"}]}`},
|
||||
}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err != nil {
|
||||
t.Fatalf("nil entry skip: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"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"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executePR868Command(t *testing.T, root *cobra.Command, args ...string) error {
|
||||
t.Helper()
|
||||
oldArgs := os.Args
|
||||
os.Args = append([]string{"dws", root.Name()}, args...)
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) {
|
||||
t.Run("hot-word delete dry-run", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉,OKR"); err != nil {
|
||||
t.Fatalf("hot-word delete dry-run: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("hot-word delete missing words", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete"); err == nil {
|
||||
t.Fatal("expected missing --words error")
|
||||
}
|
||||
})
|
||||
t.Run("hot-word delete executes", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉"); err != nil {
|
||||
t.Fatalf("hot-word delete: %v", err)
|
||||
}
|
||||
if caller.tool != "delete_personal_hotword" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("permission apply dry-run", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task-1", "--policy", "4"); err != nil {
|
||||
t.Fatalf("permission apply dry-run: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("permission apply alias uuid", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--uuid", "task-2", "--policy", "2"); err != nil {
|
||||
t.Fatalf("permission apply alias: %v", err)
|
||||
}
|
||||
if caller.tool != "apply_minutes_permission" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
if caller.args["taskUuid"] != "task-2" || caller.args["policyId"] != float64(2) {
|
||||
t.Fatalf("args=%#v", caller.args)
|
||||
}
|
||||
})
|
||||
t.Run("permission apply invalid policy", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "1"); err == nil {
|
||||
t.Fatal("expected invalid policy")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "x"); err == nil {
|
||||
t.Fatal("expected non-numeric policy")
|
||||
}
|
||||
})
|
||||
t.Run("permission apply missing flags", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--policy", "4"); err == nil {
|
||||
t.Fatal("expected missing id")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task"); err == nil {
|
||||
t.Fatal("expected missing policy")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("audio-memo list default", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list"); err != nil {
|
||||
t.Fatalf("audio-memo list: %v", err)
|
||||
}
|
||||
if caller.tool != "list_audio_memos" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
if caller.args["pageSize"] != float64(200) {
|
||||
t.Fatalf("pageSize=%#v", caller.args["pageSize"])
|
||||
}
|
||||
})
|
||||
t.Run("audio-memo list with range and cursor", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executePR868Command(t, newMinutesCommand(),
|
||||
"audio-memo", "list",
|
||||
"--max", "10",
|
||||
"--cursor", "1740000000000",
|
||||
"--start", "2026-01-01T00:00:00+08:00",
|
||||
"--end", "2026-07-21T23:59:59+08:00",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("audio-memo list ranged: %v", err)
|
||||
}
|
||||
if caller.args["cursor"] != float64(1740000000000) {
|
||||
t.Fatalf("cursor=%#v", caller.args["cursor"])
|
||||
}
|
||||
start, _ := caller.args["startTime"].(string)
|
||||
end, _ := caller.args["endTime"].(string)
|
||||
if !strings.Contains(start, "2026-01-01") || !strings.Contains(end, "2026-07-21") {
|
||||
t.Fatalf("start/end=%q/%q", start, end)
|
||||
}
|
||||
})
|
||||
t.Run("audio-memo list validation", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "0"); err == nil {
|
||||
t.Fatal("expected max validation")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "1001"); err == nil {
|
||||
t.Fatal("expected max upper bound")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--cursor", "-1"); err == nil {
|
||||
t.Fatal("expected cursor validation")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list",
|
||||
"--start", "2026-07-21T00:00:00+08:00", "--end", "2026-01-01T00:00:00+08:00"); err == nil {
|
||||
t.Fatal("expected reversed range error")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--start", "bad"); err == nil {
|
||||
t.Fatal("expected bad start")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--end", "bad"); err == nil {
|
||||
t.Fatal("expected bad end")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocExportGetTaskIDAlias(t *testing.T) {
|
||||
// Existing primary --job-id must remain usable.
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"SUCCESS","downloadUrl":"https://x"}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDocCommand(), "export", "get", "--job-id", "job-legacy"); err != nil {
|
||||
t.Fatalf("export get --job-id: %v", err)
|
||||
}
|
||||
if caller.tool != "query_export_job" || caller.args["jobId"] != "job-legacy" {
|
||||
t.Fatalf("tool/args=%q %#v", caller.tool, caller.args)
|
||||
}
|
||||
|
||||
// Add-only synonym --task-id.
|
||||
caller2 := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"PROCESSING"}`}}}
|
||||
installScriptedCaller(t, caller2)
|
||||
if err := executePR868Command(t, newDocCommand(), "export", "get", "--task-id", "job-123"); err != nil {
|
||||
t.Fatalf("export get --task-id: %v", err)
|
||||
}
|
||||
if caller2.args["jobId"] != "job-123" {
|
||||
t.Fatalf("task-id args=%#v", caller2.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveAliasAndDownloadVersion(t *testing.T) {
|
||||
t.Run("permission list max-results", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--max-results", "10"); err != nil {
|
||||
t.Fatalf("permission list: %v", err)
|
||||
}
|
||||
if caller.args["maxResults"] != 10 {
|
||||
t.Fatalf("maxResults=%#v", caller.args["maxResults"])
|
||||
}
|
||||
})
|
||||
t.Run("cover file-id alias", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "cover", "--file-id", "n1"); err != nil {
|
||||
t.Fatalf("cover --file-id: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("revert doc-id alias dry-run", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "revert", "--doc-id", "n1", "--version", "3"); err != nil {
|
||||
t.Fatalf("revert --doc-id: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("star add url alias", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "star", "add", "--url", "https://example/n1"); err != nil {
|
||||
t.Fatalf("star add --url: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("download --version routes", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
out := filepath.Join(t.TempDir(), "out.pdf")
|
||||
if err := executePR868Command(t, newDriveCommand(), "download", "--node", "n1", "--version", "3", "--output", out); err != nil {
|
||||
t.Fatalf("download --version: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailCalendarEventFolderID(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
err := executePR868Command(t, newMailCommand(),
|
||||
"calendar-event", "list",
|
||||
"--email", "a@b.com",
|
||||
"--folder-id", "cal-1",
|
||||
"--start", "2026-07-01T00:00:00Z",
|
||||
"--end", "2026-07-31T23:59:59Z",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("calendar-event list --folder-id: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUnifiedDiffEngine(t *testing.T) {
|
||||
if got := UnifiedDiff("a", []byte("same\n"), "b", []byte("same\n"), 3); got != nil {
|
||||
t.Fatalf("identical should be nil, got %q", got)
|
||||
}
|
||||
old := []byte("one\ntwo\nthree\n")
|
||||
neu := []byte("one\nTWO\nthree\nfour\n")
|
||||
diff := UnifiedDiff("old.txt", old, "new.txt", neu, 2)
|
||||
if len(diff) == 0 || !strings.Contains(string(diff), "@@") {
|
||||
t.Fatalf("expected hunk diff, got %q", diff)
|
||||
}
|
||||
_ = UnifiedDiff("o", []byte("a\nb\n"), "n", []byte("a\nc\n"), 0)
|
||||
_ = UnifiedDiff("o", []byte("{\n\n}\n"), "n", []byte("{\n x\n}\n"), 3)
|
||||
_ = UnifiedDiff("o", []byte("alpha\n"), "n", []byte("beta\n"), 1)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveLatestHelpers(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.Flags().Int("latest", 0, "")
|
||||
cmd.Flags().String("order-by", "", "")
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().Int("limit", 0, "")
|
||||
cmd.Flags().Int("max", 0, "")
|
||||
cmd.Flags().String("cursor", "", "")
|
||||
cmd.Flags().String("next-token", "", "")
|
||||
|
||||
if err := validateDriveListLatest(cmd, 0); err == nil {
|
||||
t.Fatal("expected latest lower bound")
|
||||
}
|
||||
if err := validateDriveListLatest(cmd, 51); err == nil {
|
||||
t.Fatal("expected latest upper bound")
|
||||
}
|
||||
_ = cmd.ParseFlags([]string{"--order-by=name"})
|
||||
if err := validateDriveListLatest(cmd, 3); err == nil {
|
||||
t.Fatal("expected exclusive order-by")
|
||||
}
|
||||
|
||||
cmd2 := &cobra.Command{Use: "list"}
|
||||
cmd2.Flags().Int("latest", 0, "")
|
||||
cmd2.Flags().String("order-by", "", "")
|
||||
cmd2.Flags().String("order", "", "")
|
||||
cmd2.Flags().Int("limit", 0, "")
|
||||
cmd2.Flags().Int("max", 0, "")
|
||||
cmd2.Flags().String("cursor", "", "")
|
||||
cmd2.Flags().String("next-token", "", "")
|
||||
_ = cmd2.ParseFlags([]string{"--limit=10"})
|
||||
if err := validateDriveListLatest(cmd2, 3); err == nil {
|
||||
t.Fatal("expected exclusive limit")
|
||||
}
|
||||
|
||||
cmd3 := &cobra.Command{Use: "list"}
|
||||
cmd3.Flags().Int("latest", 0, "")
|
||||
cmd3.Flags().String("order-by", "", "")
|
||||
cmd3.Flags().String("order", "", "")
|
||||
cmd3.Flags().Int("limit", 0, "")
|
||||
cmd3.Flags().Int("max", 0, "")
|
||||
cmd3.Flags().String("cursor", "", "")
|
||||
cmd3.Flags().String("next-token", "", "")
|
||||
_ = cmd3.ParseFlags([]string{"--cursor=tok"})
|
||||
if err := validateDriveListLatest(cmd3, 3); err == nil {
|
||||
t.Fatal("expected exclusive cursor")
|
||||
}
|
||||
if err := validateDriveListLatest(cmd3, 3); err == nil {
|
||||
// already failed above
|
||||
}
|
||||
_ = validateDriveListLatest(&cobra.Command{Use: "x"}, 3) // no exclusive flags
|
||||
|
||||
items := []map[string]any{
|
||||
{"name": "b.txt", "sortTime": int64(1), "rel_path": "b", "fileId": "2", "type": "file"},
|
||||
{"name": "a.txt", "sortTime": int64(2), "rel_path": "a", "fileId": "1", "type": "file"},
|
||||
{"name": "dir", "sortTime": int64(9), "type": "folder", "dentryType": "folder"},
|
||||
}
|
||||
got := applyDriveListLatest(items, 1)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("latest len=%d", len(got))
|
||||
}
|
||||
stripDriveDepthDecorations(got)
|
||||
if _, ok := got[0]["sortTime"]; ok {
|
||||
t.Fatal("sortTime should be stripped")
|
||||
}
|
||||
|
||||
if ms, ok := driveItemModifiedMillis(map[string]any{"modifiedTime": float64(123)}); !ok || ms != 123 {
|
||||
t.Fatalf("float millis=%v %v", ms, ok)
|
||||
}
|
||||
if ms, ok := toMillis("2026-01-02T03:04:05Z"); !ok || ms <= 0 {
|
||||
t.Fatalf("rfc3339 millis=%v %v", ms, ok)
|
||||
}
|
||||
if _, ok := toMillis(""); ok {
|
||||
t.Fatal("empty string should fail")
|
||||
}
|
||||
if _, ok := toMillis(float64(-1)); ok {
|
||||
t.Fatal("negative float should fail")
|
||||
}
|
||||
if ms, ok := toMillis("42"); !ok || ms != 42 {
|
||||
t.Fatalf("int string=%v %v", ms, ok)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
listCmd := &cobra.Command{Use: "list"}
|
||||
listCmd.SetContext(context.Background())
|
||||
if err := runDriveListLatest(listCmd, map[string]any{"spaceId": "s"}, "folder", 2, "*.md", true); err != nil {
|
||||
t.Fatalf("dry-run latest: %v", err)
|
||||
}
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{
|
||||
text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"skip.bin","fileId":"2","type":"file"},{"name":"dir","type":"FOLDER"}],"nextToken":""}`,
|
||||
}}}
|
||||
installScriptedCaller(t, caller)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "drive", "list"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
listCmd2 := &cobra.Command{Use: "list"}
|
||||
listCmd2.SetContext(context.Background())
|
||||
if err := runDriveListLatest(listCmd2, nil, "", 5, "*.md", false); err != nil {
|
||||
t.Fatalf("latest scan: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func executeWhiteboardCommand(t *testing.T, args ...string) error {
|
||||
t.Helper()
|
||||
oldArgs := os.Args
|
||||
os.Args = append([]string{"dws", "doc", "whiteboard"}, args...)
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
root := newDocWhiteboardCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWhiteboardInsertPaths(t *testing.T) {
|
||||
t.Run("dry-run plan", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1"); err != nil {
|
||||
t.Fatalf("whiteboard insert dry-run: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("insert with placement and verify", func(t *testing.T) {
|
||||
caller := &pr868FlexibleCaller{whiteboardID: "wb-persisted"}
|
||||
testseam.Protect(t, &deps)
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
|
||||
if err := executeWhiteboardCommand(t,
|
||||
"insert", "--node", "doc-1", "--ref-block", "ref", "--where", "before", "--index", "2", "--yes"); err != nil {
|
||||
t.Fatalf("whiteboard insert: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("soft success when verify empty", func(t *testing.T) {
|
||||
// Empty blocks are eventual-consistency pending, not hard query failure.
|
||||
caller := &pr868FlexibleCaller{emptyVerify: true}
|
||||
testseam.Protect(t, &deps)
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
|
||||
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err != nil {
|
||||
t.Fatalf("pending-block soft success: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("helpers", func(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc", "whiteboard", "insert"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
if buildWhiteboardCardJSONML("b", "w") == "" {
|
||||
t.Fatal("empty jsonml")
|
||||
}
|
||||
if extractWhiteboardID(nil) != "" {
|
||||
t.Fatal("nil attrs")
|
||||
}
|
||||
if extractWhiteboardID(map[string]any{"metadata": map[string]any{"id": "x"}}) != "x" {
|
||||
t.Fatal("extract id")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: context.Canceled}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected query error")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"other","jsonml":"[]"}]}`}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected missing block")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"not-json"}]}`}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected jsonml parse error")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"blocks":[{"blockId":"b","jsonml":"[\"card\"]"}]}}`}}})
|
||||
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected missing attrs")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"[\"card\",null]"}]}`}}})
|
||||
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected nil attrs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type pr868FlexibleCaller struct {
|
||||
whiteboardID string
|
||||
emptyVerify bool
|
||||
format string
|
||||
dry bool
|
||||
}
|
||||
|
||||
func (c *pr868FlexibleCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
if tool == "insert_document_block" {
|
||||
return textToolResult(`{}`), nil
|
||||
}
|
||||
if tool == "list_document_blocks" {
|
||||
if c.emptyVerify {
|
||||
return textToolResult(`{"blocks":[]}`), nil
|
||||
}
|
||||
blockID, _ := args["blockId"].(string)
|
||||
payload := `{"blocks":[{"blockId":"` + blockID + `","jsonml":"[\"card\",{\"metadata\":{\"type\":\"hetu/draw\",\"id\":\"` + c.whiteboardID + `\"}},[\"span\",{},[\"span\",{},\"\"]]]"}]}`
|
||||
return textToolResult(payload), nil
|
||||
}
|
||||
return textToolResult(`{}`), nil
|
||||
}
|
||||
func (c *pr868FlexibleCaller) Format() string { return c.format }
|
||||
func (c *pr868FlexibleCaller) DryRun() bool { return c.dry }
|
||||
func (*pr868FlexibleCaller) Fields() string { return "" }
|
||||
func (*pr868FlexibleCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownDiffHelpers(t *testing.T) {
|
||||
if formatFileSize(100) == "" || formatFileSize(2048) == "" || formatFileSize(2*1024*1024) == "" {
|
||||
t.Fatal("formatFileSize")
|
||||
}
|
||||
small := filepath.Join(t.TempDir(), "ok.md")
|
||||
if err := os.WriteFile(small, []byte("hi\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkFileSize(small); err != nil {
|
||||
t.Fatalf("checkFileSize small: %v", err)
|
||||
}
|
||||
if err := checkFileSize(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("expected missing file")
|
||||
}
|
||||
|
||||
diff, add, _, hunks, changed := computeUnifiedDiff("a\n", "a\nb\n", 2)
|
||||
if !changed || add < 1 || hunks < 1 || diff == "" {
|
||||
t.Fatalf("computeUnifiedDiff=%v %d %d %q", changed, add, hunks, diff)
|
||||
}
|
||||
if _, _, _, _, ch := computeUnifiedDiff("x\n", "x\n", 2); ch {
|
||||
t.Fatal("identical should be unchanged")
|
||||
}
|
||||
|
||||
right := filepath.Join(t.TempDir(), "right.md")
|
||||
if err := os.WriteFile(right, []byte("hi\nthere\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if describeDingTalkDocType("adoc") == "" {
|
||||
t.Fatal("describeDingTalkDocType")
|
||||
}
|
||||
_ = right
|
||||
}
|
||||
@@ -141,18 +141,23 @@ func TestCrossPlatformCoverageProductCommandExamplesAreExecutableContracts(t *te
|
||||
previousStdin := os.Stdin
|
||||
previousPut := httpPutFile
|
||||
previousGet := httpGetFile
|
||||
previousWhiteboardSleep := whiteboardSleep
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
os.Stdin = previousStdin
|
||||
httpPutFile = previousPut
|
||||
httpGetFile = previousGet
|
||||
whiteboardSleep = previousWhiteboardSleep
|
||||
})
|
||||
|
||||
caller := &productExampleCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
// Product examples execute real RunE paths; whiteboard insert retries must
|
||||
// not burn the suite timeout on real sleep (race CI uses a 12m package cap).
|
||||
whiteboardSleep = func(time.Duration) {}
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error {
|
||||
if destPath == "" {
|
||||
|
||||
@@ -1,475 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,726 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -77,28 +77,11 @@ func FromShortcut(s Shortcut) corecmd.Spec {
|
||||
}
|
||||
|
||||
func fromShortcutPostMount(s Shortcut) func(*cobra.Command) {
|
||||
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 {
|
||||
if len(s.Aliases) == 0 && strings.TrimSpace(s.SinglePositionalAliasFor) == "" {
|
||||
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
|
||||
@@ -134,16 +117,6 @@ 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 ""
|
||||
@@ -215,7 +188,6 @@ 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
|
||||
|
||||
@@ -66,13 +66,6 @@ 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")
|
||||
}
|
||||
@@ -149,7 +142,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, Aliases: []string{"keyword"}, AliasesVisible: true}},
|
||||
Flags: []Flag{{Name: "query", Desc: "关键词", Required: true}},
|
||||
Execute: func(rt *RuntimeContext) error { executed = rt.Str("query"); return nil },
|
||||
}
|
||||
spec := FromShortcut(s)
|
||||
@@ -160,9 +153,6 @@ 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)
|
||||
|
||||
@@ -12,10 +12,9 @@
|
||||
// limitations under the License.
|
||||
|
||||
// Package builtin aggregates all built-in shortcut service packages via blank
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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 TestCrossPlatformCoverageCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) {
|
||||
func TestCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) {
|
||||
mounted := map[string]*cobra.Command{}
|
||||
for _, service := range builtin.BaseCommands() {
|
||||
for _, command := range service.Commands() {
|
||||
@@ -83,18 +83,6 @@ func TestCrossPlatformCoverageCmdcoreMountPreservesEveryBuiltInShortcutSurface(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" {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,795 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -982,11 +982,6 @@ 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,
|
||||
@@ -998,9 +993,6 @@ func init() {
|
||||
CommentCreateInline,
|
||||
ExportSubmit,
|
||||
ExportGet,
|
||||
legacyVersionSave,
|
||||
legacyVersionList,
|
||||
legacyVersionRevert,
|
||||
VersionSave,
|
||||
VersionList,
|
||||
VersionRevert,
|
||||
|
||||
@@ -1,763 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,360 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
// 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,7 +92,6 @@ 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": {},
|
||||
@@ -209,48 +208,20 @@ 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": {},
|
||||
|
||||
@@ -13,9 +13,6 @@ 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"`
|
||||
@@ -33,34 +30,17 @@ type semanticCatalogRecord struct {
|
||||
Reviewed bool `json:"reviewed"`
|
||||
}
|
||||
|
||||
var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
|
||||
semanticCatalogJSON,
|
||||
docSemanticCatalogJSON,
|
||||
)
|
||||
var reviewedSemanticCatalog = mustLoadSemanticCatalog()
|
||||
|
||||
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(raw, &source); err != nil {
|
||||
if err := json.Unmarshal(semanticCatalogJSON, &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))
|
||||
@@ -96,12 +76,9 @@ func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) {
|
||||
panic(fmt.Sprintf("semantic catalog command %q cannot be public with availability %q",
|
||||
command, record.Availability))
|
||||
}
|
||||
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
|
||||
out[publicCatalogKey(source.Service, command)] = record
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyReviewedSemanticCatalog(s Shortcut) (Shortcut, bool) {
|
||||
|
||||
@@ -137,29 +137,6 @@ 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")
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"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,24 +20,19 @@ 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,
|
||||
args map[string]any,
|
||||
_ 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]++
|
||||
c.arguments[key] = append(c.arguments[key], args)
|
||||
if c.failAt[key] == -1 || c.failAt[key] == c.counts[key] {
|
||||
if c.failAt[key] == c.counts[key] {
|
||||
return nil, errors.New("fixture failure")
|
||||
}
|
||||
responses := c.responses[key]
|
||||
|
||||
@@ -1,541 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -102,7 +102,5 @@ 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)
|
||||
}
|
||||
|
||||
@@ -170,7 +170,5 @@ 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)
|
||||
}
|
||||
|
||||
@@ -116,6 +116,5 @@ func shareDocBuildText(url, note string) string {
|
||||
}
|
||||
|
||||
func init() {
|
||||
canonicalizeShareDoc()
|
||||
shortcut.Register(legacyShareDoc, ShareDoc)
|
||||
shortcut.Register(ShareDoc)
|
||||
}
|
||||
|
||||
@@ -100,12 +100,6 @@ 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
|
||||
|
||||
@@ -103,7 +103,10 @@ func newShortcutListRow(s shortcut.Shortcut) shortcutListRow {
|
||||
if risk == "" {
|
||||
risk = string(shortcut.RiskRead)
|
||||
}
|
||||
confirmation := shortcut.EffectiveSafety(s).Confirmation
|
||||
confirmation := "not_required"
|
||||
if risk != string(shortcut.RiskRead) {
|
||||
confirmation = "user_required"
|
||||
}
|
||||
flags := make([]shortcut.Flag, 0, len(s.Flags))
|
||||
for _, flag := range s.Flags {
|
||||
if flag.Hidden {
|
||||
|
||||
@@ -18,10 +18,7 @@ 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_PATHS = [
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_doc.json",
|
||||
]
|
||||
SEMANTIC_PATH = ROOT / "internal" / "shortcut" / "semantic_catalog.json"
|
||||
|
||||
|
||||
def load(path: Path) -> dict[str, Any]:
|
||||
@@ -110,46 +107,44 @@ 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.
|
||||
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 ""
|
||||
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}"
|
||||
)
|
||||
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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
public.sort(key=lambda r: (r["service"], r["command"]))
|
||||
followups.sort(key=lambda r: (r["suite"], r["service"], r["command"]))
|
||||
return public, followups
|
||||
|
||||
@@ -46,11 +46,11 @@ cli_version: ">=1.0.15"
|
||||
| `aitable` | 29 | `dingtalk-aitable` |
|
||||
| `attendance` | 19 | `dingtalk-misc` |
|
||||
| `calendar` | 20 | `dingtalk-calendar` |
|
||||
| `chat` | 98 | `dingtalk-chat` |
|
||||
| `chat` | 97 | `dingtalk-chat` |
|
||||
| `contact` | 14 | `dingtalk-contact` |
|
||||
| `devapp` | 19 | `dingtalk-dev` |
|
||||
| `ding` | 4 | `dingtalk-misc` |
|
||||
| `doc` | 41 | `dingtalk-doc` |
|
||||
| `doc` | 17 | `dingtalk-doc` |
|
||||
| `drive` | 7 | `dingtalk-drive` |
|
||||
| `mail` | 10 | `dingtalk-mail` |
|
||||
| `minutes` | 6 | `dingtalk-minutes` |
|
||||
|
||||
@@ -1245,11 +1245,8 @@ 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 (必填)
|
||||
@@ -1259,7 +1256,6 @@ Flags:
|
||||
|
||||
注意:
|
||||
- 以当前用户身份引用回复,语义同 chat message send;目前回复类型仅支持 text
|
||||
- 群聊 @指定成员时,正文缺少对应 <@openDingTalkId> 会自动补齐,已有裸 @openDingTalkId 会规范化;--at-all 会自动补齐 <@all>
|
||||
```
|
||||
|
||||
#### 转发单条消息 — 将一条消息从源会话转发到目标会话(源/目标均支持单聊/群聊)
|
||||
|
||||
@@ -28,7 +28,7 @@ metadata:
|
||||
<!-- VISIBLE_SHORTCUTS_START -->
|
||||
## Shortcut 发现(按需)
|
||||
|
||||
`chat` 当前有 98 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
|
||||
`chat` 当前有 97 条公开 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,18 +180,11 @@ dws chat message edit --group <openConversationId> --msg-id <openMessageId> --co
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `message reply` | 引用回复,单聊/群聊均可;群聊可 @指定成员或 @所有人 | `--conversation-id` `--ref-msg-id` `--ref-sender` `--text`;可选 `--at-open-dingtalk-ids` `--at-all` |
|
||||
| `message reply` | 引用回复,单聊/群聊均可 | `--conversation-id` `--ref-msg-id` `--ref-sender` `--text` |
|
||||
| `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
|
||||
```
|
||||
|
||||
### 话题与卡片
|
||||
|
||||
话题完整读取流程:
|
||||
|
||||
@@ -34,44 +34,20 @@ 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 | 手动保存文档版本快照 |
|
||||
|
||||
Reference in New Issue
Block a user