Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b4d808d39 | ||
|
|
c7d8ddf98d |
@@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [1.0.31] - 2026-05-21
|
||||
|
||||
Closes the last drive-surface gap with the Wukong edition: `dws drive upload` lands as a single-shot composite (`drive.get_upload_info` → HTTP PUT to OSS → `drive.commit_upload`) so a local file reaches DingTalk drive in one CLI invocation, no manual three-step orchestration. Two more drive commands — `dws drive list-spaces` (list visible drive spaces) and `dws drive delete` (delete a drive file, routed via `serverOverride` to the doc MCP server) — ship via the portal envelope; `dws cache refresh` once to pick them up. Companion skill docs teach the agent to recognise dingpan URLs of the form `alidocs.dingtalk.com/document/edit?dentryKey=…` / `…/document/preview?dentryKey=…` and pass the whole URL through to `--node` instead of trying to extract `dentryKey` by hand (the server interprets `dentryKey` and a bare `nodeId` differently — manual extraction was failing).
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws drive upload --file <path> [--folder <dentryUuid>] [--space-id <id>] [--file-name <name>] [--mime-type <type>]`** (#335, see `internal/helpers/drive.go`) — composite leaf that runs the full three-step upload internally:
|
||||
1. `drive.get_upload_info` — fetch the OSS-signed `resourceUrl` + `uploadId` + per-URL headers.
|
||||
2. HTTP `PUT` the file binary to OSS (10-minute timeout, attaches every header returned by step 1).
|
||||
3. `drive.commit_upload` — register the new file under the target space / folder.
|
||||
|
||||
`--dry-run` prints the three step invocations as a single JSON payload without making any network calls. `--file -` is rejected on purpose: this is a local-path upload, not stdin streaming. `--folder` only accepts a `dentryUuid`; pure-numeric values are rejected up front (`validateDriveParentID`) so callers don't accidentally pass a chat-link `dentryId` (a different ID namespace) where the drive API expects a `dentryUuid`. Response normalisation handles all the wrapper shapes the upstream returns — `content` / `result` envelopes, `resourceUrls[]` arrays, and the flat `resourceUrl` / `uploadUrl` fallbacks — so the composite produces a stable JSON shape regardless of which path the upstream takes. The helper only registers `upload`; the existing six envelope-generated leaves (`list` / `info` / `download` / `mkdir` / `upload-info` / `commit`) keep flowing through dynamic discovery unchanged. `pickCommands.MergeHardcodedLeaves` guarantees dynamic leaves win on collision, so this helper only fills the upload gap.
|
||||
- **`dws drive list-spaces` and `dws drive delete` (envelope rollout)** (#335, ships via portal envelope) — `list_spaces` registers as a plain `cliName` alias on the existing drive MCP server; `delete_document` registers with `serverOverride: doc` so the call routes to the doc MCP server (which owns the delete API), surfacing under the drive command tree for ergonomics. **Existing users must run `dws cache refresh` once** to pick up these two new leaves; no binary upgrade is required for them, but they pair naturally with the v1.0.31 client that ships `upload`.
|
||||
- **`skills/references/url-patterns.md`** (#335) — single authority for dispatching `alidocs.dingtalk.com` URLs across doc / sheet / wiki. Five-way split: `/i/p/<token>` short links → expand via `doc info`; `/i/nodes/<id>` node URLs → probe with `doc info` and route by `contentType` / `extension` / `nodeType`; `/spreadsheetv2/...` → `sheet`; `/document/edit|preview?dentryKey=<key>` (dingpan format) → pass the whole URL to `--node`, do not strip `dentryKey` by hand; `/i/share/...` (read-only share) → use the `read_url` fallback. The "URL precheck" Step 0 in `skills/SKILL.md` now redirects every URL-bearing prompt through this dispatcher before the agent picks a product.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`skills/references/products/doc.md` — `--node` accepts dingpan URLs end-to-end** (#335) — `dws doc info` / `dws doc read` examples gain two extra rows showing `--node "https://alidocs.dingtalk.com/document/edit?dentryKey=<KEY>"` and `…/preview?dentryKey=<KEY>` as first-class `--node` inputs. The "URL recognition & DOC_ID extraction" table adds the `document/edit|preview?dentryKey=<key>` row, and the extraction rules are split into three explicit clauses so the agent stops manually pulling `dentryKey` out of the URL and feeding it as a bare `nodeId` (which the server rejects). The "nodeId dual-format note" upgrades to "nodeId multi-format note" with four equivalent `--node` input shapes side by side.
|
||||
|
||||
## [1.0.30] - 2026-05-19
|
||||
|
||||
Aligns the open-source CLI with the IM envelope and schema-pipeline plumbing the Wukong edition has been running in pre-prod, plus three user-visible quality-of-life fixes. The most visible one: chat-bot webhook payloads carrying literal Chinese mentions (`@所有人 周报来了` / `@张三 看一下`) no longer fail with `file not found` — `@` is only treated as the `@<filename>` file-injection prefix when followed by an ASCII path-shaped character. The `chat` command tree is refactored to lean on the service-discovery envelope: thin wrappers (`chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search`) move out of the hardcoded helper and become envelope-generated dynamic commands; the helper keeps only the chat commands with real business logic (intelligent routing, current-user resolution, response normalization, stdin/@file input). A new `dws chat message reply` joins the existing `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` family. Underneath: `transform: invert_bool` lets envelopes flip boolean semantics between CLI surface and MCP body (e.g. `--off` ↔ `mute=true`); the pipeline executor fail-fast on upstream `content.errorCode` instead of polling forever; service-discovery dedup keeps two envelope entries that share an MCP endpoint but declare different `cli.id` as separate descriptors (so the `bot-root` / `bot-message` / `bot-group` trio fronting one MCP server stays as three distinct CLI command roots); and `dws chat` no longer nests as `dws chat chat` when two envelope servers both declare the same top-level command name.
|
||||
|
||||
@@ -412,7 +412,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
|
||||
| AI Tables | `aitable` | 41 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Full CRUD for Bases / datasheets / records / fields / views; charts & dashboards with public-share configs; data import/export; attachments; templates |
|
||||
| Doc | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download |
|
||||
| Drive | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | DingTalk drive file ops: list, info, download, create folders, two-phase upload |
|
||||
| Drive | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | DingTalk drive file ops: list spaces, list / info / download, create folders, one-shot `upload` (three-step composite) or two-phase `upload-info` + `commit`, delete |
|
||||
| Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session |
|
||||
| Mail | `mail` | 4 | `mailbox` `message` | List mailbox addresses, KQL message search, get full message content, send email |
|
||||
| Sheet | `sheet` | 34 | `range` `filter-view` (top-level: `create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read/write/append, dimension ops, cell merge, find/replace, named filter views + sheet-level filters, image write, async export (`submit_export_job` + `query_export_job` — no consolidated `export` in v1.0.25) |
|
||||
@@ -423,7 +423,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
|
||||
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
|
||||
|
||||
> **209 commands across 19 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
> **212 commands across 19 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
|
||||
|
||||
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
|
||||
|
||||
|
||||
+2
-2
@@ -412,7 +412,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、详情、统计 |
|
||||
| AI 表格 | `aitable` | 41 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件;模板 |
|
||||
| 文档 | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 |
|
||||
| 钉盘 | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | 钉盘文件操作:列表、详情、下载、创建文件夹、两阶段上传 |
|
||||
| 钉盘 | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | 钉盘文件操作:列出空间、文件列表 / 详情 / 下载、创建文件夹、一键 `upload`(三步合成)或两阶段 `upload-info` + `commit`、删除 |
|
||||
| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
|
||||
| 邮箱 | `mail` | 4 | `mailbox` `message` | 邮箱地址列表、KQL 邮件搜索、邮件详情、发送邮件 |
|
||||
| 在线电子表格 | `sheet` | 34 | `range` `filter-view`(顶层:`create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并、查找替换、命名筛选视图 + 表级筛选、写入图片、异步导出(`submit_export_job` + `query_export_job`,v1.0.25 暂无合并的 `export` 命令) |
|
||||
@@ -423,7 +423,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
|
||||
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
|
||||
|
||||
> **19 个产品,209 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
> **19 个产品,212 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
|
||||
|
||||
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
// 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"
|
||||
"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 one drive subcommand that the service-discovery
|
||||
// envelope cannot express on its own:
|
||||
//
|
||||
// - 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.
|
||||
//
|
||||
// Other drive-vs-wukong gaps (list-spaces and delete) are covered purely via
|
||||
// envelope toolOverrides — list_spaces as a plain alias map, delete_document
|
||||
// via serverOverride to route to the doc MCP server. No Go code needed for
|
||||
// those two; see envelope/pre-discovery.pre.json drive entry.
|
||||
//
|
||||
// The dynamic envelope still owns the six base commands (list / info /
|
||||
// download / mkdir / upload-info / commit); pickCommands.MergeHardcodedLeaves
|
||||
// guarantees dynamic leaves win on collision so this helper only fills the
|
||||
// upload gap.
|
||||
type driveHandler struct{}
|
||||
|
||||
func (driveHandler) Name() string {
|
||||
return "drive"
|
||||
}
|
||||
|
||||
func (driveHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "drive",
|
||||
Short: "钉盘扩展命令(合并到 dws drive 命令树)",
|
||||
Long: "钉盘扩展子命令: upload 本地文件一键上传(三步合成)。其余命令均由服务发现 envelope 提供。",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
root.AddCommand(newDriveUploadCommand(runner))
|
||||
return root
|
||||
}
|
||||
|
||||
// ── upload (three-step composite) ───────────────────────────
|
||||
|
||||
func newDriveUploadCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "upload",
|
||||
Short: "上传本地文件到钉盘",
|
||||
Long: `将本地文件上传到钉盘(三步自动完成):
|
||||
|
||||
1. drive get_upload_info → 获取 OSS 上传凭证 (resourceUrl + uploadId + headers)
|
||||
2. HTTP PUT 文件二进制 → OSS
|
||||
3. drive commit_upload → 提交文件入库
|
||||
|
||||
--folder 指定父目录 dentryUuid,不传则上传到空间根目录。`,
|
||||
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", "", "文件显示名称 (默认使用文件名)")
|
||||
cmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」")
|
||||
cmd.Flags().String("mime-type", "", "文件 MIME 类型,不传则自动推断")
|
||||
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则上传到空间根目录")
|
||||
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")
|
||||
}
|
||||
|
||||
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, _ := cmd.Flags().GetString("file-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, _ := cmd.Flags().GetString("folder")
|
||||
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, mimeType); 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func httpPutDriveFile(ctx context.Context, resourceURL string, headers map[string]string, filePath string, fileSize int64, fallbackMIME string) 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
|
||||
hasContentType := false
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
if strings.EqualFold(k, "Content-Type") {
|
||||
hasContentType = true
|
||||
}
|
||||
}
|
||||
if !hasContentType && fallbackMIME != "" {
|
||||
req.Header.Set("Content-Type", fallbackMIME)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -103,6 +103,7 @@ Step 3 → 加 --yes 执行命令
|
||||
## 核心流程
|
||||
作为一个智能助手,你的首要任务是**理解用户的真实、完整的意图**,而不是简单地执行命令。在选择 `dws` 的产品命令前,必须严格遵循以下四步流程:
|
||||
|
||||
0. **URL 预检**:输入含 `alidocs.dingtalk.com` URL 时,该域名下存在多种路径格式(`/i/nodes/...`、`/i/p/...`、`/spreadsheetv2/...`、`/document/edit|preview?dentryKey=...` 等),每种的处理流程不同。**必须先读取 [url-patterns.md](./references/url-patterns.md) 中的「alidocs URL 分流决策」**,按其中规则识别 URL 类型后再选择对应产品。含 `shanji.dingtalk.com` URL 时直接路由到 `minutes`。URL 已识别后直接进入对应产品流程,无需后续步骤。
|
||||
1. 意图分类:首先,判断用户指令的核心 动词/动作 属于哪一类。这比关注名词更重要。
|
||||
2. 歧义处理与信息追问:如果用户指令模糊或包含多个产品的关键字,严禁猜测。必须主动向用户追问以澄清意图。这是你作为智能助手而非命令执行器的核心价值。
|
||||
3. 精准产品映射:在完成前两步,意图已经清晰后,参考产品总览和意图判断决策树 来选择产品。
|
||||
@@ -144,6 +145,7 @@ dws schema <path> --jq '.tool.required' # 只看必填字段
|
||||
|
||||
- [references/products/](./references/products/) — 各产品命令详细参考(flag 细节以 `--help` / `dws schema` 为准)
|
||||
- [references/intent-guide.md](./references/intent-guide.md) — 意图路由指南(易混淆场景对照)
|
||||
- [references/url-patterns.md](./references/url-patterns.md) — URL 格式规范 + alidocs URL 分流决策与类型探测流程(含钉盘 `document/edit|preview?dentryKey=` 链接)
|
||||
- [references/global-reference.md](./references/global-reference.md) — 全局标志、认证、输出格式
|
||||
- [references/field-rules.md](./references/field-rules.md) — AI表格字段类型规则
|
||||
- [references/error-codes.md](./references/error-codes.md) — 错误码 + 调试流程
|
||||
|
||||
@@ -50,6 +50,8 @@ Usage:
|
||||
Example:
|
||||
dws doc info --node <DOC_ID>
|
||||
dws doc info --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
|
||||
dws doc info --node "https://alidocs.dingtalk.com/document/edit?dentryKey=<DENTRY_KEY>"
|
||||
dws doc info --node "https://alidocs.dingtalk.com/document/preview?dentryKey=<DENTRY_KEY>"
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
```
|
||||
@@ -61,6 +63,8 @@ Usage:
|
||||
Example:
|
||||
dws doc read --node <DOC_ID>
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
|
||||
dws doc read --node "https://alidocs.dingtalk.com/document/edit?dentryKey=<DENTRY_KEY>"
|
||||
dws doc read --node "https://alidocs.dingtalk.com/document/preview?dentryKey=<DENTRY_KEY>"
|
||||
Flags:
|
||||
--node string 文档 ID 或 URL (必填)
|
||||
```
|
||||
@@ -328,12 +332,14 @@ Flags:
|
||||
|------|------|----------------|
|
||||
| `alidocs.dingtalk.com/i/nodes/{id}` | `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` | 取 URL 路径最后一段:`9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` |
|
||||
| `alidocs.dingtalk.com/i/nodes/{id}?queryParams` | `https://alidocs.dingtalk.com/i/nodes/abc123?doc_type=wiki_doc` | 忽略 query 参数,取路径最后一段:`abc123` |
|
||||
| `alidocs.dingtalk.com/document/{edit\|preview}?...&dentryKey={key}` | `https://alidocs.dingtalk.com/document/edit?dentryKey=wo1g3x54FzVEJ5yE` | **不要提取 `dentryKey` 单独使用**,必须将完整 URL 原样传给 `--node` |
|
||||
|
||||
### 提取规则
|
||||
|
||||
1. 匹配 URL 中 `alidocs.dingtalk.com` 域名
|
||||
2. 取 URL path 的最后一段作为 DOC_ID(去掉 query string 和 fragment)
|
||||
3. 提取出的 DOC_ID 可直接用于所有 `--node` 参数,也可将完整 URL 传给 `--node`(CLI 会自动解析)
|
||||
2. 路径为 `/i/nodes/{id}` 时,取 URL path 的最后一段作为 DOC_ID(去掉 query string 和 fragment)
|
||||
3. 路径为 `/document/edit` 或 `/document/preview` 且 query 含 `dentryKey` 时,**禁止**提取 `dentryKey` 当 DOC_ID;将整段 URL 原样传给 `--node`,CLI 会自动解析(追踪参数如 `utm_source`、`chInfo` 也不必清理)
|
||||
4. 提取出的 DOC_ID 可直接用于所有 `--node` 参数,也可将完整 URL 传给 `--node`(CLI 会自动解析)
|
||||
|
||||
### 处理流程
|
||||
|
||||
@@ -532,18 +538,23 @@ dws doc rename --node <DOC_ID> --name "项目周报 v2" --format json
|
||||
| `file create` | `nodeId` | 后续 read / update / block 操作的 --node(仅 adoc 支持 read/update,axls/amind 等类型用各自产品的命令) |
|
||||
| `copy` / `move` | 新 `nodeId`(copy)或原 nodeId(move) | 后续 read / info 等的 --node |
|
||||
|
||||
## nodeId 双格式说明
|
||||
## nodeId 多格式说明
|
||||
|
||||
所有 `--node` 参数同时支持两种格式,系统自动识别:
|
||||
所有 `--node` 参数同时支持以下格式,系统自动识别:
|
||||
- **文档 ID**: 字母数字字符串,如 `9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
|
||||
- **文档 URL**: `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}`,如 `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
|
||||
- **文档链接(edit/preview)**: `https://alidocs.dingtalk.com/document/{edit|preview}?...&dentryKey={key}`(必须传入完整 URL,不要提取其中的 query 参数单独使用)
|
||||
|
||||
两种方式等价,以下命令效果相同:
|
||||
以下命令效果相同:
|
||||
```bash
|
||||
dws doc read --node 9E05BDRVQePjzLkZt2p2vE7kV63zgkYA
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA"
|
||||
dws doc read --node "https://alidocs.dingtalk.com/document/edit?dentryKey=wo1g3x54FzVEJ5yE"
|
||||
dws doc read --node "https://alidocs.dingtalk.com/document/preview?cid=74993670680&type=d&docKey=Pd6l2Z7V8ZWydl7M&dentryKey=rBGBr2r1HmwanAGW"
|
||||
```
|
||||
|
||||
> **注意**:`document/edit` 和 `document/preview` 格式 URL 中的 `dentryKey` 参数值不是合法的独立 nodeId,禁止提取后单独使用,必须传入完整 URL。URL 中可能包含 `utm_source`、`chInfo` 等追踪参数,无需手动去除,直接传入完整 URL 即可。
|
||||
|
||||
`--folder` 参数同样支持文件夹 URL 或 ID。
|
||||
|
||||
## 注意事项
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
# URL 格式与处理规范
|
||||
|
||||
## alidocs URL 分流决策(必须首先执行)
|
||||
|
||||
收到 `alidocs.dingtalk.com` URL 时,**必须按以下顺序判断,禁止跳过**:
|
||||
|
||||
1. URL 路径含 `/i/p/` → **分享短链**,禁止调用 `dws doc` 任何子命令 → 按下方 [分享短链处理](#分享短链处理) 执行
|
||||
2. URL 路径含 `/i/nodes/` → **节点链接**,需探测类型 → 按下方 [alidocs URL 类型探测流程](#alidocs-url-类型探测流程) 执行
|
||||
3. URL 路径含 `/spreadsheetv2/` → **电子表格直链**,直接路由到 `sheet`,将完整 URL 原样传给 `--node` 参数
|
||||
4. URL 路径含 `/document/edit` 或 `/document/preview` 且 query 参数包含 `dentryKey` → **文档链接**,直接路由到 `doc`,将完整 URL 原样传给 `--node` 参数(URL 中不一定有 `type=d`,只需匹配路径和 `dentryKey` 参数即可)
|
||||
5. 其他 alidocs URL 格式 → 告知用户当前暂不支持该链接格式
|
||||
|
||||
---
|
||||
|
||||
## 已知 URL 格式
|
||||
|
||||
需要自行拼接链接时,只能使用以下模板:
|
||||
|
||||
| 产品 | 用途 | URL 格式 | ID 来源 |
|
||||
|------|------|----------|---------|
|
||||
| `aitable` | AI表格 Base 链接 | `https://alidocs.dingtalk.com/i/nodes/{baseId}` | `base list/search/create/get` 返回的 `baseId` |
|
||||
| `aitable` | AI表格模板预览 | `https://docs.dingtalk.com/table/template/{templateId}` | `template search` 返回的 `templateId` |
|
||||
| `doc` | 文档链接 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` | `doc` 命令返回的 `dentryUuid` |
|
||||
| `sheet` | 电子表格链接 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` | `sheet create` 返回的 `dentryUuid` |
|
||||
| `sheet` | 电子表格直链 | `https://alidocs.dingtalk.com/spreadsheetv2/{key}/...?dentryKey={key}&type=s` | 用户提供的完整 URL,直接传给 `--node` |
|
||||
| `doc` | 文档链接(edit/preview) | `https://alidocs.dingtalk.com/document/{edit\|preview}?...&dentryKey={key}` | 用户提供的完整 URL,直接传给 `--node` |
|
||||
| `minutes` | 听记链接 | `https://shanji.dingtalk.com/app/transcribes/{taskUuid}` | `list mine/shared` 返回的 `taskUuid` |
|
||||
|
||||
不在此表中的产品,禁止自行拼接 URL。命令返回中包含完整链接时直接使用,否则告知用户无法提供。
|
||||
|
||||
## 分享短链处理
|
||||
|
||||
`alidocs.dingtalk.com/i/p/{shortKey}` 是钉钉文档的**对外分享短链**,`dws doc` 命令无法解析此格式。
|
||||
|
||||
### 识别规则
|
||||
|
||||
URL 路径中包含 `/i/p/` 即为分享短链(无论后面是否还有子路径),例如:
|
||||
- `https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2`
|
||||
- `https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7`
|
||||
- `https://alidocs.dingtalk.com/i/p/AbCdEfGh1234`
|
||||
- `https://alidocs.dingtalk.com/i/p/AbCdEfGh1234/sheets/XYZ789`
|
||||
|
||||
> **关键**:只要 URL 中出现 `/i/p/`,无论后面跟什么子路径(`/docs/...`、`/sheets/...` 等),都属于分享短链,一律禁止调用 `dws doc`。
|
||||
|
||||
### 处理方式
|
||||
|
||||
**不要调用 `dws doc` 任何子命令**(包括 `doc info`、`doc read` 等),`dws` 无法解析此格式。
|
||||
|
||||
- **需要获取文档内容时**:使用 `read_url` 工具直接读取该链接
|
||||
- **其他操作(如移动、复制、权限管理等)**:告知用户此链接为分享短链,无法直接执行复制、移动、权限管理等操作。如需保存该文档内容,建议用户在钉钉客户端中打开该页面,手动复制文本内容,然后可通过 `dws doc create` 创建一篇新文档并将内容写入
|
||||
|
||||
```
|
||||
# 需要读取文档内容时(无论 /i/p/ 后面有没有子路径,都用 read_url)
|
||||
read_url("https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2")
|
||||
read_url("https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7")
|
||||
|
||||
# 禁止(以下全部会失败,dws 无法解析任何含 /i/p/ 的 URL)
|
||||
dws doc info --node "https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2" --format json
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7" --format json
|
||||
```
|
||||
|
||||
### 当 `read_url` 返回内容不完整时
|
||||
|
||||
钉钉文档分享页是动态渲染的,`read_url` 可能只能获取到页面标题等有限信息,无法获取文档正文。此时**禁止猜测原因**(如"权限不足""文档为空""文档已删除"等),**禁止建议用户"提供 `/i/nodes/` 格式链接"**(分享短链和节点链接是不同体系,普通用户无法自行转换)。应直接告知用户:
|
||||
|
||||
> 这个链接是钉钉文档的分享短链,由于页面是动态渲染的,我无法通过该链接直接获取文档的完整正文内容。
|
||||
>
|
||||
> 你可以:
|
||||
> 1. 在钉钉客户端中打开该文档,将正文内容复制粘贴给我
|
||||
> 2. 如果文档已保存在你的文档空间中,可以告诉我文档名称,我通过 `dws doc search` 搜索后再读取
|
||||
|
||||
---
|
||||
|
||||
## alidocs URL 类型探测流程
|
||||
|
||||
`alidocs.dingtalk.com/i/nodes/{id}` 是钉钉文档空间的统一 URL,可能指向**文档、电子表格、多维表、文件、文件夹**等不同类型。**禁止仅凭 URL 就假定为文档**,必须先探测类型再路由到正确的产品。
|
||||
|
||||
### 探测步骤
|
||||
|
||||
```
|
||||
Step 1 → dws doc info --node "<URL>" --format json
|
||||
Step 2 → 从返回中提取 contentType、extension、nodeType 字段
|
||||
Step 3 → 按下方路由规则映射到对应产品
|
||||
```
|
||||
|
||||
### 路由映射表
|
||||
|
||||
| 条件 | 路由到产品 | 后续操作 |
|
||||
|------|-----------|---------|
|
||||
| `contentType=ALIDOC`, `extension=adoc` | `doc` | 按 [doc.md](./products/doc.md) 操作 |
|
||||
| `contentType=ALIDOC`, `extension=axls` | `sheet` | 按 [sheet.md](./products/sheet.md) 操作(仅 `axls` 在线电子表格) |
|
||||
| `contentType=ALIDOC`, `extension=able` | `aitable` | 将 nodeId 作为 baseId,按 [aitable.md](./products/aitable.md) 操作 |
|
||||
| `contentType=DOCUMENT`, `extension=xlsx` / `xls` / `xlsm` / `csv` | `doc` | 必须用 `dws doc download` 下载到本地处理,禁止走 `sheet`(非在线表格,sheet 命令无法操作) |
|
||||
| `contentType≠ALIDOC`, `nodeType=file` | `doc` | 调用 `dws doc download` 下载,返回文件下载链接 |
|
||||
| `nodeType=folder` | `doc` | 调用 `dws doc list --folder <ID>` 列出指定文件夹直接子节点列表 |
|
||||
| 以上均不匹配 | — | 告知用户当前暂不支持该类型 |
|
||||
|
||||
> axls vs xlsx 关键区分:
|
||||
> - `axls`(钉钉在线电子表格,`contentType=ALIDOC`)→ 走 `sheet` 产品线(读/写/筛选/导出等服务端原子操作)
|
||||
> - `xlsx` / `xls` / `xlsm` / `csv`(上传到文档空间的本地表格文件,`contentType=DOCUMENT`)→ 必须走 `dws doc download` 下载到本地后再解析处理,严禁错误路由到 `sheet` 产品线(sheet 命令只支持在线表格,调用 xlsx 节点会直接报错)
|
||||
> - 用户想把在线表格导出为 xlsx 文件 → 用 `dws sheet submit_export_job` 提交导出任务(输入是 `axls`,输出是 xlsx,这是 axls → xlsx 的格式转换,不属于 xlsx 读取场景)
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 用户传入: https://alidocs.dingtalk.com/i/nodes/abc123
|
||||
dws doc info --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json
|
||||
|
||||
# 返回 contentType=ALIDOC, extension=axls → 在线电子表格,路由到 sheet
|
||||
dws sheet list --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json
|
||||
|
||||
# 返回 contentType≠ALIDOC, extension=xlsx/xls/csv → 本地表格文件,必须下载处理(禁止走 sheet)
|
||||
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/xlsx456"
|
||||
|
||||
# 返回 contentType≠ALIDOC, nodeType=file → 普通文件,下载
|
||||
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/def456"
|
||||
|
||||
# 返回 nodeType=folder → 文件夹,列出子节点
|
||||
dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/ghi789" --format json
|
||||
```
|
||||
|
||||
### 何时可跳过探测
|
||||
|
||||
当用户指令中已明确指定产品(如"帮我读这个文档"、"看下这个表格的数据"),可结合用户意图**跳过探测**直接路由。仅在以下情况**必须执行探测**:
|
||||
- 用户只粘贴 URL,无其他上下文
|
||||
- 用户指令与 URL 实际类型可能不一致(如说"文档"但实际是表格)
|
||||
- 用户直接粘贴的是原始 `alidocs` URL,且没有上游命令返回来确认类型
|
||||
Reference in New Issue
Block a user