336 lines
15 KiB
Go
336 lines
15 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
package drive
|
|
|
|
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/shortcut"
|
|
)
|
|
|
|
var Inspect = shortcut.Shortcut{
|
|
Service: "drive", Command: "+inspect", Product: "drive",
|
|
Description: "聚合检查节点元数据及可选统计、公开状态和封面",
|
|
Intent: "已知节点 ID,需要一次确认名称、类型、路径,并可附带统计、公开状态或封面时使用。",
|
|
Risk: shortcut.RiskRead,
|
|
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
|
Contract: driveContract(
|
|
"+inspect", "聚合检查节点元数据及可选统计、公开状态和封面",
|
|
"已知节点 ID,需要一次确认名称、类型、路径,并可附带统计、公开状态或封面时使用。",
|
|
[]string{"读取在线文档正文使用 doc +fetch;浏览目录使用 drive +list"},
|
|
[]string{`dws drive +inspect --node <dentryUuid>`, `dws drive +inspect --node <dentryUuid> --include-stats --include-publish`},
|
|
driveObjectResult("Drive 节点聚合检查结果"), nil,
|
|
contract.ParamDecl{Name: "node", Property: "fileId"},
|
|
),
|
|
Flags: []shortcut.Flag{
|
|
{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true},
|
|
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID"},
|
|
{Name: "include-stats", Type: shortcut.FlagBool, Desc: "附带阅读、编辑、下载等统计"},
|
|
{Name: "include-publish", Type: shortcut.FlagBool, Desc: "附带互联网公开状态"},
|
|
{Name: "include-cover", Type: shortcut.FlagBool, Desc: "附带封面或缩略图地址"},
|
|
},
|
|
Tips: []string{`dws drive +inspect --node <dentryUuid>`, `dws drive +inspect --node <dentryUuid> --include-stats --include-publish`},
|
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
|
node := rt.Str("node")
|
|
params := map[string]any{"fileId": node}
|
|
if rt.Str("space-id") != "" {
|
|
params["spaceId"] = rt.Str("space-id")
|
|
}
|
|
data, err := rt.CallMCPData("drive", "get_file_info", params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
info, err := requireDriveObject(data, "drive/get_file_info")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := map[string]any{"file": info}
|
|
steps := []map[string]any{{"tool": "get_file_info", "status": "success"}}
|
|
reads := []struct {
|
|
flag, key, tool string
|
|
params map[string]any
|
|
}{
|
|
{"include-stats", "stats", "get_node_stats", map[string]any{"nodeId": node}},
|
|
{"include-publish", "publish", "get_file_publish_status", map[string]any{"fileId": node}},
|
|
{"include-cover", "cover", "get_cover", map[string]any{"nodeId": node}},
|
|
}
|
|
failures := []map[string]any{}
|
|
for _, read := range reads {
|
|
if !rt.Bool(read.flag) {
|
|
continue
|
|
}
|
|
value, callErr := rt.CallMCPReadData("drive", read.tool, read.params)
|
|
if callErr == nil {
|
|
value, callErr = requireDriveObject(value, "drive/"+read.tool)
|
|
}
|
|
if callErr != nil {
|
|
steps = append(steps, map[string]any{"tool": read.tool, "status": "failed"})
|
|
failures = append(failures, map[string]any{"tool": read.tool, "error": callErr.Error()})
|
|
continue
|
|
}
|
|
result[read.key] = value
|
|
steps = append(steps, map[string]any{"tool": read.tool, "status": "success"})
|
|
}
|
|
if len(failures) > 0 {
|
|
return apperrors.NewAPI("Drive 聚合检查只完成了部分读取",
|
|
apperrors.WithOperation("drive.inspect"),
|
|
apperrors.WithReason("drive_inspect_partial"),
|
|
apperrors.WithFailureStage("optional_reads"),
|
|
apperrors.WithExecutionStarted(false),
|
|
apperrors.WithRetryable(true),
|
|
apperrors.WithDetails(map[string]any{"status": "partial_success", "complete": false, "data": result, "steps": steps, "failures": failures}),
|
|
)
|
|
}
|
|
return rt.Output(map[string]any{"status": "success", "complete": true, "data": result, "steps": steps})
|
|
},
|
|
}
|
|
|
|
var CreateFolder = shortcut.Shortcut{
|
|
Service: "drive", Command: "+create-folder", Product: "drive",
|
|
Description: "创建钉盘文件夹并读回验证",
|
|
Intent: "在钉盘中创建普通文件夹并需要拿到经过读回验证的 fileId 时使用。",
|
|
Risk: shortcut.RiskWrite,
|
|
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
|
|
Contract: driveContract(
|
|
"+create-folder", "创建钉盘文件夹并读回验证",
|
|
"在钉盘中创建普通文件夹并需要拿到经过读回验证的 fileId 时使用。",
|
|
[]string{"知识库目录使用 wiki node create;创建在线文档使用 doc +create"},
|
|
[]string{`dws drive +create-folder --name "项目资料"`, `dws drive +create-folder --name "子目录" --folder <dentryUuid>`},
|
|
driveObjectResult("创建并验证后的文件夹"), nil,
|
|
contract.ParamDecl{Name: "folder", Property: "parentId"},
|
|
),
|
|
Flags: []shortcut.Flag{
|
|
{Name: "name", Type: shortcut.FlagString, Desc: "文件夹名称", Required: true},
|
|
{Name: "space-id", Type: shortcut.FlagString, Desc: "钉盘空间 ID"},
|
|
{Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID"},
|
|
},
|
|
Tips: []string{`dws drive +create-folder --name "项目资料"`, `dws drive +create-folder --name "子目录" --folder <dentryUuid>`},
|
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
|
params := map[string]any{"name": rt.Str("name")}
|
|
if rt.Str("space-id") != "" {
|
|
params["spaceId"] = rt.Str("space-id")
|
|
}
|
|
if rt.Str("folder") != "" {
|
|
params["parentId"] = rt.Str("folder")
|
|
}
|
|
created, err := rt.CallMCPWriteDataStrict("drive", "create_folder", params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
created, err = requireDriveWrite(created, "drive/create_folder")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nodeID := nestedString(created, "fileId", "dentryUuid", "nodeId", "id")
|
|
if nodeID == "" {
|
|
return driveResponseError("drive/create_folder", "missing_created_id", "创建响应没有文件夹 fileId;远端效果未知")
|
|
}
|
|
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": nodeID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
verified, err = requireDriveObject(verified, "drive/get_file_info")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if name := firstString(verified, "name", "fileName"); name != rt.Str("name") {
|
|
return driveResponseError("drive/create_folder", "readback_mismatch", fmt.Sprintf("创建后读回名称 %q 与请求 %q 不一致", name, rt.Str("name")))
|
|
}
|
|
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "folder": verified})
|
|
},
|
|
}
|
|
|
|
var CreateShortcut = shortcut.Shortcut{
|
|
Service: "drive", Command: "+create-shortcut", Product: "drive",
|
|
Description: "为已有节点创建快捷方式并验证新节点",
|
|
Intent: "给已有文件或文档创建快捷入口,并需要区分 shortcut 与独立副本时使用。",
|
|
Risk: shortcut.RiskWrite,
|
|
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "non_idempotent"},
|
|
Contract: driveContract(
|
|
"+create-shortcut", "为已有节点创建快捷方式并验证新节点",
|
|
"给已有文件或文档创建快捷入口,并需要区分 shortcut 与独立副本时使用。",
|
|
[]string{"需要独立副本使用 drive +copy;需要迁移原节点使用 drive +move"},
|
|
[]string{`dws drive +create-shortcut --node <SOURCE_NODE> --folder <TARGET_FOLDER>`},
|
|
driveObjectResult("创建并验证后的快捷方式节点"), nil,
|
|
contract.ParamDecl{Name: "node", Property: "nodeId"},
|
|
contract.ParamDecl{Name: "folder", Property: "targetFolderId"},
|
|
contract.ParamDecl{Name: "workspace", Property: "workspaceId"},
|
|
),
|
|
Flags: []shortcut.Flag{
|
|
{Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true},
|
|
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"},
|
|
{Name: "workspace", Type: shortcut.FlagString, Desc: "目标知识库 ID"},
|
|
},
|
|
Tips: []string{`dws drive +create-shortcut --node <SOURCE_NODE> --folder <TARGET_FOLDER>`},
|
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
|
params := map[string]any{"nodeId": rt.Str("node")}
|
|
if rt.Str("folder") != "" {
|
|
params["targetFolderId"] = rt.Str("folder")
|
|
}
|
|
if rt.Str("workspace") != "" {
|
|
params["workspaceId"] = rt.Str("workspace")
|
|
}
|
|
created, err := rt.CallMCPWriteDataStrict("drive", "create_shortcut", params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
created, err = requireDriveWrite(created, "drive/create_shortcut")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nodeID := nestedString(created, "fileId", "dentryUuid", "nodeId", "id")
|
|
if nodeID == "" {
|
|
return driveResponseError("drive/create_shortcut", "missing_created_id", "创建快捷方式未返回新节点 ID")
|
|
}
|
|
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": nodeID})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
verified, err = requireDriveObject(verified, "drive/get_file_info")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return rt.Output(map[string]any{"success": true, "nodeId": nodeID, "shortcut": verified})
|
|
},
|
|
}
|
|
|
|
var Rename = shortcut.Shortcut{
|
|
Service: "drive", Command: "+rename", Product: "doc",
|
|
Description: "重命名文件或文件夹并读回验证",
|
|
Intent: "改变已有节点名称并要求验证最终名称时使用。",
|
|
Risk: shortcut.RiskWrite,
|
|
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "unknown"},
|
|
Contract: driveContract(
|
|
"+rename", "重命名文件或文件夹并读回验证",
|
|
"改变已有节点名称并要求验证最终名称时使用。",
|
|
[]string{"改变位置使用 drive +move;替换文件内容使用 drive +upload --node"},
|
|
[]string{`dws drive +rename --node <dentryUuid> --name "新名称"`},
|
|
driveObjectResult("重命名后的节点元数据"), nil,
|
|
contract.ParamDecl{Name: "node", Property: "nodeId"},
|
|
contract.ParamDecl{Name: "name", Property: "newName"},
|
|
),
|
|
Flags: []shortcut.Flag{
|
|
{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true},
|
|
{Name: "name", Type: shortcut.FlagString, Desc: "新名称", Required: true},
|
|
},
|
|
Tips: []string{`dws drive +rename --node <dentryUuid> --name "新名称"`},
|
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
|
preflight, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": rt.Str("node")})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
preflight, err = requireDriveObject(preflight, "drive/get_file_info")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
requestName, expectedNames := normalizedDriveRename(rt.Str("name"), preflight)
|
|
written, err := rt.CallMCPWriteDataStrict("doc", "rename_document", map[string]any{"nodeId": rt.Str("node"), "newName": requestName})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := requireDriveWrite(written, "doc/rename_document"); err != nil {
|
|
return err
|
|
}
|
|
verified, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": rt.Str("node")})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
verified, err = requireDriveObject(verified, "drive/get_file_info")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name := firstString(verified, "name", "fileName")
|
|
if name == "" || !expectedNames[name] {
|
|
return driveResponseError("doc/rename_document", "readback_mismatch", fmt.Sprintf("重命名读回名称 %q 与请求 %q 不一致", name, rt.Str("name")))
|
|
}
|
|
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "file": verified})
|
|
},
|
|
}
|
|
|
|
func normalizedDriveRename(name string, info map[string]any) (string, map[string]bool) {
|
|
name = strings.TrimSpace(name)
|
|
expected := map[string]bool{name: true}
|
|
nodeType := strings.ToLower(firstString(info, "type", "nodeType", "fileType"))
|
|
if nodeType == "folder" || nodeType == "dir" || nodeType == "directory" {
|
|
return name, expected
|
|
}
|
|
extension := strings.TrimLeft(strings.ToLower(firstString(info, "extension", "fileExtension", "ext")), ".")
|
|
if extension == "" {
|
|
return name, expected
|
|
}
|
|
suffix := "." + extension
|
|
if len(name) > len(suffix) && strings.EqualFold(name[len(name)-len(suffix):], suffix) {
|
|
return name[:len(name)-len(suffix)], expected
|
|
}
|
|
expected[name+suffix] = true
|
|
return name, expected
|
|
}
|
|
|
|
var Delete = shortcut.Shortcut{
|
|
Service: "drive", Command: "+delete", Product: "doc",
|
|
Description: "将已确认节点移入回收站",
|
|
Intent: "用户明确要求删除已确认钉盘节点,并理解它会进入回收站时使用。",
|
|
Risk: shortcut.RiskHighWrite,
|
|
Safety: contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"},
|
|
Contract: driveContract(
|
|
"+delete", "将已确认节点移入回收站",
|
|
"用户明确要求删除已确认钉盘节点,并理解它会进入回收站时使用。",
|
|
[]string{"只是调整位置使用 drive +move;目标不明确时先 drive +inspect"},
|
|
[]string{`dws drive +delete --node <dentryUuid>`},
|
|
driveObjectResult("删除到回收站的终态证据"), nil,
|
|
contract.ParamDecl{Name: "node", Property: "nodeId"},
|
|
),
|
|
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true}},
|
|
Tips: []string{`dws drive +delete --node <dentryUuid>`},
|
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
|
written, err := rt.CallMCPWriteDataStrict("doc", "delete_document", map[string]any{"nodeId": rt.Str("node")})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
written, err = requireDriveWrite(written, "doc/delete_document")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "result": written})
|
|
},
|
|
}
|
|
|
|
var Stats = driveReadObjectShortcut(
|
|
"+stats", "读取节点访问和协作统计", "get_node_stats", "nodeId",
|
|
"用户要查看指定节点阅读、编辑、评论、点赞、预览或下载统计时使用。",
|
|
[]string{"只要文件名称和类型使用 drive +inspect"},
|
|
)
|
|
|
|
var Cover = driveReadObjectShortcut(
|
|
"+cover", "读取节点封面或缩略图地址", "get_cover", "nodeId",
|
|
"用户需要节点封面、首图或缩略图 URL 时使用。",
|
|
[]string{"这是封面资源,不等于 Lark 服务端多格式预览转换"},
|
|
)
|
|
|
|
func driveReadObjectShortcut(command, description, tool, property, useWhen string, avoidWhen []string) shortcut.Shortcut {
|
|
return shortcut.Shortcut{
|
|
Service: "drive", Command: command, Product: "drive", Description: description, Intent: useWhen,
|
|
Risk: shortcut.RiskRead, Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
|
Contract: driveContract(command, description, useWhen, avoidWhen,
|
|
[]string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
|
|
driveObjectResult(description), nil, contract.ParamDecl{Name: "node", Property: property}),
|
|
Flags: []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Desc: "节点 ID", Required: true}},
|
|
Tips: []string{fmt.Sprintf("dws drive %s --node <dentryUuid>", command)},
|
|
Execute: func(rt *shortcut.RuntimeContext) error {
|
|
data, err := rt.CallMCPData("drive", tool, map[string]any{property: rt.Str("node")})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data, err = requireDriveObject(data, "drive/"+tool)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return rt.Output(data)
|
|
},
|
|
}
|
|
}
|