Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b4d808d39 | ||
|
|
c7d8ddf98d | ||
|
|
754b0df056 | ||
|
|
6be124777f |
@@ -4,6 +4,46 @@ 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.
|
||||
|
||||
### Added
|
||||
|
||||
- **`transform: invert_bool` for envelope flag overrides** (#317, see `internal/compat/transform.go`) — flips a boolean at send time. Strings `true`/`1`/`yes`/`on` → `false`; `false`/`0`/`no`/`off`/`""` → `true`. Used when the CLI surface and the MCP body have opposite semantics — e.g. envelope declares `--off` on the CLI but the MCP parameter is `mute=true` for "muted". The framework flips at send time so the envelope keeps the natural CLI verb without forcing every caller to remember the inverted mapping. Coverage in `internal/compat/transform_test.go`.
|
||||
- **`dws chat message reply`** (#317, see `internal/helpers/chat.go`) — reply to a chat message. Sits alongside `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` under `dws chat message`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`chat` command tree refactored to lean on the service-discovery envelope** (#317, commit `6be1247`) — `internal/helpers/chat.go` now only carries the chat commands that need real business logic on top of the raw MCP call: `chat message send` (current-user resolution + symmetric direct/group title validation), `chat message send-by-bot` / `recall-by-bot` / `send-by-webhook` (bot routing + stdin/@file input), and `chat group create` (response normalization). The thin wrappers — `chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search` — are now produced by the envelope as dynamic commands. Net diff in the helper: `+358 / -71` overall (re-aligning to envelope-owned chat structure), and `chat_test.go` drops 71 lines of test-stubs the dynamic path covers natively. Every previously documented chat command keeps the same flag set and the same MCP tool routing — the surface is just sourced differently.
|
||||
- **Pipeline executor fail-fast on `content.errorCode`** (#317, see `internal/compat/pipeline.go`) — when an upstream tool returns a non-empty `content.errorCode`, `executePipelineCall` raises a validation error immediately with the upstream `errorMessage` instead of proceeding into the poll/download phase. Pre-execution cobra validation (`MarkFlagRequired`) only checks that a flag was set, not that its value was non-empty — so a `--required-flag ""` reaches the upstream tool and the upstream rejects with `errorCode`. Without the short-circuit the pipeline kept polling for a task ID that would never exist, either spinning to `PollTimeout` or burning through retries with no actionable error. Exit code 2 (validation), same as any other CLI-layer pre-flight rejection.
|
||||
- **Service-discovery dedup keys now include `cli.id`** (#317, see `internal/market/registry.go`) — `NormalizeServers` used to dedup envelope entries by endpoint alone (and by `displayName` in the second pass), which collapsed envelope entries that intentionally split one MCP endpoint into multiple CLI command trees. The `bot-root` / `bot-message` / `bot-group` trio all front the same `.../server/4717...` MCP endpoint and share the displayName `机器人消息`, but each declares a distinct `cli.id` and a distinct CLI command root; the old dedup kept only the last-write and dropped two of them. The dedup key now appends `#<cli.id>` when present, falling back to endpoint / name when absent so historical envelopes without `cli.id` keep their existing behaviour. Coverage in `internal/market/registry_test.go`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`@<text>` injection no longer eats Chinese mentions like `@所有人` / `@张三`** (#317, see `internal/cli/stdin.go`) — `ReadFileArg` and `ResolveInputSource` used to treat *any* value starting with `@` as the `@<filename>` injection syntax. Chat-bot webhook payloads commonly contain literal mentions, so `dws chat message send-by-bot --text "@所有人 周报"` was failing with `file not found: 所有人 周报` before the message reached the API. The new `looksLikeFilePath` heuristic accepts `@` followed by an ASCII path-prefix character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin, and passes the value through unchanged otherwise. `@A 但接下来都是中文@测试` *does* still attempt a file lookup because the rune right after `@` is ASCII — this matches the documented `@<path>` prefix shape. The historical "bare `@` is an error" behaviour is preserved. Coverage in `internal/cli/stdin_test.go::TestReadFileArgChineseAtMention`.
|
||||
- **`dws chat` no longer nests as `dws chat chat` when two envelope servers contribute the same top-level command** (#317, see `internal/compat/dynamic_commands.go`) — `BuildDynamicCommands` used to overwrite `topLevel[name]` on the second contribution and rely on `attachOrMerge` later, which then attached the *whole* incoming command (named `chat`) under the existing root, producing `dws chat chat <leaf>`. The new `mergeSubcommandsInto` moves the second contribution's *children* under the first root and drops the duplicate wrapper, so e.g. `group-chat` + `im` envelopes that both declare `cli.command: chat` produce a single flat `dws chat` subtree.
|
||||
- **Multi-server tool-name authority correction in the runtime runner** (#317, see `internal/app/runner.go` + `internal/app/direct_runtime.go`) — when two envelope servers share the same `cli.command`, the per-product endpoint map `endpoints[cmd]` in `registerDynamicServer` is second-writer-wins, and `catalog.FindProduct` may return the wrong server's endpoint for a tool whose real owner is the *other* server. `runtimeRunner.Run` now cross-checks the canonical tool→endpoint map exposed by the new `directRuntimeToolEndpoint`: when the per-tool endpoint exists and differs from the per-product endpoint the catalog returned, the tool-owner endpoint wins. Pairs with the registry dedup change above so the routing matches the dedup result.
|
||||
|
||||
## [1.0.29] - 2026-05-17
|
||||
|
||||
Three discovery-envelope products land on the open-source surface — `aiapp` (AI applications), `live` (DingTalk live streaming), and `aisearch` (enterprise people search) — closing the gap with the Wukong edition's product list. The `aisearch` envelope ships rich model-tolerance affordances (short flags, flag aliases, subcommand aliases) so AI agents that hallucinate keyword synonyms (`--query` / `--name` / `--q` / `--text` / `--find`) or alias subcommands (`search` / `find` / `query` / `user` / `people` / ...) still route to the canonical `person` tool instead of erroring out. To support that final fragment of agent tolerance, `internal/compat/registry.go` relaxes the envelope-generated leaf command's `Args` validator from `cobra.NoArgs` to `cobra.ArbitraryArgs` — restoring cobra's own default (`legacyArgs` returns nil for leaves) so trailing positional words are silently ignored. Plus the previously-shipped credential-isolation fix.
|
||||
|
||||
@@ -394,6 +394,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "Weekly Report" --text @-
|
||||
```
|
||||
|
||||
> **Note**: `@` is treated as the `@<path>` file-injection prefix only when the next character is an ASCII path-shaped character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin. Chat-bot payloads like `--text "@所有人 周报"` or `--text "@张三 看一下"` pass through unchanged, so literal mentions reach the API as-is.
|
||||
|
||||
</details>
|
||||
|
||||
## Key Services
|
||||
@@ -401,7 +403,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| Service | Command | Commands | Subcommands | Description |
|
||||
|---------|---------|:--------:|-------------|-------------|
|
||||
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
|
||||
| Chat / IM | `chat` (alias `im`) | 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | Messages (send / list / list-all / by-sender / mentions / focused / unread / topic replies / search), group CRUD + member management (incl. `add-bot`), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common groups lookup |
|
||||
| Chat / IM | `chat` (alias `im`) | 57 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | Messages (send / reply / list / list-all / by-sender / mentions / focused / unread / topic replies / search / advanced search / forward / cards / emoji & text-emotion reactions / recall / read & send status queries), group CRUD + member management (members add / remove / list / `add-bot`, member-role CRUD, invite URL, icon, settings, transfer-owner, set-admin, quit), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common-groups lookup, group/member/conversation mute, conversation set-top, conversation categories |
|
||||
| Calendar | `calendar` | 14 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management |
|
||||
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
|
||||
| Approval | `oa` | 9 | `approval` | Approve / reject / revoke, pending / initiated instances, process list, operation records |
|
||||
@@ -410,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) |
|
||||
@@ -421,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.
|
||||
|
||||
|
||||
+5
-3
@@ -394,6 +394,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
--title "周报" --text @-
|
||||
```
|
||||
|
||||
> **说明**:`@` 仅在其后是 ASCII 路径前缀字符(`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`)或 `@-`(stdin)时,才会被识别为 `@<path>` 文件注入语法。`--text "@所有人 周报"` / `--text "@张三 看一下"` 这类机器人消息中的字面 `@` 提及会原样透传到 API。
|
||||
|
||||
</details>
|
||||
|
||||
## 核心服务
|
||||
@@ -401,7 +403,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
|
||||
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|
||||
|------|------|:------:|--------|------|
|
||||
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
|
||||
| 群聊 | `chat`(别名 `im`)| 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | 消息(发送 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索)、群增删改 + 成员管理(含 `add-bot`)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊 |
|
||||
| 群聊 | `chat`(别名 `im`)| 57 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | 消息(发送 / 回复 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索 / 高级搜索 / 转发 / 卡片 / 表情与文本表情反应 / 撤回 / 已读与发送状态查询)、群增删改 + 成员管理(成员增 / 删 / 查 / `add-bot`、成员角色增删改查、邀请链接、群图标、群设置、转让群主、设置管理员、退群)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊、群/成员/会话免打扰、会话置顶、会话分类 |
|
||||
| 日历 | `calendar` | 14 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
|
||||
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
|
||||
| 审批 | `oa` | 9 | `approval` | 同意 / 拒绝 / 撤销、待我审批 / 我发起的、流程列表、操作记录 |
|
||||
@@ -410,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` 命令) |
|
||||
@@ -421,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` 产品。
|
||||
|
||||
|
||||
@@ -210,6 +210,25 @@ func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// directRuntimeToolEndpoint returns the MCP endpoint owned by the server
|
||||
// whose toolOverrides registered this tool name. Used to correct catalog
|
||||
// lookups when two envelope servers share the same cli.command and the
|
||||
// per-product endpoint map collides (see runner.go cross-check).
|
||||
func directRuntimeToolEndpoint(toolName string) (string, bool) {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
return "", false
|
||||
}
|
||||
dynamicMu.RLock()
|
||||
te := dynamicToolEndpoints
|
||||
dynamicMu.RUnlock()
|
||||
if te == nil {
|
||||
return "", false
|
||||
}
|
||||
endpoint, ok := te[toolName]
|
||||
return endpoint, ok && strings.TrimSpace(endpoint) != ""
|
||||
}
|
||||
|
||||
func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
// Priority 0: env-var override always wins (DINGTALK_<PRODUCT>_MCP_URL).
|
||||
normalized := normalizeDirectRuntimeProductID(productID)
|
||||
|
||||
@@ -219,6 +219,19 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
|
||||
if override, ok := productEndpointOverride(invocation.CanonicalProduct); ok {
|
||||
endpoint = override
|
||||
}
|
||||
// Multi-server tool-name authority correction.
|
||||
//
|
||||
// When two envelope servers share the same cli.command (e.g. group-chat
|
||||
// and im both publish `dws chat ...`), the endpoints[cmd] map in
|
||||
// registerDynamicServer is the second-writer wins, and catalog FindProduct
|
||||
// may pick the wrong product's Endpoint for a tool whose real owner is
|
||||
// a different server. Cross-check the canonical tool→endpoint map: when
|
||||
// the per-tool endpoint exists and differs from the per-product endpoint
|
||||
// catalog returned, trust the tool-owner endpoint (the server that
|
||||
// actually declares this tool in its toolOverrides).
|
||||
if toolEndpoint, ok := directRuntimeToolEndpoint(invocation.Tool); ok && toolEndpoint != "" && toolEndpoint != endpoint {
|
||||
endpoint = toolEndpoint
|
||||
}
|
||||
return r.executeInvocation(ctx, endpoint, invocation)
|
||||
}
|
||||
|
||||
|
||||
+45
-9
@@ -19,6 +19,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
@@ -122,19 +123,51 @@ func readStdinBounded() (string, error) {
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// looksLikeFilePath returns true when value should be interpreted as the
|
||||
// `@<path>` file-injection syntax. Heuristic: value must start with '@', and
|
||||
// the character right after '@' must be ASCII (letter, digit, or one of the
|
||||
// common path-prefix characters: . / ~ _ -). This rules out mistaken matches
|
||||
// for natural-language messages that happen to start with '@' followed by
|
||||
// non-ASCII text — e.g. "@所有人" should be a chat mention, not a file path.
|
||||
func looksLikeFilePath(value string) bool {
|
||||
if !strings.HasPrefix(value, "@") || len(value) < 2 {
|
||||
return false
|
||||
}
|
||||
rest := value[1:]
|
||||
if rest == "-" {
|
||||
return true // @- = stdin
|
||||
}
|
||||
first := rune(rest[0])
|
||||
if first > unicode.MaxASCII {
|
||||
// First byte is part of a multi-byte rune (e.g. Chinese) — not a path.
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case first >= 'A' && first <= 'Z',
|
||||
first >= 'a' && first <= 'z',
|
||||
first >= '0' && first <= '9',
|
||||
first == '.', first == '/', first == '~', first == '_', first == '-':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ReadFileArg reads the contents of a file referenced by the @filename syntax.
|
||||
// Returns the original value unchanged if it does not start with "@".
|
||||
// Returns the original value unchanged if it does not start with "@" or is
|
||||
// otherwise not a file-path-shaped value (e.g. "@所有人" is treated as plain
|
||||
// text, not a path).
|
||||
// Returns an error if the file cannot be read or exceeds the size limit.
|
||||
//
|
||||
// Note: @- (stdin) is NOT handled here; use ResolveInputSource instead.
|
||||
func ReadFileArg(value string) (string, bool, error) {
|
||||
if !strings.HasPrefix(value, "@") {
|
||||
// Preserve the historical bare-"@" behaviour (empty filename → error).
|
||||
if value == "@" {
|
||||
return "", false, apperrors.NewValidation("@file: filename must not be empty")
|
||||
}
|
||||
if !looksLikeFilePath(value) {
|
||||
return value, false, nil
|
||||
}
|
||||
path := value[1:]
|
||||
if path == "" {
|
||||
return "", false, apperrors.NewValidation("@file: filename must not be empty")
|
||||
}
|
||||
// @- is stdin, not a file — callers should use ResolveInputSource.
|
||||
if path == "-" {
|
||||
return value, false, nil
|
||||
@@ -156,14 +189,17 @@ func ReadFileArg(value string) (string, bool, error) {
|
||||
//
|
||||
// The flagName parameter is used only for error messages and StdinGuard tracking.
|
||||
func ResolveInputSource(value string, flagName string, guard *StdinGuard) (string, error) {
|
||||
if !strings.HasPrefix(value, "@") {
|
||||
// Preserve the historical bare-"@" behaviour (empty filename → error).
|
||||
if value == "@" {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s: @file filename must not be empty", flagName))
|
||||
}
|
||||
if !looksLikeFilePath(value) {
|
||||
// Pass through natural-language strings that happen to start with
|
||||
// '@' (e.g. "@所有人 早上好") so they reach the MCP payload intact.
|
||||
return value, nil
|
||||
}
|
||||
|
||||
path := value[1:]
|
||||
if path == "" {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("--%s: @file filename must not be empty", flagName))
|
||||
}
|
||||
|
||||
// @- reads from stdin.
|
||||
if path == "-" {
|
||||
|
||||
@@ -47,6 +47,38 @@ func TestReadFileArgPlainValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadFileArgChineseAtMention guards the @所有人-style mentions that the
|
||||
// chat bot Webhook tests rely on: an '@' followed by non-ASCII text must be
|
||||
// treated as a literal message, not as the @file injection syntax.
|
||||
func TestReadFileArgChineseAtMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []string{
|
||||
"@所有人 这是 @ 所有人 的消息",
|
||||
"@张三",
|
||||
"@A 但接下来都是中文@测试",
|
||||
}
|
||||
for _, in := range cases {
|
||||
val, isFile, err := ReadFileArg(in)
|
||||
if in == "@A 但接下来都是中文@测试" {
|
||||
// '@A' starts with ASCII, treated as path → expect file error
|
||||
if err == nil {
|
||||
t.Errorf("@A... should attempt file lookup; got val=%q isFile=%v", val, isFile)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%q: unexpected error %v", in, err)
|
||||
continue
|
||||
}
|
||||
if isFile {
|
||||
t.Errorf("%q: should be plain text, got isFile=true", in)
|
||||
}
|
||||
if val != in {
|
||||
t.Errorf("%q: got %q", in, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileArgReadsFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -236,10 +236,18 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
for _, b := range built {
|
||||
if b.parent == "" {
|
||||
name := b.cmd.Name()
|
||||
if _, exists := topLevel[name]; !exists {
|
||||
if existing, exists := topLevel[name]; exists {
|
||||
// Multiple servers contribute the same top-level command
|
||||
// (e.g. group-chat and im both register `dws chat`). Move
|
||||
// the incoming command's *children* into the existing top-
|
||||
// level command instead of attaching the whole command (which
|
||||
// would create `dws chat chat` because attachOrMerge would
|
||||
// AddCommand(b.cmd) when no same-named sub exists).
|
||||
mergeSubcommandsInto(existing, b.cmd)
|
||||
} else {
|
||||
topOrder = append(topOrder, name)
|
||||
topLevel[name] = b.cmd
|
||||
}
|
||||
topLevel[name] = b.cmd
|
||||
} else {
|
||||
children = append(children, b)
|
||||
}
|
||||
@@ -248,12 +256,16 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
|
||||
if parent, ok := topLevel[child.parent]; ok {
|
||||
attachOrMerge(parent, child.cmd)
|
||||
} else {
|
||||
// Parent not found among dynamic commands; emit as top-level.
|
||||
// Parent not found among dynamic commands; emit as top-level,
|
||||
// merging into an existing same-named top-level command if one
|
||||
// is already registered (same reasoning as the loop above).
|
||||
name := child.cmd.Name()
|
||||
if _, exists := topLevel[name]; !exists {
|
||||
if existing, exists := topLevel[name]; exists {
|
||||
mergeSubcommandsInto(existing, child.cmd)
|
||||
} else {
|
||||
topOrder = append(topOrder, name)
|
||||
topLevel[name] = child.cmd
|
||||
}
|
||||
topLevel[name] = child.cmd
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +503,24 @@ func resolveNestedGroup(root *cobra.Command, groupPath string, registry map[stri
|
||||
return ensureNestedGroup(root, groupPath, groupPath, registry)
|
||||
}
|
||||
|
||||
// mergeSubcommandsInto moves all sub-commands of src into dst, using
|
||||
// attachOrMerge so subtree merges happen recursively. src itself is left
|
||||
// empty after the call. Used when two envelope entries register the same
|
||||
// top-level command (e.g. group-chat and im both `cli.command="chat"`):
|
||||
// we want their *children* to coexist under one chat root, not have one
|
||||
// nested inside the other.
|
||||
func mergeSubcommandsInto(dst, src *cobra.Command) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
subs := make([]*cobra.Command, len(src.Commands()))
|
||||
copy(subs, src.Commands())
|
||||
for _, sub := range subs {
|
||||
src.RemoveCommand(sub)
|
||||
attachOrMerge(dst, sub)
|
||||
}
|
||||
}
|
||||
|
||||
// attachOrMerge adds child as a sub-command of parent. If parent already has a
|
||||
// sub-command with the same Name(), the two are merged recursively: child's
|
||||
// sub-commands are moved onto the existing one and child itself is discarded.
|
||||
|
||||
@@ -149,12 +149,12 @@ func executePipelineCall(
|
||||
if result.Response == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
// Fail-fast on MCP business errors. Pre-execution validation (e.g.
|
||||
// cobra MarkFlagRequired) only checks that the flag was set, not
|
||||
// that the value is non-empty — so a `--required-flag ""` reaches
|
||||
// here and the upstream tool rejects with errorCode. Without this
|
||||
// check the pipeline would happily proceed to poll/download and
|
||||
// either spin until PollTimeout or burn through retries.
|
||||
// Fail-fast on MCP business errors. Pre-execution validation (cobra
|
||||
// MarkFlagRequired) only checks that the flag was set, not that
|
||||
// the value is non-empty — so a `--required-flag ""` reaches here
|
||||
// and the upstream tool rejects with errorCode. Without this check
|
||||
// the pipeline proceeds to poll/download and either spins until
|
||||
// PollTimeout or burns through retries.
|
||||
if errCode := getDotPath(result.Response, "content.errorCode"); errCode != nil && fmt.Sprint(errCode) != "" {
|
||||
msg := getDotPath(result.Response, "content.errorMessage")
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf(
|
||||
|
||||
@@ -30,7 +30,7 @@ import (
|
||||
|
||||
// ApplyTransform applies a named transform rule to a value.
|
||||
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
|
||||
// json_parse_strict, enum_map, file_read.
|
||||
// json_parse_strict, enum_map, file_read, invert_bool.
|
||||
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
|
||||
switch strings.TrimSpace(transform) {
|
||||
case "":
|
||||
@@ -47,6 +47,31 @@ func ApplyTransform(value any, transform string, args map[string]any) (any, erro
|
||||
return transformEnumMap(value, args)
|
||||
case "file_read":
|
||||
return transformFileRead(value)
|
||||
case "invert_bool":
|
||||
return transformInvertBool(value)
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
}
|
||||
|
||||
// transformInvertBool flips a boolean: true → false, false → true. Strings
|
||||
// "true"/"false" (any case) are accepted. Used by envelope flags whose CLI
|
||||
// surface and MCP body have opposite semantics — e.g. `--off` (CLI) maps to
|
||||
// `mute=true` (MCP) for "mute is enabled", so the flag override declares
|
||||
// `transform: invert_bool` and the framework flips at send time.
|
||||
func transformInvertBool(value any) (any, error) {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return !v, nil
|
||||
case string:
|
||||
s := strings.ToLower(strings.TrimSpace(v))
|
||||
switch s {
|
||||
case "true", "1", "yes", "on":
|
||||
return false, nil
|
||||
case "false", "0", "no", "off", "":
|
||||
return true, nil
|
||||
}
|
||||
return value, nil
|
||||
default:
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -259,3 +259,29 @@ func TestFileRead_UnknownTransformPassThrough(t *testing.T) {
|
||||
t.Errorf("expected pass-through, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvertBoolTransform(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want any
|
||||
}{
|
||||
{true, false},
|
||||
{false, true},
|
||||
{"true", false},
|
||||
{"false", true},
|
||||
{"True", false},
|
||||
{"FALSE", true},
|
||||
{"on", false},
|
||||
{"off", true},
|
||||
{"", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := ApplyTransform(c.in, "invert_bool", nil)
|
||||
if err != nil {
|
||||
t.Errorf("ApplyTransform(%v, invert_bool) err=%v", c.in, err)
|
||||
}
|
||||
if got != c.want {
|
||||
t.Errorf("ApplyTransform(%v) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+72
-292
@@ -15,6 +15,7 @@ package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
@@ -30,6 +31,12 @@ func init() {
|
||||
})
|
||||
}
|
||||
|
||||
// chatHandler retains only the chat commands that carry real business logic
|
||||
// (intelligent tool routing, current-user resolution, response normalization,
|
||||
// or stdin/@file input support that dynamic commands do not yet provide).
|
||||
// Thin wrappers — search, group rename, group members list/add/remove/add-bot,
|
||||
// bot search — are now produced by the dynamic service-discovery envelope
|
||||
// (envelope/pre-discovery.json) so the helper does not have to duplicate them.
|
||||
type chatHandler struct{}
|
||||
|
||||
func (chatHandler) Name() string {
|
||||
@@ -40,7 +47,7 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "chat",
|
||||
Short: "群聊 / 消息 / 机器人",
|
||||
Long: "管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取会话消息、发送群消息、机器人消息与 Webhook。",
|
||||
Long: "钉钉会话与群聊:发送消息(用户/机器人/Webhook)、撤回机器人消息、创建群。其余命令由服务发现 envelope 提供。",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -64,11 +71,12 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
newChatMessageSendByBotCommand(runner),
|
||||
newChatMessageRecallByBotCommand(runner),
|
||||
newChatMessageSendByWebhookCommand(runner),
|
||||
newChatMessageReplyCommand(runner),
|
||||
)
|
||||
|
||||
bot := &cobra.Command{
|
||||
Use: "bot",
|
||||
Short: "机器人管理",
|
||||
group := &cobra.Command{
|
||||
Use: "group",
|
||||
Short: "群组管理",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
@@ -76,9 +84,9 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
bot.AddCommand(newChatBotSearchCommand(runner))
|
||||
group.AddCommand(newChatGroupCreateCommand(runner))
|
||||
|
||||
root.AddCommand(message, newChatSearchCommand(runner), newChatGroupCommand(runner), bot)
|
||||
root.AddCommand(message, group)
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -270,94 +278,6 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatSearchCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "根据名称搜索会话列表",
|
||||
Example: ` dws chat search --query "项目冲刺"`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
query, err := cmd.Flags().GetString("query")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --query")
|
||||
}
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return apperrors.NewValidation("--query is required")
|
||||
}
|
||||
|
||||
searchReq := map[string]any{"query": query}
|
||||
cursor, err := cmd.Flags().GetString("cursor")
|
||||
if err != nil {
|
||||
return apperrors.NewInternal("failed to read --cursor")
|
||||
}
|
||||
if strings.TrimSpace(cursor) != "" {
|
||||
searchReq["cursor"] = cursor
|
||||
}
|
||||
|
||||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"chat",
|
||||
"search_groups_by_keyword",
|
||||
map[string]any{"OpenSearchRequest": searchReq},
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
|
||||
cmd.Flags().String("query", "", "搜索关键词 (必填)")
|
||||
cmd.Flags().String("cursor", "", "分页游标 (首页留空)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatGroupCommand(runner executor.Runner) *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "group",
|
||||
Short: "群组管理",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
members := &cobra.Command{
|
||||
Use: "members",
|
||||
Short: "群成员管理",
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
// Keeps the helper-restructured group winning over the dynamic envelope's
|
||||
// `members` leaf (which only exposes `get_group_members`); without this
|
||||
// the merge layer treats the shape mismatch as "envelope is authority"
|
||||
// and drops the entire helper subtree (issue #164).
|
||||
preferLegacyLeaf(members)
|
||||
|
||||
members.AddCommand(
|
||||
newChatGroupMembersListCommand(runner),
|
||||
newChatGroupMemberAddCommand(runner),
|
||||
newChatGroupMemberRemoveCommand(runner),
|
||||
newChatGroupMembersAddBotCommand(runner),
|
||||
)
|
||||
|
||||
root.AddCommand(
|
||||
newChatGroupCreateCommand(runner),
|
||||
members,
|
||||
newChatGroupRenameCommand(runner),
|
||||
)
|
||||
return root
|
||||
}
|
||||
|
||||
func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -647,6 +567,10 @@ func newChatMessageRecallByBotCommand(runner executor.Runner) *cobra.Command {
|
||||
}
|
||||
|
||||
// ── message send-by-webhook ────────────────────────────────
|
||||
//
|
||||
// Kept as a helper (rather than delegating to the dynamic envelope) because
|
||||
// it needs --text @file / stdin pipe support via resolveStringFlag, which the
|
||||
// dynamic-command layer does not yet provide.
|
||||
|
||||
func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
@@ -713,65 +637,63 @@ func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── group members list ─────────────────────────────────────
|
||||
// ── message reply ────────────────────────────────────────
|
||||
//
|
||||
// Kept as a helper because the underlying MCP tool send_personal_message
|
||||
// requires the reply payload to be a JSON-encoded string assembled from
|
||||
// --ref-msg-id / --ref-sender / --text. Envelope toolOverride does flat
|
||||
// flag→param mapping only and cannot construct nested JSON, so this
|
||||
// orchestration must live in CLI code.
|
||||
|
||||
func newChatGroupMembersListCommand(runner executor.Runner) *cobra.Command {
|
||||
func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询群成员列表",
|
||||
Example: ` dws chat group members list --id <openconversation_id>`,
|
||||
Use: "reply",
|
||||
Short: "引用回复消息(支持单聊/群聊)",
|
||||
Long: "以当前用户身份引用某条消息并回复。需 --conversation-id 会话 ID、--ref-msg-id 被引用消息 ID、--ref-sender 原发送者 openDingTalkId、--text 回复内容。",
|
||||
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
convID, _ := cmd.Flags().GetString("conversation-id")
|
||||
refMsgID, _ := cmd.Flags().GetString("ref-msg-id")
|
||||
refSender, _ := cmd.Flags().GetString("ref-sender")
|
||||
text, _ := cmd.Flags().GetString("text")
|
||||
if strings.TrimSpace(convID) == "" {
|
||||
return apperrors.NewValidation("--conversation-id is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"openconversation_id": groupID,
|
||||
if strings.TrimSpace(refMsgID) == "" {
|
||||
return apperrors.NewValidation("--ref-msg-id is required")
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("cursor"); v != "" {
|
||||
params["cursor"] = v
|
||||
if strings.TrimSpace(refSender) == "" {
|
||||
return apperrors.NewValidation("--ref-sender is required")
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "chat", "get_group_members", params,
|
||||
))
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return apperrors.NewValidation("--text is required")
|
||||
}
|
||||
replyContent := map[string]any{
|
||||
"referenceOpenMessageId": refMsgID,
|
||||
"srcMsgSendOpenDingTalkId": refSender,
|
||||
"replyMsgType": "text",
|
||||
"content": text,
|
||||
}
|
||||
contentJSON, err := jsonMarshal(replyContent)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
|
||||
cmd.Flags().String("cursor", "", "分页游标 (首页留空)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── group rename ───────────────────────────────────────────
|
||||
|
||||
func newChatGroupRenameCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "rename",
|
||||
Short: "更新群名称",
|
||||
Example: ` dws chat group rename --id <openconversation_id> --name "新群名"`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
}
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return apperrors.NewValidation("--name is required")
|
||||
return apperrors.NewInternal("marshal reply content: " + err.Error())
|
||||
}
|
||||
params := map[string]any{
|
||||
"openconversation_id": groupID,
|
||||
"group_name": name,
|
||||
"openConversationId": convID,
|
||||
"msgType": "reply",
|
||||
"content": contentJSON,
|
||||
"clawType": "wukong",
|
||||
}
|
||||
if uuid, _ := cmd.Flags().GetString("uuid"); strings.TrimSpace(uuid) != "" {
|
||||
params["uuid"] = uuid
|
||||
}
|
||||
inv := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "chat", "update_group_name", params,
|
||||
cobracmd.LegacyCommandPath(cmd),
|
||||
"group-chat",
|
||||
"send_personal_message",
|
||||
params,
|
||||
)
|
||||
inv.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
@@ -782,160 +704,18 @@ func newChatGroupRenameCommand(runner executor.Runner) *cobra.Command {
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
|
||||
cmd.Flags().String("name", "", "新群名称 (必填)")
|
||||
cmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)")
|
||||
cmd.Flags().String("ref-msg-id", "", "被引用的消息 openMessageId (必填)")
|
||||
cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)")
|
||||
cmd.Flags().String("text", "", "回复正文 (必填)")
|
||||
cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── group members add ──────────────────────────────────────
|
||||
|
||||
func newChatGroupMemberAddCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加群成员",
|
||||
Example: ` dws chat group members add --id <openconversation_id> --users userId1,userId2`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
usersStr, _ := cmd.Flags().GetString("users")
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
}
|
||||
if strings.TrimSpace(usersStr) == "" {
|
||||
return apperrors.NewValidation("--users is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"openconversation_id": groupID,
|
||||
"userId": splitCSV(usersStr),
|
||||
}
|
||||
inv := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "chat", "add_group_member", params,
|
||||
)
|
||||
inv.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
func jsonMarshal(v any) (string, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
|
||||
cmd.Flags().String("users", "", "要添加的 userId 列表,逗号分隔 (必填)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── group members remove ───────────────────────────────────
|
||||
|
||||
func newChatGroupMemberRemoveCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Short: "移除群成员",
|
||||
Example: ` dws chat group members remove --id <openconversation_id> --users userId1,userId2`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
usersStr, _ := cmd.Flags().GetString("users")
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
}
|
||||
if strings.TrimSpace(usersStr) == "" {
|
||||
return apperrors.NewValidation("--users is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"openconversationId": groupID,
|
||||
"userIdList": splitCSV(usersStr),
|
||||
}
|
||||
inv := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "chat", "remove_group_member", params,
|
||||
)
|
||||
inv.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("id", "", "Group ID / openconversation_id (required)")
|
||||
cmd.Flags().String("users", "", "Comma-separated userId list to remove (required)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── group members add-bot ──────────────────────────────────
|
||||
|
||||
func newChatGroupMembersAddBotCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add-bot",
|
||||
Short: "Add bot to group",
|
||||
Example: ` dws chat group members add-bot --robot-code <robot-code> --id <openconversation_id>`,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
robotCode, _ := cmd.Flags().GetString("robot-code")
|
||||
groupID, _ := cmd.Flags().GetString("id")
|
||||
if strings.TrimSpace(robotCode) == "" {
|
||||
return apperrors.NewValidation("--robot-code is required")
|
||||
}
|
||||
if strings.TrimSpace(groupID) == "" {
|
||||
return apperrors.NewValidation("--id is required")
|
||||
}
|
||||
params := map[string]any{
|
||||
"robotCode": robotCode,
|
||||
"openConversationId": groupID,
|
||||
}
|
||||
inv := executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "bot", "add_robot_to_group", params,
|
||||
)
|
||||
inv.DryRun = commandDryRun(cmd)
|
||||
result, err := runner.Run(cmd.Context(), inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().String("robot-code", "", "Bot code (required)")
|
||||
cmd.Flags().String("id", "", "Group openConversationId (required)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// ── bot search ─────────────────────────────────────────────
|
||||
|
||||
func newChatBotSearchCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "Search my bots",
|
||||
Example: " dws chat bot search --page 1\n dws chat bot search --page 1 --size 10 --name \"日报\"",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
page, _ := cmd.Flags().GetInt("page")
|
||||
params := map[string]any{
|
||||
"currentPage": page,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("size"); v > 0 {
|
||||
params["pageSize"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("name"); v != "" {
|
||||
params["robotName"] = v
|
||||
}
|
||||
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
|
||||
cobracmd.LegacyCommandPath(cmd), "bot", "search_my_robots", params,
|
||||
))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeCommandPayload(cmd, result)
|
||||
},
|
||||
}
|
||||
preferLegacyLeaf(cmd)
|
||||
cmd.Flags().Int("page", 1, "Page number, starting from 1")
|
||||
cmd.Flags().Int("size", 0, "Items per page (default 50)")
|
||||
cmd.Flags().String("name", "", "Search by name")
|
||||
return cmd
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type captureRunner struct {
|
||||
@@ -307,76 +306,6 @@ func equalAny(a, b any) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatGroupMembersListSubcommand pins the explicit `list` subcommand
|
||||
// added for issue #164: previously the bare `chat group members --id` was
|
||||
// the list path, but it shape-mismatched the dynamic envelope's `members`
|
||||
// leaf and got eaten by the merge layer. Now `dws chat group members list
|
||||
// --id <cid>` is a proper leaf siblings of add/remove/add-bot.
|
||||
func TestChatGroupMembersListSubcommand(t *testing.T) {
|
||||
runner := &captureRunner{}
|
||||
groupCmd := newChatGroupCommand(runner)
|
||||
var members *cobra.Command
|
||||
for _, sub := range groupCmd.Commands() {
|
||||
if sub.Name() == "members" {
|
||||
members = sub
|
||||
break
|
||||
}
|
||||
}
|
||||
if members == nil {
|
||||
t.Fatalf("members subcommand missing under chat group")
|
||||
}
|
||||
|
||||
want := map[string]bool{"list": false, "add": false, "remove": false, "add-bot": false}
|
||||
for _, leaf := range members.Commands() {
|
||||
if _, ok := want[leaf.Name()]; ok {
|
||||
want[leaf.Name()] = true
|
||||
}
|
||||
}
|
||||
for name, seen := range want {
|
||||
if !seen {
|
||||
t.Errorf("expected `chat group members %s` subcommand, missing", name)
|
||||
}
|
||||
}
|
||||
|
||||
if members.Flags().Lookup("id") != nil {
|
||||
t.Errorf("members container should not declare --id (moved to `list` subcommand to avoid shape-mismatch with dynamic envelope)")
|
||||
}
|
||||
|
||||
var listCmd *cobra.Command
|
||||
for _, leaf := range members.Commands() {
|
||||
if leaf.Name() == "list" {
|
||||
listCmd = leaf
|
||||
break
|
||||
}
|
||||
}
|
||||
if listCmd == nil {
|
||||
t.Fatalf("`list` subcommand not found")
|
||||
}
|
||||
if listCmd.Flags().Lookup("id") == nil {
|
||||
t.Errorf("`list` subcommand must declare --id")
|
||||
}
|
||||
if listCmd.Flags().Lookup("cursor") == nil {
|
||||
t.Errorf("`list` subcommand must declare --cursor")
|
||||
}
|
||||
|
||||
// Drive execution via the group root so cobra resolves the subcommand
|
||||
// path properly (calling Execute() on a child directly would re-enter
|
||||
// the root help branch).
|
||||
var out bytes.Buffer
|
||||
groupCmd.SetOut(&out)
|
||||
groupCmd.SetErr(&out)
|
||||
groupCmd.SetArgs([]string{"members", "list", "--id", "cid-xyz"})
|
||||
if err := groupCmd.Execute(); err != nil {
|
||||
t.Fatalf("members list Execute error = %v\noutput: %s", err, out.String())
|
||||
}
|
||||
if got := runner.last.Tool; got != "get_group_members" {
|
||||
t.Fatalf("Tool = %q, want get_group_members", got)
|
||||
}
|
||||
if got := runner.last.Params["openconversation_id"]; got != "cid-xyz" {
|
||||
t.Fatalf("openconversation_id = %#v, want cid-xyz", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageSendByBotRoutesToBotProduct(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -647,18 +647,24 @@ func NormalizeServers(response ListResponse, source string) []ServerDescriptor {
|
||||
descriptor.UpdatedAt = updatedAt
|
||||
}
|
||||
|
||||
existing, exists := bestByEndpoint[descriptor.Key]
|
||||
// Dedup key includes cli.id when present so that envelopes
|
||||
// intentionally splitting one MCP endpoint into multiple CLI command
|
||||
// trees (e.g. bot-root / bot-message / bot-group all serving
|
||||
// .../server/4717... but exposing different command roots) are not
|
||||
// collapsed by endpoint-only dedup. Without cli.id the key falls
|
||||
// back to descriptor.Key (= endpoint) for backwards compatibility.
|
||||
endpointKey := dedupKeyForEndpoint(descriptor)
|
||||
existing, exists := bestByEndpoint[endpointKey]
|
||||
if !exists || descriptorIsNewer(descriptor, existing) {
|
||||
bestByEndpoint[descriptor.Key] = descriptor
|
||||
bestByEndpoint[endpointKey] = descriptor
|
||||
}
|
||||
}
|
||||
|
||||
bestByName := make(map[string]ServerDescriptor)
|
||||
for _, descriptor := range bestByEndpoint {
|
||||
nameKey := normalizeDisplayNameKey(descriptor.DisplayName)
|
||||
if nameKey == "" {
|
||||
nameKey = descriptor.Key
|
||||
}
|
||||
// Same reasoning as endpoint dedup: append cli.id so three bot-*
|
||||
// entries with displayName "机器人消息" don't collapse into one.
|
||||
nameKey := dedupKeyForName(descriptor)
|
||||
existing, exists := bestByName[nameKey]
|
||||
if !exists || descriptorIsNewer(descriptor, existing) {
|
||||
bestByName[nameKey] = descriptor
|
||||
@@ -704,6 +710,33 @@ func normalizeDisplayNameKey(displayName string) string {
|
||||
return strings.ToLower(strings.TrimSpace(displayName))
|
||||
}
|
||||
|
||||
// dedupKeyForEndpoint returns the dedup key used when collapsing multiple
|
||||
// envelope entries that share an MCP endpoint. cli.id is appended so an
|
||||
// endpoint intentionally fronting multiple CLI command roots (bot-root /
|
||||
// bot-message / bot-group all served by the same MCP server) stays as
|
||||
// distinct descriptors. When cli.id is empty (or absent), the key is the
|
||||
// endpoint alone to preserve historical dedup behaviour.
|
||||
func dedupKeyForEndpoint(descriptor ServerDescriptor) string {
|
||||
if cliID := strings.TrimSpace(descriptor.CLI.ID); cliID != "" {
|
||||
return descriptor.Key + "#" + cliID
|
||||
}
|
||||
return descriptor.Key
|
||||
}
|
||||
|
||||
// dedupKeyForName mirrors dedupKeyForEndpoint for the second-pass name-based
|
||||
// dedup so two envelopes with the same displayName but distinct cli.id (the
|
||||
// bot-* trio shares displayName "机器人消息") remain separate.
|
||||
func dedupKeyForName(descriptor ServerDescriptor) string {
|
||||
nameKey := normalizeDisplayNameKey(descriptor.DisplayName)
|
||||
if nameKey == "" {
|
||||
nameKey = descriptor.Key
|
||||
}
|
||||
if cliID := strings.TrimSpace(descriptor.CLI.ID); cliID != "" {
|
||||
return nameKey + "#" + cliID
|
||||
}
|
||||
return nameKey
|
||||
}
|
||||
|
||||
func markDeprecatedCandidate(displayName string, lifecycle LifecycleInfo) LifecycleInfo {
|
||||
if lifecycle.DeprecatedCandidate {
|
||||
return lifecycle
|
||||
|
||||
@@ -101,6 +101,65 @@ func TestNormalizeServersDeduplicatesSameNameAcrossEndpoints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeServersPreservesDistinctCLIIDsOnSharedEndpoint guards the
|
||||
// bot-root / bot-message / bot-group split (issue: chat bot subtree vanished
|
||||
// from the CLI after NormalizeServers collapsed three envelopes that share a
|
||||
// single MCP endpoint and displayName but expose different cli.id values).
|
||||
func TestNormalizeServersPreservesDistinctCLIIDsOnSharedEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sharedURL := "https://example.com/server/4717"
|
||||
sharedName := "机器人消息"
|
||||
response := ListResponse{
|
||||
Servers: []ServerEnvelope{
|
||||
{
|
||||
Server: RegistryServer{
|
||||
Name: sharedName,
|
||||
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
|
||||
},
|
||||
Meta: EnvelopeMeta{
|
||||
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
|
||||
CLI: CLIOverlay{ID: "bot-root", Command: "bot"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Server: RegistryServer{
|
||||
Name: sharedName,
|
||||
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
|
||||
},
|
||||
Meta: EnvelopeMeta{
|
||||
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
|
||||
CLI: CLIOverlay{ID: "bot-message", Command: "message"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Server: RegistryServer{
|
||||
Name: sharedName,
|
||||
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
|
||||
},
|
||||
Meta: EnvelopeMeta{
|
||||
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
|
||||
CLI: CLIOverlay{ID: "bot-group", Command: "group"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
servers := NormalizeServers(response, "live_market")
|
||||
if len(servers) != 3 {
|
||||
t.Fatalf("NormalizeServers() len = %d, want 3 (one per cli.id)", len(servers))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, s := range servers {
|
||||
seen[s.CLI.ID] = true
|
||||
}
|
||||
for _, want := range []string{"bot-root", "bot-message", "bot-group"} {
|
||||
if !seen[want] {
|
||||
t.Errorf("NormalizeServers() missing descriptor with cli.id %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeServersMarksLegacyNameAsDeprecatedCandidate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -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