diff --git a/CHANGELOG.md b/CHANGELOG.md index ae0694fe..06554d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,19 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th Transport errors keep generic retry/auth guidance without pointing callers at recovery. +## [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 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 1e139225..f860209e 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-07-29T00:06:19.285348", - "count": 265, + "generated_at": "2026-08-05T22:43:52.497190", + "count": 294, "results": [ { "suite": "read", @@ -485,7 +485,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "primary_smart", - "semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。", + "semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性;可选对资源去重后安全落盘并返回逐项失败 ledger。", "availability": "available" }, { @@ -658,6 +658,16 @@ "semantic_delta": "群邀请链接是一对一读取;Shortcut 未增加生命周期或分享编排。", "availability": "available" }, + { + "suite": "semantic", + "service": "chat", + "command": "+chat-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "对齐 lark-cli +chat-list:默认仅群聊,支持 --types group/p2p、--exclude-muted、page-size/page-token 别名,并投影 openConversationId/name/conversationType;不宣称 sort 或 bot 身份 p2p 剥离。", + "availability": "available" + }, { "suite": "semantic", "service": "chat", @@ -715,7 +725,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "primary_smart", - "semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。", + "semantic_delta": "统一群聊与两类单聊目标;省略时间时自动以当前时间向前读取最近消息,并输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文;可选对列表内资源去重后安全落盘并返回逐项失败 ledger。", "availability": "available" }, { @@ -1215,7 +1225,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "semantic_adapter", - "semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 复用 HTTPS、相对路径、无覆盖和原子落盘防护,逐资源返回下载失败 ledger。", + "semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 统一下载 mediaId 与 fileId,复用受信任下载域、相对路径、无覆盖和原子落盘防护,对重复资源去重并逐资源返回下载失败 ledger;安全本地下载沿用 read/not_required 契约,不产生非交互确认盲区。", "availability": "available" }, { @@ -1295,7 +1305,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "primary_smart", - "semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。", + "semantic_delta": "统一承接消息 mediaId 与钉盘 fileId:分别复用 IM 临时资源 URL 和 drive.download_file,只允许钉钉/OSS HTTPS 下载域且重定向复验并隔离跨域凭据,再通过工作目录内安全路径、默认不覆盖、临时文件下载和原子发布输出结构化结果。", "availability": "available" }, { @@ -1315,7 +1325,7 @@ "risk": "write", "status": "reviewed_available", "disposition": "semantic_adapter", - "semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 文本/Markdown 发送;按身份校验目标与凭据,幂等键只在真实支持的 user 分支开放,媒体上传仍诚实留在 native leaf。", + "semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 发送;current-user 支持文本、Markdown、mediaId 图片、安全相对路径本地文件上传、userId 姓名解析与幂等键,bot/webhook 仍只暴露下层真实支持的文本/Markdown 能力。", "availability": "available" }, { @@ -1344,8 +1354,8 @@ "command": "+messages-send-card", "risk": "write", "status": "reviewed_available", - "disposition": "schema_leaf", - "semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。", + "disposition": "semantic_adapter", + "semantic_delta": "既可只创建流式卡片,也可在一次调用中创建、提取 bizId、写入内容并设置流式状态;dry-run 输出两步执行计划,更新失败时保留已创建的 bizId。", "availability": "available" }, { @@ -1415,7 +1425,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "primary_smart", - "semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。", + "semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;展开下层按会话分组的 conversationMessagesList,支持精确时间窗、page-all、50 条一组 mget 富化,可选安全下载命中消息资源,并以 failure ledger 显式报告截断、富化或下载失败。", "availability": "available" }, { @@ -1435,7 +1445,7 @@ "risk": "read", "status": "reviewed_available", "disposition": "primary_smart", - "semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。", + "semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。", "availability": "available" }, { @@ -1708,123 +1718,454 @@ "status": "real-ok" }, { - "suite": "write", + "suite": "semantic", + "service": "doc", + "command": "+access-change", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "读取当前权限后再变更角色,避免把不存在的协作者当作成功更新。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+access-grant", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "在第一次写入前解析全部接收人,再批量授予文档权限并输出逐项 ledger。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+access-revoke", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "预检目标协作者权限后移除并输出逐项结果。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+background-delete", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "以 clear 语义移除文档背景色。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+background-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "校验并设置 #RRGGBB 文档背景纯色。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+checkpoint-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "写入前保存版本快照,更新后读回验证并输出逐步 ledger。", + "availability": "available" + }, + { + "suite": "semantic", "service": "doc", "command": "+comment-create", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "无 selection 创建全文评论,有 selection 时定位文本并创建划词评论。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "doc", + "command": "+comment-delete", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "永久删除指定评论,并由静态安全契约强制确认。", + "availability": "available" + }, + { + "suite": "semantic", "service": "doc", "command": "+comment-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一评论类型、解决状态与分页过滤。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "doc", "command": "+comment-reply", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一评论回复、表情回复和 mention 参数。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "doc", + "command": "+comment-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "更新指定评论正文与 mention。", + "availability": "available" + }, + { + "suite": "semantic", "service": "doc", "command": "+copy", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "旧 Doc 复制入口,仅为兼容保留;新的文件复制应使用 Drive 命令。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "doc", + "command": "+create", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一 Markdown/JSONML 内容输入、目标位置与创建后保真写入。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+create-from-template", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "支持 templateId 直达或按名称搜索消歧后创建文档。", + "availability": "available" + }, + { + "suite": "semantic", "service": "doc", "command": "+doc-append", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "doc", + "command": "+export", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。", + "availability": "available" + }, + { + "suite": "semantic", "service": "doc", "command": "+export-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "doc", "command": "+export-submit", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "导出中断恢复所需的专家入口;常规场景使用一体化 +export。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "doc", + "command": "+fetch", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一 simple/with-ids/full 细节层级与 full/outline/range/section/keyword/tags 局部读取。", + "availability": "available" + }, + { + "suite": "semantic", "service": "doc", "command": "+find-doc", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "doc", + "command": "+grant-and-share", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "先确保目标角色,再发送链接;消息失败保留逐人 ledger,并以非零退出报告 failed/partial_success。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+history-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一历史版本分页参数并返回可用于回滚的版本列表。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+history-revert", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "先验证目标版本存在,再执行回滚并读回当前文档状态。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+history-save", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+import", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+inspect", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "聚合文档元信息,并按需读取样式、权限、历史、媒体和评论。", + "availability": "available" + }, + { + "suite": "semantic", "service": "doc", "command": "+list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "doc", + "command": "+media-download", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "解析附件临时链接并通过受控相对路径、no-clobber、原子发布安全下载。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+media-insert", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "组合本地文件校验、上传凭证、OSS PUT、插块和验证。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+media-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "从文档块中提取图片、附件及其 block/resource 标识。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+media-preview", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "将正文媒体下载到受控临时目录并返回本地预览 artifact。", + "availability": "available" + }, + { + "suite": "semantic", "service": "doc", "command": "+move", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "doc", + "command": "+resource-delete", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "以幂等 clear 语义移除当前文档封面。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+resource-download", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "读取当前文档封面配置并安全下载资源到本地。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+resource-update", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "支持本地图片或 HTTPS 图片转存后设置文档封面。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "doc", + "command": "+review", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "聚合未解决评论、划词引用和确定性上下文,不调用模型生成总结。", + "availability": "available" + }, + { + "suite": "semantic", "service": "doc", "command": "+search", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一关键词、最近访问、过滤、分页和稳定精简投影,作为文档定位的 canonical 入口。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "doc", + "command": "+share", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "按姓名解析唯一用户后发送文档链接,不改变文档权限。", + "availability": "available" + }, + { + "suite": "semantic", "service": "doc", "command": "+share-doc", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "doc", "command": "+template-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "统一 MY/PUBLIC 模板浏览和分页参数。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "doc", "command": "+template-search", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按名称检索模板并返回可继续创建的 templateId。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "doc", + "command": "+update", + "risk": "write", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。", + "availability": "available" + }, + { + "suite": "semantic", "service": "doc", "command": "+version-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "doc", "command": "+version-revert", "risk": "high-risk-write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "doc", "command": "+version-save", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "alias_internal", + "semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。", + "availability": "available" }, { "suite": "write", diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 2f3a2149..8106a935 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 266 + publicShortcutCount = 294 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including hidden leaves such as minutes.shortcut_minutes_search. - schemaPublishedShortcutCount = 267 + schemaPublishedShortcutCount = 295 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 266 + publiclyDeliveredShortcutCount = 294 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { @@ -194,17 +194,9 @@ func assertDeliveryShortcutSafetyAndInterface( canonical string, ) { t.Helper() - risk := declared.Risk - if risk == "" { - risk = shortcut.RiskRead - } - wantEffect, wantRisk, wantConfirmation, wantIdempotency := "read", "low", "not_required", "idempotent" - switch risk { - case shortcut.RiskWrite: - wantEffect, wantRisk, wantConfirmation, wantIdempotency = "write", "medium", "user_required", "unknown" - case shortcut.RiskHighWrite: - wantEffect, wantRisk, wantConfirmation, wantIdempotency = "destructive", "high", "user_required", "unknown" - } + safety := shortcut.EffectiveSafety(declared) + wantEffect, wantRisk := safety.Effect, safety.Risk + wantConfirmation, wantIdempotency := safety.Confirmation, safety.Idempotency for field, want := range map[string]string{ "effect": wantEffect, "risk": wantRisk, @@ -234,6 +226,15 @@ func assertDeliveryShortcutParameters( for _, flag := range declared.Flags { if !flag.Hidden { publicFlags = append(publicFlags, flag) + if flag.AliasesVisible { + for _, alias := range flag.Aliases { + aliasFlag := flag + aliasFlag.Name = alias + aliasFlag.Default = "" + aliasFlag.Aliases = nil + publicFlags = append(publicFlags, aliasFlag) + } + } } } if got, want := len(parameters), len(publicFlags); got != want { @@ -299,6 +300,13 @@ func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool { if flag.Name == flagName && flag.Required { return true } + if flag.Required && flag.AliasesVisible { + for _, alias := range flag.Aliases { + if alias == flagName { + return true + } + } + } } public := make(map[string]bool, len(declared.Flags)) for _, flag := range declared.Flags { diff --git a/internal/helpers/doc_shortcut_bridge.go b/internal/helpers/doc_shortcut_bridge.go new file mode 100644 index 00000000..21ac0576 --- /dev/null +++ b/internal/helpers/doc_shortcut_bridge.go @@ -0,0 +1,24 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package helpers + +import "github.com/spf13/cobra" + +// RunDocImportShortcut exposes the existing, fully-tested Doc import pipeline +// to the Shortcut application layer. The Cobra leaf still owns its own flags +// and Contract; this bridge only shares the raw/API execution primitive. +func RunDocImportShortcut(cmd *cobra.Command) error { + return runImportCommand(cmd, nil, docImportFlowConfig()) +} + +// RunDocMediaInsertShortcut shares the existing prepare + OSS PUT + block +// insertion implementation with the canonical Doc Shortcut. +func RunDocMediaInsertShortcut(cmd *cobra.Command) error { + return runMediaInsert(cmd, nil) +} + +// RunDocResourceUpdateShortcut shares the cover upload/transfer pipeline. +func RunDocResourceUpdateShortcut(cmd *cobra.Command) error { + return runDocStyleCoverSet(cmd, nil) +} diff --git a/internal/localio/download.go b/internal/localio/download.go new file mode 100644 index 00000000..9a6d5fe5 --- /dev/null +++ b/internal/localio/download.go @@ -0,0 +1,475 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package localio owns safe local artifact publication shared by product +// shortcuts. Remote names and URLs are always treated as untrusted input. +package localio + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "os" + pathpkg "path" + "path/filepath" + "strings" + "sync/atomic" + "time" +) + +const ( + downloadTimeout = 10 * time.Minute + maxDownloadBytes = int64(512 << 20) +) + +type downloadTempFile interface { + io.Writer + Sync() error + Close() error +} + +var ( + createDownloadTemp = createDownloadTempInRoot + lookupDownloadIPs = net.DefaultResolver.LookupIPAddr + dialDownloadIP = (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext + localGetwd = os.Getwd + localAbs = filepath.Abs + localEvalSymlinks = filepath.EvalSymlinks + openDownloadRoot = os.OpenRoot + openDownloadParent = func(root *os.Root, name string) (*os.Root, error) { return root.OpenRoot(name) } + downloadRootStat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Stat(name) } + downloadRootLstat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Lstat(name) } + downloadRootMkdir = func(root *os.Root, name string, mode os.FileMode) error { return root.Mkdir(name, mode) } + downloadRootLink = func(root *os.Root, oldName, newName string) error { return root.Link(oldName, newName) } + downloadRootRemove = func(root *os.Root, name string) error { return root.Remove(name) } +) + +var downloadTempCounter atomic.Uint64 + +// DownloadOptions controls safe, atomic publication beneath BaseDir. +type DownloadOptions struct { + BaseDir string + Output string + PreferredName string + Headers map[string]string +} + +// DownloadResult describes the published local artifact. +type DownloadResult struct { + AbsolutePath string + RelativePath string + SizeBytes int64 +} + +// Download validates a platform-owned HTTPS URL, resolves a workspace-relative +// output path without following symlink escapes, streams into a sibling temp +// file, fsyncs it, and atomically publishes the completed file. +func Download(ctx context.Context, rawURL string, opts DownloadOptions) (DownloadResult, error) { + return downloadWithClient(ctx, rawURL, opts, secureHTTPClient()) +} + +func downloadWithClient(ctx context.Context, rawURL string, opts DownloadOptions, client *http.Client) (DownloadResult, error) { + return downloadWithClientLimit(ctx, rawURL, opts, client, maxDownloadBytes) +} + +func downloadWithClientLimit(ctx context.Context, rawURL string, opts DownloadOptions, client *http.Client, maxBytes int64) (DownloadResult, error) { + parsed, err := ValidateDownloadURL(rawURL) + if err != nil { + return DownloadResult{}, err + } + target, err := openDownloadTarget(opts.BaseDir, opts.Output, parsed.String(), opts.PreferredName) + if err != nil { + return DownloadResult{}, err + } + defer target.close() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) // URL was fully validated above + for key, value := range opts.Headers { + if strings.TrimSpace(key) != "" { + req.Header.Set(key, value) + } + } + resp, err := client.Do(req) + if err != nil { + return DownloadResult{}, fmt.Errorf("下载资源失败: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return DownloadResult{}, fmt.Errorf("下载资源失败: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + if resp.ContentLength > maxBytes { + return DownloadResult{}, fmt.Errorf("LOCAL_DOWNLOAD_TOO_LARGE: 响应大小 %d 超过上限 %d 字节", resp.ContentLength, maxBytes) + } + if err := target.verifyParent(); err != nil { + return DownloadResult{}, err + } + + tmp, tmpName, err := createDownloadTemp(target.parentRoot) + if err != nil { + return DownloadResult{}, fmt.Errorf("创建下载临时文件失败: %w", err) + } + cleanup := func() { + _ = tmp.Close() + _ = target.parentRoot.Remove(tmpName) + } + size, copyErr := io.Copy(tmp, io.LimitReader(resp.Body, maxBytes+1)) + if copyErr == nil && size > maxBytes { + copyErr = fmt.Errorf("LOCAL_DOWNLOAD_TOO_LARGE: 下载内容超过上限 %d 字节", maxBytes) + } + if copyErr == nil { + copyErr = tmp.Sync() + } + if closeErr := tmp.Close(); copyErr == nil { + copyErr = closeErr + } + if copyErr != nil { + cleanup() + return DownloadResult{}, fmt.Errorf("写入下载临时文件失败: %w", copyErr) + } + if err := target.verifyParent(); err != nil { + cleanup() + return DownloadResult{}, err + } + if err := publishTempFile(target.parentRoot, tmpName, target.destinationName); err != nil { + cleanup() + return DownloadResult{}, err + } + return DownloadResult{AbsolutePath: target.absolutePath, RelativePath: filepath.ToSlash(target.relativePath), SizeBytes: size}, nil +} + +// ValidateOutput rejects absolute paths and portable `..` escapes. +func ValidateOutput(output string) error { + output = strings.TrimSpace(output) + if output == "" { + return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 不能为空") + } + portable := strings.ReplaceAll(output, "\\", "/") + if filepath.IsAbs(output) || pathpkg.IsAbs(portable) || + (len(portable) >= 2 && portable[1] == ':' && ((portable[0] >= 'a' && portable[0] <= 'z') || (portable[0] >= 'A' && portable[0] <= 'Z'))) { + return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 只接受工作目录内的相对路径") + } + clean := pathpkg.Clean(portable) + if clean == ".." || strings.HasPrefix(clean, "../") { + return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 不允许使用 .. 逃逸工作目录") + } + return nil +} + +// ResolveOutputPath returns a symlink-safe destination below baseDir. +type downloadTarget struct { + baseRoot *os.Root + parentRoot *os.Root + parentInfo os.FileInfo + parentRelative string + destinationName string + absolutePath string + relativePath string +} + +func (target *downloadTarget) close() { + _ = target.parentRoot.Close() + _ = target.baseRoot.Close() +} + +func (target *downloadTarget) verifyParent() error { + current, err := downloadRootStat(target.baseRoot, target.parentRelative) + if err != nil || !os.SameFile(target.parentInfo, current) { + return fmt.Errorf("LOCAL_PATH_CHANGED: 下载期间输出目录被替换") + } + return nil +} + +func ResolveOutputPath(baseDir, output, rawURL, preferredName string) (string, string, error) { + target, err := openDownloadTarget(baseDir, output, rawURL, preferredName) + if err != nil { + return "", "", err + } + defer target.close() + return target.absolutePath, target.relativePath, nil +} + +func openDownloadTarget(baseDir, output, rawURL, preferredName string) (*downloadTarget, error) { + if err := ValidateOutput(output); err != nil { + return nil, err + } + if strings.TrimSpace(baseDir) == "" { + var err error + baseDir, err = localGetwd() + if err != nil { + return nil, fmt.Errorf("读取工作目录失败: %w", err) + } + } + absBase, err := localAbs(baseDir) + if err != nil { + return nil, fmt.Errorf("解析工作目录失败: %w", err) + } + realBase, err := localEvalSymlinks(absBase) + if err != nil { + return nil, fmt.Errorf("解析工作目录失败: %w", err) + } + baseRoot, err := openDownloadRoot(realBase) + if err != nil { + return nil, fmt.Errorf("打开工作目录失败: %w", err) + } + fail := func(err error) (*downloadTarget, error) { + _ = baseRoot.Close() + return nil, err + } + + rawOutput := strings.TrimSpace(output) + directoryIntent := rawOutput == "." || strings.HasSuffix(rawOutput, "/") || strings.HasSuffix(rawOutput, string(os.PathSeparator)) + candidate := filepath.Clean(rawOutput) + if info, statErr := downloadRootStat(baseRoot, candidate); statErr == nil && info.IsDir() { + directoryIntent = true + } else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: 检查输出路径失败: %w", statErr)) + } + if directoryIntent { + candidate = filepath.Join(candidate, SafeFilename(preferredName, rawURL)) + } + parent := filepath.Dir(candidate) + if err := ensureSafeParent(baseRoot, parent); err != nil { + return fail(err) + } + parentRoot, err := openDownloadParent(baseRoot, parent) + if err != nil { + return fail(fmt.Errorf("固定输出目录失败: %w", err)) + } + parentInfo, err := downloadRootStat(parentRoot, ".") + if err != nil { + _ = parentRoot.Close() + return fail(fmt.Errorf("读取输出目录身份失败: %w", err)) + } + currentParent, err := downloadRootStat(baseRoot, parent) + if err != nil || !os.SameFile(parentInfo, currentParent) { + _ = parentRoot.Close() + return fail(fmt.Errorf("LOCAL_PATH_CHANGED: 输出目录在解析期间被替换")) + } + destinationName := filepath.Base(candidate) + if info, statErr := downloadRootLstat(parentRoot, destinationName); statErr == nil { + if info.Mode()&os.ModeSymlink != 0 { + _ = parentRoot.Close() + return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: --output 目标不能是符号链接")) + } + if info.IsDir() { + _ = parentRoot.Close() + return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: --output 目标是目录")) + } + _ = parentRoot.Close() + return fail(fmt.Errorf("LOCAL_FILE_EXISTS: 目标文件已存在;请选择新的输出路径")) + } else if !errors.Is(statErr, os.ErrNotExist) { + _ = parentRoot.Close() + return fail(fmt.Errorf("检查输出文件失败: %w", statErr)) + } + return &downloadTarget{ + baseRoot: baseRoot, + parentRoot: parentRoot, + parentInfo: parentInfo, + parentRelative: parent, + destinationName: destinationName, + absolutePath: filepath.Join(realBase, candidate), + relativePath: candidate, + }, nil +} + +// SafeFilename selects a portable basename from a preferred server name or URL. +func SafeFilename(preferredName, rawURL string) string { + if name := sanitizeFilename(preferredName); name != "" { + return name + } + if parsed, err := url.Parse(rawURL); err == nil { + if decoded, decodeErr := url.PathUnescape(filepath.Base(parsed.Path)); decodeErr == nil { + if name := sanitizeFilename(decoded); name != "" { + return name + } + } + } + return "download" +} + +// ValidateDownloadURL accepts only public DingTalk and Aliyun OSS HTTPS hosts. +func ValidateDownloadURL(rawURL string) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { + return nil, fmt.Errorf("下载地址必须是受信任域名上的 HTTPS URL") + } + host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), ".")) + if host == "" || net.ParseIP(host) != nil || !allowedDownloadHost(host) { + return nil, fmt.Errorf("下载地址域名 %q 不属于受信任的钉钉或 OSS 域名", host) + } + if port := parsed.Port(); port != "" && port != "443" { + return nil, fmt.Errorf("下载地址只允许 HTTPS 默认端口") + } + return parsed, nil +} + +func secureHTTPClient() *http.Client { + transport := &http.Transport{ + // Do not use environment proxies here. DialContext must resolve and dial + // the validated download host itself; with a proxy it would receive the + // proxy address and could not enforce the target host's public-IP policy. + Proxy: nil, + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + ips, err := lookupDownloadIPs(ctx, host) + if err != nil { + return nil, err + } + for _, resolved := range ips { + if !publicIP(resolved.IP) { + return nil, fmt.Errorf("下载域名解析到非公网地址 %s", resolved.IP) + } + } + // Dial the already validated address, not the hostname, to avoid a + // second DNS lookup opening a rebinding window. + var lastErr error + for _, resolved := range ips { + conn, dialErr := dialDownloadIP(ctx, network, net.JoinHostPort(resolved.IP.String(), port)) + if dialErr == nil { + return conn, nil + } + lastErr = dialErr + } + return nil, lastErr + }, + } + client := &http.Client{Transport: transport, Timeout: downloadTimeout} + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return fmt.Errorf("下载重定向次数超过上限") + } + if _, err := ValidateDownloadURL(req.URL.String()); err != nil { + return err + } + // net/http copies arbitrary request headers from the initial request to + // every redirect. Never forward service-provided download credentials to + // a different origin, even when both hosts are on the download allowlist. + if len(via) > 0 && !sameDownloadOrigin(via[0].URL, req.URL) { + req.Header = make(http.Header) + } + return nil + } + return client +} + +func sameDownloadOrigin(left, right *url.URL) bool { + return downloadOrigin(left) == downloadOrigin(right) +} + +func downloadOrigin(parsed *url.URL) string { + port := parsed.Port() + if port == "" { + port = "443" + } + host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), ".")) + return strings.ToLower(parsed.Scheme) + "://" + net.JoinHostPort(host, port) +} + +func allowedDownloadHost(host string) bool { + return host == "dingtalk.com" || strings.HasSuffix(host, ".dingtalk.com") || + (strings.HasSuffix(host, ".aliyuncs.com") && strings.Contains(host, "oss") && !strings.Contains(host, "internal")) +} + +func publicIP(ip net.IP) bool { + addr, ok := netip.AddrFromSlice(ip) + if !ok { + return false + } + addr = addr.Unmap() + if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsMulticast() || addr.IsUnspecified() { + return false + } + for _, prefix := range nonPublicPrefixes { + if prefix.Contains(addr) { + return false + } + } + return true +} + +var nonPublicPrefixes = []netip.Prefix{ + netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT + netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments + netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1 + netip.MustParsePrefix("198.18.0.0/15"), // benchmark networks + netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2 + netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3 + netip.MustParsePrefix("240.0.0.0/4"), // reserved + netip.MustParsePrefix("2001:db8::/32"), // IPv6 documentation +} + +func ensureSafeParent(root *os.Root, parent string) error { + if parent == "." { + return nil + } + current := "." + for _, part := range strings.Split(parent, string(os.PathSeparator)) { + current = filepath.Join(current, part) + info, statErr := downloadRootLstat(root, current) + if errors.Is(statErr, os.ErrNotExist) { + if err := downloadRootMkdir(root, current, 0o755); err != nil && !errors.Is(err, os.ErrExist) { + return fmt.Errorf("创建输出目录失败: %w", err) + } + info, statErr = downloadRootLstat(root, current) + } + if statErr != nil { + return fmt.Errorf("检查输出目录失败: %w", statErr) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 父路径必须是非符号链接目录") + } + } + return nil +} + +func createDownloadTempInRoot(root *os.Root) (downloadTempFile, string, error) { + name := fmt.Sprintf(".dws-download-%d-%d", os.Getpid(), downloadTempCounter.Add(1)) + file, err := root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return nil, "", err + } + return file, name, nil +} + +func publishTempFile(root *os.Root, tempName, destinationName string) error { + if err := downloadRootLink(root, tempName, destinationName); err != nil { + if errors.Is(err, os.ErrExist) { + return fmt.Errorf("LOCAL_FILE_EXISTS: 目标文件已存在") + } + return fmt.Errorf("发布下载文件失败: %w", err) + } + if err := downloadRootRemove(root, tempName); err != nil { + return fmt.Errorf("清理下载临时文件失败: %w", err) + } + return nil +} + +func sanitizeFilename(raw string) string { + normalized := strings.ReplaceAll(raw, "\\", "/") + if strings.TrimSpace(normalized) != normalized { + return "" + } + name := filepath.Base(normalized) + if name == "" || name == "." || name == ".." || strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") { + return "" + } + for _, char := range name { + if char < 0x20 || char == 0x7f || strings.ContainsRune(`<>:"/\|?*`, char) { + return "" + } + } + stem := strings.ToUpper(strings.TrimRight(strings.SplitN(name, ".", 2)[0], " .")) + if stem == "CON" || stem == "PRN" || stem == "AUX" || stem == "NUL" || + (len(stem) == 4 && (strings.HasPrefix(stem, "COM") || strings.HasPrefix(stem, "LPT")) && stem[3] >= '1' && stem[3] <= '9') { + return "" + } + return name +} diff --git a/internal/localio/download_test.go b/internal/localio/download_test.go new file mode 100644 index 00000000..283c31fc --- /dev/null +++ b/internal/localio/download_test.go @@ -0,0 +1,726 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package localio + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } + +type failingBody struct{} + +func (failingBody) Read([]byte) (int, error) { return 0, errors.New("read failed") } +func (failingBody) Close() error { return nil } + +type coverageTempFile struct { + file *os.File + writeErr error + syncErr error + closeErr error + onClose func() +} + +func (f *coverageTempFile) Write(value []byte) (int, error) { + if f.writeErr != nil { + return 0, f.writeErr + } + return f.file.Write(value) +} +func (f *coverageTempFile) Name() string { return f.file.Name() } +func (f *coverageTempFile) Sync() error { + if f.syncErr != nil { + return f.syncErr + } + return f.file.Sync() +} +func (f *coverageTempFile) Close() error { + err := f.file.Close() + if f.onClose != nil { + f.onClose() + f.onClose = nil + } + if f.closeErr != nil { + return f.closeErr + } + return err +} + +func TestCrossPlatformCoverageDownloadURLAndPublicIPPolicy(t *testing.T) { + valid := []string{ + "https://alidocs.dingtalk.com/file.docx", + "https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md", + } + for _, raw := range valid { + if _, err := ValidateDownloadURL(raw); err != nil { + t.Errorf("ValidateDownloadURL(%q): %v", raw, err) + } + } + invalid := []string{ + "http://alidocs.dingtalk.com/file.docx", + "https://127.0.0.1/file.docx", + "https://evil.example/file.docx", + "https://oss-cn-hangzhou-internal.aliyuncs.com/file.docx", + "https://user@alidocs.dingtalk.com/file.docx", + "https://alidocs.dingtalk.com:8443/file.docx", + } + for _, raw := range invalid { + if _, err := ValidateDownloadURL(raw); err == nil { + t.Errorf("ValidateDownloadURL(%q) unexpectedly succeeded", raw) + } + } + + for _, raw := range []string{"127.0.0.1", "10.0.0.1", "100.64.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "224.0.0.1", "2001:db8::1"} { + if publicIP(net.ParseIP(raw)) { + t.Errorf("publicIP(%s) = true", raw) + } + } + for _, raw := range []string{"8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"} { + if !publicIP(net.ParseIP(raw)) { + t.Errorf("publicIP(%s) = false", raw) + } + } +} + +func TestCrossPlatformCoverageOutputPathPolicy(t *testing.T) { + for _, output := range []string{"", "../escape", "nested/../../escape", "/tmp/absolute", `C:\\absolute\\file`} { + if err := ValidateOutput(output); err == nil { + t.Errorf("ValidateOutput(%q) unexpectedly succeeded", output) + } + } + + base := t.TempDir() + destination, rel, err := ResolveOutputPath(base, "nested/file.md", "https://alidocs.dingtalk.com/file.md", "") + if err != nil { + t.Fatal(err) + } + realBase, err := filepath.EvalSymlinks(base) + if err != nil { + t.Fatal(err) + } + if rel != filepath.Join("nested", "file.md") || filepath.Dir(destination) != filepath.Join(realBase, "nested") { + t.Fatalf("destination=%q rel=%q", destination, rel) + } + if err := os.WriteFile(destination, []byte("existing"), 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := ResolveOutputPath(base, "nested/file.md", "https://alidocs.dingtalk.com/file.md", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") { + t.Fatalf("no-clobber error = %v", err) + } + + outside := t.TempDir() + link := filepath.Join(base, "outside-link") + if err := os.Symlink(outside, link); err == nil { + if _, _, err := ResolveOutputPath(base, "outside-link/file", "https://alidocs.dingtalk.com/file", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_UNSAFE") { + t.Fatalf("symlink escape error = %v", err) + } + } + + if got := SafeFilename("../evil", "https://alidocs.dingtalk.com/"); got != "evil" { + t.Errorf("SafeFilename traversal basename = %q", got) + } + for _, name := range []string{"CON", "bad?.txt", " trailing.txt"} { + if got := SafeFilename(name, "https://alidocs.dingtalk.com/"); got != "download" { + t.Errorf("SafeFilename(%q) = %q", name, got) + } + } +} + +func TestCrossPlatformCoverageDownloadAtomicNoClobber(t *testing.T) { + base := t.TempDir() + payload := "first payload" + requests := 0 + client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + requests++ + if req.URL.Host != "alidocs.oss-cn-zhangjiakou.aliyuncs.com" { + return nil, errors.New("unexpected host") + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(payload)), Header: make(http.Header)}, nil + })} + result, err := downloadWithClient(context.Background(), "https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md", DownloadOptions{ + BaseDir: base, Output: "nested/result.md", + }, client) + if err != nil { + t.Fatal(err) + } + if result.RelativePath != "nested/result.md" || result.SizeBytes != int64(len(payload)) { + t.Fatalf("result = %#v", result) + } + got, err := os.ReadFile(result.AbsolutePath) + if err != nil || string(got) != payload { + t.Fatalf("published content = %q, err=%v", got, err) + } + if _, err := downloadWithClient(context.Background(), "https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md", DownloadOptions{ + BaseDir: base, Output: "nested/result.md", + }, client); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") { + t.Fatalf("second download error = %v", err) + } + if requests != 1 { + t.Fatalf("existing destination performed %d network requests, want 1 total", requests) + } + + got, err = os.ReadFile(result.AbsolutePath) + if err != nil || string(got) != payload { + t.Fatalf("no-clobber content = %q, err=%v", got, err) + } +} + +func TestCrossPlatformCoverageDownloadSizeLimitCleansPartialFiles(t *testing.T) { + base := t.TempDir() + for _, tc := range []struct { + name string + contentLength int64 + }{ + {name: "declared", contentLength: 6}, + {name: "streamed", contentLength: -1}, + } { + t.Run(tc.name, func(t *testing.T) { + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("123456")), + Header: make(http.Header), + ContentLength: tc.contentLength, + }, nil + })} + output := tc.name + ".bin" + if _, err := downloadWithClientLimit(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{ + BaseDir: base, Output: output, + }, client, 5); err == nil || !strings.Contains(err.Error(), "LOCAL_DOWNLOAD_TOO_LARGE") { + t.Fatalf("oversized download error = %v", err) + } + if _, err := os.Stat(filepath.Join(base, output)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("oversized destination exists: %v", err) + } + entries, err := os.ReadDir(base) + if err != nil { + t.Fatal(err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".dws-download-") { + t.Fatalf("oversized download left temp file %q", entry.Name()) + } + } + }) + } +} + +func TestCrossPlatformCoverageDownloadRejectsParentReplacementDuringNetwork(t *testing.T) { + base := t.TempDir() + parent := filepath.Join(base, "nested") + if err := os.Mkdir(parent, 0o700); err != nil { + t.Fatal(err) + } + original := filepath.Join(base, "original-parent") + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + if err := os.Rename(parent, original); err != nil { + t.Skipf("platform cannot replace an open directory: %v", err) + } + if err := os.Mkdir(parent, 0o700); err != nil { + t.Fatal(err) + } + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil + })} + if _, err := downloadWithClient(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{ + BaseDir: base, Output: "nested/result.bin", + }, client); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_CHANGED") { + t.Fatalf("parent replacement error = %v", err) + } + for _, candidate := range []string{filepath.Join(parent, "result.bin"), filepath.Join(original, "result.bin")} { + if _, err := os.Stat(candidate); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("parent replacement wrote %q: %v", candidate, err) + } + } +} + +func TestCrossPlatformCoverageDownloadRejectsParentReplacementBeforePublish(t *testing.T) { + base := t.TempDir() + parent := filepath.Join(base, "nested") + if err := os.Mkdir(parent, 0o700); err != nil { + t.Fatal(err) + } + original := filepath.Join(base, "original-parent") + client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil + })} + testseam.Swap(t, &createDownloadTemp, func(root *os.Root) (downloadTempFile, string, error) { + created, name, err := createDownloadTempInRoot(root) + if err != nil { + return nil, "", err + } + return &coverageTempFile{file: created.(*os.File), onClose: func() { + if renameErr := os.Rename(parent, original); renameErr != nil { + t.Skipf("platform cannot replace an open directory: %v", renameErr) + } + if mkdirErr := os.Mkdir(parent, 0o700); mkdirErr != nil { + t.Fatal(mkdirErr) + } + }}, name, nil + }) + if _, err := downloadWithClient(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{ + BaseDir: base, Output: "nested/result.bin", + }, client); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_CHANGED") { + t.Fatalf("parent replacement error = %v", err) + } + for _, candidate := range []string{filepath.Join(parent, "result.bin"), filepath.Join(original, "result.bin")} { + if _, err := os.Stat(candidate); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("parent replacement wrote %q: %v", candidate, err) + } + } +} + +func TestCrossPlatformCoverageDownloadFailureBoundaries(t *testing.T) { + base := t.TempDir() + validURL := "https://download.dingtalk.com/file.bin" + if _, err := Download(context.Background(), "bad", DownloadOptions{BaseDir: base, Output: "x"}); err == nil { + t.Fatal("invalid URL download succeeded") + } + if _, err := Download(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "../x"}); err == nil { + t.Fatal("unsafe output download succeeded") + } + + clientError := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("transport") })} + statusClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.Header.Get("x-test") != "ok" || req.Header.Get("") != "" { + t.Errorf("headers = %#v", req.Header) + } + return &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader("backend")), Header: make(http.Header)}, nil + })} + bodyErrorClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: failingBody{}, Header: make(http.Header)}, nil + })} + if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "transport.bin"}, clientError); err == nil { + t.Fatal("transport error was ignored") + } + if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "status.bin", Headers: map[string]string{"x-test": "ok", " ": "ignored"}}, statusClient); err == nil { + t.Fatal("HTTP status error was ignored") + } + if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "copy.bin"}, bodyErrorClient); err == nil { + t.Fatal("body read error was ignored") + } + + okClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil + })} + for _, tc := range []struct { + name string + makeTemp func(*os.Root) (downloadTempFile, string, error) + }{ + {"create", func(*os.Root) (downloadTempFile, string, error) { return nil, "", errors.New("create") }}, + {"sync", func(root *os.Root) (downloadTempFile, string, error) { + created, name, err := createDownloadTempInRoot(root) + if err != nil { + return nil, "", err + } + return &coverageTempFile{file: created.(*os.File), syncErr: errors.New("sync")}, name, nil + }}, + {"close", func(root *os.Root) (downloadTempFile, string, error) { + created, name, err := createDownloadTempInRoot(root) + if err != nil { + return nil, "", err + } + return &coverageTempFile{file: created.(*os.File), closeErr: errors.New("close")}, name, nil + }}, + {"publish-race", func(root *os.Root) (downloadTempFile, string, error) { + created, name, err := createDownloadTempInRoot(root) + if err != nil { + return nil, "", err + } + return &coverageTempFile{file: created.(*os.File), onClose: func() { + file, createErr := root.OpenFile("publish-race.bin", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if createErr == nil { + _ = file.Close() + } + }}, name, nil + }}, + } { + t.Run(tc.name, func(t *testing.T) { + testseam.Swap(t, &createDownloadTemp, tc.makeTemp) + if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: tc.name + ".bin"}, okClient); err == nil { + t.Fatalf("%s failure was ignored", tc.name) + } + }) + } +} + +func TestCrossPlatformCoverageSecureHTTPClientAndFilesystemEdges(t *testing.T) { + client := secureHTTPClient() + transport := client.Transport.(*http.Transport) + if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://download.dingtalk.com/x")}, make([]*http.Request, 5)); err == nil { + t.Fatal("redirect limit accepted") + } + if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://evil.example/x")}, nil); err == nil { + t.Fatal("unsafe redirect accepted") + } + if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://download.dingtalk.com/x")}, nil); err != nil { + t.Fatal(err) + } + if _, err := transport.DialContext(context.Background(), "tcp", "bad-address"); err == nil { + t.Fatal("bad address dial succeeded") + } + + t.Run("lookup error", func(t *testing.T) { + testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) { return nil, errors.New("lookup") }) + if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil { + t.Fatal("lookup error ignored") + } + }) + t.Run("private answer", func(t *testing.T) { + testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil + }) + if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil { + t.Fatal("private DNS answer accepted") + } + }) + t.Run("public dial fallback and success", func(t *testing.T) { + testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}, {IP: net.ParseIP("1.1.1.1")}}, nil + }) + left, right := net.Pipe() + t.Cleanup(func() { _ = left.Close(); _ = right.Close() }) + calls := 0 + testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) { + calls++ + if calls == 1 { + return nil, errors.New("first") + } + return left, nil + }) + if conn, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err != nil { + t.Fatal(err) + } else { + _ = conn.Close() + } + }) + t.Run("all public dials fail", func(t *testing.T) { + testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) { + return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil + }) + testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) { return nil, errors.New("dial") }) + if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil { + t.Fatal("dial failure ignored") + } + }) + + base := t.TempDir() + if _, _, err := ResolveOutputPath("", "default-base.tmp", "https://download.dingtalk.com/x", ""); err != nil { + t.Fatal(err) + } + if _, _, err := ResolveOutputPath(filepath.Join(base, "missing"), "x", "https://download.dingtalk.com/x", ""); err == nil { + t.Fatal("missing base succeeded") + } + dir := filepath.Join(base, "directory") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + for _, output := range []string{".", "directory/", "directory"} { + if _, _, err := ResolveOutputPath(base, output, "https://download.dingtalk.com/path/name.txt", "preferred.txt"); err != nil { + t.Errorf("directory output %q: %v", output, err) + } + } + if _, _, err := ResolveOutputPath(base, "directory", "https://download.dingtalk.com/x", ""); err != nil { + t.Fatal(err) + } + targetDir := filepath.Join(base, "target-dir") + _ = os.Mkdir(targetDir, 0o700) + if _, _, err := ResolveOutputPath(base, "target-dir", "https://download.dingtalk.com/x", "x"); err != nil { + t.Fatal(err) + } + targetFile := filepath.Join(base, "existing.txt") + _ = os.WriteFile(targetFile, []byte("x"), 0o600) + if _, _, err := ResolveOutputPath(base, "existing.txt", "https://download.dingtalk.com/x", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") { + t.Fatalf("existing destination error = %v", err) + } + link := filepath.Join(base, "target-link") + if err := os.Symlink(targetFile, link); err == nil { + if _, _, err := ResolveOutputPath(base, "target-link", "https://download.dingtalk.com/x", ""); err == nil { + t.Fatal("symlink destination accepted") + } + } + fileParent := filepath.Join(base, "file-parent") + _ = os.WriteFile(fileParent, []byte("x"), 0o600) + if _, _, err := ResolveOutputPath(base, "file-parent/child", "https://download.dingtalk.com/x", ""); err == nil { + t.Fatal("file parent accepted") + } + root, err := os.OpenRoot(base) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = root.Close() }) + if err := ensureSafeParent(root, "../escape"); err == nil { + t.Fatal("escaping parent accepted") + } + if err := ensureSafeParent(root, "."); err != nil { + t.Fatal(err) + } + + source, err := root.OpenFile("source.tmp", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + t.Fatal(err) + } + _, _ = source.WriteString("x") + _ = source.Close() + destination := filepath.Join(base, "publish.txt") + _ = os.WriteFile(destination, []byte("old"), 0o600) + if err := publishTempFile(root, "source.tmp", "publish.txt"); err == nil { + t.Fatal("publish existing destination succeeded") + } + if err := publishTempFile(root, "missing-source", "new.txt"); err == nil { + t.Fatal("publish missing source succeeded") + } + symlinkDestination := filepath.Join(base, "publish-link") + if err := os.Symlink(destination, symlinkDestination); err == nil { + if err := publishTempFile(root, "source.tmp", "publish-link"); err == nil { + t.Fatal("publish to symlink succeeded") + } + } + closedRoot, err := os.OpenRoot(base) + if err != nil { + t.Fatal(err) + } + _ = closedRoot.Close() + if _, _, err := createDownloadTempInRoot(closedRoot); err == nil { + t.Fatal("temp creation in closed root succeeded") + } + + for _, name := range []string{"", ".", "..", "name.", "name ", "bad\x00", "AUX", "COM1", "LPT9"} { + _ = sanitizeFilename(name) + } + _ = SafeFilename("", "https://download.dingtalk.com/path/fallback.txt") + _ = SafeFilename("", "https://download.dingtalk.com/%zz") + _ = SafeFilename("", "://bad") + _ = publicIP(net.IP{1, 2, 3}) +} + +func TestCrossPlatformCoverageSecureHTTPClientDisablesEnvironmentProxy(t *testing.T) { + t.Setenv("HTTPS_PROXY", "http://127.0.0.1:3128") + transport := secureHTTPClient().Transport.(*http.Transport) + if transport.Proxy != nil { + t.Fatal("secure download client accepted an environment proxy") + } +} + +func TestCrossPlatformCoverageSecureHTTPClientStripsCrossOriginHeaders(t *testing.T) { + client := secureHTTPClient() + original := &http.Request{ + URL: mustURL(t, "https://download.dingtalk.com/source"), + Header: http.Header{ + "X-Oss-Security-Token": []string{"credential-a"}, + "X-Download-Auth": []string{"credential-b"}, + }, + } + + sameOrigin := &http.Request{ + URL: mustURL(t, "https://DOWNLOAD.dingtalk.com.:443/next"), + Header: original.Header.Clone(), + } + if err := client.CheckRedirect(sameOrigin, []*http.Request{original}); err != nil { + t.Fatal(err) + } + if sameOrigin.Header.Get("X-Oss-Security-Token") == "" { + t.Fatal("same-origin redirect unexpectedly stripped request headers") + } + + crossOrigin := &http.Request{ + URL: mustURL(t, "https://attacker-bucket.oss-cn-hangzhou.aliyuncs.com/next"), + Header: original.Header.Clone(), + } + if err := client.CheckRedirect(crossOrigin, []*http.Request{original}); err != nil { + t.Fatal(err) + } + if len(crossOrigin.Header) != 0 { + t.Fatalf("cross-origin redirect retained %d request headers", len(crossOrigin.Header)) + } + + multiHop := &http.Request{ + URL: mustURL(t, "https://attacker-bucket.oss-cn-hangzhou.aliyuncs.com/final"), + Header: original.Header.Clone(), + } + if err := client.CheckRedirect(multiHop, []*http.Request{original, crossOrigin}); err != nil { + t.Fatal(err) + } + if len(multiHop.Header) != 0 { + t.Fatalf("later cross-origin redirect restored %d initial request headers", len(multiHop.Header)) + } +} + +func TestCrossPlatformCoverageFilesystemInjectedFailures(t *testing.T) { + base := t.TempDir() + validURL := "https://download.dingtalk.com/x" + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := Download(cancelled, validURL, DownloadOptions{BaseDir: base, Output: "default-client.bin"}); err == nil { + t.Fatal("cancelled default client download succeeded") + } + + t.Run("getwd", func(t *testing.T) { + testseam.Swap(t, &localGetwd, func() (string, error) { return "", errors.New("getwd") }) + _, _, _ = ResolveOutputPath("", "x", validURL, "") + }) + t.Run("abs", func(t *testing.T) { + testseam.Swap(t, &localAbs, func(string) (string, error) { return "", errors.New("abs") }) + _, _, _ = ResolveOutputPath(base, "x", validURL, "") + }) + t.Run("eval base", func(t *testing.T) { + testseam.Swap(t, &localEvalSymlinks, func(string) (string, error) { return "", errors.New("eval") }) + _, _, _ = ResolveOutputPath(base, "x", validURL, "") + }) + t.Run("open base", func(t *testing.T) { + testseam.Swap(t, &openDownloadRoot, func(string) (*os.Root, error) { return nil, errors.New("open root") }) + _, _, _ = ResolveOutputPath(base, "x", validURL, "") + }) + t.Run("mkdir", func(t *testing.T) { + testseam.Swap(t, &downloadRootMkdir, func(*os.Root, string, os.FileMode) error { return errors.New("mkdir") }) + _, _, _ = ResolveOutputPath(base, "new/target", validURL, "") + }) + t.Run("lstat after mkdir", func(t *testing.T) { + calls := 0 + testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) { + if name == "new-after" { + calls++ + if calls > 1 { + return nil, errors.New("after mkdir") + } + } + return root.Lstat(name) + }) + _, _, _ = ResolveOutputPath(base, "new-after/target", validURL, "") + }) + t.Run("open parent", func(t *testing.T) { + if err := os.Mkdir(filepath.Join(base, "open-parent"), 0o700); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &openDownloadParent, func(*os.Root, string) (*os.Root, error) { return nil, errors.New("open parent") }) + _, _, _ = ResolveOutputPath(base, "open-parent/target", validURL, "") + }) + t.Run("parent stat", func(t *testing.T) { + if err := os.Mkdir(filepath.Join(base, "parent-stat"), 0o700); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &downloadRootStat, func(root *os.Root, name string) (os.FileInfo, error) { + if name == "." { + return nil, errors.New("parent stat") + } + return root.Stat(name) + }) + _, _, _ = ResolveOutputPath(base, "parent-stat/target", validURL, "") + }) + t.Run("parent identity", func(t *testing.T) { + if err := os.Mkdir(filepath.Join(base, "parent-identity"), 0o700); err != nil { + t.Fatal(err) + } + otherInfo, err := os.Stat(t.TempDir()) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &downloadRootStat, func(root *os.Root, name string) (os.FileInfo, error) { + if name == "parent-identity" { + return otherInfo, nil + } + return root.Stat(name) + }) + _, _, _ = ResolveOutputPath(base, "parent-identity/target", validURL, "") + }) + t.Run("destination directory", func(t *testing.T) { + if err := os.Mkdir(filepath.Join(base, "destination-directory"), 0o700); err != nil { + t.Fatal(err) + } + dirInfo, err := os.Stat(base) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) { + if name == "target" { + return dirInfo, nil + } + return root.Lstat(name) + }) + _, _, _ = ResolveOutputPath(base, "destination-directory/target", validURL, "") + }) + t.Run("destination symlink", func(t *testing.T) { + if err := os.Mkdir(filepath.Join(base, "destination-symlink"), 0o700); err != nil { + t.Fatal(err) + } + link := filepath.Join(base, "coverage-link") + if err := os.Symlink(filepath.Join(base, "destination-symlink"), link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + linkInfo, err := os.Lstat(link) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) { + if name == "target" { + return linkInfo, nil + } + return root.Lstat(name) + }) + _, _, _ = ResolveOutputPath(base, "destination-symlink/target", validURL, "") + }) + t.Run("destination lstat", func(t *testing.T) { + if err := os.Mkdir(filepath.Join(base, "destination-lstat"), 0o700); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) { + if name == "target" { + return nil, errors.New("destination lstat") + } + return root.Lstat(name) + }) + _, _, _ = ResolveOutputPath(base, "destination-lstat/target", validURL, "") + }) + t.Run("unsafe parent type", func(t *testing.T) { + filePath := filepath.Join(base, "unsafe-parent") + if err := os.WriteFile(filePath, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + root, err := os.OpenRoot(base) + if err != nil { + t.Fatal(err) + } + defer root.Close() + if err := ensureSafeParent(root, "unsafe-parent"); err == nil { + t.Fatal("regular file accepted as output parent") + } + }) + t.Run("publish remove", func(t *testing.T) { + root, err := os.OpenRoot(base) + if err != nil { + t.Fatal(err) + } + defer root.Close() + file, err := root.OpenFile("remove-source", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + t.Fatal(err) + } + _ = file.Close() + testseam.Swap(t, &downloadRootRemove, func(*os.Root, string) error { return errors.New("remove") }) + if err := publishTempFile(root, "remove-source", "remove-destination"); err == nil { + t.Fatal("publish remove error ignored") + } + }) +} + +func mustURL(t *testing.T, raw string) *url.URL { + t.Helper() + parsed, err := url.Parse(raw) + if err != nil { + t.Fatal(err) + } + return parsed +} diff --git a/internal/shortcut/adapter.go b/internal/shortcut/adapter.go index dcbc3fd0..ecbc3c09 100644 --- a/internal/shortcut/adapter.go +++ b/internal/shortcut/adapter.go @@ -77,11 +77,28 @@ func FromShortcut(s Shortcut) corecmd.Spec { } func fromShortcutPostMount(s Shortcut) func(*cobra.Command) { - if len(s.Aliases) == 0 && strings.TrimSpace(s.SinglePositionalAliasFor) == "" { + hasVisibleFlagAliases := false + for _, flag := range s.Flags { + if flag.AliasesVisible && len(flag.Aliases) > 0 { + hasVisibleFlagAliases = true + break + } + } + if len(s.Aliases) == 0 && strings.TrimSpace(s.SinglePositionalAliasFor) == "" && !hasVisibleFlagAliases { return nil } return func(cmd *cobra.Command) { cmd.Aliases = append([]string(nil), s.Aliases...) + for _, flag := range s.Flags { + if !flag.AliasesVisible { + continue + } + for _, alias := range flag.Aliases { + if mounted := cmd.Flags().Lookup(alias); mounted != nil { + mounted.Hidden = false + } + } + } name := strings.TrimSpace(s.SinglePositionalAliasFor) if name == "" { return @@ -117,6 +134,16 @@ func safetySpecDeclared(safety contract.SafetySpec) bool { strings.TrimSpace(safety.Idempotency) != "" } +// EffectiveSafety returns the exact safety declaration used by the runtime and +// ContractFinal. Management/listing projections must use this instead of +// re-inferring confirmation from the legacy Risk enum. +func EffectiveSafety(s Shortcut) contract.SafetySpec { + if safetySpecDeclared(s.Safety) { + return s.Safety + } + return shortcutSafetySpec(s.risk()) +} + func shortcutExamples(tips []string) string { if len(tips) == 0 { return "" @@ -188,6 +215,7 @@ func fromShortcutFlags(flags []Flag) []corecmd.FlagSpec { ValidationMode: corecmd.ValidationShortcut, RequiredError: fmt.Sprintf("缺少必填参数 --%s:%s", f.Name, f.Desc), Enum: append([]string(nil), f.Enum...), + Aliases: append([]string(nil), f.Aliases...), }) } return out diff --git a/internal/shortcut/adapter_test.go b/internal/shortcut/adapter_test.go index 8c795fff..5687ab35 100644 --- a/internal/shortcut/adapter_test.go +++ b/internal/shortcut/adapter_test.go @@ -66,6 +66,13 @@ func TestCrossPlatformCoverageFromShortcutMapsSharedBase(t *testing.T) { cs.Safety.Confirmation != "user_required" || cs.Safety.Idempotency != "unknown" { t.Fatalf("adapter safety = %#v, want destructive/high/user_required/unknown", cs.Safety) } + if got := EffectiveSafety(Shortcut{Risk: RiskWrite}); got.Effect != "write" || got.Confirmation != "user_required" { + t.Fatalf("legacy effective safety = %#v", got) + } + explicit := contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} + if got := EffectiveSafety(Shortcut{Risk: RiskHighWrite, Safety: explicit}); got != explicit { + t.Fatalf("explicit effective safety = %#v, want %#v", got, explicit) + } if cs.Orchestrate == nil { t.Fatal("multi-step Execute must project into Orchestrate") } @@ -142,7 +149,7 @@ func TestCrossPlatformCoverageFromShortcutAliasesAndPositionalAlias(t *testing.T ProductID: "chat", Name: "shortcut_search", CanonicalPath: "chat.shortcut_search", CLIPath: "chat +search", PrimaryCLIPath: "chat +search", }, }, - Flags: []Flag{{Name: "query", Desc: "关键词", Required: true}}, + Flags: []Flag{{Name: "query", Desc: "关键词", Required: true, Aliases: []string{"keyword"}, AliasesVisible: true}}, Execute: func(rt *RuntimeContext) error { executed = rt.Str("query"); return nil }, } spec := FromShortcut(s) @@ -153,6 +160,9 @@ func TestCrossPlatformCoverageFromShortcutAliasesAndPositionalAlias(t *testing.T if !cmd.HasAlias("+search-group") { t.Fatalf("cobra aliases = %#v", cmd.Aliases) } + if alias := cmd.Flags().Lookup("keyword"); alias == nil || alias.Hidden { + t.Fatalf("historically public flag alias = %#v, want visible", alias) + } cmd.SetArgs([]string{"项目群"}) if err := cmd.Execute(); err != nil || executed != "项目群" { t.Fatalf("positional execute err=%v value=%q", err, executed) diff --git a/internal/shortcut/builtin/builtin.go b/internal/shortcut/builtin/builtin.go index a2a04051..aca6aa6e 100644 --- a/internal/shortcut/builtin/builtin.go +++ b/internal/shortcut/builtin/builtin.go @@ -12,9 +12,10 @@ // limitations under the License. // Package builtin aggregates all built-in shortcut service packages via blank -// imports so their init() registrations run, then re-exports the compiled cobra -// commands. The host application depends only on this package, keeping the -// service packages free to import the core shortcut package without a cycle. +// imports so their init() registrations run, applies the reviewed semantic and +// public-catalog decorations in the core registry, then re-exports the compiled +// cobra commands. The host application depends only on this package, keeping +// the service packages free to import the core shortcut package without a cycle. // // Add a blank import here when a new service package is generated under // internal/shortcut//. diff --git a/internal/shortcut/builtin/command_surface_test.go b/internal/shortcut/builtin/command_surface_test.go index e65263e1..97fb6e5e 100644 --- a/internal/shortcut/builtin/command_surface_test.go +++ b/internal/shortcut/builtin/command_surface_test.go @@ -27,7 +27,7 @@ import ( // TestCmdcoreMountPreservesEveryBuiltInShortcutSurface is the differential // guard for the live mount migration. It derives the historical Cobra surface // directly from each Shortcut declaration and checks the command-built tree. -func TestCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) { +func TestCrossPlatformCoverageCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) { mounted := map[string]*cobra.Command{} for _, service := range builtin.BaseCommands() { for _, command := range service.Commands() { @@ -83,6 +83,18 @@ func TestCmdcoreMountPreservesEveryBuiltInShortcutSurface(t *testing.T) { spec.Service, spec.Command, flag.Name, got.Hidden, flag.Hidden) } assertShortcutDefault(t, command, spec, flag) + for _, alias := range flag.Aliases { + declaredFlags[alias] = flag + gotAlias := command.Flags().Lookup(alias) + if gotAlias == nil { + t.Errorf("%s %s: flag alias --%s is not mounted", spec.Service, spec.Command, alias) + continue + } + wantHidden := !flag.AliasesVisible + if gotAlias.Hidden != wantHidden { + t.Errorf("%s %s: flag alias --%s hidden = %v, want %v", spec.Service, spec.Command, alias, gotAlias.Hidden, wantHidden) + } + } } command.Flags().VisitAll(func(flag *pflag.Flag) { if flag.Name == "help" { diff --git a/internal/shortcut/builtin/doc_semantic_catalog_test.go b/internal/shortcut/builtin/doc_semantic_catalog_test.go new file mode 100644 index 00000000..0c21e688 --- /dev/null +++ b/internal/shortcut/builtin/doc_semantic_catalog_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageDocSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_doc.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "doc" { + t.Fatalf("semantic catalog service = %q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "doc" { + registered[item.Command] = item + } + } + if len(registered) != 47 || len(source.Shortcuts) != 47 { + t.Fatalf("registered/catalog = %d/%d, want 47/47", len(registered), len(source.Shortcuts)) + } + var missing, stale []string + public, hidden := 0, 0 + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + if !record.Reviewed || !item.SemanticReviewed || record.SemanticDelta != item.SemanticDelta || record.Disposition != item.Disposition { + t.Errorf("%s: reviewed semantic delivery mismatch", command) + } + if got := shortcut.InPublicCatalog("doc", command); got != record.Public || item.Hidden == record.Public { + t.Errorf("%s: public/hidden mismatch: catalog=%v runtimeHidden=%v", command, record.Public, item.Hidden) + } + if record.Public { + public++ + if item.Contract.Empty() { + t.Errorf("%s: public Doc shortcut has empty Contract", command) + } + } else { + hidden++ + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 45 || hidden != 2 { + t.Fatalf("public/hidden = %d/%d, want 45/2", public, hidden) + } + + wantPrimaries := map[string]string{ + "+find-doc": "+search", "+doc-append": "+update", "+version-save": "+history-save", + "+version-list": "+history-list", "+version-revert": "+history-revert", "+share-doc": "+share", + } + for command, primary := range wantPrimaries { + item := registered[command] + if item.Disposition != shortcut.DispositionAliasInternal || item.PrimaryCommand != primary { + t.Errorf("%s compatibility routing = %s/%s, want alias_internal/%s", command, item.Disposition, item.PrimaryCommand, primary) + } + } +} diff --git a/internal/shortcut/doc/common.go b/internal/shortcut/doc/common.go new file mode 100644 index 00000000..8e66f8fe --- /dev/null +++ b/internal/shortcut/doc/common.go @@ -0,0 +1,182 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package doc + +import ( + "encoding/json" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const compositeInterfaceReason = "Reviewed Doc Shortcut composite: the executable CLI owns validation, multi-step orchestration, local I/O, output projection, and confirmation; no single MCP interface represents the complete command contract." + +func docContract(command, description, intent string, examples []string, params ...contract.ParamDecl) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "doc " + command + return corecmd.ContractDecl{ + Description: description, + Parameters: params, + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: compositeInterfaceReason, + }, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{ + "需要文件树移动、复制或普通钉盘文件操作时改用 drive;非文字文档按对象类型路由到 sheet、aitable、slides 或 wiki", + }, + Examples: examples, + }, + Identity: contract.ToolIdentitySpec{ + ProductID: "doc", + Name: name, + CanonicalPath: "doc." + name, + CLIPath: cliPath, + PrimaryCLIPath: cliPath, + }, + } +} + +func withDryRun(decl corecmd.ContractDecl, kind string, remoteReads bool) corecmd.ContractDecl { + decl.DryRun = &contract.DryRunSpec{PreviewKind: kind, RemoteReads: remoteReads} + return decl +} + +func readShortcutContent(rt *shortcut.RuntimeContext, flag string) (string, error) { + raw := rt.Str(flag) + if raw == "-" { + data, err := io.ReadAll(rt.Command().InOrStdin()) + if err != nil { + return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取 stdin 失败: %v", flag, err)) + } + return string(data), nil + } + if !strings.HasPrefix(raw, "@") { + return raw, nil + } + path := strings.TrimSpace(strings.TrimPrefix(raw, "@")) + if path == "" || filepath.IsAbs(path) { + return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 只接受工作目录内的相对路径", flag)) + } + cwd, err := docGetwd() + if err != nil { + return "", apperrors.NewInternal(fmt.Sprintf("读取工作目录失败: %v", err)) + } + realBase, err := docEvalSymlinks(cwd) + if err != nil { + return "", apperrors.NewInternal(fmt.Sprintf("解析工作目录失败: %v", err)) + } + realPath, err := docEvalSymlinks(filepath.Join(realBase, filepath.Clean(path))) + if err != nil { + return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err)) + } + rel, err := docRel(realBase, realPath) + if err != nil || rel == ".." || strings.HasPrefix(filepath.ToSlash(rel), "../") { + return "", apperrors.NewValidation(fmt.Sprintf("--%s 的 @file 不能逃逸工作目录", flag)) + } + data, err := docReadFile(realPath) + if err != nil { + return "", apperrors.NewValidation(fmt.Sprintf("--%s: 读取文件 %q 失败: %v", flag, path, err)) + } + return string(data), nil +} + +func validateJSONML(raw string) (string, error) { + var value any + if err := json.Unmarshal([]byte(raw), &value); err != nil { + return "", apperrors.NewValidation(fmt.Sprintf("JSONML 解析失败: %v", err)) + } + if _, ok := value.([]any); !ok { + return "", apperrors.NewValidation("JSONML 顶层必须是数组") + } + normalized, _ := json.Marshal(value) // decoded JSON trees are always marshalable + return string(normalized), nil +} + +func docEnvelope(operation string, data any, steps ...map[string]any) map[string]any { + return map[string]any{ + "ok": true, + "status": "success", + "operation": operation, + "steps": steps, + "data": data, + "warnings": []string{}, + "compensation": map[string]any{"available": false, "reason": ""}, + } +} + +func docPartialWriteError(operation, reason, stage, message string, cause error, data map[string]any, steps []map[string]any, compensation map[string]any) error { + return apperrors.NewAPI( + message, + apperrors.WithOperation(operation), + apperrors.WithReason(reason), + apperrors.WithFailureStage(stage), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithActions("inspect the completed steps before retrying", "use the compensation details to clean up or restore the document"), + apperrors.WithDetails(map[string]any{ + "status": "partial_success", + "data": data, + "steps": steps, + "compensation": compensation, + }), + apperrors.WithCause(cause), + ) +} + +func nestedString(data map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + for _, wrapper := range []string{"result", "data", "content"} { + if inner, ok := data[wrapper].(map[string]any); ok { + if value := nestedString(inner, keys...); value != "" { + return value + } + } + } + return "" +} + +func nestedMap(data map[string]any) map[string]any { + for _, wrapper := range []string{"result", "data"} { + if inner, ok := data[wrapper].(map[string]any); ok { + return nestedMap(inner) + } + } + return data +} + +func stringSliceNonEmpty(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + out = append(out, value) + } + } + return out +} + +// blockIdentity normalizes the currently observed block response shapes. The +// element API returns element.id, while JSONML and older payloads use blockId +// or uuid. Callers pass an inherited parent identity for nested text maps. +func blockIdentity(values map[string]any, inherited string) string { + for _, key := range []string{"blockId", "id", "uuid"} { + if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return inherited +} diff --git a/internal/shortcut/doc/content_shortcuts.go b/internal/shortcut/doc/content_shortcuts.go new file mode 100644 index 00000000..6ae47090 --- /dev/null +++ b/internal/shortcut/doc/content_shortcuts.go @@ -0,0 +1,795 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package doc + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + "unicode" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var ( + docGetwd = os.Getwd + docEvalSymlinks = filepath.EvalSymlinks + docRel = filepath.Rel + docReadFile = os.ReadFile + docMkdirTemp = os.MkdirTemp + docRemoveAll = os.RemoveAll + docDownload = localio.Download +) + +var Create = shortcut.Shortcut{ + Service: "doc", + Command: "+create", + Product: productDoc, + Description: "从 Markdown 或 JSONML 创建在线文字文档", + Intent: "当用户要新建钉钉在线文字文档,并可同时写入 Markdown/JSONML 初始内容、指定文件夹或知识库位置时使用;不会用于普通文件上传或其他在线对象类型。", + Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown", + }, + Contract: docContract( + "+create", "从 Markdown 或 JSONML 创建在线文字文档", + "当用户要新建钉钉在线文字文档,并可同时写入 Markdown/JSONML 初始内容、指定文件夹或知识库位置时使用;不会用于普通文件上传或其他在线对象类型。", + []string{`dws doc +create --name "项目周报" --content "# 本周进展"`, `dws doc +create --name "模板" --content @body.json --doc-format jsonml`}, + contract.ParamDecl{Name: "folder", Property: "folderId"}, + contract.ParamDecl{Name: "workspace", Property: "workspaceId"}, + ), + Flags: []shortcut.Flag{ + {Name: "name", Type: shortcut.FlagString, Desc: "新文档名称", Required: true}, + {Name: "content", Type: shortcut.FlagString, Desc: "内容字面量、@工作目录相对文件或 - 表示 stdin"}, + {Name: "doc-format", Type: shortcut.FlagString, Default: "markdown", Desc: "内容格式", Enum: []string{"markdown", "jsonml"}}, + {Name: "folder", Type: shortcut.FlagString, Desc: "目标文档文件夹 ID"}, + {Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"}, + }, + Tips: []string{`dws doc +create --name "项目周报" --content "# 本周进展"`, `dws doc +create --name "模板" --content @body.json --doc-format jsonml`}, + Execute: func(rt *shortcut.RuntimeContext) error { + content, err := readShortcutContent(rt, "content") + if err != nil { + return err + } + format := rt.Str("doc-format") + if format == "jsonml" && content != "" { + content, err = validateJSONML(content) + if err != nil { + return err + } + } + params := map[string]any{"name": rt.Str("name")} + if rt.Str("folder") != "" { + params["folderId"] = rt.Str("folder") + } + if rt.Str("workspace") != "" { + params["workspaceId"] = rt.Str("workspace") + } + if format == "markdown" && content != "" { + params["markdown"] = content + } + if rt.DryRun() { + return rt.Output(docEnvelope("doc.create", map[string]any{"executed": false, "previewKind": "plan", "create": params, "docFormat": format, "contentBytes": len(content)})) + } + created, err := rt.CallMCPWriteData(productDoc, "create_document", params) + if err != nil { + return err + } + nodeID := nestedString(created, "nodeId", "documentId", "id") + steps := []map[string]any{{"name": "create_document", "status": "success"}} + if format == "jsonml" && content != "" { + if nodeID == "" { + return docPartialWriteError( + "doc.create", "doc_create_missing_node_id", "resolve_created_document", + "创建文档成功但响应缺少 nodeId;JSONML 尚未写入,请先在钉钉中定位新文档,不要直接重试", + nil, + map[string]any{"nodeId": "", "docFormat": format}, + append(steps, map[string]any{"name": "write_jsonml", "status": "not_started"}), + map[string]any{"available": false, "reason": "create_document did not return nodeId; locate the new document in DingTalk"}, + ) + } + if _, err := rt.CallMCPWriteData(productDoc, "update_document", map[string]any{"nodeId": nodeID, "format": "jsonml", "jsonml": content, "mode": "overwrite"}); err != nil { + return docPartialWriteError( + "doc.create", "doc_create_initial_content_failed", "write_jsonml", + fmt.Sprintf("文档已创建但 JSONML 写入失败(nodeId=%s);不要直接重试创建", nodeID), + err, + map[string]any{"nodeId": nodeID, "docFormat": format}, + append(steps, map[string]any{"name": "write_jsonml", "status": "failed"}), + map[string]any{"available": true, "action": "delete_created_document", "nodeId": nodeID, "reason": "remove the empty document before retrying create"}, + ) + } + steps = append(steps, map[string]any{"name": "write_jsonml", "status": "success"}) + } + return rt.Output(docEnvelope("doc.create", map[string]any{"nodeId": nodeID, "result": created}, steps...)) + }, +} + +var Fetch = shortcut.Shortcut{ + Service: "doc", + Command: "+fetch", + Product: productDoc, + Description: "读取完整或局部文档内容,并按 detail 控制保真度", + Intent: "当用户要读取在线文字文档正文,或需要 block ID、JSONML、outline/range/section/keyword/tags 局部内容用于精确编辑和评论时使用;非最新历史 revision 会明确拒绝。", + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, + Contract: docContract( + "+fetch", "读取完整或局部文档内容,并按 detail 控制保真度", + "当用户要读取在线文字文档正文,或需要 block ID、JSONML、outline/range/section/keyword/tags 局部内容用于精确编辑和评论时使用;非最新历史 revision 会明确拒绝。", + []string{`dws doc +fetch --node `, `dws doc +fetch --node --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 `, `dws doc +fetch --node --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 `, `dws doc +inspect --node --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 `, `dws doc +inspect --node --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 --command append --content "补充说明"`, `dws doc +update --node --command block_replace --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 --command append --content "补充说明"`, `dws doc +update --node --command block_replace --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 --mode append --content @section.md`, `dws doc +checkpoint-update --node --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 --mode append --content @section.md`, `dws doc +checkpoint-update --node --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 --export-format docx --output ./exports/`, `dws doc +export --node --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 --export-format docx --output ./exports/`, `dws doc +export --node --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 `, `dws doc +import --file ./notes.md --workspace --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 `, `dws doc +import --file ./notes.md --workspace --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) +} diff --git a/internal/shortcut/doc/doc.go b/internal/shortcut/doc/doc.go index 7feee9e8..bb3ad63c 100644 --- a/internal/shortcut/doc/doc.go +++ b/internal/shortcut/doc/doc.go @@ -982,6 +982,11 @@ var TemplateApply = shortcut.Shortcut{ } func init() { + // Expert/recovery leaves remain callable without entering Agent discovery. + CommentCreateInline.Contract = corecmd.ContractDecl{} + TemplateApply.Contract = corecmd.ContractDecl{} + canonicalizeHistoryShortcuts() + canonicalizeCommentShortcuts() shortcut.Register( Search, List, @@ -993,6 +998,9 @@ func init() { CommentCreateInline, ExportSubmit, ExportGet, + legacyVersionSave, + legacyVersionList, + legacyVersionRevert, VersionSave, VersionList, VersionRevert, diff --git a/internal/shortcut/doc/doc_shortcuts_test.go b/internal/shortcut/doc/doc_shortcuts_test.go new file mode 100644 index 00000000..92174cde --- /dev/null +++ b/internal/shortcut/doc/doc_shortcuts_test.go @@ -0,0 +1,763 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package doc + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "unicode/utf8" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type docCoverageCaller struct { + failAt int + calls int + responses map[string][]map[string]any + ctx context.Context + history []docCoverageCall +} + +type docCoverageCall struct { + tool string + params map[string]any +} + +type docCoverageErrorReader struct{} + +func (docCoverageErrorReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") } + +func (f *docCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) { + f.calls++ + f.history = append(f.history, docCoverageCall{tool: tool, params: params}) + if f.failAt == f.calls { + return nil, errors.New("injected doc coverage failure") + } + value := docCoveragePayload(tool) + if queue := f.responses[tool]; len(queue) > 0 { + value = queue[0] + f.responses[tool] = queue[1:] + } + encoded, err := json.Marshal(value) + if err != nil { + return nil, err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil +} + +func (f *docCoverageCaller) Format() string { return "json" } +func (f *docCoverageCaller) DryRun() bool { return false } +func (f *docCoverageCaller) Fields() string { return "" } +func (f *docCoverageCaller) JQ() string { return "" } + +func docCoveragePayload(tool string) map[string]any { + switch tool { + case "create_document": + return map[string]any{"data": map[string]any{"nodeId": "node-1"}} + case "get_document_content": + return map[string]any{"data": map[string]any{"revision": 1, "jsonml": `["root",{},["p",{"uuid":"block-1"},"alpha beta"]]`}} + case "list_document_blocks": + return map[string]any{"blocks": []any{map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "alpha beta"}}}}} + case "submit_export_job": + return map[string]any{"jobId": "job-1"} + case "query_export_job": + return map[string]any{"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/export.docx"} + case "list_doc_versions": + return map[string]any{"versions": []any{map[string]any{"version": 3.0}, map[string]any{"versionNumber": "4"}}} + case "search_doc_templates": + return map[string]any{"templates": []any{map[string]any{"templateId": "template-1"}}} + case "get_document_style": + return map[string]any{"data": map[string]any{"cover": map[string]any{"resourceId": "resource-1", "imageUrl": "https://download.dingtalk.com/cover.png"}}} + case "download_doc_attachment": + return map[string]any{"downloadUrl": "https://download.dingtalk.com/file.bin", "fileName": "file.bin", "headers": map[string]any{"x-test": "ok", "ignored": 1}} + case "list_comments": + return map[string]any{"commentList": []any{map[string]any{"commentKey": "comment-1", "content": "review", "quote": "alpha"}}} + default: + return map[string]any{"ok": true, "result": map[string]any{"id": "id-1"}} + } +} + +func runDocCoverage(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, args ...string) error { + return runDocCoverageInput(t, declaration, caller, strings.NewReader(""), args...) +} + +func runDocCoverageInput(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, input io.Reader, args ...string) error { + return runDocCoveragePath(t, declaration, caller, input, declaration.Command, args...) +} + +func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *docCoverageCaller, input io.Reader, commandPath string, args ...string) error { + t.Helper() + helpers.InitDeps(caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: "doc"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetIn(input) + if caller.ctx != nil { + root.SetContext(caller.ctx) + } + root.SetArgs(append([]string{"doc", commandPath}, args...)) + return root.Execute() +} + +func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testing.T) { + revisionPayload := map[string]any{"data": map[string]any{"revision": json.Number("9")}} + if got, ok := nestedRevision(revisionPayload); !ok || got != 9 { + t.Fatalf("nestedRevision = %d/%v", got, ok) + } + + blocks := map[string]any{"blocks": []any{ + map[string]any{"element": map[string]any{ + "id": "block-1", "paragraph": map[string]any{"text": "前缀😀真实追加:beta。后缀"}, + }}, + }} + matches := findSelectionMatches(blocks, "真实追加:beta。") + if len(matches) != 1 { + t.Fatalf("matches = %#v", matches) + } + if got := matches[0]; got.blockID != "block-1" || got.start != 4 || got.end != 14 { + t.Fatalf("selection match = %#v", got) + } + + jsonml := `["root",{},["p",{"uuid":"block-jsonml"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"旧入口兼容追加:gamma。"]]]]` + projected := projectKeywordMatches(map[string]any{"jsonml": jsonml}, "gamma", 80, 120) + if projected["count"] != 1 { + t.Fatalf("keyword projection = %#v", projected) + } + rows := projected["matches"].([]map[string]any) + if rows[0]["blockId"] != "block-jsonml" || rows[0]["content"] != "旧入口兼容追加:gamma。" { + t.Fatalf("keyword row = %#v", rows[0]) + } + + unicodeProjection := projectKeywordMatches(map[string]any{"id": "unicode-block", "text": "KABtargetCD"}, "TARGET", 1, 1) + unicodeRows := unicodeProjection["matches"].([]map[string]any) + if len(unicodeRows) != 1 || unicodeRows[0]["content"] != "BtargetC" || !utf8.ValidString(unicodeRows[0]["content"].(string)) { + t.Fatalf("unicode keyword projection = %#v", unicodeProjection) + } +} + +func TestCrossPlatformCoverageDocCompositePartialWriteContracts(t *testing.T) { + assertPartial := func(t *testing.T, err error, reason, stage, nodeID string, wantSteps int) *apperrors.Error { + t.Helper() + if err == nil { + t.Fatal("partial write unexpectedly succeeded") + } + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("partial write error = %#v", err) + } + if typed.Reason != reason || typed.FailureStage != stage || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable { + t.Fatalf("partial write metadata = %#v", typed) + } + if typed.Details["status"] != "partial_success" { + t.Fatalf("partial write details = %#v", typed.Details) + } + data, _ := typed.Details["data"].(map[string]any) + if data["nodeId"] != nodeID { + t.Fatalf("partial write data = %#v", data) + } + steps, _ := typed.Details["steps"].([]map[string]any) + if len(steps) != wantSteps || steps[0]["status"] != "success" || steps[len(steps)-1]["status"] == "success" { + t.Fatalf("partial write steps = %#v", steps) + } + return typed + } + + create := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{}} + err := runDocCoverage(t, Create, create, "--name", "n", "--content", `[]`, "--doc-format", "jsonml") + typed := assertPartial(t, err, "doc_create_initial_content_failed", "write_jsonml", "node-1", 2) + compensation, _ := typed.Details["compensation"].(map[string]any) + if compensation["available"] != true || compensation["nodeId"] != "node-1" || len(create.history) != 2 { + t.Fatalf("create compensation=%#v history=%#v", compensation, create.history) + } + + checkpointUpdate := &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{ + "save_doc_version": {{"version": 7.0}}, + }} + err = runDocCoverage(t, CheckpointUpdate, checkpointUpdate, "--node", "n", "--content", "body", "--yes") + typed = assertPartial(t, err, "doc_checkpoint_update_failed", "update", "n", 3) + data, _ := typed.Details["data"].(map[string]any) + compensation, _ = typed.Details["compensation"].(map[string]any) + if data["checkpointVersion"] != 7 || compensation["version"] != 7 { + t.Fatalf("checkpoint recovery metadata data=%#v compensation=%#v", data, compensation) + } + + checkpointVerify := &docCoverageCaller{failAt: 3, responses: map[string][]map[string]any{}} + err = runDocCoverage(t, CheckpointUpdate, checkpointVerify, "--node", "n", "--content", "body", "--yes") + assertPartial(t, err, "doc_checkpoint_verification_failed", "verify", "n", 3) + + historyVerify := &docCoverageCaller{failAt: 3, responses: map[string][]map[string]any{}} + err = runDocCoverage(t, VersionRevert, historyVerify, "--node", "n", "--version", "3", "--yes") + assertPartial(t, err, "doc_history_revert_verification_failed", "verify", "n", 3) +} + +func TestCrossPlatformCoverageDocUpdateAliasReachesNestedBranches(t *testing.T) { + tests := []struct { + name string + args []string + wantTools []string + }{ + { + name: "plain text replace", + args: []string{"--doc", "alias-node", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"}, + wantTools: []string{"list_document_blocks", "update_document_block"}, + }, + { + name: "block copy", + args: []string{"--doc", "alias-node", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "after", "--yes"}, + wantTools: []string{"list_document_blocks", "insert_document_block"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &docCoverageCaller{responses: map[string][]map[string]any{}} + if err := runDocCoverage(t, Update, caller, tc.args...); err != nil { + t.Fatal(err) + } + if len(caller.history) != len(tc.wantTools) { + t.Fatalf("calls = %#v", caller.history) + } + for index, call := range caller.history { + if call.tool != tc.wantTools[index] || call.params["nodeId"] != "alias-node" { + t.Fatalf("call %d = %#v, want tool=%s nodeId=alias-node", index, call, tc.wantTools[index]) + } + } + }) + } +} + +func TestCrossPlatformCoverageSelectionMatchesEnumerateEveryCandidate(t *testing.T) { + tests := []struct { + name string + text string + selection string + want int + }{ + {name: "repeated omitted range", text: "left A right; left B right", selection: "left...right", want: 3}, + {name: "empty prefix", text: "one right; two right", selection: "...right", want: 2}, + {name: "empty suffix", text: "left one; left two", selection: "left...", want: 2}, + {name: "both empty anchors", text: "whole block", selection: "...", want: 1}, + {name: "empty block", text: "", selection: "...", want: 0}, + {name: "overlapping literal", text: "aaa", selection: "aa", want: 2}, + {name: "empty selection", text: "text", selection: "", want: 0}, + {name: "missing prefix", text: "text", selection: "left...right", want: 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + matches := findSelectionMatches(map[string]any{"id": "block", "text": tc.text}, tc.selection) + if len(matches) != tc.want { + t.Fatalf("matches = %#v, want %d", matches, tc.want) + } + }) + } + + caller := &docCoverageCaller{responses: map[string][]map[string]any{ + "list_document_blocks": {{"items": []any{map[string]any{"id": "block", "text": "left A right; left B right"}}}}, + }} + err := runDocCoverage(t, CommentCreate, caller, "--node", "n", "--content", "review", "--selection", "left...right", "--yes") + if err == nil || !strings.Contains(err.Error(), "AMBIGUOUS_SELECTION") { + t.Fatalf("same-block ambiguity error = %v", err) + } + if len(caller.history) != 1 || caller.history[0].tool != "list_document_blocks" { + t.Fatalf("ambiguous selection reached a write: %#v", caller.history) + } +} + +func TestCrossPlatformCoverageDocDestructiveConfirmationBoundaries(t *testing.T) { + tests := []struct { + name string + decl shortcut.Shortcut + args []string + want []docCoverageCall + }{ + { + name: "comment delete", + decl: CommentDelete, + args: []string{"--node", "n", "--comment-key", "c"}, + want: []docCoverageCall{{tool: "delete_comment", params: map[string]any{"nodeId": "n", "commentKey": "c"}}}, + }, + { + name: "resource delete", + decl: ResourceDelete, + args: []string{"--node", "n"}, + want: []docCoverageCall{{tool: "update_document_style", params: map[string]any{"nodeId": "n", "cover": map[string]any{"action": "clear"}}}}, + }, + { + name: "history revert", + decl: VersionRevert, + args: []string{"--node", "n", "--version", "3"}, + want: []docCoverageCall{ + {tool: "list_doc_versions", params: map[string]any{"nodeId": "n"}}, + {tool: "revert_doc_version", params: map[string]any{"nodeId": "n", "version": 3}}, + {tool: "get_document_info", params: map[string]any{"nodeId": "n"}}, + }, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + unconfirmed := &docCoverageCaller{responses: map[string][]map[string]any{}} + if err := runDocCoverage(t, tc.decl, unconfirmed, tc.args...); err == nil { + t.Fatal("destructive shortcut without --yes must reject") + } + if unconfirmed.calls != 0 || len(unconfirmed.history) != 0 { + t.Fatalf("unconfirmed shortcut called MCP: %#v", unconfirmed.history) + } + + confirmed := &docCoverageCaller{responses: map[string][]map[string]any{}} + if err := runDocCoverage(t, tc.decl, confirmed, append(tc.args, "--yes")...); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(confirmed.history, tc.want) { + t.Fatalf("confirmed calls = %#v, want %#v", confirmed.history, tc.want) + } + }) + } +} + +func TestCrossPlatformCoverageReviewInfersInlineBlockFromUniqueQuote(t *testing.T) { + comments := map[string]any{"commentList": []any{ + map[string]any{"commentKey": "inline", "content": "review", "isGlobal": false, "quote": "真实追加:beta。"}, + map[string]any{"commentKey": "global", "content": "global", "isGlobal": true}, + }} + blocks := map[string]any{"blocks": []any{ + map[string]any{"element": map[string]any{"id": "block-1", "paragraph": map[string]any{"text": "真实追加:beta。"}}}, + }} + items := projectReviewComments(comments, blocks) + if len(items) != 2 { + t.Fatalf("review items = %#v", items) + } + if items[0]["blockId"] != "block-1" || items[0]["context"] != "真实追加:beta。" { + t.Fatalf("inline review = %#v", items[0]) + } + if items[1]["blockId"] != "" { + t.Fatalf("global review = %#v", items[1]) + } +} + +func TestCrossPlatformCoverageDocContentCommandsAndFailureBoundaries(t *testing.T) { + t.Chdir(t.TempDir()) + if err := os.WriteFile("body.json", []byte(`["root",{},"body"]`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile("body.md", []byte("from file"), 0o600); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + cmd shortcut.Shortcut + args []string + }{ + {"create markdown", Create, []string{"--name", "n", "--content", "body", "--folder", "f", "--workspace", "w"}}, + {"create dry", Create, []string{"--name", "n", "--content", "body", "--dry-run"}}, + {"create jsonml file", Create, []string{"--name", "n", "--content", "@body.json", "--doc-format", "jsonml"}}, + {"create stdin", Create, []string{"--name", "n", "--content", "-"}}, + {"fetch simple", Fetch, []string{"--node", "n"}}, + {"fetch keyword", Fetch, []string{"--node", "n", "--detail", "full", "--scope", "keyword", "--keyword", "alpha|none", "--context-before", "1", "--context-after", "1"}}, + {"fetch scoped", Fetch, []string{"--node", "n", "--scope", "range", "--start-block-id", "a", "--end-block-id", "b", "--tags", "p,h1", "--max-depth", "2"}}, + {"inspect base", Inspect, []string{"--node", "n"}}, + {"inspect all", Inspect, []string{"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"}}, + {"update append", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--yes"}}, + {"update overwrite jsonml", Update, []string{"--node", "n", "--command", "overwrite", "--content", `[]`, "--doc-format", "jsonml", "--yes"}}, + {"update insert text", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", "x", "--yes"}}, + {"update insert jsonml", Update, []string{"--node", "n", "--command", "block_insert_after", "--after-block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}}, + {"update replace text", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", "x", "--yes"}}, + {"update replace jsonml", Update, []string{"--node", "n", "--command", "block_replace", "--block-id", "b", "--content", `[]`, "--doc-format", "jsonml", "--yes"}}, + {"update delete", Update, []string{"--node", "n", "--command", "block_delete", "--block-id", "b", "--yes"}}, + {"update replace", Update, []string{"--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "gamma", "--yes"}}, + {"update copy", Update, []string{"--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--after-block-id", "b", "--yes"}}, + {"update revision", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"}}, + {"update dry", Update, []string{"--node", "n", "--command", "append", "--content", "x", "--dry-run", "--yes"}}, + {"checkpoint dry", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--dry-run", "--yes"}}, + {"checkpoint success", CheckpointUpdate, []string{"--node", "n", "--content", "x", "--yes"}}, + {"export dry", Export, []string{"--node", "n", "--output", "out.docx", "--dry-run"}}, + {"export success", Export, []string{"--node", "n", "--output", "out.docx"}}, + } + + testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) { + return localio.DownloadResult{RelativePath: "out.docx", SizeBytes: 7}, nil + }) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &docCoverageCaller{responses: map[string][]map[string]any{}} + var err error + if tc.name == "create stdin" { + err = runDocCoverageInput(t, tc.cmd, caller, strings.NewReader("stdin body"), tc.args...) + } else { + err = runDocCoverage(t, tc.cmd, caller, tc.args...) + } + if err != nil { + t.Fatalf("%s: %v", tc.name, err) + } + }) + } + + for _, command := range []shortcut.Shortcut{Create, Fetch, Inspect, Update, CheckpointUpdate, Export} { + for failAt := 1; failAt <= 7; failAt++ { + args := map[string][]string{ + "+create": {"--name", "n", "--content", `[]`, "--doc-format", "jsonml"}, + "+fetch": {"--node", "n", "--scope", "keyword", "--keyword", "x"}, + "+inspect": {"--node", "n", "--include-style", "--include-permissions", "--include-history", "--include-media", "--include-comments"}, + "+update": {"--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"}, + "+checkpoint-update": {"--node", "n", "--content", "x", "--yes"}, + "+export": {"--node", "n", "--output", "out.docx"}, + }[command.Command] + _ = runDocCoverage(t, command, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}}, args...) + } + } +} + +func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) { + t.Chdir(t.TempDir()) + if err := os.WriteFile("body.md", []byte("body"), 0o600); err != nil { + t.Fatal(err) + } + outside := filepath.Join(filepath.Dir(t.TempDir()), "outside.md") + _ = outside + badCases := []struct { + cmd shortcut.Shortcut + args []string + }{ + {Create, []string{"--name", "n", "--content", "@"}}, + {Create, []string{"--name", "n", "--content", "@/absolute"}}, + {Create, []string{"--name", "n", "--content", "not-json", "--doc-format", "jsonml"}}, + {Create, []string{"--name", "n", "--content", `{}`, "--doc-format", "jsonml"}}, + {Fetch, []string{"--node", "n", "--revision", "1"}}, + {Fetch, []string{"--node", "n", "--scope", "keyword"}}, + {Update, []string{"--node", "n"}}, + {Update, []string{"--command", "append", "--content", "x", "--yes"}}, + {Update, []string{"--node", "n", "--command", "append", "--yes"}}, + {Update, []string{"--node", "n", "--command", "block_delete", "--yes"}}, + {Update, []string{"--node", "n", "--command", "str_replace", "--old", "x", "--yes"}}, + {Update, []string{"--node", "n", "--command", "append", "--content", `[]`, "--doc-format", "jsonml", "--yes"}}, + } + for _, tc := range badCases { + if err := runDocCoverage(t, tc.cmd, &docCoverageCaller{responses: map[string][]map[string]any{}}, tc.args...); err == nil { + t.Errorf("%s %#v unexpectedly succeeded", tc.cmd.Command, tc.args) + } + } + + createNoNode := &docCoverageCaller{responses: map[string][]map[string]any{"create_document": {{"ok": true}}}} + if err := runDocCoverage(t, Create, createNoNode, "--name", "n", "--content", `[]`, "--doc-format", "jsonml"); err == nil { + t.Fatal("jsonml create without node id succeeded") + } + conflict := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"revision": 2}}}} + if err := runDocCoverage(t, Update, conflict, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil { + t.Fatal("revision conflict succeeded") + } + missingRevision := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_content": {{"ok": true}}}} + if err := runDocCoverage(t, Update, missingRevision, "--node", "n", "--command", "append", "--content", "x", "--expected-revision", "1", "--yes"); err == nil { + t.Fatal("missing revision succeeded") + } + + for _, value := range []any{ + map[string]any{"revision": 2.5}, map[string]any{"revision": json.Number("bad")}, map[string]any{"revision": "bad"}, + map[string]any{"data": []any{map[string]any{"versionNumber": "3"}}}, []any{map[string]any{"version": 4.0}}, "none", + } { + _, _ = nestedRevision(value) + } + if _, err := validateJSONML(`[`); err == nil { + t.Fatal("invalid jsonml succeeded") + } + if _, err := validateJSONML(`{}`); err == nil { + t.Fatal("object jsonml succeeded") + } + if nestedMap(map[string]any{"result": map[string]any{"data": map[string]any{"x": 1}}})["x"] != 1 { + t.Fatal("nestedMap did not unwrap") + } + _ = stringSliceNonEmpty([]string{"", " a "}) + + blocks := map[string]any{"items": []any{map[string]any{"id": "b", "text": "alpha alpha"}, map[string]any{"id": "resource", "src": "x"}}} + _ = projectKeywordMatches(blocks, "alpha|beta", -1, -1) + _ = projectKeywordMatches(map[string]any{"jsonml": "bad"}, "none", 1, 1) + _ = findBlock(blocks, "b") + _ = findBlock([]any{blocks}, "missing") + _ = containsResourceReference(blocks) + _ = containsResourceReference([]any{map[string]any{"x": "y"}}) + stripBlockIDs(blocks) + + if err := runDocCoveragePath(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, strings.NewReader(""), "+update", "--doc", "n", "--command", "append", "--text", "legacy", "--yes"); err != nil { + t.Fatalf("visible update flag aliases: %v", err) + } + missingNode := Update + missingNode.Flags = append([]shortcut.Flag(nil), Update.Flags...) + missingNode.Flags[0].Required = false + if err := runDocCoverage(t, missingNode, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--command", "append", "--content", "x", "--yes"); err == nil { + t.Fatal("custom missing-node validation was not reached") + } + unknown := Update + unknown.Flags = append([]shortcut.Flag(nil), Update.Flags...) + unknown.Flags[1].Enum = append(append([]string(nil), unknown.Flags[1].Enum...), "bogus") + if err := runDocCoverage(t, unknown, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--command", "bogus", "--yes"); err == nil { + t.Fatal("unknown update command succeeded") + } + + _ = runDocCoverageInput(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, docCoverageErrorReader{}, "--name", "n", "--content", "-") + for _, seamCase := range []struct { + name string + run func(*testing.T) + }{ + {"getwd", func(t *testing.T) { + testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd") }) + _ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md") + }}, + {"eval-base", func(t *testing.T) { + testseam.Swap(t, &docEvalSymlinks, func(string) (string, error) { return "", errors.New("eval") }) + _ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md") + }}, + {"eval-file", func(t *testing.T) { + calls := 0 + testseam.Swap(t, &docEvalSymlinks, func(value string) (string, error) { + calls++ + if calls == 2 { + return "", errors.New("eval file") + } + return value, nil + }) + _ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md") + }}, + {"rel", func(t *testing.T) { + testseam.Swap(t, &docRel, func(string, string) (string, error) { return "", errors.New("rel") }) + _ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md") + }}, + {"read", func(t *testing.T) { + testseam.Swap(t, &docReadFile, func(string) ([]byte, error) { return nil, errors.New("read") }) + _ = runDocCoverage(t, Create, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--name", "n", "--content", "@body.md") + }}, + } { + t.Run(seamCase.name, seamCase.run) + } + + _ = runDocCoverage(t, CheckpointUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--content", "@missing", "--yes") + _ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--command", "append", "--content", "@missing", "--yes") + _ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--command", "overwrite", "--content", "bad", "--doc-format", "jsonml", "--yes") + _ = runDocCoverage(t, Update, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "x", "--yes") + _ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"items": []any{map[string]any{"id": "a", "text": "alpha"}, map[string]any{"id": "b", "text": "alpha"}}}}}}, "--node", "n", "--command", "str_replace", "--old", "alpha", "--new", "x", "--yes") + _ = runDocCoverage(t, Update, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--yes") + _ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"ok": true}}}}, "--node", "n", "--command", "block_copy_insert_after", "--block-id", "missing", "--yes") + _ = runDocCoverage(t, Update, &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"id": "block-1", "resourceId": "r"}}}}, "--node", "n", "--command", "block_copy_insert_after", "--block-id", "block-1", "--yes") + + for _, response := range []map[string]any{ + {"ok": true}, + {"jobId": "j"}, + } { + caller := &docCoverageCaller{responses: map[string][]map[string]any{"submit_export_job": {response}, "query_export_job": {{"status": "FAILED", "message": "bad"}}}} + _ = runDocCoverage(t, Export, caller, "--node", "n", "--output", "x", "--max-polls", "1") + } + timeout := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}} + _ = runDocCoverage(t, Export, timeout, "--node", "n", "--output", "x", "--max-polls", "1") + processingThenSuccess := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}, {"status": "SUCCESS", "downloadUrl": "https://download.dingtalk.com/x"}}}} + _ = runDocCoverage(t, Export, processingThenSuccess, "--node", "n", "--output", "x", "--max-polls", "2") + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + cancelledCaller := &docCoverageCaller{ctx: cancelled, responses: map[string][]map[string]any{"query_export_job": {{"status": "PROCESSING"}}}} + _ = runDocCoverage(t, Export, cancelledCaller, "--node", "n", "--output", "x", "--max-polls", "2") + _ = runDocCoverage(t, Export, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--output", "x", "--max-polls", "0") + missingURL := &docCoverageCaller{responses: map[string][]map[string]any{"query_export_job": {{"status": "SUCCESS"}}}} + _ = runDocCoverage(t, Export, missingURL, "--node", "n", "--output", "x") +} + +func TestCrossPlatformCoverageDocHistoryTemplateReviewAndMedia(t *testing.T) { + t.Chdir(t.TempDir()) + testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) { + return localio.DownloadResult{RelativePath: "artifact.bin", SizeBytes: 9}, nil + }) + commands := []struct { + decl shortcut.Shortcut + args []string + }{ + {VersionList, []string{"--node", "n", "--page-size", "2", "--page-token", "p"}}, + {VersionList, []string{"--node", "n", "--limit", "2", "--cursor", "p"}}, + {VersionRevert, []string{"--node", "n", "--version", "3", "--yes"}}, + {VersionRevert, []string{"--node", "n", "--version", "3", "--dry-run", "--yes"}}, + {CreateFromTemplate, []string{"--template-id", "t", "--name", "n", "--folder", "f", "--workspace", "w"}}, + {CreateFromTemplate, []string{"--query", "q", "--source", "PUBLIC", "--dry-run"}}, + {Review, []string{"--node", "n"}}, + {CommentUpdate, []string{"--node", "n", "--comment-key", "c", "--content", "x", "--mention", "u"}}, + {CommentDelete, []string{"--node", "n", "--comment-key", "c", "--dry-run", "--yes"}}, + {CommentCreate, []string{"--node", "n", "--content", "x", "--yes"}}, + {CommentCreate, []string{"--node", "n", "--content", "x", "--block-id", "b", "--start", "0", "--end", "1", "--selected-text", "a", "--mention", "u", "--yes"}}, + {CommentCreate, []string{"--node", "n", "--content", "x", "--selection", "alpha", "--yes"}}, + {MediaList, []string{"--node", "n"}}, + {MediaPreview, []string{"--node", "n", "--resource-id", "r"}}, + {MediaPreview, []string{"--node", "n", "--resource-id", "r", "--dry-run"}}, + {MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "m.bin"}}, + {MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "m.bin", "--dry-run"}}, + {ResourceDownload, []string{"--node", "n", "--output", "cover.png"}}, + {ResourceDownload, []string{"--node", "n", "--output", "cover.png", "--dry-run"}}, + {ResourceDelete, []string{"--node", "n", "--dry-run", "--yes"}}, + {BackgroundUpdate, []string{"--node", "n", "--color", "#ABCDEF"}}, + {BackgroundDelete, []string{"--node", "n", "--dry-run", "--yes"}}, + {BackgroundDelete, []string{"--node", "n", "--yes"}}, + {ResourceDelete, []string{"--node", "n", "--yes"}}, + {CommentDelete, []string{"--node", "n", "--comment-key", "c", "--yes"}}, + } + for _, item := range commands { + if err := runDocCoverage(t, item.decl, &docCoverageCaller{responses: map[string][]map[string]any{}}, item.args...); err != nil { + t.Errorf("%s: %v", item.decl.Command, err) + } + } + + for _, declaration := range []shortcut.Shortcut{VersionRevert, CreateFromTemplate, Review, MediaList, MediaDownload, ResourceDownload} { + args := map[string][]string{ + "+history-revert": {"--node", "n", "--version", "3", "--yes"}, + "+create-from-template": {"--query", "q"}, + "+review": {"--node", "n"}, + "+media-list": {"--node", "n"}, + "+media-download": {"--node", "n", "--resource-id", "r", "--output", "m.bin"}, + "+resource-download": {"--node", "n", "--output", "cover.png"}, + }[declaration.Command] + for failAt := 1; failAt <= 4; failAt++ { + _ = runDocCoverage(t, declaration, &docCoverageCaller{failAt: failAt, responses: map[string][]map[string]any{}}, args...) + } + } + + for _, value := range []any{ + map[string]any{"version": 3.0}, map[string]any{"version": 3.5}, map[string]any{"version": "3"}, map[string]any{"version": "bad"}, + []any{map[string]any{"revision": 3.0}}, "none", + } { + _ = containsVersion(value, 3) + } + _ = collectTemplateIDs(map[string]any{"template_id": "t1", "nested": []any{map[string]any{"templateId": "t1"}, map[string]any{"templateId": "t2"}, "x"}}) + _ = collectTemplateIDs("none") + _ = collectMediaItems(map[string]any{"id": "b", "resourceId": "r", "src": "u", "name": "n", "type": "file", "mimeType": "x", "viewType": "v", "children": []any{map[string]any{"resourceUrl": "u2"}}}) + _ = nestedStringDeep([]any{map[string]any{"x": map[string]any{"url": " u "}}}, "url") + _ = nestedStringDeep("none", "url") + + badComments := [][]string{ + {"--node", "n", "--content", "x", "--block-id", "b", "--yes"}, + {"--node", "n", "--content", "x", "--block-id", "b", "--start", "2", "--end", "1", "--yes"}, + {"--node", "n", "--content", "x", "--block-id", "b", "--start", "0", "--end", "1", "--selection", "x", "--yes"}, + } + for _, args := range badComments { + if err := runDocCoverage(t, CommentCreate, &docCoverageCaller{responses: map[string][]map[string]any{}}, args...); err == nil { + t.Errorf("invalid comment args succeeded: %#v", args) + } + } + ambiguous := &docCoverageCaller{responses: map[string][]map[string]any{"list_document_blocks": {{"items": []any{map[string]any{"id": "a", "text": "x"}, map[string]any{"id": "b", "text": "x"}}}}}} + if err := runDocCoverage(t, CommentCreate, ambiguous, "--node", "n", "--content", "c", "--selection", "x", "--yes"); err == nil { + t.Fatal("ambiguous comment selection succeeded") + } + _ = findSelectionMatches(map[string]any{"id": "b", "text": "left middle right"}, "left...right") + _ = findSelectionMatches([]any{map[string]any{"id": "b", "text": "none"}}, "x") + _ = runDocCoverage(t, CommentCreate, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--content", "c", "--selection", "x", "--yes") + + globalReview := &docCoverageCaller{responses: map[string][]map[string]any{"list_comments": {{"comments": []any{map[string]any{"commentKey": "global", "content": "g"}}}}}} + _ = runDocCoverage(t, Review, globalReview, "--node", "n") + _ = runDocCoverage(t, VersionRevert, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--version", "3", "--yes") + _ = runDocCoverage(t, VersionRevert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--version", "99", "--yes") + _ = runDocCoverage(t, CreateFromTemplate, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--template-id", "t") + multipleTemplates := &docCoverageCaller{responses: map[string][]map[string]any{"search_doc_templates": {{"templates": []any{map[string]any{"templateId": "a"}, map[string]any{"templateId": "b"}}}}}} + _ = runDocCoverage(t, CreateFromTemplate, multipleTemplates, "--query", "q") + + if err := os.WriteFile("media.bin", []byte("media"), 0o600); err != nil { + t.Fatal(err) + } + _ = runDocCoverage(t, MediaInsert, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--file", "media.bin", "--dry-run", "--yes") + _ = runDocCoverage(t, ResourceUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--image", "https://example.com/cover.png", "--dry-run", "--yes") + _ = runDocCoverage(t, Import, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--file", "media.bin", "--folder", "f", "--dry-run") + + resourceOnly := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_style": {{"resourceId": "r"}}}} + _ = runDocCoverage(t, ResourceDownload, resourceOnly, "--node", "n", "--output", "cover.png") + emptyStyle := &docCoverageCaller{responses: map[string][]map[string]any{"get_document_style": {{"ok": true}}}} + _ = runDocCoverage(t, ResourceDownload, emptyStyle, "--node", "n", "--output", "cover.png") + _ = runDocCoverage(t, ResourceDownload, &docCoverageCaller{failAt: 2, responses: map[string][]map[string]any{"get_document_style": {{"resourceId": "r"}}}}, "--node", "n", "--output", "cover.png") + _, _ = downloadResolvedResource(nil, map[string]any{}, ".", "x") + _ = runDocCoverage(t, MediaPreview, &docCoverageCaller{failAt: 1, responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r") + _ = runDocCoverage(t, BackgroundUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--color", "bad") + _ = runDocCoverage(t, BackgroundUpdate, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--color", "#ABCDEG") + + t.Run("preview mkdir failure", func(t *testing.T) { + testseam.Swap(t, &docMkdirTemp, func(string, string) (string, error) { return "", errors.New("mkdir") }) + _ = runDocCoverage(t, MediaPreview, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r") + }) + t.Run("preview download cleanup", func(t *testing.T) { + removed := false + testseam.Swap(t, &docDownload, func(context.Context, string, localio.DownloadOptions) (localio.DownloadResult, error) { + return localio.DownloadResult{}, errors.New("download") + }) + testseam.Swap(t, &docRemoveAll, func(string) error { removed = true; return nil }) + _ = runDocCoverage(t, MediaPreview, &docCoverageCaller{responses: map[string][]map[string]any{}}, "--node", "n", "--resource-id", "r") + if !removed { + t.Fatal("preview failure did not clean temporary directory") + } + }) +} + +func TestCrossPlatformCoverageDocDownloadAndWorkingDirectoryErrors(t *testing.T) { + t.Chdir(t.TempDir()) + testseam.Swap(t, &docDownload, func(_ context.Context, _ string, _ localio.DownloadOptions) (localio.DownloadResult, error) { + return localio.DownloadResult{}, errors.New("download failed") + }) + for _, item := range []struct { + decl shortcut.Shortcut + args []string + }{ + {Export, []string{"--node", "n", "--output", "out.docx"}}, + {MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}}, + {ResourceDownload, []string{"--node", "n", "--output", "out.png"}}, + } { + if err := runDocCoverage(t, item.decl, &docCoverageCaller{responses: map[string][]map[string]any{}}, item.args...); err == nil { + t.Errorf("%s download error was ignored", item.decl.Command) + } + } + + testseam.Swap(t, &docGetwd, func() (string, error) { return "", errors.New("getwd failed") }) + for _, item := range []struct { + decl shortcut.Shortcut + args []string + }{ + {Export, []string{"--node", "n", "--output", "out.docx"}}, + {MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}}, + {ResourceDownload, []string{"--node", "n", "--output", "out.png"}}, + } { + _ = runDocCoverage(t, item.decl, &docCoverageCaller{responses: map[string][]map[string]any{}}, item.args...) + } +} + +func TestCrossPlatformCoverageDocDownloadsHaveNoOverwriteEscape(t *testing.T) { + for _, item := range []struct { + decl shortcut.Shortcut + args []string + }{ + {Export, []string{"--node", "n", "--output", "out.docx"}}, + {MediaDownload, []string{"--node", "n", "--resource-id", "r", "--output", "out.bin"}}, + {ResourceDownload, []string{"--node", "n", "--output", "out.png"}}, + } { + t.Run(item.decl.Command, func(t *testing.T) { + for _, flag := range item.decl.Flags { + if flag.Name == "overwrite" { + t.Fatal("download shortcut still declares --overwrite") + } + } + caller := &docCoverageCaller{responses: map[string][]map[string]any{}} + err := runDocCoverage(t, item.decl, caller, append(item.args, "--overwrite")...) + if err == nil { + t.Fatal("--overwrite unexpectedly accepted") + } + if caller.calls != 0 { + t.Fatalf("rejected --overwrite performed %d MCP calls", caller.calls) + } + }) + } +} diff --git a/internal/shortcut/doc/history_template_shortcuts.go b/internal/shortcut/doc/history_template_shortcuts.go new file mode 100644 index 00000000..5cbd8da2 --- /dev/null +++ b/internal/shortcut/doc/history_template_shortcuts.go @@ -0,0 +1,249 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package doc + +import ( + "fmt" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var ( + legacyVersionSave shortcut.Shortcut + legacyVersionList shortcut.Shortcut + legacyVersionRevert shortcut.Shortcut +) + +func canonicalizeHistoryShortcuts() { + legacyVersionSave = VersionSave + legacyVersionList = VersionList + legacyVersionRevert = VersionRevert + + VersionSave.Command = "+history-save" + VersionSave.Aliases = nil + VersionSave.Description = "手动保存当前文档版本快照" + VersionSave.Intent = "当用户要在重要修改前后手动建立一个可回滚的文档历史快照时使用;保存快照本身无需交互确认。" + VersionSave.Safety = contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"} + VersionSave.Contract = docContract("+history-save", VersionSave.Description, VersionSave.Intent, []string{`dws doc +history-save --node `}) + VersionSave.Tips = []string{`dws doc +history-save --node `} + + 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 `, `dws doc +history-list --node --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 `, `dws doc +history-list --node --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 --version 3`}) + VersionRevert.Tips = []string{`dws doc +history-revert --node --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 --content "请补充数据来源"`, `dws doc +comment-create --node --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 --content "请补充数据来源"`, `dws doc +comment-create --node --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 --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 --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) +} diff --git a/internal/shortcut/doc/media_style_shortcuts.go b/internal/shortcut/doc/media_style_shortcuts.go new file mode 100644 index 00000000..0dbb7bb4 --- /dev/null +++ b/internal/shortcut/doc/media_style_shortcuts.go @@ -0,0 +1,360 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package doc + +import ( + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var MediaList = shortcut.Shortcut{ + Service: "doc", Command: "+media-list", Product: productDoc, + Description: "列出文档正文中的图片和附件资源", + Intent: "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。", + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, + Contract: docContract("+media-list", "列出文档正文中的图片和附件资源", + "当用户要发现文档内可下载或可定位的图片、附件及其 blockId/resourceId 时使用;只读取并投影媒体节点。", + []string{`dws doc +media-list --node `}), + Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}}, + Tips: []string{`dws doc +media-list --node `}, + 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 --file ./report.pdf`, `dws doc +media-insert --node --file ./image.png --ref-block --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 --file ./report.pdf`, `dws doc +media-insert --node --file ./image.png --ref-block --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 --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 --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 --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 --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 --image https://example.com/cover.png`, `dws doc +resource-update --node --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 --image https://example.com/cover.png`, `dws doc +resource-update --node --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 --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 --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 `}), + Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}}, + Tips: []string{`dws doc +resource-delete --node `}, + 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 --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 --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 `}), + Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}}, + Tips: []string{`dws doc +background-delete --node `}, + 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 != "") || resourceURL != "" { + row := map[string]any{"blockId": blockID} + if resourceID != "" && resourceID != "" { + 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) +} diff --git a/internal/shortcut/doc/review_shortcuts.go b/internal/shortcut/doc/review_shortcuts.go new file mode 100644 index 00000000..a1f07dd4 --- /dev/null +++ b/internal/shortcut/doc/review_shortcuts.go @@ -0,0 +1,306 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package doc + +import ( + "fmt" + "strings" + "unicode/utf16" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var Review = shortcut.Shortcut{ + Service: "doc", Command: "+review", Product: productComment, + Description: "聚合未解决评论、引用原文和块上下文", + Intent: "当用户要确定性查看一篇文档仍待处理的 review 意见时使用;聚合 unresolved 评论与 block 上下文,不调用模型生成总结。", + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}, + Contract: docContract("+review", "聚合未解决评论、引用原文和块上下文", + "当用户要确定性查看一篇文档仍待处理的 review 意见时使用;聚合 unresolved 评论与 block 上下文,不调用模型生成总结。", + []string{`dws doc +review --node `}), + Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL", Required: true}}, + Tips: []string{`dws doc +review --node `}, + 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 --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 --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 --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 --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) +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 8f190845..9c264261 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -92,6 +92,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "chat\u0000+chat-dismiss": {}, "chat\u0000+chat-get-by-id": {}, "chat\u0000+chat-invite-url": {}, + "chat\u0000+chat-list": {}, "chat\u0000+chat-list-all": {}, "chat\u0000+chat-list-join-requests": {}, "chat\u0000+chat-list-mine": {}, @@ -208,20 +209,48 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "ding\u0000+recall-personal": {}, "ding\u0000+receiver-status": {}, "ding\u0000+send-personal": {}, + "doc\u0000+access-change": {}, + "doc\u0000+access-grant": {}, + "doc\u0000+access-revoke": {}, + "doc\u0000+background-delete": {}, + "doc\u0000+background-update": {}, + "doc\u0000+checkpoint-update": {}, "doc\u0000+comment-create": {}, + "doc\u0000+comment-delete": {}, "doc\u0000+comment-list": {}, "doc\u0000+comment-reply": {}, + "doc\u0000+comment-update": {}, "doc\u0000+copy": {}, + "doc\u0000+create": {}, + "doc\u0000+create-from-template": {}, "doc\u0000+doc-append": {}, + "doc\u0000+export": {}, "doc\u0000+export-get": {}, "doc\u0000+export-submit": {}, + "doc\u0000+fetch": {}, "doc\u0000+find-doc": {}, + "doc\u0000+grant-and-share": {}, + "doc\u0000+history-list": {}, + "doc\u0000+history-revert": {}, + "doc\u0000+history-save": {}, + "doc\u0000+import": {}, + "doc\u0000+inspect": {}, "doc\u0000+list": {}, + "doc\u0000+media-download": {}, + "doc\u0000+media-insert": {}, + "doc\u0000+media-list": {}, + "doc\u0000+media-preview": {}, "doc\u0000+move": {}, + "doc\u0000+resource-delete": {}, + "doc\u0000+resource-download": {}, + "doc\u0000+resource-update": {}, + "doc\u0000+review": {}, "doc\u0000+search": {}, + "doc\u0000+share": {}, "doc\u0000+share-doc": {}, "doc\u0000+template-list": {}, "doc\u0000+template-search": {}, + "doc\u0000+update": {}, "doc\u0000+version-list": {}, "doc\u0000+version-revert": {}, "doc\u0000+version-save": {}, diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index a3800173..edf86236 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -13,6 +13,9 @@ import ( //go:embed semantic_catalog.json var semanticCatalogJSON []byte +//go:embed semantic_catalog_doc.json +var docSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -30,17 +33,34 @@ type semanticCatalogRecord struct { Reviewed bool `json:"reviewed"` } -var reviewedSemanticCatalog = mustLoadSemanticCatalog() +var reviewedSemanticCatalog = mustLoadSemanticCatalogs( + semanticCatalogJSON, + docSemanticCatalogJSON, +) +func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { + out := make(map[string]semanticCatalogRecord) + for _, raw := range sources { + loadSemanticCatalog(raw, out) + } + return out +} + +// mustLoadSemanticCatalog is retained for focused validation tests of the +// legacy single-source loader. Production loads every reviewed product source +// through mustLoadSemanticCatalogs above. func mustLoadSemanticCatalog() map[string]semanticCatalogRecord { + return mustLoadSemanticCatalogs(semanticCatalogJSON) +} + +func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) { var source semanticCatalogFile - if err := json.Unmarshal(semanticCatalogJSON, &source); err != nil { + if err := json.Unmarshal(raw, &source); err != nil { panic(fmt.Sprintf("invalid shortcut semantic catalog: %v", err)) } if source.Version != 1 || strings.TrimSpace(source.Service) == "" { panic("invalid shortcut semantic catalog header") } - out := make(map[string]semanticCatalogRecord, len(source.Shortcuts)) for command, record := range source.Shortcuts { if !strings.HasPrefix(command, "+") { panic(fmt.Sprintf("semantic catalog command %q lacks + prefix", command)) @@ -76,9 +96,12 @@ func mustLoadSemanticCatalog() map[string]semanticCatalogRecord { panic(fmt.Sprintf("semantic catalog command %q cannot be public with availability %q", command, record.Availability)) } - out[publicCatalogKey(source.Service, command)] = record + key := publicCatalogKey(source.Service, command) + if _, exists := out[key]; exists { + panic(fmt.Sprintf("duplicate shortcut semantic catalog entry %s %s", source.Service, command)) + } + out[key] = record } - return out } func applyReviewedSemanticCatalog(s Shortcut) (Shortcut, bool) { diff --git a/internal/shortcut/semantic_catalog_coverage_test.go b/internal/shortcut/semantic_catalog_coverage_test.go index 8c3e3c4b..61d00569 100644 --- a/internal/shortcut/semantic_catalog_coverage_test.go +++ b/internal/shortcut/semantic_catalog_coverage_test.go @@ -137,6 +137,29 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T) } } +func TestCrossPlatformCoverageSemanticCatalogRejectsCrossSourceDuplicates(t *testing.T) { + valid := []byte(`{ + "version": 1, + "service": "duplicate-test", + "default_availability": "available", + "shortcuts": { + "+same": { + "disposition": "semantic_adapter", + "semantic_delta": "reviewed", + "risk": "read", + "public": true, + "reviewed": true + } + } + }`) + defer func() { + if recover() == nil { + t.Fatal("duplicate semantic records did not panic") + } + }() + _ = mustLoadSemanticCatalogs(valid, valid) +} + func TestCrossPlatformCoveragePublicCatalogSemanticAndGeneratedLookups(t *testing.T) { if !InPublicCatalog("chat", "+messages-send") { t.Fatal("reviewed public semantic shortcut is missing") diff --git a/internal/shortcut/semantic_catalog_doc.json b/internal/shortcut/semantic_catalog_doc.json new file mode 100644 index 00000000..8d0655a7 --- /dev/null +++ b/internal/shortcut/semantic_catalog_doc.json @@ -0,0 +1,56 @@ +{ + "version": 1, + "service": "doc", + "default_availability": "available", + "shortcuts": { + "+search": {"disposition":"semantic_adapter","semantic_delta":"统一关键词、最近访问、过滤、分页和稳定精简投影,作为文档定位的 canonical 入口。","risk":"read","public":true,"reviewed":true}, + "+create": {"disposition":"semantic_adapter","semantic_delta":"统一 Markdown/JSONML 内容输入、目标位置与创建后保真写入。","risk":"write","public":true,"reviewed":true}, + "+fetch": {"disposition":"semantic_adapter","semantic_delta":"统一 simple/with-ids/full 细节层级与 full/outline/range/section/keyword/tags 局部读取。","risk":"read","public":true,"reviewed":true}, + "+inspect": {"disposition":"primary_smart","semantic_delta":"聚合文档元信息,并按需读取样式、权限、历史、媒体和评论。","risk":"read","public":true,"reviewed":true}, + "+update": {"disposition":"primary_smart","semantic_delta":"统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。","risk":"write","public":true,"reviewed":true}, + "+checkpoint-update": {"disposition":"primary_smart","semantic_delta":"写入前保存版本快照,更新后读回验证并输出逐步 ledger。","risk":"write","public":true,"reviewed":true}, + "+export": {"disposition":"primary_smart","semantic_delta":"一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。","risk":"read","public":true,"reviewed":true}, + "+import": {"disposition":"primary_smart","semantic_delta":"一体化创建会话、上传、确认转换并轮询导入结果。","risk":"write","public":true,"reviewed":true}, + "+history-save": {"disposition":"semantic_adapter","semantic_delta":"以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。","risk":"write","public":true,"reviewed":true}, + "+history-list": {"disposition":"semantic_adapter","semantic_delta":"统一历史版本分页参数并返回可用于回滚的版本列表。","risk":"read","public":true,"reviewed":true}, + "+history-revert": {"disposition":"primary_smart","semantic_delta":"先验证目标版本存在,再执行回滚并读回当前文档状态。","risk":"high-risk-write","public":true,"reviewed":true}, + "+template-list": {"disposition":"semantic_adapter","semantic_delta":"统一 MY/PUBLIC 模板浏览和分页参数。","risk":"read","public":true,"reviewed":true}, + "+template-search": {"disposition":"semantic_adapter","semantic_delta":"按名称检索模板并返回可继续创建的 templateId。","risk":"read","public":true,"reviewed":true}, + "+create-from-template": {"disposition":"primary_smart","semantic_delta":"支持 templateId 直达或按名称搜索消歧后创建文档。","risk":"write","public":true,"reviewed":true}, + "+media-list": {"disposition":"semantic_adapter","semantic_delta":"从文档块中提取图片、附件及其 block/resource 标识。","risk":"read","public":true,"reviewed":true}, + "+media-insert": {"disposition":"primary_smart","semantic_delta":"组合本地文件校验、上传凭证、OSS PUT、插块和验证。","risk":"write","public":true,"reviewed":true}, + "+media-download": {"disposition":"primary_smart","semantic_delta":"解析附件临时链接并通过受控相对路径、no-clobber、原子发布安全下载。","risk":"read","public":true,"reviewed":true}, + "+media-preview": {"disposition":"primary_smart","semantic_delta":"将正文媒体下载到受控临时目录并返回本地预览 artifact。","risk":"read","public":true,"reviewed":true}, + "+resource-update": {"disposition":"primary_smart","semantic_delta":"支持本地图片或 HTTPS 图片转存后设置文档封面。","risk":"write","public":true,"reviewed":true}, + "+resource-download": {"disposition":"primary_smart","semantic_delta":"读取当前文档封面配置并安全下载资源到本地。","risk":"read","public":true,"reviewed":true}, + "+resource-delete": {"disposition":"semantic_adapter","semantic_delta":"以幂等 clear 语义移除当前文档封面。","risk":"high-risk-write","public":true,"reviewed":true}, + "+background-update": {"disposition":"semantic_adapter","semantic_delta":"校验并设置 #RRGGBB 文档背景纯色。","risk":"write","public":true,"reviewed":true}, + "+background-delete": {"disposition":"semantic_adapter","semantic_delta":"以 clear 语义移除文档背景色。","risk":"write","public":true,"reviewed":true}, + "+comment-list": {"disposition":"semantic_adapter","semantic_delta":"统一评论类型、解决状态与分页过滤。","risk":"read","public":true,"reviewed":true}, + "+review": {"disposition":"primary_smart","semantic_delta":"聚合未解决评论、划词引用和确定性上下文,不调用模型生成总结。","risk":"read","public":true,"reviewed":true}, + "+comment-create": {"disposition":"primary_smart","semantic_delta":"无 selection 创建全文评论,有 selection 时定位文本并创建划词评论。","risk":"write","public":true,"reviewed":true}, + "+comment-reply": {"disposition":"semantic_adapter","semantic_delta":"统一评论回复、表情回复和 mention 参数。","risk":"write","public":true,"reviewed":true}, + "+comment-update": {"disposition":"semantic_adapter","semantic_delta":"更新指定评论正文与 mention。","risk":"write","public":true,"reviewed":true}, + "+comment-delete": {"disposition":"semantic_adapter","semantic_delta":"永久删除指定评论,并由静态安全契约强制确认。","risk":"high-risk-write","public":true,"reviewed":true}, + "+access-grant": {"disposition":"primary_smart","semantic_delta":"在第一次写入前解析全部接收人,再批量授予文档权限并输出逐项 ledger。","risk":"write","public":true,"reviewed":true}, + "+access-change": {"disposition":"primary_smart","semantic_delta":"读取当前权限后再变更角色,避免把不存在的协作者当作成功更新。","risk":"write","public":true,"reviewed":true}, + "+access-revoke": {"disposition":"primary_smart","semantic_delta":"预检目标协作者权限后移除并输出逐项结果。","risk":"high-risk-write","public":true,"reviewed":true}, + "+share": {"disposition":"primary_smart","semantic_delta":"按姓名解析唯一用户后发送文档链接,不改变文档权限。","risk":"write","public":true,"reviewed":true}, + "+grant-and-share": {"disposition":"primary_smart","semantic_delta":"先确保目标角色,再发送链接;消息失败保留逐人 ledger,并以非零退出报告 failed/partial_success。","risk":"write","public":true,"reviewed":true}, + + "+find-doc": {"disposition":"alias_internal","semantic_delta":"保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。","risk":"read","primary":"+search","public":true,"reviewed":true}, + "+doc-append": {"disposition":"alias_internal","semantic_delta":"保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。","risk":"write","primary":"+update","public":true,"reviewed":true}, + "+version-save": {"disposition":"alias_internal","semantic_delta":"保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。","risk":"write","primary":"+history-save","public":true,"reviewed":true}, + "+version-list": {"disposition":"alias_internal","semantic_delta":"保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。","risk":"read","primary":"+history-list","public":true,"reviewed":true}, + "+version-revert": {"disposition":"alias_internal","semantic_delta":"保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。","risk":"high-risk-write","primary":"+history-revert","public":true,"reviewed":true}, + "+share-doc": {"disposition":"alias_internal","semantic_delta":"保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。","risk":"write","primary":"+share","public":true,"reviewed":true}, + + "+list": {"disposition":"alias_internal","semantic_delta":"旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。","risk":"read","primary":"drive list","public":true,"reviewed":true}, + "+copy": {"disposition":"alias_internal","semantic_delta":"旧 Doc 复制入口,仅为兼容保留;新的文件复制应使用 Drive 命令。","risk":"write","primary":"drive copy","public":true,"reviewed":true}, + "+move": {"disposition":"alias_internal","semantic_delta":"旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。","risk":"write","primary":"drive move","public":true,"reviewed":true}, + "+export-submit": {"disposition":"alias_internal","semantic_delta":"导出中断恢复所需的专家入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true}, + "+export-get": {"disposition":"alias_internal","semantic_delta":"按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。","risk":"read","primary":"+export","public":true,"reviewed":true}, + "+comment-create-inline": {"disposition":"alias_internal","semantic_delta":"已知 block/start/end 时使用的低级批注入口;常规场景使用 +comment-create。","risk":"write","primary":"+comment-create","public":false,"reviewed":true}, + "+template-apply": {"disposition":"alias_internal","semantic_delta":"已知 templateId 时使用的低级入口;常规场景使用 +create-from-template。","risk":"write","primary":"+create-from-template","public":false,"reviewed":true} + } +} diff --git a/internal/shortcut/smart/coverage_completion_test.go b/internal/shortcut/smart/coverage_completion_test.go index fc522911..eaaba2d0 100644 --- a/internal/shortcut/smart/coverage_completion_test.go +++ b/internal/shortcut/smart/coverage_completion_test.go @@ -20,19 +20,24 @@ type smartCoverageCaller struct { responses map[string][]string failAt map[string]int counts map[string]int + arguments map[string][]map[string]any } func (c *smartCoverageCaller) CallTool( _ context.Context, product, tool string, - _ map[string]any, + args map[string]any, ) (*edition.ToolResult, error) { if c.counts == nil { c.counts = map[string]int{} } + if c.arguments == nil { + c.arguments = map[string][]map[string]any{} + } key := product + "/" + tool c.counts[key]++ - if c.failAt[key] == c.counts[key] { + c.arguments[key] = append(c.arguments[key], args) + if c.failAt[key] == -1 || c.failAt[key] == c.counts[key] { return nil, errors.New("fixture failure") } responses := c.responses[key] diff --git a/internal/shortcut/smart/doc_access.go b/internal/shortcut/smart/doc_access.go new file mode 100644 index 00000000..62b71ceb --- /dev/null +++ b/internal/shortcut/smart/doc_access.go @@ -0,0 +1,541 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var ( + resolveDocPermissionUser = resolveUser + resolveDocShareUser = resolveOpenDingTalkUser + legacyShareDoc shortcut.Shortcut +) + +func docSmartContract(command, description, intent string, examples []string, dryRun bool) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "doc " + command + decl := corecmd.ContractDecl{ + Description: description, + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: "Reviewed cross-product Doc Shortcut composite: contact resolution, document permissions, messaging, confirmation, and output ledger cannot be represented by one MCP interface.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{ + "已知 userId 且只需原子权限变更时可直接使用 doc permission 命令;知识库容器成员使用 wiki;普通文件权限使用 drive", + }, + Examples: examples, + }, + Identity: contract.ToolIdentitySpec{ + ProductID: "doc", Name: name, CanonicalPath: "doc." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + } + if dryRun { + decl.DryRun = &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewPlan, RemoteReads: true} + } + return decl +} + +func canonicalizeShareDoc() { + legacyShareDoc = ShareDoc + // Preserve the historical identity and string-typed --to flag while using + // the same pre-resolve-and-send implementation as the canonical command. + legacyShareDoc.Execute = executeShare + ShareDoc.Command = "+share" + ShareDoc.Aliases = nil + ShareDoc.Description = "按姓名发送文档链接,不改变文档权限" + ShareDoc.Intent = "当用户已有文档 URL、要按姓名私信给一个或多个人但不改变权限时使用;第一次发消息前先完成全部姓名唯一解析。" + ShareDoc.Contract = docSmartContract("+share", ShareDoc.Description, ShareDoc.Intent, + []string{`dws doc +share --to 张三 --url https://alidocs.dingtalk.com/i/nodes/ --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/ --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 --to 张三,李四 --role READER`}, false), + Flags: permissionFlags(true), + Tips: []string{`dws doc +access-grant --node --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 --to 张三 --role EDITOR`}, false), + Flags: permissionFlags(true), + Tips: []string{`dws doc +access-change --node --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 --to 张三`}, false), + Flags: permissionFlags(false), + Tips: []string{`dws doc +access-revoke --node --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 --url https://alidocs.dingtalk.com/i/nodes/ --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 --url https://alidocs.dingtalk.com/i/nodes/ --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) +} diff --git a/internal/shortcut/smart/doc_access_test.go b/internal/shortcut/smart/doc_access_test.go new file mode 100644 index 00000000..b81252a9 --- /dev/null +++ b/internal/shortcut/smart/doc_access_test.go @@ -0,0 +1,396 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "reflect" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/spf13/cobra" +) + +func docAccessCoverageResponses(permission string) map[string][]string { + return map[string][]string{ + "contact/search_contact_by_key_word": {`{"result":[{"userId":"u1","name":"张三","openDingTalkId":"open1"}]}`}, + "doc/list_permission": {permission}, + "doc/add_permission": {`{"result":{"ok":true}}`}, + "doc/update_permission": {`{"result":{"ok":true}}`}, + "doc/remove_permission": {`{"result":{"ok":true}}`}, + "chat/send_personal_message": {`{"result":{"messageId":"m1"}}`}, + } +} + +type docAccessErrorWriter struct{} + +func (docAccessErrorWriter) Write([]byte) (int, error) { return 0, errors.New("output failure") } + +func runDocAccessCoverage(t *testing.T, caller *smartCoverageCaller, args ...string) error { + t.Helper() + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + root.SetIn(bytes.NewReader(nil)) + root.SetArgs(args) + return root.Execute() +} + +func runDocAccessCoverageOutput(t *testing.T, caller *smartCoverageCaller, args ...string) (map[string]any, error) { + t.Helper() + helpers.InitDeps(caller) + root := newPlatformCoverageRoot() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetIn(bytes.NewReader(nil)) + root.SetArgs(args) + err := root.Execute() + if stdout.Len() == 0 { + return nil, err + } + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("decode output %q: %v", stdout.String(), err) + } + return payload, err +} + +func runDocAccessDeclaration(t *testing.T, declaration shortcut.Shortcut, caller *smartCoverageCaller, args ...string) error { + t.Helper() + helpers.InitDeps(caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: "doc"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + root.SetArgs(append([]string{"doc", declaration.Command}, args...)) + return root.Execute() +} + +func TestCrossPlatformCoverageDocAccessSuccessDryRunAndPreflight(t *testing.T) { + if docSmartContract("+test", "d", "i", nil, true).DryRun == nil { + t.Fatal("explicit doc smart dry-run declaration missing") + } + present := `{"result":[{"userId":"u1","roleId":"READER"}]}` + empty := `{"result":[]}` + cases := []struct { + name string + permission string + args []string + wantErr bool + }{ + {"grant", present, []string{"doc", "+access-grant", "--node", "n", "--to", "张三", "--role", "READER", "--workspace", "w", "--yes"}, false}, + {"grant dry", present, []string{"doc", "+access-grant", "--node", "n", "--to", "张三", "--dry-run", "--yes"}, false}, + {"change", present, []string{"doc", "+access-change", "--node", "n", "--to", "张三", "--role", "EDITOR", "--yes"}, false}, + {"change dry", present, []string{"doc", "+access-change", "--node", "n", "--to", "张三", "--dry-run", "--yes"}, false}, + {"change missing", empty, []string{"doc", "+access-change", "--node", "n", "--to", "张三", "--yes"}, true}, + {"revoke", present, []string{"doc", "+access-revoke", "--node", "n", "--to", "张三", "--workspace", "w", "--yes"}, false}, + {"revoke dry", present, []string{"doc", "+access-revoke", "--node", "n", "--to", "张三", "--dry-run", "--yes"}, false}, + {"revoke missing", empty, []string{"doc", "+access-revoke", "--node", "n", "--to", "张三", "--yes"}, true}, + {"share", present, []string{"doc", "+share", "--to", "张三", "--url", "https://example.com/doc", "--note", "看一下", "--yes"}, false}, + {"share dry", present, []string{"doc", "+share", "--to", "张三", "--url", "https://example.com/doc", "--dry-run", "--yes"}, false}, + {"grant share existing", present, []string{"doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes"}, false}, + {"grant share missing", empty, []string{"doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes"}, false}, + {"grant share dry", empty, []string{"doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--dry-run", "--yes"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &smartCoverageCaller{responses: docAccessCoverageResponses(tc.permission), failAt: map[string]int{}} + err := runDocAccessCoverage(t, caller, tc.args...) + if (err != nil) != tc.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + +func TestCrossPlatformCoverageDocGrantAndShareProjectionMatchesWrite(t *testing.T) { + permission := func(payload map[string]any) string { + t.Helper() + data, _ := payload["data"].(map[string]any) + recipients, _ := data["recipients"].([]any) + if len(recipients) != 1 { + t.Fatalf("recipients = %#v", data["recipients"]) + } + row, _ := recipients[0].(map[string]any) + value, _ := row["permission"].(string) + return value + } + + missingCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[]}`), failAt: map[string]int{}} + missing, err := runDocAccessCoverageOutput(t, missingCaller, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes") + if err != nil { + t.Fatal(err) + } + if missing["operation"] != "doc.grant_and_share" || permission(missing) != "granted" { + t.Fatalf("missing projection = %#v", missing) + } + if missingCaller.counts["doc/add_permission"] != 1 { + t.Fatalf("missing add calls = %d", missingCaller.counts["doc/add_permission"]) + } + + presentCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[{"id":"u1","roleId":"READER"}]}`), failAt: map[string]int{}} + present, err := runDocAccessCoverageOutput(t, presentCaller, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes") + if err != nil { + t.Fatal(err) + } + if present["operation"] != "doc.grant_and_share" || permission(present) != "unchanged" { + t.Fatalf("present projection = %#v", present) + } + if presentCaller.counts["doc/add_permission"] != 0 || presentCaller.counts["doc/update_permission"] != 0 { + t.Fatalf("present writes = %#v", presentCaller.counts) + } + + upgradeCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[{"userId":"u1","roleId":"READER"}]}`), failAt: map[string]int{}} + upgraded, err := runDocAccessCoverageOutput(t, upgradeCaller, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--role", "EDITOR", "--yes") + if err != nil { + t.Fatal(err) + } + if permission(upgraded) != "upgraded" || upgradeCaller.counts["doc/add_permission"] != 0 || upgradeCaller.counts["doc/update_permission"] != 1 || upgradeCaller.counts["chat/send_personal_message"] != 1 { + t.Fatalf("upgrade projection=%#v calls=%#v", upgraded, upgradeCaller.counts) + } + wantUpgrade := map[string]any{"nodeId": "n", "roleId": "EDITOR", "userIds": []string{"u1"}} + if got := upgradeCaller.arguments["doc/update_permission"]; len(got) != 1 || !reflect.DeepEqual(got[0], wantUpgrade) { + t.Fatalf("upgrade params = %#v, want %#v", got, wantUpgrade) + } + + unknownCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[{"userId":"u1"}]}`), failAt: map[string]int{}} + if _, err := runDocAccessCoverageOutput(t, unknownCaller, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes"); err == nil { + t.Fatal("unknown existing role must stop before permission writes and messaging") + } + if unknownCaller.counts["doc/add_permission"] != 0 || unknownCaller.counts["doc/update_permission"] != 0 || unknownCaller.counts["chat/send_personal_message"] != 0 { + t.Fatalf("unknown role continued: %#v", unknownCaller.counts) + } + + shareCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[]}`), failAt: map[string]int{}} + shared, err := runDocAccessCoverageOutput(t, shareCaller, "doc", "+share", "--to", "张三", "--url", "https://example.com/doc", "--yes") + if err != nil { + t.Fatal(err) + } + if shared["operation"] != "doc.share" || permission(shared) != "unchanged" { + t.Fatalf("share projection = %#v", shared) + } + + if missing := usersMissingPermission(map[string]any{"result": []any{map[string]any{"userId": "u10"}}}, []contactUser{{name: "张三", userID: "u1"}}); len(missing) != 1 { + t.Fatalf("substring permission match must not pass: %#v", missing) + } + + roles := map[string]int{"READER": 1, "DOWNLOADER": 2, "EDITOR": 3, "MANAGER": 4, "OWNER": 5, "unknown": 0} + for role, want := range roles { + if got := permissionRoleRank(role); got != want { + t.Fatalf("permissionRoleRank(%q) = %d, want %d", role, got, want) + } + } + collected := map[string]string{} + collectPermissionRoles(map[string]any{"result": []any{ + map[string]any{"userID": "u2", "permissionRole": "READER"}, + map[string]any{"memberId": "u2", "permissionType": "EDITOR"}, + map[string]any{"targetId": "u3", "roleType": "DOWNLOADER"}, + map[string]any{"uid": "u4", "roleID": "MANAGER"}, + map[string]any{"id": "u5", "role": "OWNER"}, + }}, collected) + if !reflect.DeepEqual(collected, map[string]string{"u2": "EDITOR", "u3": "DOWNLOADER", "u4": "MANAGER", "u5": "OWNER"}) { + t.Fatalf("collected roles = %#v", collected) + } +} + +func TestCrossPlatformCoverageDocAccessRevokeConfirmationBoundary(t *testing.T) { + present := `{"result":[{"userId":"u1","roleId":"READER"}]}` + unconfirmed := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}} + if err := runDocAccessCoverage(t, unconfirmed, "doc", "+access-revoke", "--node", "n", "--to", "张三"); err == nil { + t.Fatal("access revoke without --yes must reject") + } + if len(unconfirmed.counts) != 0 { + t.Fatalf("unconfirmed access revoke called MCP: %#v", unconfirmed.counts) + } + + confirmed := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}} + if err := runDocAccessCoverage(t, confirmed, "doc", "+access-revoke", "--node", "n", "--to", "张三", "--yes"); err != nil { + t.Fatal(err) + } + wantCounts := map[string]int{ + "contact/search_contact_by_key_word": 1, + "doc/list_permission": 1, + "doc/remove_permission": 1, + } + if !reflect.DeepEqual(confirmed.counts, wantCounts) { + t.Fatalf("confirmed calls = %#v, want %#v", confirmed.counts, wantCounts) + } + wantRemove := map[string]any{"nodeId": "n", "userIds": []string{"u1"}} + if got := confirmed.arguments["doc/remove_permission"]; len(got) != 1 || !reflect.DeepEqual(got[0], wantRemove) { + t.Fatalf("remove params = %#v, want %#v", got, wantRemove) + } +} + +func TestCrossPlatformCoverageDocShareKeepsLegacyStringFlagAndCSV(t *testing.T) { + testseam.Swap(t, &resolveDocShareUser, func(_ *shortcut.RuntimeContext, name string) (contactUser, error) { + return contactUser{name: name, userID: "user-" + name, openDingTalkID: "open-" + name}, nil + }) + responses := docAccessCoverageResponses(`{"result":[]}`) + responses["chat/send_personal_message"] = []string{ + `{"result":{"messageId":"m1"}}`, + `{"result":{"messageId":"m2"}}`, + } + caller := &smartCoverageCaller{responses: responses, failAt: map[string]int{}} + payload, err := runDocAccessCoverageOutput(t, caller, "doc", "+share-doc", "--to", "alice,bob", "--url", "https://example.com/doc", "--yes") + if err != nil { + t.Fatal(err) + } + data, _ := payload["data"].(map[string]any) + recipients, _ := data["recipients"].([]any) + if len(recipients) != 2 || caller.counts["chat/send_personal_message"] != 2 { + t.Fatalf("recipients=%#v send calls=%d", recipients, caller.counts["chat/send_personal_message"]) + } +} + +func TestCrossPlatformCoverageDocShareFailureExitContracts(t *testing.T) { + resolve := func(_ *shortcut.RuntimeContext, name string) (contactUser, error) { + return contactUser{name: name, userID: "user-" + name, openDingTalkID: "open-" + name}, nil + } + testseam.Swap(t, &resolveDocShareUser, resolve) + testseam.Swap(t, &resolveDocPermissionUser, resolve) + + assertFailure := func(t *testing.T, payload map[string]any, err error, wantStatus string, wantSucceeded, wantFailed int) { + t.Helper() + if err == nil { + t.Fatal("message failure must return a non-zero exit error") + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "doc_share_message_failed" || typed.ExitCode() != 1 { + t.Fatalf("message error = %#v", err) + } + if payload["ok"] != false || payload["status"] != wantStatus { + t.Fatalf("failure payload = %#v", payload) + } + data, _ := payload["data"].(map[string]any) + if data["succeededCount"] != float64(wantSucceeded) || data["failedCount"] != float64(wantFailed) { + t.Fatalf("failure counts = %#v", data) + } + } + + allShareCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[]}`), failAt: map[string]int{"chat/send_personal_message": -1}} + allShare, err := runDocAccessCoverageOutput(t, allShareCaller, "doc", "+share", "--to", "alice", "--url", "https://example.com/doc", "--yes") + assertFailure(t, allShare, err, "failed", 0, 1) + + permissions := `{"result":[{"userId":"user-alice","roleId":"READER"},{"userId":"user-bob","roleId":"READER"}]}` + allGrantCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(permissions), failAt: map[string]int{"chat/send_personal_message": -1}} + allGrant, err := runDocAccessCoverageOutput(t, allGrantCaller, "doc", "+grant-and-share", "--node", "n", "--to", "alice,bob", "--url", "https://example.com/doc", "--yes") + assertFailure(t, allGrant, err, "failed", 0, 2) + + partialCaller := &smartCoverageCaller{responses: docAccessCoverageResponses(permissions), failAt: map[string]int{"chat/send_personal_message": 2}} + partial, err := runDocAccessCoverageOutput(t, partialCaller, "doc", "+grant-and-share", "--node", "n", "--to", "alice,bob", "--url", "https://example.com/doc", "--yes") + assertFailure(t, partial, err, "partial_success", 1, 1) + + helpers.InitDeps(&smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[]}`), failAt: map[string]int{}}) + root := newPlatformCoverageRoot() + root.SetOut(docAccessErrorWriter{}) + root.SetErr(io.Discard) + root.SetIn(bytes.NewReader(nil)) + root.SetArgs([]string{"doc", "+share", "--to", "alice", "--url", "https://example.com/doc", "--yes"}) + if err := root.Execute(); err == nil || err.Error() != "output failure" { + t.Fatalf("output failure = %v", err) + } +} + +func TestCrossPlatformCoverageDocGrantAndSharePartialPermissionFailure(t *testing.T) { + resolve := func(_ *shortcut.RuntimeContext, name string) (contactUser, error) { + return contactUser{name: name, userID: "user-" + name, openDingTalkID: "open-" + name}, nil + } + testseam.Swap(t, &resolveDocPermissionUser, resolve) + + responses := docAccessCoverageResponses(`{"result":[{"userId":"user-bob","roleId":"READER"}]}`) + caller := &smartCoverageCaller{responses: responses, failAt: map[string]int{"doc/update_permission": 1}} + err := runDocAccessCoverage(t, caller, "doc", "+grant-and-share", "--node", "n", "--to", "alice,bob", "--url", "https://example.com/doc", "--role", "EDITOR", "--yes") + if err == nil { + t.Fatal("partial permission write unexpectedly succeeded") + } + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "doc_grant_permission_partial_failure" || typed.FailureStage != "update_permission" || typed.ExecutionStarted == nil || !*typed.ExecutionStarted || !typed.RetryableSet || typed.Retryable { + t.Fatalf("partial permission error = %#v", err) + } + if typed.Details["status"] != "partial_success" { + t.Fatalf("partial permission details = %#v", typed.Details) + } + steps, _ := typed.Details["steps"].([]map[string]any) + if len(steps) != 3 || steps[0]["status"] != "success" || steps[1]["status"] != "failed" || steps[2]["status"] != "not_started" { + t.Fatalf("partial permission steps = %#v", steps) + } + if caller.counts["doc/add_permission"] != 1 || caller.counts["doc/update_permission"] != 1 || caller.counts["chat/send_personal_message"] != 0 { + t.Fatalf("partial permission calls = %#v", caller.counts) + } +} + +func TestCrossPlatformCoverageDocAccessFailureBoundaries(t *testing.T) { + present := `{"result":[{"userId":"u1","roleId":"READER"}]}` + commands := []struct { + args []string + fails []string + }{ + {[]string{"doc", "+access-grant", "--node", "n", "--to", "张三", "--yes"}, []string{"contact/search_contact_by_key_word", "doc/add_permission"}}, + {[]string{"doc", "+access-change", "--node", "n", "--to", "张三", "--yes"}, []string{"contact/search_contact_by_key_word", "doc/list_permission", "doc/update_permission"}}, + {[]string{"doc", "+access-revoke", "--node", "n", "--to", "张三", "--yes"}, []string{"contact/search_contact_by_key_word", "doc/list_permission", "doc/remove_permission"}}, + {[]string{"doc", "+share", "--to", "张三", "--url", "https://example.com/doc", "--yes"}, []string{"contact/search_contact_by_key_word", "chat/send_personal_message"}}, + {[]string{"doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes"}, []string{"contact/search_contact_by_key_word", "doc/list_permission", "chat/send_personal_message"}}, + } + for _, command := range commands { + for _, fail := range command.fails { + responses := docAccessCoverageResponses(present) + if fail == "doc/add_permission" { + responses["doc/list_permission"] = []string{`{"result":[]}`} + } + caller := &smartCoverageCaller{responses: responses, failAt: map[string]int{fail: 1}} + _ = runDocAccessCoverage(t, caller, command.args...) + } + } + + external := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}} + external.responses["contact/search_contact_by_key_word"] = []string{`{"result":[{"name":"外部","openDingTalkId":"open-ext"}]}`} + _ = runDocAccessCoverage(t, external, "doc", "+access-grant", "--node", "n", "--to", "外部", "--yes") + noOpenID := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}} + noOpenID.responses["contact/search_contact_by_key_word"] = []string{`{"result":[{"name":"张三","userId":"u1"}]}`} + _ = runDocAccessCoverage(t, noOpenID, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes") + emptyName := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}} + _ = runDocAccessCoverage(t, emptyName, "doc", "+access-grant", "--node", "n", "--to", " ", "--yes") + duplicate := &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}} + _ = runDocAccessCoverage(t, duplicate, "doc", "+access-grant", "--node", "n", "--to", "张三,张三", "--yes") + _ = usersMissingPermission(map[string]any{}, []contactUser{{name: "empty"}}) + + optionalTo := AccessGrant + optionalTo.Flags = append([]shortcut.Flag(nil), AccessGrant.Flags...) + optionalTo.Flags[1].Required = false + _ = runDocAccessDeclaration(t, optionalTo, &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}, "--node", "n", "--to", " ", "--yes") + _ = runDocAccessDeclaration(t, optionalTo, &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}, "--node", "n", "--yes") + + t.Run("permission resolver defensive identity check", func(t *testing.T) { + testseam.Swap(t, &resolveDocPermissionUser, func(*shortcut.RuntimeContext, string) (contactUser, error) { + return contactUser{name: "external", openDingTalkID: "open"}, nil + }) + _ = runDocAccessCoverage(t, &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}, "doc", "+access-grant", "--node", "n", "--to", "external", "--yes") + }) + t.Run("permission resolver error", func(t *testing.T) { + testseam.Swap(t, &resolveDocPermissionUser, func(*shortcut.RuntimeContext, string) (contactUser, error) { + return contactUser{}, errors.New("resolve") + }) + _ = runDocAccessCoverage(t, &smartCoverageCaller{responses: docAccessCoverageResponses(present), failAt: map[string]int{}}, "doc", "+access-grant", "--node", "n", "--to", "x", "--yes") + }) + + grantShareAddFailure := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[]}`), failAt: map[string]int{"doc/add_permission": 1}} + _ = runDocAccessCoverage(t, grantShareAddFailure, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--yes") + grantShareUpdateFailure := &smartCoverageCaller{responses: docAccessCoverageResponses(`{"result":[{"userId":"u1","roleId":"READER"}]}`), failAt: map[string]int{"doc/update_permission": 1}} + _ = runDocAccessCoverage(t, grantShareUpdateFailure, "doc", "+grant-and-share", "--node", "n", "--to", "张三", "--url", "https://example.com/doc", "--role", "EDITOR", "--yes") + if grantShareUpdateFailure.counts["chat/send_personal_message"] != 0 { + t.Fatalf("message sent after permission upgrade failure: %#v", grantShareUpdateFailure.counts) + } +} diff --git a/internal/shortcut/smart/doc_append.go b/internal/shortcut/smart/doc_append.go index eb6ca1da..e00f526a 100644 --- a/internal/shortcut/smart/doc_append.go +++ b/internal/shortcut/smart/doc_append.go @@ -102,5 +102,7 @@ var DocAppend = shortcut.Shortcut{ } func init() { + // Keep the historical command and Schema identity alongside the richer + // canonical doc +update surface for backwards compatibility. shortcut.Register(DocAppend) } diff --git a/internal/shortcut/smart/find_doc.go b/internal/shortcut/smart/find_doc.go index 5c950ec5..7d5636f5 100644 --- a/internal/shortcut/smart/find_doc.go +++ b/internal/shortcut/smart/find_doc.go @@ -170,5 +170,7 @@ func shortcutFindDocStr(m map[string]any, keys ...string) string { } func init() { + // Keep the historical command and Schema identity alongside the richer + // canonical doc +search surface for backwards compatibility. shortcut.Register(FindDoc) } diff --git a/internal/shortcut/smart/share_doc.go b/internal/shortcut/smart/share_doc.go index 0c539ed1..382b54c9 100644 --- a/internal/shortcut/smart/share_doc.go +++ b/internal/shortcut/smart/share_doc.go @@ -116,5 +116,6 @@ func shareDocBuildText(url, note string) string { } func init() { - shortcut.Register(ShareDoc) + canonicalizeShareDoc() + shortcut.Register(legacyShareDoc, ShareDoc) } diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go index 9f7c9be5..ebcdb0f5 100644 --- a/internal/shortcut/types.go +++ b/internal/shortcut/types.go @@ -100,6 +100,12 @@ type Flag struct { Enum []string `json:"enum"` // Hidden hides the flag from --help while keeping it usable. Hidden bool `json:"-"` + // Aliases are hidden executable flag spellings for compatibility. They do + // not create additional Schema parameters; validation and value fallback + // remain attached to the canonical Name. AliasesVisible is a narrow + // compatibility escape hatch for aliases that were historically public. + Aliases []string `json:"-"` + AliasesVisible bool `json:"-"` } // ConstraintKind is a machine-readable cross-parameter or custom validation diff --git a/internal/shortcut/usage/command.go b/internal/shortcut/usage/command.go index e8f36358..cd59dd51 100644 --- a/internal/shortcut/usage/command.go +++ b/internal/shortcut/usage/command.go @@ -103,10 +103,7 @@ func newShortcutListRow(s shortcut.Shortcut) shortcutListRow { if risk == "" { risk = string(shortcut.RiskRead) } - confirmation := "not_required" - if risk != string(shortcut.RiskRead) { - confirmation = "user_required" - } + confirmation := shortcut.EffectiveSafety(s).Confirmation flags := make([]shortcut.Flag, 0, len(s.Flags)) for _, flag := range s.Flags { if flag.Hidden { diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 40012b48..d8cb52ae 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -18,7 +18,10 @@ GO_PATH = ROOT / "internal" / "shortcut" / "public_catalog_generated.go" CATALOG_PATH = ROOT / "docs" / "shortcut-public-catalog.json" FOLLOWUP_MD_PATH = ROOT / "docs" / "shortcut-real-test-followups.md" FOLLOWUP_JSON_PATH = ROOT / "docs" / "shortcut-real-test-followups.json" -SEMANTIC_PATH = ROOT / "internal" / "shortcut" / "semantic_catalog.json" +SEMANTIC_PATHS = [ + ROOT / "internal" / "shortcut" / "semantic_catalog.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_doc.json", +] def load(path: Path) -> dict[str, Any]: @@ -107,44 +110,46 @@ def collect() -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: # whether the current account happened to have a fixture for a real run. # Keep the real-run rows as evidence/follow-ups, but publish Chat entries # exclusively from the reviewed semantic catalog. - semantic = load(SEMANTIC_PATH) - service = semantic.get("service") or "" - if service != "chat": - raise ValueError(f"unexpected semantic catalog service: {service!r}") - public = [row for row in evidence_public if row["service"] != service] - for command, record in semantic.get("shortcuts", {}).items(): - if not record.get("public"): - continue - if not record.get("reviewed"): - raise ValueError(f"public semantic shortcut is not reviewed: {command}") - availability = ( - record.get("availability") - or semantic.get("default_availability") - or "" - ) - if availability != "available": - raise ValueError( - f"public semantic shortcut is not available: {command}={availability}" + semantics = [load(path) for path in SEMANTIC_PATHS] + semantic_services = {semantic.get("service") or "" for semantic in semantics} + if "" in semantic_services or len(semantic_services) != len(semantics): + raise ValueError(f"invalid or duplicate semantic catalog services: {semantic_services!r}") + public = [row for row in evidence_public if row["service"] not in semantic_services] + for semantic in semantics: + service = semantic["service"] + for command, record in semantic.get("shortcuts", {}).items(): + if not record.get("public"): + continue + if not record.get("reviewed"): + raise ValueError(f"public semantic shortcut is not reviewed: {service} {command}") + availability = ( + record.get("availability") + or semantic.get("default_availability") + or "" ) - observed = evidence_by_key.get((service, command), {}) - risk = record.get("risk") or "" - if not risk: - raise ValueError(f"public semantic shortcut lacks reviewed risk: {command}") - if observed.get("risk") and observed["risk"] != risk: - raise ValueError( - f"semantic shortcut risk drift: {command}: " - f"reviewed={risk} observed={observed['risk']}" - ) - public.append({ - "suite": "semantic", - "service": service, - "command": command, - "risk": risk, - "status": "reviewed_available", - "disposition": record.get("disposition") or "", - "semantic_delta": record.get("semantic_delta") or "", - "availability": availability, - }) + if availability != "available": + raise ValueError( + f"public semantic shortcut is not available: {service} {command}={availability}" + ) + observed = evidence_by_key.get((service, command), {}) + risk = record.get("risk") or "" + if not risk: + raise ValueError(f"public semantic shortcut lacks reviewed risk: {service} {command}") + if observed.get("risk") and observed["risk"] != risk: + raise ValueError( + f"semantic shortcut risk drift: {service} {command}: " + f"reviewed={risk} observed={observed['risk']}" + ) + public.append({ + "suite": "semantic", + "service": service, + "command": command, + "risk": risk, + "status": "reviewed_available", + "disposition": record.get("disposition") or "", + "semantic_delta": record.get("semantic_delta") or "", + "availability": availability, + }) public.sort(key=lambda r: (r["service"], r["command"])) followups.sort(key=lambda r: (r["suite"], r["service"], r["command"])) return public, followups diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index ba630f6a..2d50f025 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -46,11 +46,11 @@ cli_version: ">=1.0.15" | `aitable` | 29 | `dingtalk-aitable` | | `attendance` | 19 | `dingtalk-misc` | | `calendar` | 20 | `dingtalk-calendar` | -| `chat` | 97 | `dingtalk-chat` | +| `chat` | 98 | `dingtalk-chat` | | `contact` | 14 | `dingtalk-contact` | | `devapp` | 19 | `dingtalk-misc` | | `ding` | 4 | `dingtalk-misc` | -| `doc` | 17 | `dingtalk-doc` | +| `doc` | 41 | `dingtalk-doc` | | `drive` | 7 | `dingtalk-drive` | | `mail` | 10 | `dingtalk-mail` | | `minutes` | 6 | `dingtalk-minutes` | diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index 8a2c75e3..8ad44903 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -28,7 +28,7 @@ metadata: ## Shortcut 发现(按需) -`chat` 当前有 97 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。 +`chat` 当前有 98 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。 仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service chat --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。 diff --git a/skills/multi/dingtalk-doc/SKILL.md b/skills/multi/dingtalk-doc/SKILL.md index a7e7da62..248cf6f3 100644 --- a/skills/multi/dingtalk-doc/SKILL.md +++ b/skills/multi/dingtalk-doc/SKILL.md @@ -34,20 +34,44 @@ metadata: | Shortcut | 风险 | 适用场景 | |---|---|---| +| `dws doc +access-change` | write | 预检已有协作者后变更文档角色 | +| `dws doc +access-grant` | write | 按姓名解析后批量授予文档权限 | +| `dws doc +access-revoke` | high-risk-write | 预检并移除指定协作者的文档权限 | +| `dws doc +background-delete` | write | 清除文档背景色 | +| `dws doc +background-update` | write | 设置文档 #RRGGBB 背景纯色 | +| `dws doc +checkpoint-update` | write | 先保存可回滚版本,再更新并读回验证 | | `dws doc +comment-create` | write | 在文档上创建一条评论 | +| `dws doc +comment-delete` | high-risk-write | 永久删除指定文档评论 | | `dws doc +comment-list` | read | 查询文档评论列表 | | `dws doc +comment-reply` | write | 回复文档中的一条评论 | +| `dws doc +comment-update` | write | 更新指定文档评论正文和 mention | | `dws doc +copy` | write | 复制文档/文件到指定文件夹或知识库 | +| `dws doc +create` | write | 从 Markdown 或 JSONML 创建在线文字文档 | +| `dws doc +create-from-template` | write | 按 templateId 直达或搜索消歧后创建文档 | | `dws doc +doc-append` | write | 在文档末尾追加一段文本(安全追加,不改动原有内容) | +| `dws doc +export` | read | 提交、轮询并安全下载在线文档导出文件 | | `dws doc +export-get` | read | 根据 jobId 查询文档导出任务结果 | | `dws doc +export-submit` | read | 提交在线文档导出任务 (docx/markdown/pdf),返回 jobId | +| `dws doc +fetch` | read | 读取完整或局部文档内容,并按 detail 控制保真度 | | `dws doc +find-doc` | read | 按关键词搜索云文档并投影关键字段(只读) | +| `dws doc +grant-and-share` | write | 确保目标角色后按姓名逐人发送文档链接 | +| `dws doc +import` | write | 上传本地文件并等待转换成在线文档对象 | +| `dws doc +inspect` | read | 聚合文档元信息,并按需附带样式、权限、历史、媒体和评论 | | `dws doc +list` | read | 列出文件夹或知识库下的直接子节点 | +| `dws doc +media-download` | read | 安全下载文档正文附件到工作目录 | +| `dws doc +media-insert` | write | 上传本地图片或文件并插入文档正文 | +| `dws doc +media-list` | read | 列出文档正文中的图片和附件资源 | +| `dws doc +media-preview` | read | 下载正文媒体到受控临时目录并返回预览路径 | | `dws doc +move` | write | 移动文档/文件到指定文件夹或知识库 | +| `dws doc +resource-delete` | high-risk-write | 幂等清除文档封面 | +| `dws doc +resource-download` | read | 读取并安全下载当前文档封面 | +| `dws doc +resource-update` | write | 从本地图片或 HTTPS URL 设置文档封面 | +| `dws doc +review` | read | 聚合未解决评论、引用原文和块上下文 | | `dws doc +search` | read | 按关键词搜索有权限的文档 (不传则返回最近访问) | | `dws doc +share-doc` | write | 按姓名把文档链接私信发给某人(自动解析 userId) | | `dws doc +template-list` | read | 获取文档模板列表 | | `dws doc +template-search` | read | 根据关键词搜索文档模板 | +| `dws doc +update` | write | 追加、覆盖或按 block 精确更新文档内容 | | `dws doc +version-list` | read | 查看文档历史版本列表 | | `dws doc +version-revert` | high-risk-write | 回滚文档到指定历史版本 | | `dws doc +version-save` | write | 手动保存文档版本快照 |