Files
修雨 4c5f1faeb1 feat: align open CLI with dws-wukong via cedar discovery version code (#509)
Switches the discovery version code bamboo -> cedar and aligns the open edition CLI with dws-wukong across communication (calendar book/acl/attendee, minutes tag, mail folder/template/contact, chat file upload, todo add-attachment, attendance transforms) and structured-office (aitable advperm/view/section/workflow/record, sheet/drive/wiki/doc) domains. Includes output-envelope parity, parse_bool/attendance_class_check_time transforms, --calendar-id support, CHANGELOG 1.0.43 and README command-index refresh. cedar config validated on pre and prod endpoints.
2026-06-26 21:49:38 +08:00

1428 lines
50 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
func init() {
RegisterPublic(func() Handler {
return driveHandler{}
})
}
// driveHandler exposes the drive command surface that the service-discovery
// envelope may not express consistently in older/pre caches:
//
// - upload — three-step composite: drive.get_upload_info → HTTP PUT to OSS →
// drive.commit_upload. The envelope PipelineStep schema currently supports
// type:"call" (MCP tool invocation) and type:"download" (HTTP GET sink),
// but has no type:"upload" for streaming a local file to an OSS-signed
// PUT URL with per-URL headers. Until the envelope schema grows that
// capability, this helper is the canonical client-side glue.
//
// For the remaining leaves the helper keeps the same command names as the
// dynamic envelope, but sets a higher override priority so the local binary can
// provide stable validation and aliases without depending on shared config
// rollout timing.
type driveHandler struct{}
func (driveHandler) Name() string {
return "drive"
}
func (driveHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "drive",
Short: "钉盘文件管理",
Long: `钉盘:列出文件/文件夹、获取元数据、下载链接、创建文件夹、获取上传信息、提交上传。`,
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
preferLegacyLeaf(root)
root.AddCommand(
newDriveListCommand(runner),
newDriveListSpacesCommand(runner),
newDriveInfoCommand(runner),
newDriveDownloadCommand(runner),
newDriveMkdirCommand(runner),
newDriveUploadInfoCommand(runner),
newDriveCommitCommand(runner),
newDriveUploadCommand(runner),
newDriveDeleteCommand(runner),
newDriveSearchCommand(runner),
newDriveCopyCommand(runner),
newDriveMoveCommand(runner),
newDriveRenameCommand(runner),
newDrivePermissionCommand(runner),
newDriveFolderCommand(runner),
)
return root
}
// ── dynamic-compatible leaves ──────────────────────────────
func newDriveListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "获取文件/文件夹列表",
Example: ` dws drive list --limit 20
dws drive list --limit 20 --folder <dentryUuid> --order-by name --order asc`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
maxResults := driveIntFlagOrFallback(cmd, "limit", "max", "page-size")
if maxResults <= 0 {
maxResults = 20
}
if workspaceID := driveFlagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
params := map[string]any{"workspaceId": workspaceID}
if folderID := driveFlagOrFallback(cmd, "folder", "parent-id"); folderID != "" {
params["folderId"] = normalizeDocNodeID(folderID)
}
if maxResults > 0 {
params["pageSize"] = maxResults
}
addDriveStringParam(cmd, params, "pageToken", "cursor", "next-token")
return runDriveInvocation(cmd, runner, "doc", "list_nodes", params)
}
params := map[string]any{"maxResults": float64(maxResults)}
addDriveStringParam(cmd, params, "spaceId", "space-id")
if parentID := driveFlagOrFallback(cmd, "folder", "parent-id"); parentID != "" {
if err := validateDriveParentID(parentID); err != nil {
return err
}
params["parentId"] = parentID
}
addDriveStringParam(cmd, params, "nextToken", "cursor", "next-token")
addDriveStringParam(cmd, params, "orderBy", "order-by")
addDriveStringParam(cmd, params, "order", "order")
if thumbnail, _ := cmd.Flags().GetBool("thumbnail"); thumbnail {
params["withThumbnail"] = true
}
return runDriveInvocation(cmd, runner, "drive", "list_files", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().Int("limit", 20, "每页返回数量,默认 20,最大 100 (可选)")
cmd.Flags().Int("max", 0, "--limit 的别名(向后兼容)")
_ = cmd.Flags().MarkHidden("max")
cmd.Flags().Int("page-size", 0, "--limit 的兼容别名")
_ = cmd.Flags().MarkHidden("page-size")
cmd.Flags().String("space-id", "", "空间 ID,不传则使用「我的文件」对应 spaceId (可选)")
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则列出空间根目录 (可选)")
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
cmd.Flags().String("workspace", "", "文档空间/知识库 ID,传入则路由到文档空间")
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
cmd.Flags().String("cursor", "", "分页游标,首次不传 (可选)")
addDriveHiddenStringFlag(cmd, "next-token", "--cursor 的兼容别名")
cmd.Flags().String("order-by", "", "排序字段: createTime|modifyTime|name (可选)")
cmd.Flags().String("order", "", "排序方向: asc|desc,默认 desc (可选)")
cmd.Flags().Bool("thumbnail", false, "是否返回缩略图信息 (可选)")
return cmd
}
func newDriveListSpacesCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list-spaces",
Short: "获取钉盘空间列表",
Long: `列出当前用户可访问的钉盘空间,返回 spaceId、spaceName、rootFolderId 等信息。
spaceType 筛选规则:
orgSpace(默认): 返回企业空间列表,支持 nextToken 分页
mySpace: 返回用户的"我的文件"个人空间(单个)`,
Example: ` dws drive list-spaces
dws drive list-spaces --space-type mySpace
dws drive list-spaces --space-type orgSpace --limit 20 --cursor <TOKEN>`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintln(cmd.ErrOrStderr(), "warning: deprecated: use dws wiki space list instead.")
params := map[string]any{}
maxResults, _ := cmd.Flags().GetInt("limit")
if !cmd.Flags().Changed("limit") {
if limit, _ := cmd.Flags().GetInt("max"); limit > 0 {
maxResults = limit
}
}
if maxResults > 0 {
params["maxResults"] = float64(maxResults)
}
addDriveStringParam(cmd, params, "spaceType", "space-type")
addDriveStringParam(cmd, params, "nextToken", "cursor", "next-token")
return runDriveInvocation(cmd, runner, "drive", "list_spaces", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().Int("limit", 20, "每页返回数量 (默认 20,最大 50),仅 spaceType 为 orgSpace 时有效")
cmd.Flags().Int("max", 0, "--limit 的兼容别名")
_ = cmd.Flags().MarkHidden("max")
cmd.Flags().String("space-type", "", "空间类型: orgSpace=企业空间(默认), mySpace=我的文件 (可选)")
cmd.Flags().String("cursor", "", "分页游标,仅企业空间支持分页 (可选)")
addDriveHiddenStringFlag(cmd, "next-token", "--cursor 的兼容别名")
return cmd
}
func newDriveInfoCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "info",
Short: "获取文件元数据信息",
Long: `获取钉盘文件/文件夹的元数据信息。
如果目标文件属于钉钉文档(在线文档/表格/脑图等),会自动跟进调用
钉钉文档接口获取更准确的文档信息(如真实文档名称),并合并输出。`,
Example: ` dws drive info --node <dentryUuid> # 查询 fileId: dws drive list`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
fileID, err := driveRequiredFlagOrFallback(cmd, "node", "file-id")
if err != nil {
return err
}
params := map[string]any{"fileId": fileID}
addDriveStringParam(cmd, params, "spaceId", "space-id")
return runDriveInfo(cmd, runner, params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
addDriveHiddenStringFlag(cmd, "file-id", "--node 的兼容别名")
cmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
return cmd
}
func newDriveDownloadCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "download",
Short: "下载钉盘文件到本地",
Long: `下载钉盘中的文件到本地(两步下载流程)。
流程:
1. 获取下载 URL 和签名请求头 (download_file)
2. HTTP GET 下载文件二进制内容到本地
--output 指定本地保存路径,可以是文件路径或目录。
如果指定目录,文件名从下载 URL 中自动推断。`,
Example: ` dws drive download --node <dentryUuid> --output ./report.pdf
dws drive download --node <dentryUuid> --output ~/downloads/`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runDriveDownload(cmd, runner)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
addDriveHiddenStringFlag(cmd, "file-id", "--node 的兼容别名")
cmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
cmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
return cmd
}
func newDriveMkdirCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "mkdir",
Short: "创建文件夹",
Example: ` dws drive mkdir --name "项目资料"
dws drive mkdir --name "子目录" --folder <dentryUuid>`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
name, err := driveRequiredFlag(cmd, "name")
if err != nil {
return err
}
params := map[string]any{"name": name}
addDriveStringParam(cmd, params, "spaceId", "space-id")
if parentID := driveFlagOrFallback(cmd, "folder", "parent-id"); parentID != "" {
if err := validateDriveParentID(parentID); err != nil {
return err
}
params["parentId"] = parentID
}
return runDriveInvocation(cmd, runner, "drive", "create_folder", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("name", "", "文件夹名称,最长 50 字符 (必填)")
cmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则在空间根目录下创建 (可选)")
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
return cmd
}
func newDriveUploadInfoCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "upload-info",
Short: "获取文件上传信息",
Example: ` dws drive upload-info --file-name "报告.pdf" --file-size 102400
dws drive upload-info --file-name "readme.txt" --file-size 1024 --folder <dentryUuid>`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
fileName, err := driveRequiredFlag(cmd, "file-name")
if err != nil {
return err
}
fileSize, _ := cmd.Flags().GetInt64("file-size")
if fileSize <= 0 {
return apperrors.NewValidation("--file-size is required and must be a positive integer")
}
params := map[string]any{
"fileName": fileName,
"fileSize": float64(fileSize),
}
addDriveStringParam(cmd, params, "spaceId", "space-id")
addDriveStringParam(cmd, params, "mimeType", "mime-type")
if parentID := driveFlagOrFallback(cmd, "folder", "parent-id"); parentID != "" {
if err := validateDriveParentID(parentID); err != nil {
return err
}
params["parentId"] = parentID
}
return runDriveInvocation(cmd, runner, "drive", "get_upload_info", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("file-name", "", "文件名,须包含扩展名,如 报告.pdf (必填)")
cmd.Flags().Int64("file-size", 0, "文件大小(字节)(必填)")
_ = cmd.MarkFlagRequired("file-size")
cmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
cmd.Flags().String("mime-type", "", "文件 MIME 类型,如 application/pdf,不传则自动推断 (可选)")
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则上传到空间根目录 (可选)")
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
return cmd
}
func newDriveCommitCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "commit",
Short: "提交文件上传",
Example: ` dws drive commit --file-name "报告.pdf" --file-size 102400 --upload-id <uploadId>`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
fileName, err := driveRequiredFlag(cmd, "file-name")
if err != nil {
return err
}
uploadID, err := driveRequiredFlag(cmd, "upload-id")
if err != nil {
return err
}
fileSize, _ := cmd.Flags().GetInt64("file-size")
if fileSize <= 0 {
return apperrors.NewValidation("--file-size is required and must be a positive integer")
}
params := map[string]any{
"fileName": fileName,
"fileSize": float64(fileSize),
"uploadId": uploadID,
}
addDriveStringParam(cmd, params, "spaceId", "space-id")
if parentID := driveFlagOrFallback(cmd, "folder", "parent-id"); parentID != "" {
if err := validateDriveParentID(parentID); err != nil {
return err
}
params["parentId"] = parentID
}
return runDriveInvocation(cmd, runner, "drive", "commit_upload", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("file-name", "", "文件名(含扩展名),须与 get_upload_info 时一致 (必填)")
cmd.Flags().Int64("file-size", 0, "文件大小(字节),须与 get_upload_info 时一致 (必填)")
_ = cmd.MarkFlagRequired("file-size")
cmd.Flags().String("upload-id", "", "上传 ID,来自 get_upload_info 返回的 uploadId (必填)")
cmd.Flags().String("space-id", "", "空间 ID,不传则使用「我的文件」 (可选)")
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则提交到根目录 (可选)")
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
return cmd
}
// ── upload (three-step composite) ───────────────────────────
func newDriveUploadCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "upload",
Short: "上传本地文件到钉盘",
Long: `将本地文件上传到钉盘(三步自动完成)。
流程:
1. 获取 OSS 上传凭证 (get_upload_info)
2. HTTP PUT 上传文件二进制到 OSS
3. 提交文件入库 (commit_upload)
上传位置: --folder 指定父目录,不传则上传到空间根目录。`,
Example: ` dws drive upload --file ./report.pdf
dws drive upload --file ./slides.pptx --file-name "Q1汇报.pptx"
dws drive upload --file ./data.xlsx --folder <dentryUuid>`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runDriveUpload(cmd, runner)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("file", "", "本地文件路径 (必填)")
cmd.Flags().String("file-name", "", "文件显示名称 (默认使用文件名)")
addDriveHiddenStringFlag(cmd, "name", "--file-name 的兼容别名")
cmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
cmd.Flags().String("mime-type", "", "文件 MIME 类型,不传则自动推断 (可选)")
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则上传到空间根目录 (可选)")
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
cmd.Flags().String("workspace", "", "目标知识库 ID,传入时路由到文档空间上传")
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
cmd.Flags().Bool("convert", false, "是否转换为钉钉在线文档(文档空间上传时生效)")
return cmd
}
func runDriveUpload(cmd *cobra.Command, runner executor.Runner) error {
filePath, _ := cmd.Flags().GetString("file")
if strings.TrimSpace(filePath) == "" {
return apperrors.NewValidation("--file is required")
}
if workspaceID := driveFlagOrFallback(cmd, "workspace", "workspace-id"); workspaceID != "" {
return runDriveUploadToDoc(cmd, runner, workspaceID)
}
absPath, err := filepath.Abs(filePath)
if err != nil {
return apperrors.NewValidation("无法解析文件路径: " + err.Error())
}
fi, err := os.Stat(absPath)
if err != nil {
return apperrors.NewValidation("文件不存在或无法读取: " + absPath)
}
if fi.IsDir() {
return apperrors.NewValidation("--file 不能是目录: " + absPath)
}
fileSize := fi.Size()
if fileSize <= 0 {
return apperrors.NewValidation("文件为空")
}
fileName := driveFlagOrFallback(cmd, "file-name", "name")
if strings.TrimSpace(fileName) == "" {
fileName = filepath.Base(absPath)
}
spaceID, _ := cmd.Flags().GetString("space-id")
mimeType, _ := cmd.Flags().GetString("mime-type")
if strings.TrimSpace(mimeType) == "" {
mimeType = detectMIME(fileName)
}
parentID := driveFlagOrFallback(cmd, "folder", "parent-id")
if err := validateDriveParentID(parentID); err != nil {
return err
}
// Step 1 params
step1Params := map[string]any{
"fileName": fileName,
"fileSize": float64(fileSize),
}
if strings.TrimSpace(spaceID) != "" {
step1Params["spaceId"] = spaceID
}
if strings.TrimSpace(mimeType) != "" {
step1Params["mimeType"] = mimeType
}
if strings.TrimSpace(parentID) != "" {
step1Params["parentId"] = parentID
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, map[string]any{
"dry_run": true,
"step_1_get_upload_info": executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "drive", "get_upload_info", step1Params,
),
"step_2_http_put_oss": "PUT file bytes to resourceUrls[0].url with returned headers",
"step_3_commit_upload": "drive commit_upload with uploadId from step 1",
"file": absPath,
"size": fileSize,
"name": fileName,
})
}
// Step 1: get_upload_info
fmt.Fprintf(os.Stderr, "[1/3] 获取上传凭证 %s (%d 字节, %s)...\n", fileName, fileSize, mimeType)
step1 := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "drive", "get_upload_info", step1Params,
)
step1Result, err := runner.Run(cmd.Context(), step1)
if err != nil {
return fmt.Errorf("获取上传凭证失败: %w", err)
}
resourceURL, uploadID, ossHeaders, err := parseDriveUploadInfo(step1Result.Response)
if err != nil {
return err
}
// Step 2: HTTP PUT to OSS
fmt.Fprintln(os.Stderr, "[2/3] 上传文件到 OSS...")
if err := httpPutDriveFile(cmd.Context(), resourceURL, ossHeaders, absPath, fileSize); err != nil {
return err
}
// Step 3: commit_upload
fmt.Fprintln(os.Stderr, "[3/3] 提交文件入库...")
step3Params := map[string]any{
"fileName": fileName,
"fileSize": float64(fileSize),
"uploadId": uploadID,
}
if strings.TrimSpace(spaceID) != "" {
step3Params["spaceId"] = spaceID
}
if strings.TrimSpace(parentID) != "" {
step3Params["parentId"] = parentID
}
step3 := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "drive", "commit_upload", step3Params,
)
result, err := runner.Run(cmd.Context(), step3)
if err != nil {
return fmt.Errorf("提交文件入库失败: %w", err)
}
return writeCommandPayload(cmd, result)
}
func runDriveUploadToDoc(cmd *cobra.Command, runner executor.Runner, workspaceID string) error {
filePath, _ := cmd.Flags().GetString("file")
absPath, err := filepath.Abs(filePath)
if err != nil {
return apperrors.NewValidation("无法解析文件路径: " + err.Error())
}
fi, err := os.Stat(absPath)
if err != nil {
return apperrors.NewValidation("文件不存在或无法读取: " + absPath)
}
if fi.IsDir() {
return apperrors.NewValidation("--file 不能是目录: " + absPath)
}
fileSize := fi.Size()
if fileSize <= 0 {
return apperrors.NewValidation("文件为空")
}
fileName := driveFlagOrFallback(cmd, "file-name", "name")
if fileName == "" {
fileName = filepath.Base(absPath)
}
folderID := driveFlagOrFallback(cmd, "folder", "parent-id")
if folderID != "" {
folderID = normalizeDocNodeID(folderID)
}
step1Params := map[string]any{"workspaceId": workspaceID}
if folderID != "" {
step1Params["folderId"] = folderID
}
commitParams := map[string]any{
"name": fileName,
"fileSize": float64(fileSize),
"workspaceId": workspaceID,
}
if folderID != "" {
commitParams["folderId"] = folderID
}
if convert, _ := cmd.Flags().GetBool("convert"); convert {
commitParams["convertToOnlineDoc"] = true
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, map[string]any{
"dry_run": true,
"step_1_get_file_upload_info": executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "get_file_upload_info", step1Params,
),
"step_2_http_put_oss": "PUT file bytes to resourceUrl with returned headers",
"step_3_commit_uploaded_file": executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "commit_uploaded_file", commitParams,
),
"file": absPath,
"name": fileName,
"size": fileSize,
})
}
fmt.Fprintf(cmd.ErrOrStderr(), "[1/3] 获取文档空间上传凭证 %s (%d 字节)...\n", fileName, fileSize)
step1Result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "get_file_upload_info", step1Params,
))
if err != nil {
return fmt.Errorf("获取上传凭证失败: %w", err)
}
resourceURL, uploadKey, headers, err := extractDocFileUploadInfo(step1Result.Response)
if err != nil {
return err
}
fmt.Fprintln(cmd.ErrOrStderr(), "[2/3] 上传文件到 OSS...")
if err := httpPutDriveFile(cmd.Context(), resourceURL, headers, absPath, fileSize); err != nil {
return err
}
fmt.Fprintln(cmd.ErrOrStderr(), "[3/3] 提交文件入库...")
commitParams["uploadKey"] = uploadKey
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "doc", "commit_uploaded_file", commitParams,
))
if err != nil {
return fmt.Errorf("提交文件入库失败: %w", err)
}
return writeCommandPayload(cmd, result)
}
// ── delete (drive surface routed to doc MCP server) ────────
func newDriveDeleteCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "delete",
Short: "删除文件/文件夹到回收站",
Long: `将钉盘中的文件或文件夹移入回收站。
注意: 这是一个危险操作,文件将被移入回收站。执行前需要确认,或传入 --yes 跳过确认。
--node 对应 drive list 返回的 fileId 字段(即 dentryUuid)。
权限要求: 对文档有"管理"权限。`,
Example: ` dws drive delete --node <dentryUuid> --yes # 查询 fileId: dws drive list
dws drive delete --node <dentryUuid> # 交互式确认后删除`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
fileID, err := driveRequiredFlagOrFallback(cmd, "node", "file-id")
if err != nil {
return err
}
if !confirmDeletePrompt(cmd, "钉盘节点", fileID) {
return nil
}
params := map[string]any{"nodeId": fileID}
return runDriveInvocation(cmd, runner, "doc", "delete_document", params)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("node", "", "文件/文件夹 ID (dentryUuid),即 drive list 返回的 fileId (必填)")
addDriveHiddenStringFlag(cmd, "file-id", "--node 的兼容别名")
cmd.Flags().BoolP("yes", "y", false, "跳过确认直接删除")
return cmd
}
func newDriveSearchCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "search",
Short: "搜索文件(聚合钉盘和文档空间)",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
keyword := driveFlagOrFallback(cmd, "query", "keyword")
if keyword == "" {
return apperrors.NewValidation("--query is required")
}
target := driveStringFlag(cmd, "target")
driveParams := map[string]any{"keyword": keyword}
if target != "" && target != "all" {
driveParams["searchTarget"] = target
}
addDriveStringSliceParam(cmd, driveParams, "fileTypes", "file-types")
addDriveStringSliceParam(cmd, driveParams, "extensions", "extensions")
addDriveStringSliceParam(cmd, driveParams, "creatorUserIds", "creator-uids")
addDriveInt64Param(cmd, driveParams, "createdTimeFrom", "created-from")
addDriveInt64Param(cmd, driveParams, "createdTimeTo", "created-to")
addDriveInt64Param(cmd, driveParams, "modifiedTimeFrom", "modified-from")
addDriveInt64Param(cmd, driveParams, "modifiedTimeTo", "modified-to")
if pageSize := driveIntFlagOrFallback(cmd, "limit", "page-size"); pageSize > 0 {
driveParams["pageSize"] = float64(pageSize)
}
addDriveStringParam(cmd, driveParams, "pageToken", "cursor", "page-token")
if target == "file" || target == "space" {
return runDriveInvocation(cmd, runner, "drive", "search_files", driveParams)
}
driveResult, driveErr := driveInvocationResult(cmd, runner, "drive", "search_files", driveParams)
docParams := map[string]any{"keyword": keyword}
if pageSize := driveIntFlagOrFallback(cmd, "limit", "page-size"); pageSize > 0 {
docParams["pageSize"] = pageSize
}
if extensions, ok := driveParams["extensions"]; ok {
docParams["extensions"] = extensions
}
docResult, docErr := driveInvocationResult(cmd, runner, "doc", "search_documents", docParams)
if driveErr != nil && docErr != nil {
return fmt.Errorf("aggregated search failed: drive: %v; doc: %v", driveErr, docErr)
}
result := map[string]any{}
if driveErr == nil {
result["drive_results"] = driveResult.Response
}
if docErr == nil {
result["doc_results"] = docResult.Response
}
return writeCommandPayload(cmd, map[string]any{"success": true, "result": result})
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("query", "", "搜索关键词 (必填)")
addDriveHiddenStringFlag(cmd, "keyword", "--query 的兼容别名")
cmd.Flags().String("target", "", "搜索范围: all(默认) / file / space")
cmd.Flags().StringSlice("file-types", nil, "按文件内容类型过滤")
cmd.Flags().StringSlice("extensions", nil, "按文件扩展名过滤")
cmd.Flags().StringSlice("creator-uids", nil, "按创建者 userId 过滤")
cmd.Flags().Int64("created-from", 0, "创建时间起始毫秒时间戳")
cmd.Flags().Int64("created-to", 0, "创建时间截止毫秒时间戳")
cmd.Flags().Int64("modified-from", 0, "修改时间起始毫秒时间戳")
cmd.Flags().Int64("modified-to", 0, "修改时间截止毫秒时间戳")
cmd.Flags().Int("limit", 0, "每页返回数量")
cmd.Flags().Int("page-size", 0, "--limit 的兼容别名")
_ = cmd.Flags().MarkHidden("page-size")
cmd.Flags().String("cursor", "", "分页游标")
addDriveHiddenStringFlag(cmd, "page-token", "--cursor 的兼容别名")
return cmd
}
func newDriveCopyCommand(runner executor.Runner) *cobra.Command {
return newDriveDocTransferCommand(runner, "copy", "copy_document")
}
func newDriveMoveCommand(runner executor.Runner) *cobra.Command {
return newDriveDocTransferCommand(runner, "move", "move_document")
}
func newDriveDocTransferCommand(runner executor.Runner, use, tool string) *cobra.Command {
cmd := &cobra.Command{
Use: use,
Short: use + " 文档空间节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
params := map[string]any{"nodeId": normalizeDocNodeID(nodeID)}
if folder := driveFlagOrFallback(cmd, "folder", "parent-id", "parent-node-id", "parent-folder-id"); folder != "" {
params["targetFolderId"] = normalizeDocNodeID(folder)
}
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
return runDriveInvocation(cmd, runner, "doc", tool, params)
},
}
preferLegacyLeaf(cmd)
addDriveDocNodeFlags(cmd)
cmd.Flags().String("folder", "", "目标文件夹 nodeId")
addDriveHiddenStringFlag(cmd, "parent-id", "--folder 的兼容别名")
addDriveHiddenStringFlag(cmd, "parent-node-id", "--folder 的兼容别名")
addDriveHiddenStringFlag(cmd, "parent-folder-id", "--folder 的兼容别名")
cmd.Flags().String("workspace", "", "目标知识库 ID")
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
return cmd
}
func newDriveRenameCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "rename",
Short: "重命名文档空间节点",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
name, err := driveRequiredFlagOrFallback(cmd, "name", "title")
if err != nil {
return err
}
return runDriveInvocation(cmd, runner, "doc", "rename_document", map[string]any{
"nodeId": normalizeDocNodeID(nodeID),
"newName": name,
})
},
}
preferLegacyLeaf(cmd)
addDriveDocNodeFlags(cmd)
cmd.Flags().String("name", "", "新名称 (必填)")
addDriveHiddenStringFlag(cmd, "title", "--name 的兼容别名")
return cmd
}
func newDrivePermissionCommand(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "permission",
Aliases: []string{"perm"},
Short: "文档空间节点权限管理",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
root.AddCommand(
newDrivePermissionMutationCommand(runner, "add", "add_permission", true),
newDrivePermissionMutationCommand(runner, "update", "update_permission", true),
newDrivePermissionListCommand(runner),
newDrivePermissionRemoveCommand(runner),
)
preferLegacyLeaf(root)
return root
}
func newDrivePermissionMutationCommand(runner executor.Runner, use, tool string, requireRole bool) *cobra.Command {
cmd := &cobra.Command{
Use: use,
Short: use + " 文档空间节点权限",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
rawUsers := driveFlagOrFallback(cmd, "users", "user", "uid")
if rawUsers == "" {
return apperrors.NewValidation("--users is required")
}
userIDs, err := parseDocPermissionUsers(rawUsers)
if err != nil {
return err
}
params := map[string]any{
"nodeId": normalizeDocNodeID(nodeID),
"userIds": userIDs,
}
if requireRole {
rawRole, err := driveRequiredFlag(cmd, "role")
if err != nil {
return err
}
role, ok := normalizeDocPermissionRole(rawRole)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("invalid --role: %s", rawRole))
}
params["roleId"] = role
}
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
return runDriveInvocation(cmd, runner, "doc", tool, params)
},
}
preferLegacyLeaf(cmd)
addDriveDocNodeFlags(cmd)
cmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
addDriveHiddenStringFlag(cmd, "user", "--users 的兼容别名")
addDriveHiddenStringFlag(cmd, "uid", "--users 的兼容别名")
if requireRole {
cmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
}
cmd.Flags().String("workspace", "", "知识库 ID (选填)")
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
return cmd
}
func newDrivePermissionListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "查询文档空间节点协作者",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
nodeID, err := driveRequiredFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
if err != nil {
return err
}
params := map[string]any{"nodeId": normalizeDocNodeID(nodeID)}
if limit := driveIntFlagOrFallback(cmd, "limit", "max-results", "page-size"); limit > 0 {
params["maxResults"] = limit
}
if filterRole := driveStringFlag(cmd, "filter-role"); filterRole != "" {
params["filterRoleIds"] = parseDriveRoleList(filterRole)
}
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
return runDriveInvocation(cmd, runner, "doc", "list_permission", params)
},
}
preferLegacyLeaf(cmd)
addDriveDocNodeFlags(cmd)
cmd.Flags().Int("limit", 30, "返回成员数上限")
cmd.Flags().Int("max-results", 0, "--limit 的兼容别名")
_ = cmd.Flags().MarkHidden("max-results")
cmd.Flags().Int("page-size", 0, "--limit 的兼容别名")
_ = cmd.Flags().MarkHidden("page-size")
cmd.Flags().String("filter-role", "", "按角色过滤,逗号分隔")
cmd.Flags().String("workspace", "", "知识库 ID (选填)")
addDriveHiddenStringFlag(cmd, "workspace-id", "--workspace 的兼容别名")
return cmd
}
func newDrivePermissionRemoveCommand(runner executor.Runner) *cobra.Command {
cmd := newDrivePermissionMutationCommand(runner, "remove", "remove_permission", false)
cmd.Aliases = []string{"rm"}
return cmd
}
func newDriveFolderCommand(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "folder",
Short: "文档空间文件夹兼容入口(deprecated)",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
create := &cobra.Command{
Use: "create",
Short: "创建文档空间文件夹(deprecated)",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Fprintln(cmd.ErrOrStderr(), "warning: deprecated: use dws wiki node create --type folder instead.")
name, err := driveRequiredFlagOrFallback(cmd, "name", "title")
if err != nil {
return err
}
params := map[string]any{"name": name}
if folder := driveFlagOrFallback(cmd, "folder", "parent-id"); folder != "" {
params["folderId"] = normalizeDocNodeID(folder)
}
addDriveStringParam(cmd, params, "workspaceId", "workspace", "workspace-id")
return runDriveInvocation(cmd, runner, "doc", "create_folder", params)
},
}
preferLegacyLeaf(root)
preferLegacyLeaf(create)
create.Flags().String("name", "", "文件夹名称 (必填)")
addDriveHiddenStringFlag(create, "title", "--name 的兼容别名")
create.Flags().String("folder", "", "父文件夹 nodeId 或 URL")
addDriveHiddenStringFlag(create, "parent-id", "--folder 的兼容别名")
create.Flags().String("workspace", "", "目标知识库 ID")
addDriveHiddenStringFlag(create, "workspace-id", "--workspace 的兼容别名")
root.AddCommand(create)
root.Hidden = true
return root
}
func runDriveInfo(cmd *cobra.Command, runner executor.Runner, params map[string]any) error {
result, err := driveInvocationResult(cmd, runner, "drive", "get_file_info", params)
if err != nil {
return err
}
content := driveResultContent(result)
driveResult := driveInnerResult(content)
if !isDriveDingTalkDocResult(driveResult) {
return writeCommandPayload(cmd, result)
}
nodeID := driveStringFromMap(driveResult, "fileId")
if nodeID == "" {
nodeID, _ = params["fileId"].(string)
}
docResult, err := driveInvocationResult(cmd, runner, "doc", "get_document_info", map[string]any{"nodeId": nodeID})
if err != nil {
return writeCommandPayload(cmd, result)
}
docContent := driveResultContent(docResult)
innerDoc := driveInnerResult(docContent)
if len(innerDoc) == 0 {
return writeCommandPayload(cmd, result)
}
for _, field := range []string{"dentryId", "path", "fileSize", "extension", "type", "fileId"} {
if value, ok := driveResult[field]; ok {
if _, exists := innerDoc[field]; !exists {
innerDoc[field] = value
}
}
}
if _, hasWrapper := docContent["result"]; hasWrapper {
docContent["result"] = innerDoc
return writeCommandPayload(cmd, docContent)
}
return writeCommandPayload(cmd, map[string]any{"success": true, "result": innerDoc})
}
func runDriveDownload(cmd *cobra.Command, runner executor.Runner) error {
fileID, err := driveRequiredFlagOrFallback(cmd, "node", "file-id")
if err != nil {
return err
}
outputPath, err := driveRequiredFlag(cmd, "output")
if err != nil {
return err
}
params := map[string]any{"fileId": fileID}
addDriveStringParam(cmd, params, "spaceId", "space-id")
if commandDryRun(cmd) {
return writeCommandPayload(cmd, map[string]any{
"dry_run": true,
"step_1_download_file": executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "drive", "download_file", params,
),
"step_2_http_get": "GET file bytes from returned downloadUrl/resourceUrl",
"output": outputPath,
})
}
result, err := driveInvocationResult(cmd, runner, "drive", "download_file", params)
if err != nil {
return err
}
resourceURL, serverFilename, headers, err := parseDriveDownloadInfo(driveResultContent(result))
if err != nil {
return err
}
if info, statErr := os.Stat(outputPath); statErr == nil && info.IsDir() {
outputPath = filepath.Join(outputPath, driveDownloadFilename(serverFilename, resourceURL))
}
if err := httpGetDriveFile(cmd.Context(), resourceURL, headers, outputPath); err != nil {
return err
}
return writeCommandPayload(cmd, map[string]any{
"success": true,
"downloadUrl": resourceURL,
"output": outputPath,
})
}
func runDriveInvocation(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) error {
result, err := driveInvocationResult(cmd, runner, product, tool, params)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
}
func driveInvocationResult(cmd *cobra.Command, runner executor.Runner, product, tool string, params map[string]any) (executor.Result, error) {
invocation := executor.NewHelperInvocation(cobracmd.LegacyCommandPath(cmd), product, tool, params)
invocation.DryRun = commandDryRun(cmd)
return runner.Run(cmd.Context(), invocation)
}
func driveResultContent(result executor.Result) map[string]any {
if content, ok := result.Response["content"].(map[string]any); ok {
return content
}
if len(result.Response) > 0 {
return result.Response
}
return map[string]any{}
}
func driveInnerResult(content map[string]any) map[string]any {
if content == nil {
return map[string]any{}
}
if result, ok := content["result"].(map[string]any); ok {
return result
}
return content
}
func isDriveDingTalkDocResult(result map[string]any) bool {
if len(result) == 0 {
return false
}
extension := strings.ToLower(driveStringFromMap(result, "extension"))
switch extension {
case "adoc", "axls", "amind", "adraw":
return true
}
return strings.Contains(driveStringFromMap(result, "message"), "钉钉文档")
}
func parseDriveDownloadInfo(content map[string]any) (resourceURL string, filename string, headers map[string]string, err error) {
data := driveInnerResult(content)
filename = driveStringFromMap(data, "fileName")
if filename == "" {
filename = driveStringFromMap(data, "name")
}
switch v := data["resourceUrl"].(type) {
case string:
resourceURL = v
case []any:
if len(v) > 0 {
resourceURL, _ = v[0].(string)
}
}
if resourceURL == "" {
resourceURL, _ = data["downloadUrl"].(string)
}
if resourceURL == "" {
err = apperrors.NewValidation("download_file 返回不完整: resourceUrl/downloadUrl 为空")
return
}
headers = make(map[string]string)
if h, ok := data["headers"].(map[string]any); ok {
for k, v := range h {
if s, ok := v.(string); ok {
headers[k] = s
}
}
}
return
}
func driveDownloadFilename(serverFilename, rawURL string) string {
if name := cleanDriveFilename(serverFilename); name != "" {
return name
}
return inferDriveFilename(rawURL)
}
func cleanDriveFilename(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return ""
}
name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
if name == "" || name == "." || name == "/" {
return ""
}
return name
}
func inferDriveFilename(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err == nil {
name := strings.TrimSpace(filepath.Base(parsed.Path))
if name != "" && name != "." && name != "/" {
if decoded, decodeErr := url.PathUnescape(name); decodeErr == nil && decoded != "" {
return decoded
}
return name
}
}
return "download"
}
func httpGetDriveFile(ctx context.Context, resourceURL string, headers map[string]string, outputPath string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)
if err != nil {
return fmt.Errorf("构建下载请求失败: %w", err)
}
for k, v := range headers {
req.Header.Set(k, v)
}
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("下载失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("下载失败 HTTP %d: %s", resp.StatusCode, string(body))
}
out, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer out.Close()
if _, err := io.Copy(out, resp.Body); err != nil {
return fmt.Errorf("写入下载文件失败: %w", err)
}
return nil
}
func driveStringFlag(cmd *cobra.Command, name string) string {
if cmd == nil {
return ""
}
if value, err := cmd.Flags().GetString(name); err == nil && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
if value, err := cmd.InheritedFlags().GetString(name); err == nil && strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
return ""
}
func driveFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) string {
if value := driveStringFlag(cmd, primary); value != "" {
return value
}
for _, alias := range aliases {
if value := driveStringFlag(cmd, alias); value != "" {
return value
}
}
return ""
}
func driveIntFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) int {
if cmd == nil {
return 0
}
if cmd.Flags().Changed(primary) {
value, _ := cmd.Flags().GetInt(primary)
return value
}
for _, alias := range aliases {
if cmd.Flags().Changed(alias) {
value, _ := cmd.Flags().GetInt(alias)
return value
}
}
value, _ := cmd.Flags().GetInt(primary)
return value
}
func driveRequiredFlag(cmd *cobra.Command, name string) (string, error) {
if value := driveStringFlag(cmd, name); value != "" {
return value, nil
}
return "", apperrors.NewValidation(fmt.Sprintf("--%s is required", name))
}
func driveRequiredFlagOrFallback(cmd *cobra.Command, primary string, aliases ...string) (string, error) {
if value := driveFlagOrFallback(cmd, primary, aliases...); value != "" {
return value, nil
}
return "", apperrors.NewValidation(fmt.Sprintf("--%s is required", primary))
}
func addDriveStringParam(cmd *cobra.Command, params map[string]any, paramName string, flags ...string) {
if value := driveFlagOrFallback(cmd, flags[0], flags[1:]...); value != "" {
params[paramName] = value
}
}
func addDriveStringSliceParam(cmd *cobra.Command, params map[string]any, paramName, flag string) {
if !cmd.Flags().Changed(flag) {
return
}
values, err := cmd.Flags().GetStringSlice(flag)
if err != nil || len(values) == 0 {
return
}
cleaned := make([]string, 0, len(values))
for _, value := range values {
for _, part := range strings.Split(value, ",") {
if item := strings.TrimSpace(part); item != "" {
cleaned = append(cleaned, item)
}
}
}
if len(cleaned) > 0 {
params[paramName] = cleaned
}
}
func addDriveInt64Param(cmd *cobra.Command, params map[string]any, paramName, flag string) {
if !cmd.Flags().Changed(flag) {
return
}
value, err := cmd.Flags().GetInt64(flag)
if err == nil && value > 0 {
params[paramName] = value
}
}
func addDriveDocNodeFlags(cmd *cobra.Command) {
cmd.Flags().String("node", "", "节点 ID 或 URL (必填)")
addDriveHiddenStringFlag(cmd, "url", "--node 的兼容别名")
addDriveHiddenStringFlag(cmd, "id", "--node 的兼容别名")
addDriveHiddenStringFlag(cmd, "node-id", "--node 的兼容别名")
addDriveHiddenStringFlag(cmd, "doc-id", "--node 的兼容别名")
addDriveHiddenStringFlag(cmd, "file-id", "--node 的兼容别名")
}
func parseDriveRoleList(raw string) []string {
roles := make([]string, 0)
for _, part := range strings.Split(raw, ",") {
role := strings.ToUpper(strings.TrimSpace(part))
if role == "" {
continue
}
if normalized, ok := normalizeDocPermissionRole(role); ok {
roles = append(roles, normalized)
continue
}
if role == "OWNER" {
roles = append(roles, role)
}
}
return roles
}
func addDriveHiddenStringFlag(cmd *cobra.Command, name, usage string) {
cmd.Flags().String(name, "", usage)
_ = cmd.Flags().MarkHidden(name)
}
func driveStringFromMap(values map[string]any, key string) string {
value, _ := values[key].(string)
return strings.TrimSpace(value)
}
// validateDriveParentID rejects pure-numeric IDs (which are dentryId values
// from the chat link namespace, not drive's dentryUuid).
func validateDriveParentID(parentID string) error {
value := strings.TrimSpace(parentID)
if value == "" {
return nil
}
for _, r := range value {
if r < '0' || r > '9' {
return nil
}
}
return apperrors.NewValidation(fmt.Sprintf(
"invalid drive --folder %q: pure numeric IDs are usually dentryId values from chat links, not drive dentryUuid; use a parent folder dentryUuid from drive list, or omit --folder to use the space root",
parentID,
))
}
// parseDriveUploadInfo extracts resourceUrl / uploadId / OSS headers from the
// drive.get_upload_info response. The actual server payload is:
//
// {
// "uploadId": "...",
// "resourceUrls": [
// { "url": "https://...", "headers": { ... } }
// ]
// }
//
// MCP gateway may wrap the payload with a "content" or "result" envelope, so we
// peel one layer if present, and also accept legacy flat resourceUrl/uploadUrl
// fields as a fallback.
func parseDriveUploadInfo(resp map[string]any) (resourceURL, uploadID string, headers map[string]string, err error) {
if resp == nil {
err = apperrors.NewValidation("get_upload_info 返回为空")
return
}
data := resp
if content, ok := data["content"].(map[string]any); ok && len(content) > 0 {
data = content
}
if result, ok := data["result"].(map[string]any); ok && len(result) > 0 {
data = result
}
uploadID, _ = data["uploadId"].(string)
if urls, ok := data["resourceUrls"].([]any); ok && len(urls) > 0 {
if first, ok := urls[0].(map[string]any); ok {
resourceURL, _ = first["url"].(string)
headers = make(map[string]string)
if h, ok := first["headers"].(map[string]any); ok {
for k, v := range h {
if s, ok := v.(string); ok {
headers[k] = s
}
}
}
}
}
if resourceURL == "" {
resourceURL, _ = data["resourceUrl"].(string)
}
if resourceURL == "" {
resourceURL, _ = data["uploadUrl"].(string)
}
if resourceURL == "" || uploadID == "" {
err = apperrors.NewValidation(fmt.Sprintf(
"get_upload_info 返回不完整: resourceUrl=%q, uploadId=%q", resourceURL, uploadID,
))
return
}
if headers == nil {
headers = make(map[string]string)
if h, ok := data["headers"].(map[string]any); ok {
for k, v := range h {
if s, ok := v.(string); ok {
headers[k] = s
}
}
}
}
return
}
// httpPutDriveFile uploads the file at filePath to a DingTalk drive OSS presigned URL.
//
// PROTOCOL CONTRACT: the headers map is authoritative. It contains the exact
// and complete set of HTTP headers required for the upload. An empty map means
// "no client-side headers needed" (this is the normal case for DingTalk drive,
// where the signature is embedded in the URL query string).
//
// DO NOT add Content-Type or any other client-inferred header here. DingTalk
// drive uses OSS v1 presigned URLs whose StringToSign includes the Content-Type
// header that the server saw at signing time (typically empty). Any client-side
// addition of Content-Type makes the signature computed by OSS at PUT time
// differ from the server's presignature → 403 SignatureDoesNotMatch.
//
// If a future OSS endpoint requires header-based signing (Authorization header
// instead of URL signing), introduce a separate helper rather than reintroducing
// a fallback here. The aitable attachment upload helper in aitable.go does set
// Content-Type because its OSS endpoint uses a different signing mode where the
// server includes the client-declared mime in its signature computation; do not
// unify the two helpers without re-validating both endpoints.
func httpPutDriveFile(ctx context.Context, resourceURL string, headers map[string]string, filePath string, fileSize int64) error {
f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("无法打开文件: %w", err)
}
defer f.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPut, resourceURL, f)
if err != nil {
return fmt.Errorf("构建 OSS 上传请求失败: %w", err)
}
req.ContentLength = fileSize
for k, v := range headers {
req.Header.Set(k, v)
}
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("OSS 上传失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("OSS 上传失败 HTTP %d: %s", resp.StatusCode, string(body))
}
return nil
}