Compare commits

..
Author SHA1 Message Date
chichuan bc7b96ba5f Merge pull request #858 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.4
docs: seal v1.0.56-beta.4 changelog
2026-08-04 09:46:33 +08:00
chichuan 9539c887f6 docs: seal v1.0.56-beta.4 changelog 2026-08-04 09:35:44 +08:00
github-actions[bot] 6607f44724 Merge pull request #852 from AlwaysLee/feat/center-protocol-transfer
feat: multipart download engine with checkpoint resume and credential refresh
2026-08-04 09:29:17 +08:00
半圭 837a96fe3d fix: show friendly message on Ctrl+C instead of internal error JSON
When user interrupts multipart download with Ctrl+C, display a helpful
message indicating checkpoint is saved and download can be resumed,
instead of returning an internal error with context.Canceled.
2026-08-04 08:58:17 +08:00
半圭 fdcd44f9e3 fix: handle Ctrl+C (SIGINT) gracefully during drive download
- Replace context.Background() with cmd.Context() in download and
  download-version commands so SIGINT propagates to download goroutines
- Enables graceful interruption of multipart downloads via Ctrl+C
2026-08-03 23:21:59 +08:00
半圭 34c0c86a59 feat: center protocol upload/download refactoring with multipart download
- Add multipart download engine (drive_transfer.go) with Range probe,
  resume support, and credential auto-refresh on 401/403
- Add --part-size, --parallel, --no-resume flags to drive download and
  download-version commands
- Replace httpGetFile with driveTransferDownload for chunked parallel
  downloads in download and download-version commands
- Replace uploadToDrive credential parsing with driveUploadPut
  (transparent header pass-through, retry on 401/403)
- Add typed httpStatusError for non-2xx HTTP responses in doc.go
- Add comprehensive unit tests (32 cases) for drive_transfer
- Update drive reference documentation with multipart download behavior
- Add E2E test for multipart download (auto-test/, gitignored)

CR: 28984991
2026-08-03 23:21:59 +08:00
github-actions[bot] b2cbca2762 chore: update beta formula for v1.0.56-beta.3 [skip ci] 2026-08-03 12:55:19 +00:00
chichuan 9ce95db08e Merge pull request #855 from DingTalk-Real-AI/codex/changelog-v1.0.56-beta.3
docs: seal v1.0.56-beta.3 changelog
2026-08-03 20:39:44 +08:00
chichuan e99c20a0a1 docs: seal v1.0.56-beta.3 changelog 2026-08-03 20:34:17 +08:00
github-actions[bot] 96bfae079a Merge pull request #846 from wxianfeng/fix/event-unix-socket-tmpdir
fix(event): use secure Unix bus runtime directory
2026-08-03 16:04:41 +08:00
wxianfeng bb18cdba3b Merge upstream/main into fix/event-unix-socket-tmpdir 2026-08-03 15:45:38 +08:00
wxianfeng 015a1f85ca fix(event): satisfy platform coverage gate 2026-08-03 15:42:17 +08:00
github-actions[bot] 8854e0d1d4 Merge pull request #851 from abucraft/codex/aitable-workflow-docs
feat: add aitable workflow edit example command
2026-08-03 07:01:27 +00:00
镜玄 22862508b8 feat: add aitable workflow edit example command 2026-08-03 14:47:59 +08:00
wxianfeng 5459bcc524 fix(event): secure Unix bus runtime directory 2026-08-03 11:40:01 +08:00
wxianfeng 029c665029 fix(event): place Unix bus sockets in temp dir 2026-07-31 18:01:34 +08:00
92 changed files with 8814 additions and 32371 deletions
+29
View File
@@ -6,6 +6,35 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.56-beta.4] - 2026-08-04
This beta adds PR #852 on top of v1.0.56-beta.3. It makes Drive downloads
resilient for large files through parallel transfer, validated resumable
checkpoints, and automatic credential refresh.
### Added
- **Multipart Drive downloads** (#852) — adds `--part-size`, `--parallel`, and
`--no-resume` to `drive download` and `drive download-version`. Files above
the part-size threshold use a Range probe and parallel chunks, resume from a
fingerprint-validated checkpoint, refresh credentials on 401/403, and keep
the checkpoint when Ctrl+C interrupts a transfer.
## [1.0.56-beta.3] - 2026-08-03
This beta adds PRs #846 and #851 on top of v1.0.56-beta.2. It adds a
service-provided Aitable workflow-editing reference command and makes local
event-bus IPC reliable on shared filesystems by placing Unix sockets in a
validated private runtime directory.
### Added
- **Aitable workflow editing reference** (#851) — adds `dws aitable workflow edit-example`, a parameter-free read command that returns the service-provided workflow editing documentation and `workflow-dsl/v1` examples through `aitable/edit_workflow_example`.
### Fixed
- **Event bus sockets on shared filesystems** (#846) — Unix event buses now place their local IPC socket in a private per-user runtime directory (`XDG_RUNTIME_DIR` when available, otherwise a `0700` per-UID directory under the system temporary directory) while retaining locks, metadata, logs, and subscription state in the configured Workdir. Listener and dial paths validate directory ownership and permissions before use. This prevents `dws event consume` from failing with `bind: errno 524` when `~/.dws` is hosted on NFS, CSI, FUSE, or another filesystem that does not support Unix Domain Sockets without exposing the socket directly in a shared `/tmp` root. When `XDG_RUNTIME_DIR` is unavailable, the per-UID directory name is deterministic: ownership validation prevents endpoint hijacking, but another local user can pre-create the directory to deny service; multi-user deployments should provide a private `XDG_RUNTIME_DIR`.
## [1.0.56-beta.2] - 2026-07-30
This beta adds PRs #831 and #835 on top of v1.0.56-beta.1. It separates
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.56-beta.2"
version "1.0.56-beta.3"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-darwin-arm64.tar.gz"
sha256 "19b52b5427dbf24acfeb1da16a93e6edfd0443389a778abbbfd381ff8f656139"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-darwin-arm64.tar.gz"
sha256 "5d35bb3fca7883a4ee51e1561aefd6b954b104313359a7c05b30a333ea41e749"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-darwin-amd64.tar.gz"
sha256 "621da52d04f391234d160a0522d70c1fb2e36da59102ef201a9a3a11b706b3e8"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-darwin-amd64.tar.gz"
sha256 "5a94069a3ab2c811d915639bbfd9ff17035b77501d5f9070c0b540ffe525a41b"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-linux-arm64.tar.gz"
sha256 "4ba956463f4b583c1727f58026a6bc1fb23d27537083e3bafd541a05ab15b48b"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-linux-arm64.tar.gz"
sha256 "e9cbc1c647f3ea703e1b93264c0e7d92871cfabe26fef26fbe17b9dff4b80c0f"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-linux-amd64.tar.gz"
sha256 "a8156ec5b89355faf8c08a65d9c088f89a7b411a418fb4b488f3d472efc79670"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-linux-amd64.tar.gz"
sha256 "7c475841ea871d204f9f6efc7d6e5378cf19db4541aeaa8a27d1387fa6f2a1f1"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.2/dws-skills.zip"
sha256 "92c71fdeade88b3b76a00cb74ebd3223cc4110c7ee151d36d782537613129967"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56-beta.3/dws-skills.zip"
sha256 "b29b35345613bc9260ae37578eb982472591c9dc548b02176b5b9ba0bdc431f2"
end
def install
@@ -1,301 +0,0 @@
# IM Chat Skill 精简与渐进加载优化方案
## 1. 背景
当前 `im-chat-skill-hint-align` 分支已经增强了 Chat Skill 的 Shortcut 路由、执行骨架、身份边界、查询与资源处理、低频原子回退和错误导航。与 Lark IM Skill 对比后,DWS 在 Agent 路由和执行约束上更直接,但根 Skill 仍存在以下问题:
- 高频执行骨架与核心意图表重复。
- Runtime Shortcut Catalog、leaf Schema 和 leaf Help 的读取规则在多处重复。
- Shortcut 错误处理与 Workflow 错误导航重复。
- 身份、ID 和三种置顶对象的边界分散在不同章节。
- 缺少简短、集中且可复用的核心对象与查询结果语义。
- Frontmatter 能力召回仍可扩展,但不能削弱 DING、邮件和班级群等产品边界。
本方案只调整根文件 `skills/multi/dingtalk-chat/SKILL.md` 的组织和必要语义,不把 API 手册、完整 Shortcut 清单或权限表重新放回根 Skill。
## 2. 修改目标
1. 提高高频 Chat 意图的直接命中率,减少不必要的 Catalog、Schema 和 `--help` 调用。
2. 提前建立渐进加载顺序,避免模型在高频任务中优先进入原子命令树。
3. 补齐身份、核心对象、ID 和查询结果的必要语义,降低错误重试和 ID 混用。
4. 合并重复 SOP,确保新增内容不会增加根 Skill 的总体 token。
5. 保持参数、安全和完整 API 事实由 leaf Schema、Help 和 references 按需提供。
## 3. 设计原则
### 3.1 根 Skill 只保留决策必需信息
根 Skill 应负责:
- Skill 触发范围和跨产品排除边界。
- 渐进加载与能力选择顺序。
- 高频意图到精确 Shortcut 的映射。
- 身份、ID、幂等、分页、部分失败等跨命令不变量。
- 低频能力的导航入口。
- 错误恢复和停止条件。
以下内容继续留在 Schema 或 references:
- 完整 Shortcut Catalog。
- API Resources 全量列表。
- 权限 scope 表。
- 叶级参数全集和接口字段格式。
- 只服务单个命令的实现细节。
### 3.2 渐进加载规则必须早于命令骨架
模型应在看到具体命令前,先知道何时直接执行、何时才加载额外上下文。但完整一级命令树不应提前,以免低频原子命令干扰高频 Shortcut 选择。
### 3.3 同一事实只保留一个权威位置
- 高频 Shortcut 只出现在一张核心意图表中。
- Catalog、Schema 和 Help 的读取顺序只定义一次。
- 错误恢复与 reference 导航只定义一次。
- 身份、对象和 ID 的公共边界集中定义,后续章节只引用,不重复解释。
## 4. 目标章节结构
```text
1. Frontmatter
2. Preconditions
3. 加载与路由顺序
4. 核心对象与 ID
5. 核心意图与执行骨架
6. 统一发送
7. 查询、资源与卡片
8. 低频原子路由
├── 一级命令树
├── branch references
└── 低频操作回退表
9. 错误恢复与按需 Reference
10. 跨产品协作
```
## 5. 具体修改
### 5.1 扩展 Frontmatter 产品能力
扩展 `description` 的正向能力召回,覆盖:
- 单聊、群聊、建群、群搜索和群成员管理。
- 消息发送、回复、转发、撤回、查询和聊天记录搜索。
- 图片、文件和消息资源下载。
- 表情回应、收藏、Pin、消息置顶和会话置顶。
- 应用机器人、Webhook 和互动卡片。
- 未读、红点、消息已读状态和会话分类。
同时保留明确排除:
- DING、短信和电话转到 `dingtalk-ding`。
- 邮件转到 `dingtalk-mail`。
- 班级群转到对应的低频产品 Skill。
- 找人本身由 `dingtalk-contact` 或 `dingtalk-aisearch` 负责,Chat 只消费真实人员 ID。
Frontmatter 只描述真实能力和路由边界,不加入参数、SOP 或 token 实现细节。
### 5.2 前移并合并渐进加载规则
将现有“Shortcut 发现”“Shortcut 执行契约”和“渐进加载与一级路由”的加载决策部分合并为紧随 Preconditions 的唯一章节:
```markdown
## 加载与路由顺序
1. 已知高频意图:直接使用“核心意图与执行骨架”,不查 Help。
2. 已有匹配 Shortcut:直接执行;参数、约束或安全不确定时才查 leaf Schema。
3. 仅 Cobra flags 不确定时查 leaf `--help`。
4. 现有路由无法定位低频能力时,才查 Runtime Shortcut Catalog。
5. 没有 Shortcut 时,按需读取对应 branch reference,进入原子命令。
```
同一章节保留以下公共规则:
- 路由优先级为 `exact recipe/runnable script > public Shortcut > atomic command`。
- 不猜测 `cli_path` 或参数名称。
- `confirmation=user_required` 时先确认,再添加 `--yes`。
- 来源冲突时采用更安全的解释并报告契约漂移。
- 命令已确定且参数清楚时直接执行,不为验证已知路径重复发现。
删除其他章节重复出现的 Catalog、Schema、Help 选择说明。
### 5.3 新增“核心对象与 ID”小表
增加不超过 8 行的表格,集中表达:
| 对象 | 核心标识与边界 |
|---|---|
| 人员 | 姓名必须先解析成唯一真实的 `userId` 或 `openDingTalkId`,名称不能作为 ID 传递 |
| 会话 | 使用真实 `openConversationId` / cid;群名只能用于 Shortcut 的目标解析 |
| 消息 | 使用真实 `openMessageId` / msgId,并保持与身份及会话一致 |
| 发送任务 | `openTaskId` 只用于查询发送状态,不能替代消息 ID |
| Thread | thread/topic ID 必须绑定真实会话,不跨会话复用 |
| 身份 | current-user、app-bot 和 Webhook 是不同操作者,不能自动互换 |
| 状态 | 收藏、消息置顶、消息 Pin 和会话置顶作用于不同对象 |
新增后删除后文对这些边界的重复说明。
### 5.4 合并高频骨架与核心意图表
删除独立的“高频直接执行骨架”,将其全部合入唯一的“核心意图与执行骨架”表。表格固定为三列:
| 用户意图 | 精确 Shortcut 骨架 | 必须保留的执行边界 |
|---|---|---|
至少覆盖以下高频场景:
- 姓名发单聊、群名发群消息。
- user、bot、webhook 三种身份发送。
- 建群、改群名、拉人和成员查询。
- 拉取会话消息、查询详情、撤回和发送状态。
- 关键词搜索、组合搜索和查询 @ 我的消息。
- 群邀请链接和群机器人。
- 会话置顶和收藏列表。
- 查和某人的聊天记录。
- 群消息翻页导出。
- 机器人多群广播。
表中直接给出正确参数骨架;命中后照抄参数名,不先调用 `--help`。同一个 Shortcut 不再在其他表中重复列出。
### 5.5 补充统一身份规则
在“统一发送”开头加入统一规则:
> 身份决定真实操作者、可见范围和可用能力;同一目标使用 user、bot 或 webhook 时,结果和权限可能不同,禁止自动切换身份重试。
继续保留:
- 发送前检查身份、目标、正文、标题、@、消息类型和附件路径。
- 重试复用相同 `--idempotency-key`。
- user、bot、webhook 的精确发送模板。
- @ 占位符、新行和文件能力边界。
不加入 Lark 的 access token 类型说明。
### 5.6 补充查询结果与增强失败语义
在“查询、资源与卡片”中增加:
- 发送者名称缺失时保留真实 ID,不猜姓名,也不自动扩大通讯录查询。
- 可选增强字段缺失不代表主查询失败;增强请求失败时保留主结果并写入 per-item ledger。
继续保留:
- `--page-all` 只在确需完整分页时使用。
- 部分失败保留已有结果,禁止把不完整结果声明为完整。
- 资源下载默认关闭,显式请求后才增加请求和本地输出。
- 子消息资源优先使用子 `messageId`。
- 输出路径、覆盖、HTTPS 和重定向安全限制。
### 5.7 拆分“渐进加载”与“一级命令树”
前移的只有加载决策。完整一级命令树及 branch references 改名为“低频原子路由”,保留在高频意图、统一发送和查询规则之后。
这样可以:
- 防止高频任务优先进入 atomic branch。
- 降低不必要的 Schema 和 Help 查询。
- 继续为没有 Shortcut 的能力提供确定导航。
低频原子回退表继续保留收藏、编辑、外部群升级、群昵称、分类、共同群、群公告、群身份、置顶、未读、已读、授权、退群和解散群等差异化入口。
### 5.8 合并错误恢复与 Workflow 导航
将现有“Shortcut 错误处理”和“Workflow 与错误导航”合并为:
```markdown
## 错误恢复与按需 Reference
```
只保留以下规则:
- 路径或参数错误时,按 Catalog、Schema、Help 的既定顺序校正一次。
- 始终从实际输出重新提取下游 ID。
- 复杂消息任务按需读取 `01-messaging.md`。
- Onboarding 按需读取对应 workflow。
- 命令错误按需读取 `chat-error-recovery.md`。
- 权限不足、歧义未消除、无结果或契约冲突时停止并报告。
删除其他位置重复的 `01-messaging.md` 和错误恢复入口。
### 5.9 保留跨产品协作边界
继续保留根 Skill 中不可由 Chat 自己完成的路由:
- 人名解析到 Contact / AISearch。
- DING、短信和电话到 Ding Skill。
- 邮件到 Mail Skill。
- 本地文件与已有 mediaId 的发送差异。
如果某项边界已经在 Frontmatter 或核心对象表中完整表达,正文只保留执行阶段真正需要的补充,不重复整段说明。
## 6. 删除与合并清单
| 当前内容 | 处理方式 |
|---|---|
| “Shortcut 发现(按需)” | 合入前置“加载与路由顺序” |
| “Shortcut 执行契约” | 公共规则合入前置章节 |
| “高频直接执行骨架” | 删除,内容合入核心意图表 |
| “渐进加载与一级路由”中的加载说明 | 前移并去重 |
| 完整一级命令树 | 保留,改放“低频原子路由” |
| “Shortcut 错误处理” | 合入统一错误章节 |
| “Workflow 与错误导航” | 合入统一错误章节 |
| 分散的身份、ID、置顶说明 | 合入核心对象表或统一身份规则 |
| 重复的 `01-messaging.md` 入口 | 只保留一处 |
## 7. 不纳入本次修改
- 不展开 97 个公开 Shortcut。
- 不复制 Lark 的完整 API Resources 和权限 scope 表。
- 不在根 Skill 中维护 leaf 参数全集。
- 不引入与当前 CLI 不一致的新命令或参数。
- 不改变 Schema、Help、Runtime Catalog 和 reference 的事实优先级。
- 不通过增加默认查询、自动通讯录查询或默认资源增强来换取结果丰富度。
## 8. 实施顺序
1. 更新 Frontmatter description,确认能力召回和排除边界。
2. 合并并前移“加载与路由顺序”。
3. 新增“核心对象与 ID”表,删除相应重复边界。
4. 合并高频骨架和核心意图表。
5. 补充统一身份规则。
6. 补充查询结果和增强失败语义。
7. 将一级命令树调整为后置的“低频原子路由”。
8. 合并错误恢复与 reference 导航。
9. 全文检查重复命令、重复 reference 和冲突参数。
10. 运行 Skill 格式及相关策略测试,并用高频场景做静态路由验证。
## 9. 验收标准
### 9.1 内容与结构
- 根 Skill 中只有一份 Catalog、Schema、Help 读取顺序。
- 根 Skill 中只有一张高频意图与 Shortcut 骨架表。
- 根 Skill 中只有一个错误恢复章节。
- `01-messaging.md` 的同类导航不重复。
- 核心对象与 ID 表不超过 8 行数据。
- 一级原子命令树位于高频路由之后。
- Frontmatter 同时覆盖主要产品能力和明确排除边界。
### 9.2 执行行为
- “发给某人”“发到某群”“查 @ 我”“改群名”等高频意图可直接选中已评审 Shortcut,不先查 Help。
- 低频未知意图才触发 Runtime Shortcut Catalog。
- 参数或安全不确定时读取 leaf Schema;只有 Cobra flags 不确定时读取 leaf Help。
- user、bot、webhook 不被自动互换。
- `openTaskId`、消息 ID、会话 ID 不混用。
- 发送者名称或 reaction 等增强缺失时,不把主查询误判为失败。
- 部分失败保留已有结果并明确报告 ledger/completeness。
### 9.3 Token 与维护成本
- 修改后的根 Skill 不超过当前 211 行,并以不丢失必要路由和边界为前提尽量低于 195 行。
- 文件单词数和字符数不高于修改前基线。
- 新增内容通过删除重复 SOP 抵消。
- 不新增完整 Shortcut、API 或权限清单。
## 10. 预期收益
- 减少高频任务中的 `--help` 和重复 Schema 查询。
- 降低因身份切换、ID 混用和发送者名称缺失导致的错误重试。
- 让 Shortcut、Schema、Help、Catalog 和 references 各自保持单一职责。
- 在不增加默认 token 和耗时的前提下,提高 Chat Skill 的选择准确率和执行成功率。
- 降低后续新增 Shortcut 时同时维护多张表和多处规则的漂移风险。
-162
View File
@@ -1,162 +0,0 @@
// 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 app
import (
stderrors "errors"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func TestValidateChatWorkbookRawArgs(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
args []string
want string
}{
{
name: "members group flag",
args: []string{"chat", "group", "members", "list", "--group", "cid-demo", "--format", "json"},
want: "群成员列表命令路径或群参数不正确",
},
{
name: "rename group flag",
args: []string{"chat", "group", "rename", "--group=cid-demo", "--name", "新群名"},
want: "群重命名命令不支持 --group",
},
{
name: "image local path",
args: []string{"chat", "message", "send", "--group", "cid-demo", "--msg-type", "image", "--file-path", "/tmp/x.png"},
want: "image 消息不能直接使用 --file-path",
},
{
name: "unsupported message type",
args: []string{"chat", "message", "send", "--group", "cid-demo", "--msg-type=sticker"},
want: "不支持指定的 --msg-type:sticker",
},
{
name: "numeric group id required",
args: []string{"chat", "group", "get-by-group-id", "--group-id", "cid-demo"},
want: "--group-id 必须是数字群号",
},
{
name: "file media id conflict",
args: []string{"chat", "message", "send", "--group", "cid-demo", "--msg-type", "file", "--media-id", "media"},
want: "文件消息不能使用 --media-id",
},
{
name: "silent text media conflict",
args: []string{"chat", "message", "send", "--group", "cid-demo", "--media-id", "media", "--text", "file.pdf"},
want: "检测到 --media-id,但没有指定媒体消息类型",
},
{
name: "dismiss numeric group id",
args: []string{"chat", "group", "dismiss", "--group", "12345678"},
want: "解散群命令需要 openConversationId,不是数字群号",
},
} {
t.Run(tc.name, func(t *testing.T) {
err := validateChatWorkbookRawArgs(tc.args)
var typed *apperrors.Error
if !stderrors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Message != tc.want || len(typed.Actions) == 0 || len(typed.Examples) == 0 {
t.Fatalf("guidance = %#v", typed)
}
})
}
if err := validateChatWorkbookRawArgs([]string{"chat", "group", "rename", "--id", "cid-demo"}); err != nil {
t.Fatalf("canonical rename args rejected: %v", err)
}
}
func TestChatWorkbookHelpGuidanceCoverage(t *testing.T) {
t.Parallel()
for _, path := range []string{
"chat group members",
"chat group members add",
"chat group members remove",
"chat group members add-bot",
"chat group members remove-bot",
"chat group members list-by-ids",
"chat group create",
"chat group rename",
"chat message list",
"chat message search",
"chat message search-advanced",
"chat message list-all",
"chat message list-by-sender",
} {
guide, ok := chatWorkbookHelpGuidance[path]
if !ok || guide.reason == "" || guide.action == "" || guide.example == "" {
t.Fatalf("incomplete help guidance for %q: %#v", path, guide)
}
}
}
func TestRawArgsFlagValue(t *testing.T) {
t.Parallel()
if got := rawArgsFlagValue([]string{"--msg-type", "image"}, "msg-type"); got != "image" {
t.Fatalf("separate value = %q", got)
}
if got := rawArgsFlagValue([]string{"--msg-type=file"}, "msg-type"); got != "file" {
t.Fatalf("equals value = %q", got)
}
if got := rawArgsFlagValue([]string{"--text", "hello"}, "msg-type"); got != "" {
t.Fatalf("missing value = %q", got)
}
}
func TestRawArgsRequestJSON(t *testing.T) {
t.Parallel()
for _, args := range [][]string{
{"chat", "search", "--format", "json"},
{"chat", "search", "--format=json"},
{"chat", "search", "-f", "JSON"},
{"chat", "search", "-f=json"},
} {
if !rawArgsRequestJSON(args) {
t.Fatalf("rawArgsRequestJSON(%v) = false", args)
}
}
}
func TestSuppressJSONDeprecationPreamble(t *testing.T) {
t.Parallel()
root := NewRootCommand()
cmd := mustFindCommand(t, root, "chat", "media", "upload")
if cmd.Deprecated == "" {
t.Fatal("fixture command is not deprecated")
}
suppressJSONDeprecationPreamble(root, []string{"chat", "media", "upload", "--format", "json"})
if cmd.Deprecated != "" {
t.Fatalf("JSON execution kept deprecation preamble: %q", cmd.Deprecated)
}
plainRoot := NewRootCommand()
plain := mustFindCommand(t, plainRoot, "chat", "media", "upload")
suppressJSONDeprecationPreamble(plainRoot, []string{"chat", "media", "upload"})
if plain.Deprecated == "" {
t.Fatal("human execution unexpectedly removed deprecation metadata")
}
}
-462
View File
@@ -24,7 +24,6 @@ import (
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
@@ -113,16 +112,6 @@ func Execute() (exitCode int) {
root := rootNewRootCommandWithEngine(ctx, engine)
timing.Record("cmd_init", time.Since(initStart))
if err := validateChatWorkbookRawArgs(os.Args[1:]); err != nil {
if rawArgsRequestJSON(os.Args[1:]) {
_ = apperrors.PrintJSON(os.Stderr, err)
} else {
_ = apperrors.PrintHumanAt(os.Stderr, err, resolveVerbosity(root))
}
return apperrors.ExitCode(err)
}
suppressJSONDeprecationPreamble(root, os.Args[1:])
// Run PreParse handlers on raw argv before Cobra parses flags.
// This corrects model-generated errors like --userId → --user-id
// and --limit100 → --limit 100.
@@ -138,7 +127,6 @@ func Execute() (exitCode int) {
executed = root
}
err = rewordRequiredFlagError(err)
err = enrichChatWorkbookError(executed, err)
if isUnknownCommandError(err) {
executed.SetOut(os.Stderr)
_ = executed.Help()
@@ -153,453 +141,6 @@ func Execute() (exitCode int) {
return 0
}
func suppressJSONDeprecationPreamble(root *cobra.Command, args []string) {
if root == nil || !rawArgsRequestJSON(args) || len(args) < 3 {
return
}
if args[0] != "chat" || args[1] != "media" || args[2] != "upload" {
return
}
if cmd, _, err := root.Find([]string{"chat", "media", "upload"}); err == nil && cmd != nil {
cmd.Deprecated = ""
}
}
func validateChatWorkbookRawArgs(args []string) error {
path := strings.Join(args, " ")
switch {
case strings.HasPrefix(path, "chat message send ") && rawArgsFlagValue(args, "msg-type") == "file" &&
rawArgsContainFlag(args, "media-id"):
return apperrors.NewValidation(
"文件消息不能使用 --media-id",
apperrors.WithReason("PDF、DOCX、XLSX 和本地图片等文件通过 --file-path 上传发送;mediaId 仅用于已有媒体标识的 image 消息"),
apperrors.WithActions("移除 --media-id", "补充 --file-path 并保留 --msg-type file"),
apperrors.WithExamples(`dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json`),
)
case strings.HasPrefix(path, "chat message send ") && rawArgsContainFlag(args, "media-id") &&
rawArgsFlagValue(args, "msg-type") == "":
return apperrors.NewValidation(
"检测到 --media-id,但没有指定媒体消息类型",
apperrors.WithReason("未指定 --msg-type 时命令会进入文本分支,可能把文件名当成普通文字发送"),
apperrors.WithActions("已有图片 mediaId 时补充 --msg-type image", "发送 PDF/DOCX/XLSX 时移除 --media-id,改用 --msg-type file --file-path"),
apperrors.WithExamples(`dws chat message send --group <openConversationId> --msg-type image --media-id <mediaId> --format json`, `dws chat message send --group <openConversationId> --msg-type file --file-path ./thesis.pdf --format json`),
)
case strings.HasPrefix(path, "chat message send ") && rawArgsFlagValue(args, "msg-type") == "image" &&
rawArgsContainFlag(args, "file-path") && !rawArgsContainFlag(args, "media-id"):
filePath := rawArgsFlagValue(args, "file-path")
return apperrors.NewValidation(
"image 消息不能直接使用 --file-path",
apperrors.WithReason("msg-type=image 只接受已有 mediaId;本地图片路径不能自动转换为 mediaId"),
apperrors.WithActions("发送本地图片时改用 --msg-type file", "保留原路径并通过 --file-path 发送为文件附件"),
apperrors.WithExamples(fmt.Sprintf(`dws chat message send --group <openConversationId> --msg-type file --file-path %q --format json`, filePath)),
)
case strings.HasPrefix(path, "chat message send ") && rawArgsFlagValue(args, "msg-type") == "image" &&
!rawArgsContainFlag(args, "media-id"):
return apperrors.NewValidation(
"图片消息缺少 --media-id",
apperrors.WithReason("msg-type=image 只接受上游已经获得的有效 mediaId,不能把本地文件名当作 mediaId"),
apperrors.WithActions("已有 mediaId 时补充 --media-id", "发送本地图片时改用 --msg-type file --file-path"),
apperrors.WithExamples(`dws chat message send --group <openConversationId> --msg-type image --media-id <mediaId> --format json`, `dws chat message send --group <openConversationId> --msg-type file --file-path ./image.png --format json`),
)
case strings.HasPrefix(path, "chat message send "):
msgType := rawArgsFlagValue(args, "msg-type")
switch msgType {
case "", "text", "markdown", "image", "file", "audio", "video", "location", "profile":
default:
return apperrors.NewValidation(
"不支持指定的 --msg-type:"+msgType,
apperrors.WithReason("当前命令不支持 sticker/card 等消息类型;文本或 Markdown 消息无需传 --msg-type"),
apperrors.WithActions("文本消息移除 --msg-type 并使用 --text", "媒体消息使用 image、file、audio、video、location 或 profile"),
apperrors.WithExamples(`dws chat message send --group <openConversationId> --text "hi" --format json`),
)
}
case strings.HasPrefix(path, "chat group get-by-group-id "):
value := rawArgsFlagValue(args, "group-id")
if value != "" {
if _, err := strconv.ParseInt(value, 10, 64); err != nil {
return apperrors.NewValidation(
"--group-id 必须是数字群号",
apperrors.WithReason("cid 开头的值是 openConversationId,不是 get-by-group-id 所需的数字群号"),
apperrors.WithActions("如果已有 openConversationId,请改用接受 --group 的群查询命令", "只有拿到数字群号时才调用 get-by-group-id"),
apperrors.WithExamples(`dws chat group get-by-group-id --group-id 12345678 --format json`),
)
}
}
case strings.HasPrefix(path, "chat group dismiss ") && rawArgsContainFlag(args, "group"):
value := rawArgsFlagValue(args, "group")
if _, err := strconv.ParseInt(value, 10, 64); err == nil {
return apperrors.NewValidation(
"解散群命令需要 openConversationId,不是数字群号",
apperrors.WithReason("--group 应传 cid 开头或服务端返回的 openConversationId;数字群号只用于 get-by-group-id"),
apperrors.WithActions("先通过 chat search 获取 openConversationId", "确认目标群及不可逆影响后再执行解散"),
apperrors.WithExamples(`dws chat group dismiss --group <openConversationId> --format json`),
)
}
case strings.HasPrefix(path, "chat group members ") && rawArgsContainFlag(args, "group"):
return apperrors.NewValidation(
"群成员列表命令路径或群参数不正确",
apperrors.WithReason("群成员列表的可执行命令是 chat group members,群 ID 参数名为 --id;不存在 members list --group 这一组合"),
apperrors.WithActions("移除多余的 list 子命令", "将 --group 改为 --id"),
apperrors.WithExamples(`dws chat group members --id <openConversationId> --format json`),
)
case strings.HasPrefix(path, "chat group rename ") && rawArgsContainFlag(args, "group"):
return apperrors.NewValidation(
"群重命名命令不支持 --group",
apperrors.WithReason("chat group rename 使用 --id 接收群 openConversationId,而不是 --group"),
apperrors.WithActions("将 --group 改为 --id", "群 ID 不确定时先用 chat search 查询"),
apperrors.WithExamples(`dws chat group rename --id <openConversationId> --name "新群名" --format json`),
)
}
return nil
}
func rawArgsContainFlag(args []string, name string) bool {
prefix := "--" + name
for _, arg := range args {
if arg == prefix || strings.HasPrefix(arg, prefix+"=") {
return true
}
}
return false
}
func rawArgsFlagValue(args []string, name string) string {
prefix := "--" + name
for i, arg := range args {
if strings.HasPrefix(arg, prefix+"=") {
return strings.TrimPrefix(arg, prefix+"=")
}
if arg == prefix && i+1 < len(args) {
return args[i+1]
}
}
return ""
}
func rawArgsRequestJSON(args []string) bool {
for i, arg := range args {
if arg == "--format=json" || arg == "-f=json" {
return true
}
if (arg == "--format" || arg == "-f") && i+1 < len(args) && strings.EqualFold(args[i+1], "json") {
return true
}
}
return false
}
type chatWorkbookGuidance struct {
message string
reason string
actions []string
examples []string
}
var chatRequiredGuidance = map[string]chatWorkbookGuidance{
"chat message send-by-webhook": {
"Webhook 发送参数不完整",
"Webhook 消息必须同时提供机器人地址中的 access_token、标题和正文;不能降级为普通群消息",
[]string{"从自定义机器人 Webhook 地址提取 token", "同时补齐 --title 和 --text,并确保包含机器人安全关键词"},
[]string{`dws chat message send-by-webhook --token <access_token> --title "dws测试通知" --text "dws测试:评测结果已出" --format json`},
},
"chat group rename": {
"群重命名缺少群 ID 或新名称", "--id 必须是群 openConversationId,--name 是新的群名称",
[]string{"先用 chat search 获取群 openConversationId", "同时提供 --id 和 --name"},
[]string{`dws chat group rename --id <openConversationId> --name "新群名" --format json`},
},
"chat group dismiss": {
"解散群缺少目标群 ID", "解散群不可逆且需要群主权限,--group 必须是 openConversationId",
[]string{"先确认目标群和影响范围", "获取 openConversationId 后再执行,并按运行时要求确认"},
[]string{`dws chat group dismiss --group <openConversationId> --format json`},
},
"chat group quit": {
"退出群缺少目标群 ID", "quit 表示当前用户退出群聊,不会解散整个群;--group 必须是 openConversationId",
[]string{"确认你要退出而不是解散群", "先获取目标群 openConversationId"},
[]string{`dws chat group quit --group <openConversationId> --format json`},
},
"chat group set-admin": {
"设置群管理员参数不完整", "需要目标群以及一个或多个成员;默认设为管理员,--off 表示取消管理员",
[]string{"补充 --group", "通过 --user 或 --users 指定成员,取消管理员时增加 --off"},
[]string{`dws chat group set-admin --group <openConversationId> --users <userId1>,<userId2> --format json`},
},
"chat group transfer-owner": {
"转让群主参数不完整", "--group 指定群,--new-owner 使用 openDingTalkId,--user 使用 userId",
[]string{"补充群 openConversationId", "在 --new-owner 和 --user 中选择一个新群主标识"},
[]string{`dws chat group transfer-owner --group <openConversationId> --new-owner <openDingTalkId> --format json`},
},
"chat group update-nick": {
"修改本人群昵称参数不完整", "update-nick 只修改当前登录用户在指定群里的昵称,需要群 ID 和新昵称",
[]string{"补充 --group openConversationId", "补充新的昵称参数"},
[]string{`dws chat group update-nick --group <openConversationId> --nick "新昵称" --format json`},
},
"chat group update-icon": {
"更新群头像参数不完整", "需要群 openConversationId 和上游已经获得的有效图片 mediaId",
[]string{"补充 --group", "从上游媒体能力获取 mediaId 后传入 --icon-media-id"},
[]string{`dws chat group update-icon --group <openConversationId> --icon-media-id <mediaId> --format json`},
},
"chat group share-invite": {
"分享群邀请参数不完整", "--source 是被分享群,--target 是接收分享的会话,--receiver 是接收分享的单聊用户",
[]string{"补充 --source", "在 --target 和 --receiver 中选择一个接收目标"},
[]string{`dws chat group share-invite --source <源群ID> --target <目标会话ID> --format json`},
},
"chat message reply": {
"引用回复参数不完整", "会话 ID、原消息 ID、原发送者和回复正文必须来自或对应同一条原消息",
[]string{"先拉取目标消息", "补齐 conversation-id、ref-msg-id、ref-sender 和 text"},
[]string{`dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到" --format json`},
},
"chat message forward": {
"转发消息参数不完整", "消息 ID 必须属于源会话,并需要明确源会话和目标会话",
[]string{"先从源会话拉取真实消息 ID", "确认 src 和 dest 没有写反"},
[]string{`dws chat message forward --src-conversation-id <源会话ID> --msg-id <openMessageId> --dest-conversation-id <目标会话ID> --format json`},
},
"chat message recall": {
"撤回消息参数不完整", "用户消息撤回需要会话 ID 和本人发送的消息 ID;机器人消息应使用 recall-by-bot",
[]string{"确认消息由当前用户发送", "补齐 conversation-id 和 msg-id"},
[]string{`dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId> --format json`},
},
"chat message read-status": {
"查询消息已读状态参数不完整", "只能查询当前用户发出消息的已读状态,需要会话和消息标识",
[]string{"补齐会话和消息 ID", "人员筛选时区分 userId 与 openDingTalkId"},
[]string{`dws chat message read-status --conversation-id <openConversationId> --message-id <openMessageId> --format json`},
},
"chat message list-by-ids": {
"缺少消息 ID 列表", "--msg-ids 使用逗号分隔的真实 openMessageId,单次最多 50 条",
[]string{"先拉取真实消息 ID", "将不超过 50 条 ID 用逗号连接"},
[]string{`dws chat message list-by-ids --msg-ids <id1>,<id2> --format json`},
},
"chat message download-media": {
"媒体下载参数不完整", "type、resource-id、message-id、open-conversation-id 和 output 必须完整,且资源与消息来自同一条消息",
[]string{"先拉取目标媒体消息", "从同一条消息取得资源、消息和会话标识"},
[]string{`dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/ --format json`},
},
"chat message add-emoji": {
"添加表情回应参数不完整", "需要真实会话 ID、消息 ID 和 emoji 名称",
[]string{"先拉取目标消息", "补齐 conversation-id、msg-id 和 emoji"},
[]string{`dws chat message add-emoji --conversation-id <openConversationId> --msg-id <openMessageId> --emoji "赞" --format json`},
},
"chat message remove-emoji": {
"移除表情回应参数不完整", "只能移除当前用户已添加的同名回应,需要会话、消息和 emoji 名称完全匹配",
[]string{"确认当前用户添加过该回应", "补齐 conversation-id、msg-id 和 emoji"},
[]string{`dws chat message remove-emoji --conversation-id <openConversationId> --msg-id <openMessageId> --emoji "赞" --format json`},
},
"chat message list-by-sender": {
"按发送者查询参数不完整", "必须提供开始时间以及发送者 userId/openDingTalkId 二选一,可选 end 和 cursor",
[]string{"补充 --start", "在 sender-user-id 和 sender-open-dingtalk-id 中选择一个"},
[]string{`dws chat message list-by-sender --sender-user-id <userId> --start "2026-07-14T00:00:00+08:00" --format json`},
},
"chat message query-send-status": {
"缺少发送任务 ID", "--open-task-id 来自 message send 返回的 openTaskId,不是消息 ID",
[]string{"先执行 message send", "从发送结果读取 openTaskId"},
[]string{`dws chat message query-send-status --open-task-id <openTaskId> --format json`},
},
}
func enrichChatWorkbookError(cmd *cobra.Command, err error) error {
if cmd == nil || err == nil {
return err
}
path := cmd.CommandPath()
if fields := strings.Fields(path); len(fields) > 1 {
path = strings.Join(fields[1:], " ")
}
message := err.Error()
var guide chatWorkbookGuidance
switch {
case path == "chat message send" &&
(strings.Contains(message, "unknown flag: --at-user-ids") ||
strings.Contains(message, "unknown flag: --at-users") ||
strings.Contains(message, "unknown flag: --mention")):
guide = chatWorkbookGuidance{
"群消息 @成员参数不正确",
"当前用户身份发送群消息时使用 --at-open-dingtalk-ids,参数值必须是成员的 openDingTalkId;--at-user-ids、--at-users、--mention 均不是有效参数",
[]string{"先查询目标成员的 openDingTalkId", "改用 --at-open-dingtalk-ids,并在正文中写入 <@openDingTalkId>"},
[]string{`dws chat message send --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --text "<@openDingTalkId> 请关注" --format json`},
}
case path == "chat media upload":
guide = chatWorkbookGuidance{
"chat media upload 已下线",
"当前 CLI 不再通过该命令把本地文件转换为 mediaId,本地图片和文件统一由 message send 的 file 路径上传并发送",
[]string{"发送本地图片或文件时使用 --msg-type file --file-path", "只有上游已提供 mediaId 时才使用 --msg-type image --media-id"},
[]string{`dws chat message send --group <openConversationId> --msg-type file --file-path ./image.png --format json`},
}
case path == "chat group members" && strings.Contains(message, "unknown flag: --group"):
guide = chatWorkbookGuidance{
"群成员列表命令路径或群参数不正确",
"群成员列表的可执行命令是 chat group members,群 ID 参数名为 --id;不存在 members list --group 这一组合",
[]string{"移除多余的 list 子命令", "将 --group 改为 --id"},
[]string{`dws chat group members --id <openConversationId> --format json`},
}
case path == "chat group rename" && strings.Contains(message, "unknown flag: --group"):
guide = chatWorkbookGuidance{
"群重命名命令不支持 --group",
"chat group rename 使用 --id 接收群 openConversationId,而不是 --group",
[]string{"将 --group 改为 --id", "群 ID 不确定时先用 chat search 查询"},
[]string{`dws chat group rename --id <openConversationId> --name "新群名" --format json`},
}
case path == "chat group create" && strings.Contains(message, "unknown flag: --members"):
guide = chatWorkbookGuidance{
"建群命令不支持 --members",
"chat group create 使用 --users 接收逗号分隔的成员 userId;--members 是其他命令的参数名",
[]string{"将 --members 改为 --users", "成员标识不确定时先查询 userId"},
[]string{`dws chat group create --name "V2评审小组" --users 489149,550582 --format json`},
}
case path == "chat group bots" && strings.Contains(message, "unknown flag: --id"):
guide = chatWorkbookGuidance{
"群机器人列表命令不支持 --id",
"chat group bots 使用 --group 接收群 openConversationId;该参数名与 members、rename 命令不同",
[]string{"将 --id 改为 --group", "群 ID 不确定时先用 chat search 查询"},
[]string{`dws chat group bots --group <openConversationId> --format json`},
}
case path == "chat message list-mentions" && strings.Contains(message, "required flag"):
guide = chatWorkbookGuidance{
"缺少必填参数:--start、--end",
"查询 @我 消息必须同时提供 ISO-8601 格式的开始和结束时间;只提供分页参数不能确定查询范围",
[]string{"同时补充 --start 和 --end,不要逐个参数反复试错", "按本地时区设置明确的查询时间窗"},
[]string{`dws chat message list-mentions --start "2026-07-23T00:00:00+08:00" --end "2026-07-30T23:59:59+08:00" --limit 50 --format json`},
}
case path == "chat message send" && strings.Contains(message, "--group, --user or --open-dingtalk-id is required"):
guide = chatWorkbookGuidance{
"缺少消息接收目标",
"发送消息必须在 --group、--user、--open-dingtalk-id 中选择且只选择一个接收目标",
[]string{"发群消息时先查询并传入群 openConversationId", "发单聊时先查询并传入 userId 或 openDingTalkId"},
[]string{`dws chat message send --group <openConversationId> --text "评测消息" --format json`, `dws chat message send --open-dingtalk-id <openDingTalkId> --text "评测消息" --format json`},
}
case path == "chat search" && strings.Contains(message, "query"):
guide = chatWorkbookGuidance{
"缺少群聊搜索关键词:--query",
"群聊搜索需要关键词才能定位候选群,不能使用空查询",
[]string{"使用 --query 传入群名称或名称片段", "从结果中读取 openConversationId 供后续群命令使用"},
[]string{`dws chat search --query "项目群" --format json`},
}
case path == "chat message search-advanced":
guide = chatWorkbookGuidance{
"高级消息搜索至少需要一个搜索条件",
"空条件搜索无法限定目标消息,必须提供关键词、人员、@我状态或会话范围中的至少一种",
[]string{"按内容搜索时传入 --query", "也可通过 --user、--at-me 或 --conversation-ids 缩小范围"},
[]string{`dws chat message search-advanced --query "评审" --format json`},
}
case path == "chat message search" && strings.Contains(message, "required flag"):
guide = chatWorkbookGuidance{
"关键词消息搜索缺少完整查询条件",
"关键词消息搜索需要 --query、--start 和 --end;当前命令没有提供完整的关键词和时间范围",
[]string{"补充搜索关键词", "同时提供 ISO-8601 格式的开始和结束时间"},
[]string{`dws chat message search --query "评审" --start "2026-07-23T00:00:00+08:00" --end "2026-07-30T23:59:59+08:00" --format json`},
}
case path == "chat message list-all" && strings.Contains(message, "required flag"):
guide = chatWorkbookGuidance{
"跨会话消息查询缺少时间范围",
"拉取全部会话消息必须使用 --start 和 --end 限定范围,避免无边界查询历史消息",
[]string{"同时补充 --start 和 --end", "结果存在 hasMore 时使用 nextCursor 继续翻页"},
[]string{`dws chat message list-all --start "2026-07-23T00:00:00+08:00" --end "2026-07-30T23:59:59+08:00" --limit 50 --format json`},
}
case path == "chat message list-topic-replies" && strings.Contains(message, "topic-id"):
guide = chatWorkbookGuidance{
"缺少话题定位参数:--topic-id",
"topic-id 不能臆造,必须来自同一群聊消息列表中目标话题消息的 openConvThreadId",
[]string{"先执行 chat message list 拉取目标群消息", "从目标话题消息读取 openConvThreadId 并作为 --topic-id"},
[]string{`dws chat message list --group <openConversationId> --time "2026-07-30 23:59:59" --direction older --format json`, `dws chat message list-topic-replies --group <openConversationId> --topic-id <openConvThreadId> --limit 50 --format json`},
}
case path == "chat message send-by-bot" && strings.Contains(message, "required flag"):
guide = chatWorkbookGuidance{
"机器人发送消息缺少必填参数",
"机器人发送需要 robotCode、标题、正文以及群聊或单聊目标,当前参数不完整",
[]string{"补充 --robot-code 和 --title", "通过 --group 或用户参数指定接收目标"},
[]string{`dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "通知" --text "hello" --format json`},
}
case path == "chat message recall-by-bot" && strings.Contains(message, "required flag"):
guide = chatWorkbookGuidance{
"机器人撤回消息缺少 robotCode 或 processQueryKey",
"--keys 的 processQueryKey 来自机器人发送消息的返回结果,不能凭空构造",
[]string{"补充发送该消息的 --robot-code", "从发送结果读取 processQueryKey 并传给 --keys"},
[]string{`dws chat message recall-by-bot --robot-code <robotCode> --group <openConversationId> --keys <processQueryKey> --format json`},
}
case path == "chat message list" && strings.Contains(message, "required flag") && strings.Contains(message, "--time"):
guide = chatWorkbookGuidance{
"拉取会话消息缺少时间锚点:--time",
"消息列表按时间向前或向后拉取,必须提供一个明确的时间锚点",
[]string{"补充格式为 YYYY-MM-DD HH:mm:ss 的 --time", "使用 --direction older 或 newer 明确查询方向"},
[]string{`dws chat message list --group <openConversationId> --time "2026-07-30 10:00:00" --direction older --format json`},
}
case path == "chat message list" && strings.Contains(message, "--group, --user or --open-dingtalk-id is required"):
guide = chatWorkbookGuidance{
"拉取消息时缺少会话目标",
"必须在群聊 openConversationId、单聊 userId、单聊 openDingTalkId 中选择且只选择一个目标",
[]string{"群聊先用 chat search 获取 openConversationId", "单聊先查询人员标识,再传 --user 或 --open-dingtalk-id"},
[]string{`dws chat message list --group <openConversationId> --time "2026-07-15 10:00:00" --format json`},
}
case path == "chat message send" && strings.Contains(message, "media-id is required"):
guide = chatWorkbookGuidance{
"图片消息缺少 --media-id",
"msg-type=image 只接受上游已经获得的有效 mediaId,不能把本地文件名当作 mediaId",
[]string{"已有 mediaId 时补充 --media-id", "发送本地图片时改用 --msg-type file --file-path"},
[]string{`dws chat message send --group <openConversationId> --msg-type image --media-id <mediaId> --format json`, `dws chat message send --group <openConversationId> --msg-type file --file-path ./image.png --format json`},
}
case path == "chat message send" && strings.Contains(message, "readable local --file-path is required"):
guide = chatWorkbookGuidance{
"文件消息缺少可读的本地文件",
"file、audio、video 消息需要可读的 --file-path;旧版 dentry 参数则必须成组提供",
[]string{"优先传入当前机器上可读的 --file-path", "使用旧参数时同时提供 dentry-id、space-id 和 file-name"},
[]string{`dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json`},
}
case path == "chat message send" && strings.Contains(message, "--file-path must be a readable local file"):
guide = chatWorkbookGuidance{
"--file-path 指向的文件不可读",
"指定路径不存在、不是普通文件或当前进程没有读取权限,因此无法上传并发送",
[]string{"检查路径拼写并确认文件存在", "改用当前用户可读取的绝对路径或工作目录相对路径"},
[]string{`dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json`},
}
case path == "chat message send" && strings.Contains(message, "unsupported --msg-type"):
guide = chatWorkbookGuidance{
"不支持指定的 --msg-type",
"card 不是当前命令支持的消息类型;文本或 Markdown 消息无需传 --msg-type",
[]string{"文本消息移除 --msg-type 并使用 --text", "媒体消息仅使用 image、file、audio、video、location 或 profile"},
[]string{`dws chat message send --group <openConversationId> --text "消息正文" --format json`},
}
case path == "chat message send" && strings.Contains(message, "message content required"):
guide = chatWorkbookGuidance{
"群消息缺少正文内容",
"未提供 --text 或位置参数,同时也没有选择需要专用参数的媒体消息类型",
[]string{"发送文字时补充 --text", "发送文件时使用 --msg-type file --file-path"},
[]string{`dws chat message send --group <openConversationId> --text "消息正文" --format json`},
}
}
if guide.message == "" {
if required, ok := chatRequiredGuidance[path]; ok &&
(strings.Contains(message, "required") || strings.Contains(message, "缺少")) {
guide = required
} else if strings.HasPrefix(path, "chat ") &&
(strings.Contains(message, "required") ||
strings.Contains(message, "invalid") ||
strings.Contains(message, "unsupported") ||
strings.Contains(message, "unknown flag") ||
strings.Contains(message, "must be")) {
example := fmt.Sprintf("dws %s --help", path)
if meta, ok := cli.ResolveMeta(path); ok && len(meta.Selection.Examples) > 0 {
example = meta.Selection.Examples[0]
if !strings.Contains(example, "--format") {
example += " --format json"
}
}
guide = chatWorkbookGuidance{
"Chat 命令参数校验失败",
message,
[]string{"根据错误补齐或修正参数", fmt.Sprintf("运行 dws %s --help 核对当前命令参数", path)},
[]string{example},
}
} else {
return err
}
}
return apperrors.NewValidation(
guide.message,
apperrors.WithReason(guide.reason),
apperrors.WithHint(guide.actions[0]),
apperrors.WithActions(guide.actions...),
apperrors.WithExamples(guide.examples...),
apperrors.WithCause(err),
)
}
// newPreParseValidationError keeps pipeline handler identity in internal logs
// while exposing only the underlying parameter-domain error to CLI users.
func newPreParseValidationError(err error) error {
@@ -679,9 +220,6 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
)
}
if enriched := enrichChatWorkbookError(cmd, err); enriched != err {
return enriched
}
// Common flag aliases and suggestions
suggestions := map[string]string{
-141
View File
@@ -43,153 +43,12 @@ func configureRootHelp(root *cobra.Command) {
if cmd != root {
defaultHelpFunc(cmd, args)
cli.RenderSafetyAnnotation(cmd)
renderChatAgentSelectionHint(cmd)
return
}
renderRootHelp(root)
})
}
type chatHelpGuidance struct {
reason string
action string
example string
}
var chatWorkbookHelpGuidance = map[string]chatHelpGuidance{
"chat group members": {
"群成员列表固定使用 --id 传群 openConversationId,不使用消息命令的 --group。",
"先查群 ID,再直接执行 members;不要追加多余的 list 子命令。",
`dws chat group members --id <openConversationId> --format json`,
},
"chat group members add": {
"添加群成员固定使用 --id 指定群、--users 指定成员。",
"先查询群 ID 和成员 userId/openDingTalkId,再执行添加。",
`dws chat group members add --id <openConversationId> --users <userId1>,<userId2> --format json`,
},
"chat group members remove": {
"移除群成员使用 --id 和 --users,且不能移除群主。",
"先确认成员和不可逆影响,检查群主身份后再执行。",
`dws chat group members remove --id <openConversationId> --users <userId> --format json`,
},
"chat group members add-bot": {
"添加机器人属于群成员管理,群参数沿用 --id,并需要 robot-code。",
"确认机器人编码和目标群后执行。",
`dws chat group members add-bot --id <openConversationId> --robot-code <robotCode> --format json`,
},
"chat group members remove-bot": {
"移除机器人固定使用 --id 指定群、--bot-id 指定群内机器人。",
"先列出群机器人取得 openBotId,再执行移除。",
`dws chat group members remove-bot --id <openConversationId> --bot-id <openBotId> --format json`,
},
"chat group members list-by-ids": {
"批量查询成员详情使用 --id + --users,users 为成员标识列表。",
"确认目标群和成员 ID 后再查询。",
`dws chat group members list-by-ids --id <openConversationId> --users <openDingTalkId1>,<openDingTalkId2> --format json`,
},
"chat group create": {
"建群使用 --users;创建结果中的群 ID 可继续传给 members add 和 rename。",
"先准备成员 userId,创建后保存返回的 openConversationId。",
`dws chat group create --name "项目群" --users <userId1>,<userId2> --format json`,
},
"chat group rename": {
"群改名只使用 --id + --name,不能使用 --group。",
"先通过 chat search 获取 openConversationId。",
`dws chat group rename --id <openConversationId> --name "新群名" --format json`,
},
"chat message list": {
"message list 按会话和时间拉取消息,不执行服务端关键词搜索。",
"按关键词查找时改用 message search;拉历史时提供会话和 time。",
`dws chat message list --group <openConversationId> --time "2026-07-30 23:59:59" --direction older --format json`,
},
"chat message search": {
"关键词审计应使用服务端搜索,并同时提供 query、start、end。",
"不要用 message list 拉全量后人工筛选。",
`dws chat message search --query "评审" --start "2026-07-01T00:00:00+08:00" --end "2026-07-31T23:59:59+08:00" --format json`,
},
"chat message search-advanced": {
"简单关键词优先 message search;只有组合人员、@、会话等条件时才使用 search-advanced。",
"至少提供一个真实搜索条件,分页参数不算搜索条件。",
`dws chat message search-advanced --query "评审" --conversation-ids <openConversationId> --format json`,
},
"chat message list-all": {
"list-all 按时间跨会话拉取消息,不执行关键词匹配。",
"需要关键词时改用 message search,并始终限制时间范围。",
`dws chat message list-all --start "2026-07-01T00:00:00+08:00" --end "2026-07-31T23:59:59+08:00" --format json`,
},
"chat message list-by-sender": {
"list-by-sender 的核心条件是发送者;核心条件是关键词时应使用 message search。",
"提供发送者 ID 和开始时间,按 nextCursor 翻页。",
`dws chat message list-by-sender --sender-user-id <userId> --start "2026-07-01T00:00:00+08:00" --format json`,
},
}
func renderChatWorkbookHelpGuidance(cmd *cobra.Command) {
if cmd == nil {
return
}
path := strings.TrimSpace(strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" "))
guide, ok := chatWorkbookHelpGuidance[path]
if !ok {
meta, metaOK := cli.ResolveMeta(path)
if !metaOK || meta.Identity.ProductID != "chat" {
return
}
reason := meta.Selection.AgentSummary
if reason == "" {
reason = "执行前需要确认该 Chat 命令的适用场景、必填参数和安全边界。"
}
action := "根据帮助正文补齐必填参数,并在实际执行时增加 --format json。"
if len(meta.Selection.UseWhen) > 0 {
action = meta.Selection.UseWhen[0]
}
example := "dws " + path + " --format json"
if len(meta.Selection.Examples) > 0 {
example = meta.Selection.Examples[0]
if !strings.Contains(example, "--format") {
example += " --format json"
}
}
guide = chatHelpGuidance{reason: reason, action: action, example: example}
}
w := cmd.ErrOrStderr()
_, _ = fmt.Fprintln(w, "错误信息:当前为执行前 guidance,不是运行失败")
_, _ = fmt.Fprintln(w, "原因:"+guide.reason)
_, _ = fmt.Fprintln(w, "建议操作:")
_, _ = fmt.Fprintln(w, "1. "+guide.action)
_, _ = fmt.Fprintln(w, "示例:")
_, _ = fmt.Fprintln(w, "1. "+guide.example)
}
// renderChatAgentSelectionHint exposes the reviewed Chat selection contract in
// command help without reintroducing a second product-local guidance map.
// Selection prose remains authored in schema_hints/selection/chat.json and is
// consumed through the repository-wide ResolveMeta API.
func renderChatAgentSelectionHint(cmd *cobra.Command) {
cliPath := strings.TrimSpace(strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" "))
meta, ok := cli.ResolveMeta(cliPath)
if !ok || meta.Identity.ProductID != "chat" {
return
}
selection := meta.Selection
w := cmd.OutOrStdout()
_, _ = fmt.Fprintln(w, "Agent guidance:")
if selection.AgentSummary != "" {
_, _ = fmt.Fprintf(w, " Outcome: %s\n", selection.AgentSummary)
}
for _, scenario := range selection.UseWhen {
_, _ = fmt.Fprintf(w, " Use when: %s\n", scenario)
}
for _, scenario := range selection.AvoidWhen {
_, _ = fmt.Fprintf(w, " Avoid when: %s\n", scenario)
}
for _, example := range selection.Examples {
_, _ = fmt.Fprintf(w, " Example: %s\n", example)
}
_, _ = fmt.Fprintln(w, " Output: Agent execution should add --format json.")
}
func renderRootHelp(root *cobra.Command) {
services := visibleMCPRootCommands(root)
utilities := visibleUtilityRootCommands(root)
-21
View File
@@ -69,27 +69,6 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
}
}
func TestChatAgentGuidanceRendersOnlyOnStdout(t *testing.T) {
cmd := NewRootCommand()
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"chat", "clear-messages", "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat clear-messages --help: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
}
for _, want := range []string{"Agent guidance:", "Outcome:", "Use when:", "Avoid when:", "Example:", "Output:"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("chat help stdout missing %q:\n%s", want, stdout.String())
}
}
if got := strings.TrimSpace(stderr.String()); got != "" {
t.Fatalf("chat help wrote guidance or warnings to stderr:\n%s", got)
}
}
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
+2 -2
View File
@@ -742,7 +742,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint(apperrors.SuggestBusinessHint(callResult.Content)),
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
apperrors.WithServerDiag(diag),
)
// PAT scope error in business response: offer human-readable output and retry
@@ -767,7 +767,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("business_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint(apperrors.SuggestBusinessHint(callResult.Content)),
apperrors.WithHint("The API returned a business-level error. Check required parameters and values."),
apperrors.WithServerDiag(diag),
)
}
@@ -106,7 +106,7 @@ func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 159; got != want {
if got, want := int(product["count"].(float64)), 129; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
+15 -5
View File
@@ -131,9 +131,12 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat +chat-bots",
Aliases: map[string]string{
"chat": "group",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat +chat-dismiss",
@@ -148,9 +151,12 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat +chat-invite-url",
Aliases: map[string]string{
"chat": "group",
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat +chat-mute",
@@ -307,7 +313,11 @@ var generatedParamAliases = []ParamAliasEntry{
},
{
CLIPath: "chat +messages-mget",
Blocked: []string{"msg-id", "open-message-id", "ref-msg-id", "src-msg-id"},
Aliases: map[string]string{
"message-ids": "msg-ids",
"open-message-ids": "msg-ids",
},
Blocked: []string{"message-id", "msg-id", "open-message-id", "ref-msg-id", "src-msg-id"},
},
{
CLIPath: "chat +messages-read-status",
@@ -37075,6 +37075,265 @@
"用户明确要求停止某自动化工作流时"
]
},
"aitable workflow edit-example": {
"agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"agent_summary_source": "dws-agent-selection/aitable",
"availability": "available",
"avoid_when": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"confirmation": "not_required",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws aitable workflow edit-example"
],
"field_provenance": {
"agent_summary": {
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"candidates": [
{
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
}
]
},
"availability": {
"value": "available",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"avoid_when": {
"value": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"candidates": [
{
"value": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
}
]
},
"confirmation": {
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "not_required",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"effect": {
"value": "read",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "read",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"examples": {
"value": [
"dws aitable workflow edit-example"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"candidates": [
{
"value": [
"dws aitable workflow edit-example"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
}
]
},
"idempotency": {
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "idempotent",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"interface_mode": {
"value": "composite",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "composite",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"interface_reason": {
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"interface_ref": {
"value": null,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"candidates": [
{
"value": null,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
}
]
},
"reviewed": {
"value": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
},
{
"value": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": false,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
}
]
},
"risk": {
"value": "low",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"candidates": [
{
"value": "low",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry."
}
]
},
"use_when": {
"value": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"candidates": [
{
"value": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
],
"source": "internal/cli/schema_hints/selection/aitable.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。"
}
]
}
},
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"risk": "low",
"source_refs": [
"cobra-help:dws aitable workflow edit-example --help",
"internal/cli/schema_command_registry.json#aitable.workflow_edit_example",
"internal/cli/schema_hints/metadata/aitable.json",
"internal/cli/schema_hints/selection/aitable.json",
"mcp-contract:aitable/edit_workflow_example",
"skills/mono/references/products/aitable/aitable-workflow.md"
],
"use_when": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
]
},
"aitable workflow enable": {
"agent_summary": "启用工作流。",
"agent_summary_source": "dws-agent-selection/aitable",
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -1,18 +1,18 @@
{
"version": 1,
"source_hash": "sha256:7111ced2e1cd0764338aacce792d95603734177a82deea4a4642626691940485",
"surface_hash": "sha256:4897d582a472d9d3596f88fb4546e7795d6b9bc334d020712f5d895ce894f7a7",
"source_hash": "sha256:f3d5e5d6ca3c613b1d004d68eca89679d0f8cffe882d7fed0fbb1bcef078fdc9",
"surface_hash": "sha256:41044fe1b6723564c40d684381ac6d6f23c4dfd58f9769350f02f223f02fa894",
"coverage": {
"surface_products": 26,
"products_with_metadata": 26,
"surface_tools": 875,
"tools_with_metadata": 875,
"tools_with_agent_summary": 875,
"tools_with_use_when": 875,
"tools_with_avoid_when": 875,
"tools_with_examples": 875,
"tools_with_interface_mode": 875,
"unmatched_skill_tools": 96,
"surface_tools": 846,
"tools_with_metadata": 846,
"tools_with_agent_summary": 846,
"tools_with_use_when": 846,
"tools_with_avoid_when": 846,
"tools_with_examples": 846,
"tools_with_interface_mode": 846,
"unmatched_skill_tools": 122,
"unreviewed_skill_tools": 11
},
"products": {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -97777,6 +97777,326 @@
"用户明确要求停止某自动化工作流时"
]
},
"aitable.workflow_edit_example": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
"cobra-help:dws aitable workflow edit-example --help",
"internal/cli/schema_command_registry.json#aitable.workflow_edit_example",
"internal/cli/schema_hints/metadata/aitable.json",
"internal/cli/schema_hints/selection/aitable.json",
"mcp-contract:aitable/edit_workflow_example",
"skills/mono/references/products/aitable/aitable-workflow.md"
],
"agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"agent_summary_source": "dws-agent-selection/aitable",
"availability": "available",
"avoid_when": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"canonical_path": "aitable.workflow_edit_example",
"cli_name": "edit-example",
"cli_path": "aitable workflow edit-example",
"confirmation": "not_required",
"description": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。",
"display": "AI 表格操作",
"effect": "read",
"effect_source": "agent-hint",
"examples": [
"dws aitable workflow edit-example"
],
"field_provenance": {
"agent_summary": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"selected": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。"
},
"availability": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "available"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "available"
},
"avoid_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"selected": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
]
},
"canonical_path": {
"candidates": [
{
"precedence": "command_registry",
"selected": true,
"source": "reviewed_command_registry",
"source_ref": "aitable workflow edit-example",
"value": "aitable.workflow_edit_example"
}
],
"precedence": "command_registry",
"resolution": "registry_identity",
"source": "reviewed_command_registry",
"source_ref": "aitable workflow edit-example",
"value": "aitable.workflow_edit_example"
},
"confirmation": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "not_required"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "not_required"
},
"description": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "返回服务端提供的 AI 表格工作流编辑文档与示例。\n可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。"
},
"effect": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "read"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "read"
},
"examples": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"selected": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"dws aitable workflow edit-example"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"dws aitable workflow edit-example"
]
},
"idempotency": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "idempotent"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "idempotent"
},
"interface_mode": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "composite"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "composite"
},
"interface_reason": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
},
"interface_ref": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": null
}
],
"precedence": "reviewed_explicit",
"resolution": "interface_disposition_matrix",
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": null
},
"reviewed": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": true
},
{
"precedence": "reviewed_explicit",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"selected": false,
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": true
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": true
},
"risk": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"selected": true,
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "low"
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"source": "internal/cli/schema_hints/metadata/aitable.json",
"value": "low"
},
"title": {
"candidates": [
{
"precedence": "cobra_help",
"selected": true,
"source": "cobra_help",
"value": "获取工作流编辑文档与示例"
}
],
"precedence": "cobra_help",
"resolution": "highest_precedence",
"source": "cobra_help",
"value": "获取工作流编辑文档与示例"
},
"use_when": {
"candidates": [
{
"precedence": "reviewed_explicit",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"selected": true,
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
]
}
],
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"source": "internal/cli/schema_hints/selection/aitable.json",
"value": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
]
}
},
"group": "workflow",
"has_parameters": false,
"idempotency": "idempotent",
"interface_mode": "composite",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"is_alias": false,
"name": "workflow_edit_example",
"parameter_count": 0,
"parameters": {},
"path": "aitable.workflow_edit_example",
"primary_cli_path": "aitable workflow edit-example",
"product_id": "aitable",
"reviewed": true,
"risk": "low",
"source": "reviewed_command_registry",
"title": "获取工作流编辑文档与示例",
"use_when": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
]
},
"aitable.workflow_enable": {
"agent_metadata_source": "embedded-skill-metadata",
"agent_source_refs": [
File diff suppressed because it is too large Load Diff
+576 -2
View File
@@ -5312,8 +5312,99 @@
"is_alias": false,
"metadata_source": "embedded-mcp-metadata",
"name": "download_file",
"parameter_count": 3,
"parameter_count": 6,
"parameters": {
"no-resume": {
"description": "关闭断点续传 (可选)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "关闭断点续传 (可选)"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "关闭断点续传 (可选)"
},
"property": {
"candidates": [
{
"precedence": "reviewed_mapping_exclusion",
"review_reason": "CLI-only transfer-layer flag controlling download resume behaviour; not an MCP interface parameter.",
"selected": true,
"source": "reviewed_mapping_exclusion",
"value": ""
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "noResume"
}
],
"precedence": "reviewed_mapping_exclusion",
"resolution": "highest_precedence",
"review_reason": "CLI-only transfer-layer flag controlling download resume behaviour; not an MCP interface parameter.",
"source": "reviewed_mapping_exclusion",
"value": ""
},
"required": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": false
}
],
"precedence": "default",
"resolution": "fallback",
"source": "default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "boolean"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "boolean"
}
},
"required": false,
"type": "boolean"
},
"node": {
"description": "文件 ID (dentryUuid) (必填)",
"field_provenance": {
@@ -5520,6 +5611,202 @@
"required": true,
"type": "string"
},
"parallel": {
"default": "4",
"description": "分片下载并发数,范围 1-8 (可选)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "分片下载并发数,范围 1-8 (可选)"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "分片下载并发数,范围 1-8 (可选)"
},
"property": {
"candidates": [
{
"precedence": "reviewed_mapping_exclusion",
"review_reason": "CLI-only transfer-layer flag controlling parallel chunk downloads; not an MCP interface parameter.",
"selected": true,
"source": "reviewed_mapping_exclusion",
"value": ""
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "parallel"
}
],
"precedence": "reviewed_mapping_exclusion",
"resolution": "highest_precedence",
"review_reason": "CLI-only transfer-layer flag controlling parallel chunk downloads; not an MCP interface parameter.",
"source": "reviewed_mapping_exclusion",
"value": ""
},
"required": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_nonzero_default",
"value": false
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_nonzero_default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "integer"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "integer"
}
},
"required": false,
"type": "integer"
},
"part-size": {
"default": "16MB",
"description": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
},
"property": {
"candidates": [
{
"precedence": "reviewed_mapping_exclusion",
"review_reason": "CLI-only transfer-layer flag controlling download chunk size; not an MCP interface parameter.",
"selected": true,
"source": "reviewed_mapping_exclusion",
"value": ""
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "partSize"
}
],
"precedence": "reviewed_mapping_exclusion",
"resolution": "highest_precedence",
"review_reason": "CLI-only transfer-layer flag controlling download chunk size; not an MCP interface parameter.",
"source": "reviewed_mapping_exclusion",
"value": ""
},
"required": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_nonzero_default",
"value": false
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_nonzero_default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "string"
}
},
"required": false,
"type": "string"
},
"space-id": {
"description": "文件所属空间 ID (可选)",
"field_provenance": {
@@ -5937,8 +6224,99 @@
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"is_alias": false,
"name": "download_file_version",
"parameter_count": 3,
"parameter_count": 6,
"parameters": {
"no-resume": {
"description": "关闭断点续传 (可选)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "关闭断点续传 (可选)"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "关闭断点续传 (可选)"
},
"property": {
"candidates": [
{
"precedence": "reviewed_mapping_exclusion",
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI wrapper input and does not publish a direct interface property.",
"selected": true,
"source": "reviewed_mapping_exclusion",
"value": ""
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "noResume"
}
],
"precedence": "reviewed_mapping_exclusion",
"resolution": "highest_precedence",
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI wrapper input and does not publish a direct interface property.",
"source": "reviewed_mapping_exclusion",
"value": ""
},
"required": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": false
}
],
"precedence": "default",
"resolution": "fallback",
"source": "default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "boolean"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "boolean"
}
},
"required": false,
"type": "boolean"
},
"node": {
"description": "文件 ID (dentryUuid) 或 URL (必填)",
"field_provenance": {
@@ -6133,6 +6511,202 @@
"required": true,
"type": "string"
},
"parallel": {
"default": "4",
"description": "分片下载并发数,范围 1-8 (可选)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "分片下载并发数,范围 1-8 (可选)"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "分片下载并发数,范围 1-8 (可选)"
},
"property": {
"candidates": [
{
"precedence": "reviewed_mapping_exclusion",
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --parallel is a CLI wrapper input and does not publish a direct interface property.",
"selected": true,
"source": "reviewed_mapping_exclusion",
"value": ""
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "parallel"
}
],
"precedence": "reviewed_mapping_exclusion",
"resolution": "highest_precedence",
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --parallel is a CLI wrapper input and does not publish a direct interface property.",
"source": "reviewed_mapping_exclusion",
"value": ""
},
"required": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_nonzero_default",
"value": false
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_nonzero_default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "integer"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "integer"
}
},
"required": false,
"type": "integer"
},
"part-size": {
"default": "16MB",
"description": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)",
"field_provenance": {
"description": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_usage",
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": ""
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_usage",
"value": "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)"
},
"property": {
"candidates": [
{
"precedence": "reviewed_mapping_exclusion",
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --part-size is a CLI wrapper input and does not publish a direct interface property.",
"selected": true,
"source": "reviewed_mapping_exclusion",
"value": ""
},
{
"precedence": "inference",
"selected": false,
"source": "flag_name_inference",
"value": "partSize"
}
],
"precedence": "reviewed_mapping_exclusion",
"resolution": "highest_precedence",
"review_reason": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --part-size is a CLI wrapper input and does not publish a direct interface property.",
"source": "reviewed_mapping_exclusion",
"value": ""
},
"required": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_nonzero_default",
"value": false
},
{
"precedence": "default",
"selected": false,
"source": "default",
"value": false
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_nonzero_default",
"value": false
},
"required_when": {
"candidates": [
{
"precedence": "default",
"selected": true,
"source": "default",
"value": ""
}
],
"precedence": "default",
"resolution": "highest_precedence",
"source": "default",
"value": ""
},
"type": {
"candidates": [
{
"precedence": "cobra_contract",
"selected": true,
"source": "cobra_flag_type",
"value": "string"
}
],
"precedence": "cobra_contract",
"resolution": "highest_precedence",
"source": "cobra_flag_type",
"value": "string"
}
},
"required": false,
"type": "string"
},
"version": {
"description": "历史版本号 (必填,正整数,从 drive list --versions 获取)",
"field_provenance": {
@@ -79,6 +79,36 @@
"calendar acl add",
"calendar acl delete",
"calendar book update",
"chat category add-conv",
"chat category create",
"chat category delete",
"chat category remove-conv",
"chat category rename",
"chat chmod",
"chat clear-all-red-point",
"chat clear-messages",
"chat clear-red-point",
"chat data-auth cross-org",
"chat group audit-join-validation",
"chat group list-all",
"chat group list-join-validations",
"chat group members list-by-ids",
"chat group notice create",
"chat group notice edit",
"chat group notice get",
"chat group notice list",
"chat group share-invite",
"chat group update-alias",
"chat hide",
"chat list-all-conversations",
"chat mark-read",
"chat mark-unread",
"chat message list-emotion-replies",
"chat message set-top-msg",
"chat message unset-top-msg",
"chat mute-at-all",
"chat mute-red-envelope",
"chat text translate",
"contact label get",
"contact label list",
"contact label list-members",
@@ -452,6 +452,10 @@
"canonical_path": "aitable.workflow_disable",
"cli_path": "aitable workflow disable"
},
{
"canonical_path": "aitable.workflow_edit_example",
"cli_path": "aitable workflow edit-example"
},
{
"canonical_path": "aitable.workflow_enable",
"cli_path": "aitable workflow enable"
@@ -1,378 +1,6 @@
{
"id": "chat",
"tools": [
{
"canonical_path": "chat.add_conv_to_categories",
"cli_path": "chat category add-conv"
},
{
"canonical_path": "chat.add_custom_group_role",
"cli_path": "chat group-role add"
},
{
"canonical_path": "chat.add_emoji_reaction",
"cli_path": "chat message add-emoji"
},
{
"canonical_path": "chat.add_group_member",
"cli_path": "chat group members add"
},
{
"canonical_path": "chat.add_message_favorite",
"cli_path": "chat message add-favorite"
},
{
"canonical_path": "chat.add_robot_to_group",
"cli_path": "chat group members add-bot"
},
{
"canonical_path": "chat.add_text_emotion",
"cli_path": "chat message add-text-emotion"
},
{
"canonical_path": "chat.audit_join_group",
"cli_path": "chat group audit-join-validation"
},
{
"canonical_path": "chat.batch_query_group_chat_settings",
"cli_path": "chat group user-settings query"
},
{
"canonical_path": "chat.batch_update_group_chat_settings",
"cli_path": "chat group user-settings set"
},
{
"canonical_path": "chat.clear_all_red_point",
"cli_path": "chat clear-all-red-point"
},
{
"canonical_path": "chat.clear_conversation_messages",
"cli_path": "chat clear-messages"
},
{
"canonical_path": "chat.clear_conversation_red_point",
"cli_path": "chat clear-red-point"
},
{
"canonical_path": "chat.combine_forward_messages",
"cli_path": "chat message combine-forward"
},
{
"canonical_path": "chat.create_and_send_card",
"cli_path": "chat message send-card"
},
{
"canonical_path": "chat.create_conv_category",
"cli_path": "chat category create"
},
{
"canonical_path": "chat.create_group_conversation",
"cli_path": "chat group create"
},
{
"canonical_path": "chat.create_group_notice",
"cli_path": "chat group notice create"
},
{
"canonical_path": "chat.create_smart_conv_category",
"cli_path": "chat category create-smart"
},
{
"canonical_path": "chat.create_text_emotion",
"cli_path": "chat message create-text-emotion"
},
{
"canonical_path": "chat.delete_conv_category",
"cli_path": "chat category delete"
},
{
"canonical_path": "chat.dismiss_group",
"cli_path": "chat group dismiss"
},
{
"canonical_path": "chat.download_media",
"cli_path": "chat message download-media"
},
{
"canonical_path": "chat.edit_group_notice",
"cli_path": "chat group notice edit"
},
{
"canonical_path": "chat.edit_message",
"cli_path": "chat message edit"
},
{
"canonical_path": "chat.forward_message",
"cli_path": "chat message forward"
},
{
"canonical_path": "chat.forward_topic",
"cli_path": "chat message forward-topic"
},
{
"canonical_path": "chat.get_conv_categories_info",
"cli_path": "chat category batch-info"
},
{
"canonical_path": "chat.get_conv_info_by_group_id",
"cli_path": "chat group get-by-group-id"
},
{
"canonical_path": "chat.get_conversation_info",
"cli_path": "chat conversation-info"
},
{
"canonical_path": "chat.get_group_invite_url",
"cli_path": "chat group invite-url"
},
{
"canonical_path": "chat.get_group_mute_config",
"cli_path": "chat group get-mute-config"
},
{
"canonical_path": "chat.get_group_notice",
"cli_path": "chat group notice get"
},
{
"canonical_path": "chat.grant_cross_org_data_access",
"cli_path": "chat data-auth cross-org"
},
{
"canonical_path": "chat.grant_permission",
"cli_path": "chat chmod"
},
{
"canonical_path": "chat.hide_conversation",
"cli_path": "chat hide"
},
{
"canonical_path": "chat.list_all_conversations",
"cli_path": "chat list-all-conversations"
},
{
"canonical_path": "chat.list_apply_join_group_records",
"cli_path": "chat group list-join-validations"
},
{
"canonical_path": "chat.list_conv_categories_by_conv",
"cli_path": "chat category list-by-conv"
},
{
"canonical_path": "chat.list_conversation_message_v2",
"cli_path": "chat message list"
},
{
"canonical_path": "chat.list_conversations_by_category",
"cli_path": "chat category list-conversations"
},
{
"canonical_path": "chat.list_custom_group_roles",
"cli_path": "chat group-role list"
},
{
"canonical_path": "chat.list_group_bots",
"cli_path": "chat group bots"
},
{
"canonical_path": "chat.list_group_member_by_ids",
"cli_path": "chat group members list-by-ids"
},
{
"canonical_path": "chat.list_group_notices",
"cli_path": "chat group notice list"
},
{
"canonical_path": "chat.list_individual_chat_message",
"cli_path": "chat message list-direct"
},
{
"canonical_path": "chat.list_message_emotion_replies",
"cli_path": "chat message list-emotion-replies"
},
{
"canonical_path": "chat.list_message_favorites",
"cli_path": "chat message list-favorites"
},
{
"canonical_path": "chat.list_messages_by_ids",
"cli_path": "chat message list-by-ids"
},
{
"canonical_path": "chat.list_my_groups_pagination",
"cli_path": "chat group list-all"
},
{
"canonical_path": "chat.list_owned_or_admin_groups",
"cli_path": "chat group list-my-groups"
},
{
"canonical_path": "chat.list_pin_messages",
"cli_path": "chat message list-pin-msg"
},
{
"canonical_path": "chat.list_special_focus_messages",
"cli_path": "chat message list-focused"
},
{
"canonical_path": "chat.list_top_conversations",
"cli_path": "chat list-top-conversations"
},
{
"canonical_path": "chat.list_topic_replies",
"cli_path": "chat message list-topic-replies"
},
{
"canonical_path": "chat.list_user_define_conv_categories",
"cli_path": "chat category list"
},
{
"canonical_path": "chat.mark_conversation_unread",
"cli_path": "chat mark-unread"
},
{
"canonical_path": "chat.mark_message_read",
"cli_path": "chat mark-read"
},
{
"canonical_path": "chat.query_custom_user_roles",
"cli_path": "chat group-role query-user"
},
{
"canonical_path": "chat.query_message_send_status",
"cli_path": "chat message query-send-status"
},
{
"canonical_path": "chat.query_msg_read_status",
"cli_path": "chat message read-status"
},
{
"canonical_path": "chat.quit_group",
"cli_path": "chat group quit"
},
{
"canonical_path": "chat.recall_message",
"cli_path": "chat message recall"
},
{
"canonical_path": "chat.recall_robot_message",
"cli_path": "chat message recall-by-bot"
},
{
"canonical_path": "chat.remove_conv_from_categories",
"cli_path": "chat category remove-conv"
},
{
"canonical_path": "chat.remove_custom_group_role",
"cli_path": "chat group-role remove"
},
{
"canonical_path": "chat.remove_custom_user_roles",
"cli_path": "chat group-role remove-user"
},
{
"canonical_path": "chat.remove_emoji_reaction",
"cli_path": "chat message remove-emoji"
},
{
"canonical_path": "chat.remove_group_member",
"cli_path": "chat group members remove"
},
{
"canonical_path": "chat.remove_message_favorite",
"cli_path": "chat message remove-favorite"
},
{
"canonical_path": "chat.remove_robot_in_group",
"cli_path": "chat group members remove-bot"
},
{
"canonical_path": "chat.remove_text_emotion",
"cli_path": "chat message remove-text-emotion"
},
{
"canonical_path": "chat.rename_conv_category",
"cli_path": "chat category rename"
},
{
"canonical_path": "chat.reply_personal_message",
"cli_path": "chat message reply"
},
{
"canonical_path": "chat.search_at_me_message",
"cli_path": "chat message list-mentions"
},
{
"canonical_path": "chat.search_bots",
"cli_path": "chat bot find"
},
{
"canonical_path": "chat.search_common_groups",
"cli_path": "chat search-common"
},
{
"canonical_path": "chat.search_groups",
"cli_path": "chat search"
},
{
"canonical_path": "chat.search_messages",
"cli_path": "chat message search-advanced"
},
{
"canonical_path": "chat.search_messages_by_keyword",
"cli_path": "chat message search"
},
{
"canonical_path": "chat.search_messages_by_sender",
"cli_path": "chat message list-by-sender"
},
{
"canonical_path": "chat.search_messages_by_time_range",
"cli_path": "chat message list-all"
},
{
"canonical_path": "chat.search_my_robots",
"cli_path": "chat bot search"
},
{
"canonical_path": "chat.send_message_by_custom_robot",
"cli_path": "chat message send-by-webhook"
},
{
"canonical_path": "chat.send_personal_message",
"cli_path": "chat message send"
},
{
"canonical_path": "chat.send_robot_message",
"cli_path": "chat message send-by-bot"
},
{
"canonical_path": "chat.set_custom_user_roles",
"cli_path": "chat group-role set-user"
},
{
"canonical_path": "chat.set_group_member_mute_list",
"cli_path": "chat group-mute-member"
},
{
"canonical_path": "chat.set_group_mute",
"cli_path": "chat group-mute"
},
{
"canonical_path": "chat.set_pin_message",
"cli_path": "chat message set-pin-msg"
},
{
"canonical_path": "chat.set_top_conversation",
"cli_path": "chat set-top"
},
{
"canonical_path": "chat.set_top_message",
"cli_path": "chat message set-top-msg"
},
{
"canonical_path": "chat.share_group_invite_url",
"cli_path": "chat group share-invite"
},
{
"canonical_path": "chat.shortcut_at_me",
"cli_path": "chat +at-me"
@@ -429,10 +57,6 @@
"canonical_path": "chat.shortcut_chat_list_mine",
"cli_path": "chat +chat-list-mine"
},
{
"canonical_path": "chat.shortcut_chat_messages",
"cli_path": "chat +chat-messages"
},
{
"canonical_path": "chat.shortcut_chat_mute",
"cli_path": "chat +chat-mute"
@@ -525,18 +149,10 @@
"canonical_path": "chat.shortcut_messages_read_status",
"cli_path": "chat +messages-read-status"
},
{
"canonical_path": "chat.shortcut_messages_send",
"cli_path": "chat +messages-send"
},
{
"canonical_path": "chat.shortcut_messages_send_by_webhook",
"cli_path": "chat +messages-send-by-webhook"
},
{
"canonical_path": "chat.shortcut_messages_send_card",
"cli_path": "chat +messages-send-card"
},
{
"canonical_path": "chat.shortcut_messages_update_card",
"cli_path": "chat +messages-update-card"
@@ -545,62 +161,114 @@
"canonical_path": "chat.shortcut_my_groups",
"cli_path": "chat +my-groups"
},
{
"canonical_path": "chat.shortcut_search_msg",
"cli_path": "chat +search-msg"
},
{
"canonical_path": "chat.shortcut_send_to_group",
"cli_path": "chat +send-to-group"
},
{
"canonical_path": "chat.shortcut_thread_replies",
"cli_path": "chat +thread-replies"
},
{
"canonical_path": "chat.shortcut_unread_chats",
"cli_path": "chat +unread-chats"
},
{
"canonical_path": "chat.transfer_group_owner",
"cli_path": "chat group transfer-owner"
"canonical_path": "chat.search_bots",
"cli_path": "chat bot find"
},
{
"canonical_path": "chat.translate",
"cli_path": "chat text translate"
"canonical_path": "chat.search_my_robots",
"cli_path": "chat bot search"
},
{
"canonical_path": "chat.unread_message_conversation_list",
"cli_path": "chat message list-unread-conversations"
"canonical_path": "chat.get_conv_categories_info",
"cli_path": "chat category batch-info"
},
{
"canonical_path": "chat.unset_pin_message",
"cli_path": "chat message unset-pin-msg"
"canonical_path": "chat.create_smart_conv_category",
"cli_path": "chat category create-smart"
},
{
"canonical_path": "chat.unset_top_message",
"cli_path": "chat message unset-top-msg"
"canonical_path": "chat.list_user_define_conv_categories",
"cli_path": "chat category list"
},
{
"canonical_path": "chat.update_at_all_notification_off",
"cli_path": "chat mute-at-all"
"canonical_path": "chat.list_conv_categories_by_conv",
"cli_path": "chat category list-by-conv"
},
{
"canonical_path": "chat.list_conversations_by_category",
"cli_path": "chat category list-conversations"
},
{
"canonical_path": "chat.get_conversation_info",
"cli_path": "chat conversation-info"
},
{
"canonical_path": "chat.list_group_bots",
"cli_path": "chat group bots"
},
{
"canonical_path": "chat.create_group_conversation",
"cli_path": "chat group create"
},
{
"canonical_path": "chat.dismiss_group",
"cli_path": "chat group dismiss"
},
{
"canonical_path": "chat.get_conv_info_by_group_id",
"cli_path": "chat group get-by-group-id"
},
{
"canonical_path": "chat.get_group_mute_config",
"cli_path": "chat group get-mute-config"
},
{
"canonical_path": "chat.get_group_invite_url",
"cli_path": "chat group invite-url"
},
{
"canonical_path": "chat.list_owned_or_admin_groups",
"cli_path": "chat group list-my-groups"
},
{
"canonical_path": "chat.add_group_member",
"cli_path": "chat group members add"
},
{
"canonical_path": "chat.add_robot_to_group",
"cli_path": "chat group members add-bot"
},
{
"canonical_path": "chat.remove_group_member",
"cli_path": "chat group members remove"
},
{
"canonical_path": "chat.remove_robot_in_group",
"cli_path": "chat group members remove-bot"
},
{
"canonical_path": "chat.quit_group",
"cli_path": "chat group quit"
},
{
"canonical_path": "chat.update_group_name",
"cli_path": "chat group rename"
},
{
"canonical_path": "chat.update_conv_member_roles",
"cli_path": "chat group set-admin"
},
{
"canonical_path": "chat.update_custom_group_role",
"cli_path": "chat group-role update"
"canonical_path": "chat.update_show_history_msg_option",
"cli_path": "chat group set-history"
},
{
"canonical_path": "chat.transfer_group_owner",
"cli_path": "chat group transfer-owner"
},
{
"canonical_path": "chat.update_group_icon",
"cli_path": "chat group update-icon"
},
{
"canonical_path": "chat.update_group_name",
"cli_path": "chat group rename"
},
{
"canonical_path": "chat.update_group_nick",
"cli_path": "chat group update-nick"
@@ -610,16 +278,200 @@
"cli_path": "chat group update-settings"
},
{
"canonical_path": "chat.update_notification_off",
"cli_path": "chat mute"
"canonical_path": "chat.upgrade_group_to_external",
"cli_path": "chat group upgrade-to-external"
},
{
"canonical_path": "chat.update_red_env_notification_off",
"cli_path": "chat mute-red-envelope"
"canonical_path": "chat.batch_query_group_chat_settings",
"cli_path": "chat group user-settings query"
},
{
"canonical_path": "chat.update_show_history_msg_option",
"cli_path": "chat group set-history"
"canonical_path": "chat.batch_update_group_chat_settings",
"cli_path": "chat group user-settings set"
},
{
"canonical_path": "chat.set_group_mute",
"cli_path": "chat group-mute"
},
{
"canonical_path": "chat.set_group_member_mute_list",
"cli_path": "chat group-mute-member"
},
{
"canonical_path": "chat.add_custom_group_role",
"cli_path": "chat group-role add"
},
{
"canonical_path": "chat.list_custom_group_roles",
"cli_path": "chat group-role list"
},
{
"canonical_path": "chat.query_custom_user_roles",
"cli_path": "chat group-role query-user"
},
{
"canonical_path": "chat.remove_custom_group_role",
"cli_path": "chat group-role remove"
},
{
"canonical_path": "chat.remove_custom_user_roles",
"cli_path": "chat group-role remove-user"
},
{
"canonical_path": "chat.set_custom_user_roles",
"cli_path": "chat group-role set-user"
},
{
"canonical_path": "chat.update_custom_group_role",
"cli_path": "chat group-role update"
},
{
"canonical_path": "chat.list_top_conversations",
"cli_path": "chat list-top-conversations"
},
{
"canonical_path": "chat.add_emoji_reaction",
"cli_path": "chat message add-emoji"
},
{
"canonical_path": "chat.add_message_favorite",
"cli_path": "chat message add-favorite"
},
{
"canonical_path": "chat.add_text_emotion",
"cli_path": "chat message add-text-emotion"
},
{
"canonical_path": "chat.combine_forward_messages",
"cli_path": "chat message combine-forward"
},
{
"canonical_path": "chat.create_text_emotion",
"cli_path": "chat message create-text-emotion"
},
{
"canonical_path": "chat.download_media",
"cli_path": "chat message download-media"
},
{
"canonical_path": "chat.edit_message",
"cli_path": "chat message edit"
},
{
"canonical_path": "chat.forward_message",
"cli_path": "chat message forward"
},
{
"canonical_path": "chat.forward_topic",
"cli_path": "chat message forward-topic"
},
{
"canonical_path": "chat.list_conversation_message_v2",
"cli_path": "chat message list"
},
{
"canonical_path": "chat.search_messages_by_time_range",
"cli_path": "chat message list-all"
},
{
"canonical_path": "chat.list_messages_by_ids",
"cli_path": "chat message list-by-ids"
},
{
"canonical_path": "chat.search_messages_by_sender",
"cli_path": "chat message list-by-sender"
},
{
"canonical_path": "chat.list_individual_chat_message",
"cli_path": "chat message list-direct"
},
{
"canonical_path": "chat.list_message_favorites",
"cli_path": "chat message list-favorites"
},
{
"canonical_path": "chat.list_special_focus_messages",
"cli_path": "chat message list-focused"
},
{
"canonical_path": "chat.search_at_me_message",
"cli_path": "chat message list-mentions"
},
{
"canonical_path": "chat.list_pin_messages",
"cli_path": "chat message list-pin-msg"
},
{
"canonical_path": "chat.list_topic_replies",
"cli_path": "chat message list-topic-replies"
},
{
"canonical_path": "chat.unread_message_conversation_list",
"cli_path": "chat message list-unread-conversations"
},
{
"canonical_path": "chat.query_message_send_status",
"cli_path": "chat message query-send-status"
},
{
"canonical_path": "chat.query_msg_read_status",
"cli_path": "chat message read-status"
},
{
"canonical_path": "chat.recall_message",
"cli_path": "chat message recall"
},
{
"canonical_path": "chat.recall_robot_message",
"cli_path": "chat message recall-by-bot"
},
{
"canonical_path": "chat.remove_emoji_reaction",
"cli_path": "chat message remove-emoji"
},
{
"canonical_path": "chat.remove_message_favorite",
"cli_path": "chat message remove-favorite"
},
{
"canonical_path": "chat.remove_text_emotion",
"cli_path": "chat message remove-text-emotion"
},
{
"canonical_path": "chat.reply_personal_message",
"cli_path": "chat message reply"
},
{
"canonical_path": "chat.search_messages_by_keyword",
"cli_path": "chat message search"
},
{
"canonical_path": "chat.search_messages",
"cli_path": "chat message search-advanced"
},
{
"canonical_path": "chat.send_personal_message",
"cli_path": "chat message send"
},
{
"canonical_path": "chat.send_robot_message",
"cli_path": "chat message send-by-bot"
},
{
"canonical_path": "chat.send_message_by_custom_robot",
"cli_path": "chat message send-by-webhook"
},
{
"canonical_path": "chat.create_and_send_card",
"cli_path": "chat message send-card"
},
{
"canonical_path": "chat.set_pin_message",
"cli_path": "chat message set-pin-msg"
},
{
"canonical_path": "chat.unset_pin_message",
"cli_path": "chat message unset-pin-msg"
},
{
"canonical_path": "chat.update_streaming_card",
@@ -630,12 +482,40 @@
"cli_path": "chat message update-text-emotion"
},
{
"canonical_path": "chat.update_user_group_alias",
"cli_path": "chat group update-alias"
"canonical_path": "chat.update_notification_off",
"cli_path": "chat mute"
},
{
"canonical_path": "chat.upgrade_group_to_external",
"cli_path": "chat group upgrade-to-external"
"canonical_path": "chat.search_groups",
"cli_path": "chat search"
},
{
"canonical_path": "chat.search_common_groups",
"cli_path": "chat search-common"
},
{
"canonical_path": "chat.set_top_conversation",
"cli_path": "chat set-top"
},
{
"canonical_path": "chat.shortcut_chat_messages",
"cli_path": "chat +chat-messages"
},
{
"canonical_path": "chat.shortcut_messages_send",
"cli_path": "chat +messages-send"
},
{
"canonical_path": "chat.shortcut_messages_send_card",
"cli_path": "chat +messages-send-card"
},
{
"canonical_path": "chat.shortcut_search_msg",
"cli_path": "chat +search-msg"
},
{
"canonical_path": "chat.shortcut_thread_replies",
"cli_path": "chat +thread-replies"
}
]
}
@@ -999,6 +999,19 @@
"reviewed": true,
"runtime_gate": "confirm_delete"
},
"aitable.workflow_edit_example": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
"reviewed": true,
"review_reason": "The command sends empty arguments to aitable/edit_workflow_example and returns the service-provided workflow editing documentation and examples. The operation is read-only and safe to retry.",
"cli_path": "aitable workflow edit-example",
"runtime_gate": "none"
},
"aitable.workflow_enable": {
"interface_ref": {
"product_id": "aitable-helper",
@@ -801,396 +801,6 @@
"cli_path": "chat group upgrade-to-external",
"runtime_gate": "confirm_dangerous"
},
"chat.add_conv_to_categories": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns validation and calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native category command with its real Cobra contract; runtime has no confirmation gate.",
"cli_path": "chat category add-conv",
"runtime_gate": "none"
},
"chat.create_conv_category": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns title validation and calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native category command and preserve the runtime 15-character title validation; runtime has no confirmation gate.",
"cli_path": "chat category create",
"runtime_gate": "none"
},
"chat.delete_conv_category": {
"effect": "destructive",
"risk": "high",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote category-delete helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native category deletion command with its explicit typed --yes confirmation gate.",
"cli_path": "chat category delete",
"runtime_gate": "typed_yes"
},
"chat.remove_conv_from_categories": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns validation and calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native category membership command with its real Cobra contract; runtime has no confirmation gate.",
"cli_path": "chat category remove-conv",
"runtime_gate": "none"
},
"chat.rename_conv_category": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns title validation and calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native category rename command and preserve the runtime 15-character title validation; runtime has no confirmation gate.",
"cli_path": "chat category rename",
"runtime_gate": "none"
},
"chat.grant_permission": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed permission adapter: the executable CLI normalizes grant scope and parameters before calling chat_permission_grant.",
"reviewed": true,
"review_reason": "Publish the documented chat permission grant surface without inventing a runtime confirmation gate.",
"cli_path": "chat chmod",
"runtime_gate": "none"
},
"chat.clear_all_red_point": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native red-point command; runtime has no confirmation gate.",
"cli_path": "chat clear-all-red-point",
"runtime_gate": "none"
},
"chat.clear_conversation_messages": {
"effect": "destructive",
"risk": "high",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote conversation-clear helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native clear-messages command with its explicit typed --yes confirmation gate.",
"cli_path": "chat clear-messages",
"runtime_gate": "typed_yes"
},
"chat.clear_conversation_red_point": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native red-point command; runtime has no confirmation gate.",
"cli_path": "chat clear-red-point",
"runtime_gate": "none"
},
"chat.grant_cross_org_data_access": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed cross-organization permission adapter: the executable CLI assembles scoped chat_permission_grant arguments.",
"reviewed": true,
"review_reason": "Publish the documented cross-organization data authorization command without inventing a runtime confirmation gate.",
"cli_path": "chat data-auth cross-org",
"runtime_gate": "none"
},
"chat.audit_join_group": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI validates supported audit statuses before calling a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented join-audit command and preserve the runtime AuditApprove/AuditDelete restriction.",
"cli_path": "chat group audit-join-validation",
"runtime_gate": "none"
},
"chat.list_my_groups_pagination": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native paginated group-list command.",
"cli_path": "chat group list-all",
"runtime_gate": "none"
},
"chat.list_apply_join_group_records": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native join-validation list command.",
"cli_path": "chat group list-join-validations",
"runtime_gate": "none"
},
"chat.list_group_member_by_ids": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote member lookup helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native batch group-member lookup command.",
"cli_path": "chat group members list-by-ids",
"runtime_gate": "none"
},
"chat.create_group_notice": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI assembles notice scheduling and notification arguments for a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native group notice creation command; runtime has no confirmation gate.",
"cli_path": "chat group notice create",
"runtime_gate": "none"
},
"chat.edit_group_notice": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI assembles notice update arguments for a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native group notice edit command; runtime has no confirmation gate.",
"cli_path": "chat group notice edit",
"runtime_gate": "none"
},
"chat.get_group_notice": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI validates notice IDs before calling a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native group notice detail command.",
"cli_path": "chat group notice get",
"runtime_gate": "none"
},
"chat.list_group_notices": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native group notice list command.",
"cli_path": "chat group notice list",
"runtime_gate": "none"
},
"chat.share_group_invite_url": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI enforces target/receiver exclusivity before calling a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native invite-sharing command; runtime has no confirmation gate.",
"cli_path": "chat group share-invite",
"runtime_gate": "none"
},
"chat.update_user_group_alias": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote personal-alias helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native personal group-alias command.",
"cli_path": "chat group update-alias",
"runtime_gate": "none"
},
"chat.hide_conversation": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote conversation visibility helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native hide-conversation command.",
"cli_path": "chat hide",
"runtime_gate": "none"
},
"chat.list_all_conversations": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native all-conversations list command.",
"cli_path": "chat list-all-conversations",
"runtime_gate": "none"
},
"chat.mark_message_read": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote read-state helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native mark-read command.",
"cli_path": "chat mark-read",
"runtime_gate": "none"
},
"chat.mark_conversation_unread": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote read-state helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native mark-unread command.",
"cli_path": "chat mark-unread",
"runtime_gate": "none"
},
"chat.list_message_emotion_replies": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote emotion-reply helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native batch emotion reply list command.",
"cli_path": "chat message list-emotion-replies",
"runtime_gate": "none"
},
"chat.set_top_message": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote message-top helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native set-top-message command.",
"cli_path": "chat message set-top-msg",
"runtime_gate": "none"
},
"chat.unset_top_message": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote message-top helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native unset-top-message command.",
"cli_path": "chat message unset-top-msg",
"runtime_gate": "none"
},
"chat.update_at_all_notification_off": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote notification preference helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native @all notification preference command.",
"cli_path": "chat mute-at-all",
"runtime_gate": "none"
},
"chat.update_red_env_notification_off": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote notification preference helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native red-envelope notification preference command.",
"cli_path": "chat mute-red-envelope",
"runtime_gate": "none"
},
"chat.translate": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote translation helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native chat text translation command.",
"cli_path": "chat text translate",
"runtime_gate": "none"
},
"chat.shortcut_bot_search": {
"effect": "read",
"risk": "low",
@@ -7,7 +7,7 @@
"channel": "open-source"
},
"coverage": {
"source_tools": 875,
"source_tools": 846,
"matched_tools": 71
},
"tools": {
@@ -2250,6 +2250,26 @@
"dws-schema-live:none (helper/composite; Skill+Cobra)"
]
},
"aitable.workflow_edit_example": {
"agent_summary": "获取 AI 表格工作流编辑文档与 workflow-dsl/v1 示例。",
"use_when": [
"创建或更新工作流前,需要确认最新 workflow-dsl/v1 结构、节点写法或完整示例时"
],
"avoid_when": [
"实际创建工作流用 workflow create;修改已有工作流用 workflow update;查询已发布定义用 workflow get"
],
"examples": [
"dws aitable workflow edit-example"
],
"reviewed": true,
"review_reason": "依据新增 Cobra leaf 和用户提供的 aitable/edit_workflow_example 空参数 MCP 契约审阅选型语义,将该命令限定为 create/update 前的只读文档入口。",
"source_refs": [
"internal/cli/schema_command_registry.json#aitable.workflow_edit_example",
"cobra-help:dws aitable workflow edit-example --help",
"skills/mono/references/products/aitable/aitable-workflow.md",
"mcp-contract:aitable/edit_workflow_example"
]
},
"aitable.workflow_enable": {
"agent_summary": "启用工作流。",
"use_when": [
+16 -615
View File
@@ -150,12 +150,12 @@
]
},
"chat.create_and_send_card": {
"agent_summary": "创建并向群聊或单聊发送通用流式卡片",
"agent_summary": "创建并向群聊或单聊发送互动卡片",
"use_when": [
"需要通用流式卡片交互且已准备接收会话或用户时"
"需要卡片式交互且已准备接收会话或用户时"
],
"avoid_when": [
"只发送普通文本时使用 send 或 send-by-bot;转发现有审批、日历、待办等原生产品卡片时应先取得真实 openMessageId,再使用 message forward"
"只发送普通文本时使用 send 或 send-by-bot"
],
"examples": [
"dws chat message send-card --group <openConversationId>"
@@ -263,7 +263,7 @@
"已知消息、会话和资源 ID,需要保存媒体文件时"
],
"avoid_when": [
"只查看文本消息内容时使用对应消息查询命令;本命令不是发送附件的前置步骤,本地文件发送直接使用 message send --msg-type file --file-path"
"只查看文本消息内容时使用对应消息查询命令"
],
"examples": [
"dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ."
@@ -280,10 +280,10 @@
"chat.forward_message": {
"agent_summary": "把一条已有消息转发到另一个会话",
"use_when": [
"已知同一源会话中的真实 openMessageId 与源、目标会话 ID,需要保留原消息或原生产品卡片时"
"已知源消息与源、目标会话 ID 时"
],
"avoid_when": [
"合并转发多条消息时使用 chat message combine-forward;OA 实例 ID、日历事件 ID、待办任务 ID 等产品对象 ID 不能代替消息 ID"
"合并转发多条消息时使用 chat message combine-forward"
],
"examples": [
"dws chat message forward --src-conversation-id <srcConversationId> --msg-id <openMessageId> --dest-conversation-id <destConversationId>"
@@ -340,7 +340,7 @@
"chat.get_conversation_info": {
"agent_summary": "获取群聊或单聊会话的详细信息",
"use_when": [
"已知群 ID 或单聊用户标识并需要解析会话详情时;--group、--user、--open-dingtalk-id 只能选择一个"
"已知群 ID 或用户标识并需要解析会话详情时"
],
"avoid_when": [
"按群名查找会话时使用 chat search"
@@ -383,7 +383,7 @@
"用户明确指定某个会话,并要读取消息或追溯引用回复中的原消息上下文时"
],
"avoid_when": [
"跨全部会话按时间查询时使用 chat message list-all;关键词搜索或审计应使用 message search,不要拉最近消息后在本地筛选"
"跨全部会话按时间查询时使用 chat message list-all"
],
"examples": [
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50"
@@ -663,7 +663,7 @@
"发送命令返回 openTaskId 后需要确认投递结果时"
],
"avoid_when": [
"没有 message send 真实返回的 openTaskId 时不要使用;openMessageId 和会话 ID 都不能代替 openTaskId"
"没有 openTaskId 或只需查消息内容时不要使用"
],
"examples": [
"dws chat message query-send-status --open-task-id <openTaskId>"
@@ -905,7 +905,7 @@
"chat.reply_personal_message": {
"agent_summary": "引用指定消息发送个人回复",
"use_when": [
"用户要针对某条已有消息进行引用回复,且 conversationId、openMessageId、senderOpenDingTalkId 来自同一条消息时"
"用户要针对某条已有消息进行引用回复时"
],
"avoid_when": [
"无需引用上下文的普通消息使用 chat message send"
@@ -1138,10 +1138,10 @@
"chat.send_personal_message": {
"agent_summary": "以当前用户身份发送群聊或单聊消息",
"use_when": [
"用户明确要以个人身份发送消息时;文本用 --text,图片仅在已有有效 mediaId 时用 --msg-type image --media-id,本地 PDF/DOCX/XLSX 等文件用 --msg-type file --file-path"
"用户明确要以个人身份发送文本或媒体消息时"
],
"avoid_when": [
"机器人身份或 Webhook 发送应使用对应命令;发送审批、日历或待办摘要不会创建对应产品对象"
"机器人身份或 Webhook 发送应使用对应命令"
],
"examples": [
"dws chat message send --group <openConversationId> \"项目已更新\""
@@ -1398,7 +1398,7 @@
"chat.update_group_name": {
"agent_summary": "修改指定群聊的名称",
"use_when": [
"需要把已有群聊改成用户指定的准确名称时;名称不得擅自加前缀、截断或改写,复合任务中重命名后继续完成后续步骤"
"需要给已有群聊重命名时"
],
"avoid_when": [
"只修改个人可见备注时不要使用群名称更新"
@@ -1478,10 +1478,10 @@
"chat.update_streaming_card": {
"agent_summary": "更新已发送流式卡片的内容和状态",
"use_when": [
"已有 message send-card 真实返回的 bizId,并需要追加内容或结束流式输出时;最后一次更新使用完成状态"
"已有 bizId 并需要追加内容或结束流式输出时"
],
"avoid_when": [
"创建新卡片时使用 chat message send-card;openMessageId、OA 实例 ID、日历事件 ID或待办任务 ID 不能代替 bizId"
"创建新卡片时使用 chat message send-card"
],
"examples": [
"dws chat message update-card --biz-id <bizId> --content \"处理完成\" --flow-status 2"
@@ -1596,604 +1596,6 @@
"live-dws-schema:chat.upgrade_group_to_external#FAILED"
]
},
"chat.add_conv_to_categories": {
"agent_summary": "把一个会话加入一个或多个现有自定义分组",
"use_when": [
"已从 category list 取得真实 categoryId,并要把当前账号可访问的会话归入这些分组时"
],
"avoid_when": [
"创建新分组使用 category create;移出分组使用 category remove-conv"
],
"examples": [
"dws chat category add-conv --group <openConversationId> --category-ids 123,456"
],
"reviewed": true,
"review_reason": "依据 nanrun 树形路由与当前 Cobra/多维表参考复核会话分组写入路径;要求使用真实 ID 并在复合任务中继续完成后续步骤。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.add_conv_to_categories",
"cobra-help:dws chat category add-conv",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.create_conv_category": {
"agent_summary": "创建用户自定义会话分组",
"use_when": [
"用户明确要新建手工管理的会话分组,且名称不超过 15 个字符时"
],
"avoid_when": [
"按关键词或成员自动归类应使用 category create-smart;不得静默截断、缩写或改写用户名称"
],
"examples": [
"dws chat category create --title \"项目群\""
],
"reviewed": true,
"review_reason": "迁移 nanrun 的分组标题和复合任务 guidance 到 reviewed selection;保持当前 runtime 的 15 字符校验。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.create_conv_category",
"cobra-help:dws chat category create",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.delete_conv_category": {
"agent_summary": "删除指定的用户自定义会话分组",
"use_when": [
"用户明确要删除已知 categoryId 的自定义分组时"
],
"avoid_when": [
"只想把会话移出分组时使用 category remove-conv;目标不明确时不要删除"
],
"examples": [
"dws chat category delete --category-id 123"
],
"reviewed": true,
"review_reason": "依据当前原生命令与 nanrun 树形 category 分支补齐删除能力的选择边界。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.delete_conv_category",
"cobra-help:dws chat category delete",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.remove_conv_from_categories": {
"agent_summary": "把一个会话从一个或多个自定义分组移出",
"use_when": [
"已知会话和真实 categoryId,需要解除现有分组归属时"
],
"avoid_when": [
"删除整个分组使用 category delete;加入分组使用 category add-conv"
],
"examples": [
"dws chat category remove-conv --group <openConversationId> --category-ids 123,456"
],
"reviewed": true,
"review_reason": "依据当前原生命令与 nanrun 树形 category 分支补齐会话移出分组的选择边界。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.remove_conv_from_categories",
"cobra-help:dws chat category remove-conv",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.rename_conv_category": {
"agent_summary": "重命名用户自定义会话分组",
"use_when": [
"已知 categoryId,且用户给出了不超过 15 个字符的新名称时"
],
"avoid_when": [
"不得为绕过长度限制而截断、缩写或改写;创建新分组使用 category create"
],
"examples": [
"dws chat category rename --category-id 123 --title \"新名称\""
],
"reviewed": true,
"review_reason": "迁移 nanrun 的分组标题和复合任务 guidance 到 reviewed selection;保持当前 runtime 的 15 字符校验。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.rename_conv_category",
"cobra-help:dws chat category rename",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.grant_permission": {
"agent_summary": "为指定 chat scope 和业务参数发起高风险操作授权",
"use_when": [
"目标 chat 操作因行为授权缺失而需要按 scope、目标和时效发起授权时"
],
"avoid_when": [
"不要把它当作实际发送、撤回或群管理命令;跨组织数据读取授权使用 data-auth cross-org"
],
"examples": [
"dws chat chmod chat.message:send --grant-type timed --ttl 24h --permParam openCid=<openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前授权 Cobra、dws-shared 安全规则和树形 data-auth/chmod 分支补齐 Agent 选路。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.grant_permission",
"cobra-help:dws chat chmod",
"skills/multi/dingtalk-chat/SKILL.md"
]
},
"chat.clear_all_red_point": {
"agent_summary": "清除当前用户所有会话的未读红点",
"use_when": [
"用户明确要求全部会话一键已读或清除所有红点时"
],
"avoid_when": [
"只处理一个会话时使用 clear-red-point;本命令不会删除聊天记录"
],
"examples": [
"dws chat clear-all-red-point"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 和 nanrun 会话状态树补齐全局红点清零能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.clear_all_red_point",
"cobra-help:dws chat clear-all-red-point",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.clear_conversation_messages": {
"agent_summary": "清空当前用户在指定会话中的聊天记录",
"use_when": [
"用户明确要求清空某个已知会话的本地聊天记录时"
],
"avoid_when": [
"仅清除未读红点使用 clear-red-point;未确认真实 openConversationId 时不要执行"
],
"examples": [
"dws chat clear-messages --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 和 nanrun 会话状态树补齐清空会话消息的严格目标边界。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.clear_conversation_messages",
"cobra-help:dws chat clear-messages",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.clear_conversation_red_point": {
"agent_summary": "清除指定会话的未读红点",
"use_when": [
"用户只要求把某个已知会话标为已读或清除红点时"
],
"avoid_when": [
"全部会话一键已读使用 clear-all-red-point;本命令不会删除消息"
],
"examples": [
"dws chat clear-red-point --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 和 nanrun 会话状态树补齐单会话红点清理能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.clear_conversation_red_point",
"cobra-help:dws chat clear-red-point",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.grant_cross_org_data_access": {
"agent_summary": "发起 chat 跨组织数据读取授权",
"use_when": [
"跨组织拉取聊天数据因 data scope 缺失,需要为目标组织或全部组织授权时"
],
"avoid_when": [
"发送、撤回和群管理授权使用 chat chmod;普通同组织查询不要预先授权"
],
"examples": [
"dws chat data-auth cross-org --target-org-id 439446171"
],
"reviewed": true,
"review_reason": "依据当前授权 Cobra、dws-shared 跨组织规则和 nanrun data-auth 分支补齐 Agent 选路。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.grant_cross_org_data_access",
"cobra-help:dws chat data-auth cross-org",
"skills/multi/dingtalk-chat/SKILL.md"
]
},
"chat.audit_join_group": {
"agent_summary": "审批一条群聊入群验证记录",
"use_when": [
"已从 list-join-validations 取得真实记录、申请人和邀请人 ID,需要执行 AuditApprove 或 AuditDelete 时"
],
"avoid_when": [
"列出申请使用 list-join-validations;服务端不支持 AuditIgnore、AuditRefuse 或 AuditBlock"
],
"examples": [
"dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <userId> --inviter <userId> --status AuditApprove"
],
"reviewed": true,
"review_reason": "依据 nanrun 的状态 hint 和当前 runtime 支持范围补齐入群审核选择语义。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.audit_join_group",
"cobra-help:dws chat group audit-join-validation",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_my_groups_pagination": {
"agent_summary": "分页拉取当前用户加入的全部群聊",
"use_when": [
"需要完整分页列出我加入的所有群,并沿用 nextCursor 继续读取时"
],
"avoid_when": [
"只查我创建或管理的群使用 group list-my-groups;按关键词找群使用 chat search"
],
"examples": [
"dws chat group list-all --limit 100"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun group 树补齐分页群列表能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_my_groups_pagination",
"cobra-help:dws chat group list-all",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_apply_join_group_records": {
"agent_summary": "分页拉取当前用户相关的入群验证记录",
"use_when": [
"需要查看待处理或历史入群申请,并取得后续审核所需记录 ID 时"
],
"avoid_when": [
"执行审批使用 audit-join-validation;普通群成员列表使用 group members"
],
"examples": [
"dws chat group list-join-validations --limit 20"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun group 树补齐入群验证列表能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_apply_join_group_records",
"cobra-help:dws chat group list-join-validations",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_group_member_by_ids": {
"agent_summary": "按成员 openDingTalkId 批量查询群成员详情",
"use_when": [
"已知群 openConversationId 和一组成员 openDingTalkId,需要批量取成员详情时"
],
"avoid_when": [
"列出整个群成员直接使用 group members --id;不要臆造 members list 子命令"
],
"examples": [
"dws chat group members list-by-ids --id <openConversationId> --users openDingTalkId1,openDingTalkId2"
],
"reviewed": true,
"review_reason": "迁移 nanrun 的群成员 flag guidance 到 reviewed selection,并固定 --id/--users 参数边界。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_group_member_by_ids",
"cobra-help:dws chat group members list-by-ids",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.create_group_notice": {
"agent_summary": "在指定群聊发布即时或定时群公告",
"use_when": [
"用户明确要发布群公告,并已给出群和 Markdown 正文时"
],
"avoid_when": [
"普通聊天消息使用 message send;修改已有公告使用 group notice edit"
],
"examples": [
"dws chat group notice create --group <openConversationId> --content \"今晚 22 点系统维护\""
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun notice 子树补齐群公告创建能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.create_group_notice",
"cobra-help:dws chat group notice create",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.edit_group_notice": {
"agent_summary": "整体替换指定群公告的正文与可选状态",
"use_when": [
"已从公告列表取得真实 dataId,需要修改现有群公告时"
],
"avoid_when": [
"发布新公告使用 group notice create;只查看内容使用 group notice get"
],
"examples": [
"dws chat group notice edit --group <openConversationId> --notice-id <dataId> --content \"更新后的公告\""
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun notice 子树补齐群公告编辑能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.edit_group_notice",
"cobra-help:dws chat group notice edit",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.get_group_notice": {
"agent_summary": "读取指定群公告的详情",
"use_when": [
"已知群和真实公告 dataId,需要查看正文、发布者或统计信息时"
],
"avoid_when": [
"不知道 dataId 时先用 group notice list;不要使用占位符公告 ID"
],
"examples": [
"dws chat group notice get --group <openConversationId> --notice-id <dataId>"
],
"reviewed": true,
"review_reason": "依据 nanrun 的真实 noticeId hint 和当前 Cobra 补齐公告详情选择语义。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.get_group_notice",
"cobra-help:dws chat group notice get",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_group_notices": {
"agent_summary": "分页读取指定群的已发布或定时公告",
"use_when": [
"需要列出群公告并取得后续详情或编辑使用的 dataId 时"
],
"avoid_when": [
"读取单条已知公告使用 group notice get;普通群消息使用 message list"
],
"examples": [
"dws chat group notice list --group <openConversationId> --limit 20"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun notice 子树补齐群公告列表能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_group_notices",
"cobra-help:dws chat group notice list",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.share_group_invite_url": {
"agent_summary": "把一个群的邀请链接分享到目标会话或单聊用户",
"use_when": [
"当前用户已加入源群,且要把邀请链接发给一个目标会话或一个接收人时"
],
"avoid_when": [
"只获取邀请链接使用 group invite-url;--target 与 --receiver 只能选择一个"
],
"examples": [
"dws chat group share-invite --source <sourceConversationId> --target <targetConversationId>"
],
"reviewed": true,
"review_reason": "迁移 nanrun 的 target/receiver 互斥 hint 与源群边界到 reviewed selection。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.share_group_invite_url",
"cobra-help:dws chat group share-invite",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.update_user_group_alias": {
"agent_summary": "设置仅当前用户可见的群备注",
"use_when": [
"用户要修改自己看到的群备注,而不是修改群的公开名称时"
],
"avoid_when": [
"修改所有成员看到的群名使用 group rename;修改自己群昵称使用 group update-nick"
],
"examples": [
"dws chat group update-alias --group <openConversationId> --alias-title \"客户项目群\""
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun group 树补齐群备注和群名/群昵称消歧。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.update_user_group_alias",
"cobra-help:dws chat group update-alias",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.hide_conversation": {
"agent_summary": "从当前用户的会话列表隐藏指定会话",
"use_when": [
"用户明确要隐藏一个已知会话,接受收到新消息后可能再次出现时"
],
"avoid_when": [
"免打扰使用 chat mute;退出群聊使用 group quit;本命令不会删除消息"
],
"examples": [
"dws chat hide --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话状态树补齐隐藏会话能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.hide_conversation",
"cobra-help:dws chat hide",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_all_conversations": {
"agent_summary": "分页获取当前用户的全部单聊和群聊会话",
"use_when": [
"需要完整枚举会话并沿用 nextCursor 翻页时"
],
"avoid_when": [
"只看置顶会话使用 list-top-conversations;只看群聊使用 group list-all"
],
"examples": [
"dws chat list-all-conversations --limit 50"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话树补齐全部会话列表能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_all_conversations",
"cobra-help:dws chat list-all-conversations",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.mark_message_read": {
"agent_summary": "把指定消息及之前的消息标记为已读",
"use_when": [
"已知同一会话中的真实 openMessageId,需要推进已读位置时"
],
"avoid_when": [
"只清除会话红点使用 clear-red-point;查询他人是否已读使用 message read-status"
],
"examples": [
"dws chat mark-read --conversation-id <openConversationId> --message-id <openMessageId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话状态树补齐消息已读写入能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.mark_message_read",
"cobra-help:dws chat mark-read",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.mark_conversation_unread": {
"agent_summary": "把指定会话标记为未读",
"use_when": [
"用户要稍后处理某个已知会话并将其重新标为未读时"
],
"avoid_when": [
"清除红点使用 clear-red-point;查询未读会话使用 message list-unread-conversations"
],
"examples": [
"dws chat mark-unread --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话状态树补齐标记未读能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.mark_conversation_unread",
"cobra-help:dws chat mark-unread",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_message_emotion_replies": {
"agent_summary": "批量读取多条消息的 emoji 与文字表情回应",
"use_when": [
"已从消息查询取得一组真实 openMessageId,需要汇总回应信息时"
],
"avoid_when": [
"添加或移除回应使用 add/remove-emoji 或 add/remove-text-emotion;不要传占位符 ID"
],
"examples": [
"dws chat message list-emotion-replies --msg-ids <openMessageId1>,<openMessageId2>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun message 树补齐批量消息回应读取能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_message_emotion_replies",
"cobra-help:dws chat message list-emotion-replies",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.set_top_message": {
"agent_summary": "把指定消息置顶到会话顶部",
"use_when": [
"已从目标会话取得真实且同源的 openMessageId,需要设置消息置顶时"
],
"avoid_when": [
"置顶整个会话使用 chat set-top;钉住消息使用 message set-pin-msg"
],
"examples": [
"dws chat message set-top-msg --open-conversation-id <openConversationId> --msg-id <openMessageId>"
],
"reviewed": true,
"review_reason": "迁移 nanrun 的真实消息 ID guidance 到 reviewed selection,并与会话置顶和 Pin 消歧。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.set_top_message",
"cobra-help:dws chat message set-top-msg",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.unset_top_message": {
"agent_summary": "取消指定会话中的消息置顶",
"use_when": [
"已知同源的会话和真实 openMessageId,需要取消消息置顶时"
],
"avoid_when": [
"取消会话置顶使用 chat set-top --off;取消 Pin 使用 message unset-pin-msg"
],
"examples": [
"dws chat message unset-top-msg --open-conversation-id <openConversationId> --msg-id <openMessageId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun message 树补齐取消消息置顶的选择边界。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.unset_top_message",
"cobra-help:dws chat message unset-top-msg",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.update_at_all_notification_off": {
"agent_summary": "关闭或恢复指定会话的 @所有人通知",
"use_when": [
"用户只想调整某个会话的 @所有人提醒偏好时"
],
"avoid_when": [
"关闭全部会话通知使用 chat mute;红包通知偏好使用 mute-red-envelope"
],
"examples": [
"dws chat mute-at-all --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话通知树补齐 @all 通知偏好能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.update_at_all_notification_off",
"cobra-help:dws chat mute-at-all",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.update_red_env_notification_off": {
"agent_summary": "关闭或恢复指定会话的红包通知",
"use_when": [
"用户只想调整某个会话的红包消息提醒偏好时"
],
"avoid_when": [
"关闭全部会话通知使用 chat mute;@所有人通知偏好使用 mute-at-all"
],
"examples": [
"dws chat mute-red-envelope --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话通知树补齐红包通知偏好能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.update_red_env_notification_off",
"cobra-help:dws chat mute-red-envelope",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.translate": {
"agent_summary": "把指定聊天文本翻译成目标语言",
"use_when": [
"用户给出文本并明确要求翻译为支持的语言代码时"
],
"avoid_when": [
"翻译文档或文件内容应使用对应产品能力;本命令只处理传入文本"
],
"examples": [
"dws chat text translate --query \"你好世界\" --to en_US"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun text 分支补齐聊天文本翻译能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.translate",
"cobra-help:dws chat text translate",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.shortcut_bot_search": {
"agent_summary": "搜索当前用户自己创建的机器人",
"use_when": [
@@ -3023,8 +2425,7 @@
"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget",
"按群名找群或解析群 openConversationId 时不要使用;改用 +chat-search"
"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"
],
"examples": [
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
@@ -1790,9 +1790,15 @@
"drive.apply_permission --reason": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --reason is a CLI wrapper input and does not publish a direct interface property.",
"drive.apply_permission --role": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --role is a CLI wrapper input and does not publish a direct interface property.",
"drive.apply_permission --users": "Reviewed unpinned adapter: drive.apply_permission has no singular pinned interface_ref; --users is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file --no-resume": "CLI-only transfer-layer flag controlling download resume behaviour; not an MCP interface parameter.",
"drive.download_file --output": "local output path",
"drive.download_file --parallel": "CLI-only transfer-layer flag controlling parallel chunk downloads; not an MCP interface parameter.",
"drive.download_file --part-size": "CLI-only transfer-layer flag controlling download chunk size; not an MCP interface parameter.",
"drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --parallel": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --parallel is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --part-size": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --part-size is a CLI wrapper input and does not publish a direct interface property.",
"drive.download_file_version --version": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --version is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_cover --node": "Reviewed unpinned adapter: drive.get_cover has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
"drive.get_star_list --content-types": "Reviewed unpinned adapter: drive.get_star_list has no singular pinned interface_ref; --content-types is a CLI wrapper input and does not publish a direct interface property.",
-264
View File
@@ -207,270 +207,6 @@ func TestCrossPlatformCoveragePATURLAndMutationCoverageEdges(t *testing.T) {
}
}
func TestCrossPlatformCoveragePATClassification(t *testing.T) {
patErr := &PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`}
if patErr.Error() != patErr.RawJSON || patErr.RawStderr() != patErr.RawJSON || patErr.ExitCode() != ExitCodePermission {
t.Fatalf("PATError contract changed: %#v", patErr)
}
if !IsPATError(patErr) || IsPATError(stderrors.New("plain")) {
t.Fatal("PAT error classification changed")
}
if !IsPATNoPermissionCode("PAT_NO_PERMISSION") || IsPATNoPermissionCode("UNKNOWN") {
t.Fatal("PAT permission code classification changed")
}
if code, ok := lookupCodeIn(map[string]any{
"code": 1,
"errorCode": "PAT_NO_PERMISSION",
}, patNoPermissionCodes); !ok || code != "PAT_NO_PERMISSION" {
t.Fatalf("lookupCodeIn fallback = %q, %v", code, ok)
}
if code, ok := lookupCodeIn(map[string]any{"code": "UNKNOWN"}, patNoPermissionCodes); ok || code != "" {
t.Fatalf("lookupCodeIn unknown = %q, %v", code, ok)
}
for _, tc := range []struct {
body map[string]any
want string
ok bool
}{
{body: map[string]any{"code": "PAT_NO_PERMISSION"}, want: "PAT_NO_PERMISSION", ok: true},
{body: map[string]any{"error_code": "PAT_SCOPE_AUTH_REQUIRED"}, want: "PAT_SCOPE_AUTH_REQUIRED", ok: true},
{body: map[string]any{"code": "UNKNOWN"}},
} {
code, ok := getPATErrorCode(tc.body)
if code != tc.want || ok != tc.ok {
t.Fatalf("getPATErrorCode(%v) = %q, %v", tc.body, code, ok)
}
}
if code, ok := getDWSGatewayErrorCode(map[string]any{"errorCode": "DWS_AUTH_SERVICE_FAILED"}); !ok || code != "DWS_AUTH_SERVICE_FAILED" {
t.Fatalf("gateway code = %q, %v", code, ok)
}
if !isNotLoggedInError(map[string]any{
"error": 1,
"message": "Missing service_id or access_key",
}) {
t.Fatal("missing-login response was not recognized")
}
if isNotLoggedInError(map[string]any{"message": "other"}) {
t.Fatal("ordinary response was recognized as missing login")
}
for _, body := range []map[string]any{
{"error": "failure"},
{"success": false},
{"success": "FALSE"},
} {
if !isBusinessError(body) {
t.Fatalf("business error was not recognized: %#v", body)
}
}
for _, body := range []map[string]any{
{},
{"success": true},
{"success": "true"},
} {
if isBusinessError(body) {
t.Fatalf("successful response was classified as a business error: %#v", body)
}
}
if err := ClassifyToolResultContent(map[string]any{"code": "DWS_SERVICE_UNAUTHORIZED"}); err == nil {
t.Fatal("gateway tool result was not classified")
}
if err := ClassifyToolResultContent(map[string]any{"code": "PAT_BATCH_AUTH_PENDING"}); !IsPATError(err) {
t.Fatalf("PAT tool result = %T %v", err, err)
}
if err := ClassifyToolResultContent(map[string]any{"success": true}); err != nil {
t.Fatalf("successful tool result = %v", err)
}
responseCases := []struct {
name string
text string
kind string
}{
{name: "invalid json", text: "not-json", kind: "nil"},
{name: "gateway", text: `{"code":"DWS_AUTH_SERVICE_FAILED"}`, kind: "error"},
{name: "not logged in", text: `{"message":"Missing service_id or access_key"}`, kind: "error"},
{name: "pat", text: `{"code":"PAT_NO_PERMISSION"}`, kind: "pat"},
{name: "business", text: `{"success":false,"message":"参数错误"}`, kind: "error"},
{name: "success", text: `{"success":true}`, kind: "nil"},
}
for _, tc := range responseCases {
t.Run(tc.name, func(t *testing.T) {
err := ClassifyMCPResponseText(tc.text)
switch tc.kind {
case "nil":
if err != nil {
t.Fatalf("ClassifyMCPResponseText() = %v", err)
}
case "pat":
if !IsPATError(err) {
t.Fatalf("ClassifyMCPResponseText() = %T %v", err, err)
}
default:
if err == nil {
t.Fatal("ClassifyMCPResponseText() returned nil")
}
}
})
}
if !strings.Contains(authExpiredHint(), "auth login") || !strings.Contains(notLoggedInHint(), "auth login") {
t.Fatal("authentication recovery hints lost the login command")
}
if got := ClassifyPatAuthCheck(map[string]any{"code": "PAT_LOW_RISK_NO_PERMISSION"}); got == nil {
t.Fatal("ClassifyPatAuthCheck() returned nil")
}
if got := ClassifyPatAuthCheck(map[string]any{"code": "UNKNOWN"}); got != nil {
t.Fatalf("ClassifyPatAuthCheck() = %#v", got)
}
wrapped := stderrors.Join(stderrors.New("outer"), patErr)
if got := AsPatAuthCheckError(wrapped); got != patErr {
t.Fatalf("AsPatAuthCheckError() = %#v", got)
}
if got := AsPatAuthCheckError(stderrors.New("plain")); got != nil {
t.Fatalf("AsPatAuthCheckError() = %#v", got)
}
}
func TestSuggestBusinessHintChatRecovery(t *testing.T) {
tests := []struct {
name string
body map[string]any
want string
}{
{
name: "missing group role context",
body: map[string]any{
"error": map[string]any{"code": "IM_ERROR", "message": "listRoles null"},
"summary": "context",
"code": "TOP_LEVEL",
},
want: "list-my-groups",
},
{name: "legacy open id spelling", body: map[string]any{"message": "OpendId is not in conversation"}, want: "实际加入"},
{name: "open id outside conversation", body: map[string]any{"message": "OpenId is not in conversation"}, want: "实际加入"},
{name: "operator outside source group", body: map[string]any{"message": "The operator is not in this group chat"}, want: "源群"},
{name: "missing invitation receiver", body: map[string]any{"message": "targetOpenConversationId和receiverUid不能同时为空"}, want: "--receiver"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := SuggestBusinessHint(tc.body); !strings.Contains(got, tc.want) {
t.Errorf("SuggestBusinessHint(%v) = %q, want containing %q", tc.body, got, tc.want)
}
})
}
}
func TestCrossPlatformCoveragePATSerializationAndPolicyEdges(t *testing.T) {
oldHost := hostControlProvider
oldBrowser := patBrowserProvider
t.Cleanup(func() {
SetHostControlProvider(oldHost)
SetPATOpenBrowserProvider(oldBrowser)
})
SetHostControlProvider(func() string { return "" })
if block := HostControlBlock(); block != nil {
t.Fatalf("empty host provider returned %#v", block)
}
SetHostControlProvider(func() string { return "codex" })
SetPATOpenBrowserProvider(func() bool { return false })
out := map[string]any{
"data": map[string]any{
"authUrl": " https://example.test/fe/old#%2FpersonalAuthorization%3FflowId%3Df%26userCode%3Du ",
"callbacks": map[string]any{"owner": "cli"},
},
}
ApplyHostMutations(out)
data := out["data"].(map[string]any)
if data["openBrowser"] != false || data["hostControl"] == nil || data["uri"] == "" {
t.Fatalf("host mutations = %#v", data)
}
if _, ok := data["authUrl"]; ok {
t.Fatalf("authUrl alias was not removed: %#v", data)
}
if _, ok := data["callbacks"]; ok {
t.Fatalf("legacy callbacks were not removed: %#v", data)
}
rawPolicy := cleanPATJSON(map[string]any{
"message": "organization denied",
"scope": "chat.read",
}, "PAT_ORG_POLICY_DENIED")
var policyPayload map[string]any
if err := json.Unmarshal([]byte(rawPolicy), &policyPayload); err != nil {
t.Fatalf("decode policy PAT JSON: %v", err)
}
policyData := policyPayload["data"].(map[string]any)
for key, want := range map[string]any{
"policy": "OPEN_SOURCE_ORG_SCOPE_FORBIDDEN",
"message": "organization denied",
"action": "contact_org_admin",
"openBrowser": false,
"retryable": false,
} {
if got := policyData[key]; got != want {
t.Fatalf("policy data %s = %#v, want %#v", key, got, want)
}
}
if !strings.Contains(policyData["hint"].(string), "organization denied") {
t.Fatalf("policy hint = %#v", policyData["hint"])
}
rawDefault := cleanPATJSON(map[string]any{}, "PAT_ORG_POLICY_DENIED")
var defaultPayload map[string]any
if err := json.Unmarshal([]byte(rawDefault), &defaultPayload); err != nil {
t.Fatalf("decode default policy PAT JSON: %v", err)
}
defaultData := defaultPayload["data"].(map[string]any)
if !strings.Contains(defaultData["hint"].(string), "组织策略") {
t.Fatalf("default policy hint = %#v", defaultData["hint"])
}
prepopulated := map[string]any{"data": map[string]any{
"policy": "CUSTOM",
"message": "existing",
"hint": "existing hint",
}}
applyOrgPolicyDeniedHint(prepopulated, map[string]any{"message": "ignored"})
prepopulatedData := prepopulated["data"].(map[string]any)
if prepopulatedData["policy"] != "CUSTOM" ||
prepopulatedData["message"] != "existing" ||
prepopulatedData["hint"] != "existing hint" {
t.Fatalf("prepopulated policy fields changed: %#v", prepopulatedData)
}
if got := stringValue(map[string]any{
"number": 1,
"blank": " ",
"value": " kept ",
}, "number", "blank", "value"); got != "kept" {
t.Fatalf("stringValue fallback = %q", got)
}
if got := stringValue(map[string]any{"blank": " "}, "blank", "missing"); got != "" {
t.Fatalf("stringValue empty = %q", got)
}
cleaned := stripClassFields(map[string]any{
"class": "top",
"items": []any{
map[string]any{"class": "nested", "keep": "yes"},
"scalar",
},
}).(map[string]any)
if _, ok := cleaned["class"]; ok {
t.Fatalf("top-level class was retained: %#v", cleaned)
}
items := cleaned["items"].([]any)
nested := items[0].(map[string]any)
if _, ok := nested["class"]; ok || nested["keep"] != "yes" || items[1] != "scalar" {
t.Fatalf("nested class cleanup = %#v", cleaned)
}
}
func mustParseURLForTest(t *testing.T, raw string) *url.URL {
t.Helper()
parsed, err := url.Parse(raw)
+14 -53
View File
@@ -42,24 +42,23 @@ const (
// Error is the structured repository-local error model for the Go rewrite.
type Error struct {
Category Category
Message string
Operation string
ServerKey string
Retryable bool
Category Category
Message string
Operation string
ServerKey string
Retryable bool
RetryableSet bool
RetryAfterSeconds *int64
NextRetryAt *time.Time
Reason string
Hint string
Actions []string
Examples []string
AvailableFlags []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
Reason string
Hint string
Actions []string
AvailableFlags []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
}
func (e *Error) Error() string {
@@ -170,22 +169,6 @@ func WithActions(actions ...string) Option {
}
}
// WithExamples records copyable command examples for recovery.
func WithExamples(examples ...string) Option {
return func(err *Error) {
out := make([]string, 0, len(examples))
for _, example := range examples {
if strings.TrimSpace(example) == "" {
continue
}
out = append(out, example)
}
if len(out) > 0 {
err.Examples = out
}
}
}
// WithAvailableFlags records visible local flag names for agent recovery.
func WithAvailableFlags(names ...string) Option {
return func(err *Error) {
@@ -326,12 +309,7 @@ func PrintJSON(w io.Writer, err error) error {
}
if len(typed.Actions) > 0 {
errorPayload["actions"] = typed.Actions
errorPayload["suggested_actions"] = typed.Actions
}
if len(typed.Examples) > 0 {
errorPayload["examples"] = typed.Examples
}
errorPayload["error_message"] = typed.Message
if len(typed.AvailableFlags) > 0 {
errorPayload["available_flags"] = typed.AvailableFlags
}
@@ -410,23 +388,6 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
return writeErr
}
if len(typed.Examples) > 0 {
lines := []string{
"错误信息:" + typed.Message,
"原因:" + typed.Reason,
"建议操作:",
}
for i, action := range typed.Actions {
lines = append(lines, fmt.Sprintf("%d. %s", i+1, action))
}
lines = append(lines, "示例:")
for i, example := range typed.Examples {
lines = append(lines, fmt.Sprintf("%d. %s", i+1, example))
}
_, writeErr := fmt.Fprintln(w, strings.Join(lines, "\n"))
return writeErr
}
// Line 1: Error summary
lines := []string{
fmt.Sprintf("%s %s", tui.StateMark("error"), tui.Danger(fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message))),
-40
View File
@@ -192,46 +192,6 @@ func TestPrintJSON_AvailableFlags(t *testing.T) {
}
}
func TestPrintJSON_FourPartGuidance(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewValidation(
"缺少参数",
WithReason("必须提供时间范围"),
WithActions("补充开始时间", "补充结束时间"),
WithExamples(`dws chat message list-all --start "..." --end "..." --format json`),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
for _, want := range []string{`"error_message"`, `"reason"`, `"suggested_actions"`, `"examples"`} {
if !strings.Contains(got, want) {
t.Fatalf("four-part JSON output missing %s: %q", want, got)
}
}
}
func TestPrintHuman_FourPartGuidance(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintHuman(&b, NewValidation(
"缺少参数",
WithReason("必须提供时间范围"),
WithActions("补充开始和结束时间"),
WithExamples(`dws chat message list-all --start "..." --end "..."`),
)); err != nil {
t.Fatalf("PrintHuman() error = %v", err)
}
got := b.String()
for _, want := range []string{"错误信息:", "原因:", "建议操作:", "示例:"} {
if !strings.Contains(got, want) {
t.Fatalf("four-part human output missing %q: %q", want, got)
}
}
}
func TestPrintHuman(t *testing.T) {
t.Parallel()
-28
View File
@@ -308,13 +308,6 @@ func notLoggedInHint() string {
return "请先登录:dws auth login"
}
// SuggestBusinessHint returns an actionable recovery hint for a parsed MCP
// business-error payload. Runtime callers share this entry point so product
// helpers and the generic runner do not drift.
func SuggestBusinessHint(body map[string]any) string {
return suggestForBusinessErrorText(body)
}
func suggestForBusinessErrorText(body map[string]any) string {
msg := ""
if v, ok := body["errorMsg"].(string); ok {
@@ -323,19 +316,6 @@ func suggestForBusinessErrorText(body map[string]any) string {
msg = v
} else if v, ok := body["error"].(string); ok {
msg = v
} else if nested, ok := body["error"].(map[string]any); ok {
if code, ok := nested["code"].(string); ok {
msg = code
}
if message, ok := nested["message"].(string); ok {
msg = strings.TrimSpace(msg + " " + message)
}
}
if summary, ok := body["summary"].(string); ok {
msg = strings.TrimSpace(msg + " " + summary)
}
if code, ok := body["code"].(string); ok {
msg = strings.TrimSpace(msg + " " + code)
}
switch {
case strings.Contains(msg, "搜索内容不能为空"):
@@ -346,14 +326,6 @@ func suggestForBusinessErrorText(body map[string]any) string {
return "API rate limit exceeded, wait a moment and retry"
case strings.Contains(msg, "参数错误") || strings.Contains(msg, "param error"):
return "Check input parameters. Use --help for available flags"
case strings.Contains(msg, "listRoles null"):
return "当前群的群身份或权限上下文不可用。请先用 dws chat group list-my-groups --format json 选择当前账号实际加入或管理的群,再核对群成员与权限。"
case strings.Contains(msg, "OpendId is not in conversation") || strings.Contains(msg, "OpenId is not in conversation"):
return "当前账号不在该会话中。请先用 dws chat group list-my-groups --format json 选择实际加入的群,并重新获取该会话中的真实 OpendId。"
case strings.Contains(msg, "The operator is not in this group chat"):
return "当前操作者不在源群中。请重新选择当前账号已加入的群,或先完成入群;不要只替换接收方后重复原命令。"
case strings.Contains(msg, "targetOpenConversationId和receiverUid不能同时为空"):
return "分享群邀请链接必须提供接收目标:群到群使用 --target,群到人使用 --receiver;同时确认 --source 是当前操作者已加入的源群。"
default:
return "MCP tool returned a business error; check parameters and refer to skill documentation."
}
+3 -1
View File
@@ -25,7 +25,9 @@ import (
)
// MetaFileName is the on-disk name of the bus metadata file. It lives
// alongside bus.lock and bus.sock inside the bus working directory.
// alongside bus.lock inside the bus working directory. Unix bus sockets live
// in a private per-user runtime directory so shared config filesystems do not
// need socket support.
const MetaFileName = "bus.meta"
// Meta is the JSON document written once at bus startup. Its primary
+3 -3
View File
@@ -30,9 +30,9 @@ import (
type SpawnFunc func(SpawnConfig) (pid int, err error)
// DiscoverConfig describes one discover attempt. WorkDir holds bus.lock and
// usually (on Unix) bus.sock — see dwsevent.IPCEndpoint for the short-path
// fallback when WorkDir is too deep; the caller must mkdir it with
// pkg/config.DirPerm beforehand.
// persistent bus metadata; Unix sockets live in a private per-user runtime
// directory so WorkDir may reside on a shared filesystem without socket
// support. The caller must mkdir WorkDir with pkg/config.DirPerm beforehand.
type DiscoverConfig struct {
WorkDir string
IPCEndpoint string
+2 -2
View File
@@ -69,8 +69,8 @@ type BusEntry struct {
// IPCEndpoint returns the IPC endpoint for this entry. Delegates to
// dwsevent.IPCEndpoint so status/stop dial exactly where consume and the
// bus daemon bound (including the short-path fallback when WorkDir is too
// deep for sun_path).
// bus daemon bound (a private per-user runtime path on Unix and a named pipe
// on Windows).
func (e BusEntry) IPCEndpoint() string {
hash := e.ClientIDHash
if e.IdentityHash != "" {
+36 -14
View File
@@ -17,8 +17,16 @@ import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
)
const eventRuntimeDirPrefix = "dws-event-"
func currentUserID() string {
return strconv.Itoa(os.Geteuid())
}
// MaxUnixSocketPath returns the longest Unix socket path accepted by
// bind/connect on this OS (Go rejects longer names with EINVAL before
// the syscall). sockaddr_un.sun_path is 104 bytes on darwin and the
@@ -35,17 +43,19 @@ func maxUnixSocketPath(goos string) int {
}
// IPCEndpoint returns the bus IPC endpoint for one identity: a Named Pipe
// name on Windows, otherwise bus.sock inside workDir.
// name on Windows, otherwise a deterministic Unix socket under a private
// per-user runtime directory.
//
// The canonical Unix location is <workDir>/bus.sock, but workDir derives
// from the config dir, which can be arbitrarily deep (e.g. dwssb sandboxes
// use ~/.dwssb/sandboxes/<name>/config/...). When the canonical path would
// exceed the OS sun_path limit, the socket falls back to a short
// deterministic path under os.TempDir keyed by a hash of workDir, so every
// process (consume parent, forked _bus child, status/stop tooling) that
// derives the endpoint from the same workDir agrees on the location.
// bus.lock / bus.meta / bus.log always stay in workDir — only the socket
// moves.
// Unix sockets must live on a local filesystem that supports bind(2).
// Config directories may reside on NFS, CSI, FUSE, or other shared mounts
// that reject Unix socket creation with ENOTSUPP. On Unix, the endpoint uses
// XDG_RUNTIME_DIR when it is absolute and short enough; otherwise it falls
// back to a per-UID directory under os.TempDir. The transport creates and
// validates that directory as owner-only before listening or dialing. The
// socket name is keyed by a hash of workDir so every process (consume parent,
// forked _bus child, status/stop tooling) that derives the endpoint from the
// same workDir agrees on the location. bus.lock / bus.meta / bus.log always
// stay in workDir.
//
// This is the single source of truth for endpoint derivation; the cobra
// layer and busctl must not re-implement the shape.
@@ -60,9 +70,21 @@ func ipcEndpointForOS(goos, workDir, editionName string, sourceKind SourceKind,
if goos == "windows" {
return `\\.\pipe\dws-event-` + editionName + "-" + string(sourceKind) + "-" + identityHash
}
sock := filepath.Join(workDir, "bus.sock")
if len(sock) <= maxUnixSocketPath(goos) {
return sock
return unixSocketEndpoint(goos, workDir, strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")), os.TempDir())
}
func unixSocketEndpoint(goos, workDir, runtimeDir, tempDir string) string {
socketName := "dws-evt-" + IdentityHash(workDir) + ".sock"
userDirName := eventRuntimeDirPrefix + currentUserID()
if filepath.IsAbs(runtimeDir) {
candidate := filepath.Join(runtimeDir, userDirName, socketName)
if len(candidate) <= maxUnixSocketPath(goos) {
return candidate
}
}
return filepath.Join(os.TempDir(), "dws-evt-"+IdentityHash(workDir)+".sock")
fallback := filepath.Join(tempDir, userDirName, socketName)
if len(fallback) <= maxUnixSocketPath(goos) {
return fallback
}
return filepath.Join("/tmp", userDirName, socketName)
}
+19 -1
View File
@@ -14,6 +14,7 @@
package event
import (
"os"
"path/filepath"
"strings"
"testing"
@@ -33,10 +34,27 @@ func TestCrossPlatformCoverageEndpointPortableCoverageEdges(t *testing.T) {
if got := ipcEndpointForOS("windows", "ignored", "open", "", "hash"); got != `\\.\pipe\dws-event-open-app_stream-hash` {
t.Fatalf("Windows endpoint = %q", got)
}
if got := ipcEndpointForOS("darwin", "short", "open", SourceKindPersonalStream, "hash"); got != filepath.Join("short", "bus.sock") {
runtimeRoot := filepath.VolumeName(os.TempDir()) + string(filepath.Separator)
runtimeDir := filepath.Join(runtimeRoot, "dws-xdg-runtime")
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
workDir := "portable-xdg-workdir"
wantXDG := filepath.Join(runtimeDir, eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
if got := ipcEndpointForOS("linux", workDir, "open", SourceKindPersonalStream, "hash"); got != wantXDG {
t.Fatalf("XDG Unix endpoint = %q, want %q", got, wantXDG)
}
t.Setenv("XDG_RUNTIME_DIR", "")
if got := ipcEndpointForOS("darwin", "short", "open", SourceKindPersonalStream, "hash"); got != filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash("short")+".sock") {
t.Fatalf("short Unix endpoint = %q", got)
}
if got := ipcEndpointForOS("darwin", strings.Repeat("x", 200), "open", SourceKindAppStream, "hash"); !strings.Contains(got, "dws-evt-") {
t.Fatalf("long Unix endpoint = %q", got)
}
longTempDir := filepath.Join(string(filepath.Separator), strings.Repeat("long-temp-root", 20))
wantShortFallback := filepath.Join("/tmp", eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
if got := unixSocketEndpoint("darwin", workDir, "", longTempDir); got != wantShortFallback {
t.Fatalf("overlong temp endpoint = %q, want %q", got, wantShortFallback)
}
}
+57 -5
View File
@@ -23,6 +23,7 @@ import (
)
func TestEndpointPlatformVariants(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "")
if maxUnixSocketPath("linux") != 107 || maxUnixSocketPath("darwin") != 103 {
t.Fatal("Unix socket limits changed")
}
@@ -31,7 +32,7 @@ func TestEndpointPlatformVariants(t *testing.T) {
t.Fatalf("Windows pipe = %q", pipe)
}
short := ipcEndpointForOS("darwin", "/tmp/events", "open", SourceKindPersonalStream, "hash")
if short != filepath.Join("/tmp/events", "bus.sock") {
if short != filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash("/tmp/events")+".sock") {
t.Fatalf("short Unix endpoint = %q", short)
}
long := ipcEndpointForOS("darwin", "/"+strings.Repeat("deep/", 40), "open", SourceKindAppStream, "hash")
@@ -40,16 +41,40 @@ func TestEndpointPlatformVariants(t *testing.T) {
}
}
func TestIPCEndpointShortWorkDirUsesCanonicalPath(t *testing.T) {
workDir := "/tmp/dws/events/open/app_stream/aabbccdd00112233"
func TestIPCEndpointUsesXDGUserRuntimeDir(t *testing.T) {
tempRoot, err := filepath.EvalSymlinks("/tmp")
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
runtimeDir, err := os.MkdirTemp(tempRoot, "dws-xdg-")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(runtimeDir) })
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
workDir := "/shared/events/open/app_stream/aabbccdd00112233"
got := IPCEndpoint(workDir, "open", SourceKindAppStream, "aabbccdd00112233")
want := filepath.Join(workDir, "bus.sock")
want := filepath.Join(runtimeDir, eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
if got != want {
t.Fatalf("IPCEndpoint = %q, want %q", got, want)
}
}
func TestIPCEndpointLongWorkDirFallsBackUnderTempDir(t *testing.T) {
func TestIPCEndpointWithoutXDGUsesPerUserLocalTempDir(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "")
workDir := "/tmp/dws/events/open/app_stream/aabbccdd00112233"
got := IPCEndpoint(workDir, "open", SourceKindAppStream, "aabbccdd00112233")
want := filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
if got != want {
t.Fatalf("IPCEndpoint = %q, want %q", got, want)
}
if strings.HasPrefix(got, workDir) {
t.Fatalf("IPCEndpoint = %q, want endpoint outside workDir", got)
}
}
func TestIPCEndpointLongWorkDirUsesLocalTempDir(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "")
// Mirrors the dwssb sandbox layout that produced a 111-byte socket
// path — over macOS's 103-byte usable sun_path budget.
workDir := "/Users/zhengyubai/.dwssb/sandboxes/event-subscribe/config/events/open/personal_stream/3928ce0fb4860a52"
@@ -66,6 +91,7 @@ func TestIPCEndpointLongWorkDirFallsBackUnderTempDir(t *testing.T) {
}
func TestIPCEndpointFallbackIsDeterministicPerWorkDir(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "")
long := strings.Repeat("x", 120)
a := IPCEndpoint("/base/"+long+"/one", "open", SourceKindPersonalStream, "hash")
b := IPCEndpoint("/base/"+long+"/one", "open", SourceKindPersonalStream, "hash")
@@ -77,3 +103,29 @@ func TestIPCEndpointFallbackIsDeterministicPerWorkDir(t *testing.T) {
t.Fatalf("different workDirs collided on endpoint %q", a)
}
}
func TestIPCEndpointLongXDGPathFallsBackToTempDir(t *testing.T) {
t.Setenv("XDG_RUNTIME_DIR", "/"+strings.Repeat("runtime/", 30))
workDir := "/shared/events/open/personal_stream/aabbccdd00112233"
got := ipcEndpointForOS("linux", workDir, "open", SourceKindPersonalStream, "hash")
wantPrefix := filepath.Join(os.TempDir(), eventRuntimeDirPrefix+currentUserID()) + string(filepath.Separator)
if !strings.HasPrefix(got, wantPrefix) {
t.Fatalf("IPCEndpoint = %q, want fallback under %q", got, wantPrefix)
}
if len(got) > maxUnixSocketPath("linux") {
t.Fatalf("fallback path still too long: %d > %d (%q)", len(got), maxUnixSocketPath("linux"), got)
}
}
func TestIPCEndpointLongTempDirUsesShortSystemFallback(t *testing.T) {
workDir := "/shared/events/open/personal_stream/aabbccdd00112233"
longTempDir := "/" + strings.Repeat("long-temp-root/", 20)
got := unixSocketEndpoint("linux", workDir, "", longTempDir)
want := filepath.Join("/tmp", eventRuntimeDirPrefix+currentUserID(), "dws-evt-"+IdentityHash(workDir)+".sock")
if got != want {
t.Fatalf("IPCEndpoint = %q, want short fallback %q", got, want)
}
if len(got) > maxUnixSocketPath("linux") {
t.Fatalf("short fallback path too long: %d > %d (%q)", len(got), maxUnixSocketPath("linux"), got)
}
}
@@ -73,6 +73,83 @@ func TestCrossPlatformCoverageUnixListenErrorCoverage(t *testing.T) {
}
}
func TestCrossPlatformCoverageUnixSocketDirectoryErrorCoverage(t *testing.T) {
oldLstat, oldRuntimeStat, oldMkdir := lstatSocketPath, statSocketRuntimeRoot, mkdirSocketDir
t.Cleanup(func() {
lstatSocketPath, statSocketRuntimeRoot, mkdirSocketDir = oldLstat, oldRuntimeStat, oldMkdir
})
wantErr := errors.New("synthetic socket directory failure")
if err := ensureSocketDir("relative/bus.sock", true); err == nil || !strings.Contains(err.Error(), "must be absolute") {
t.Fatalf("relative socket path error = %v", err)
}
root := shortSecureTempDir(t)
missingRootPath := filepath.Join(root, "missing-root", "dws-event-test", "bus.sock")
if err := ensureSocketDir(missingRootPath, false); err == nil || !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing runtime root error = %v", err)
}
rootInfo, err := oldLstat(root)
if err != nil {
t.Fatalf("lstat secure root: %v", err)
}
lstatSocketPath = func(path string) (os.FileInfo, error) {
if path == "/tmp" {
return fileInfoWithMode{FileInfo: rootInfo, mode: os.ModeSymlink | 0o777}, nil
}
return oldLstat(path)
}
statSocketRuntimeRoot = func(path string) (os.FileInfo, error) {
if path == "/tmp" {
return nil, wantErr
}
return oldRuntimeStat(path)
}
if err := ensureSocketDir("/tmp/dws-event-coverage/bus.sock", false); !errors.Is(err, wantErr) {
t.Fatalf("runtime root resolution error = %v", err)
}
lstatSocketPath, statSocketRuntimeRoot = oldLstat, oldRuntimeStat
rootFile := filepath.Join(root, "runtime-root-file")
if err := os.WriteFile(rootFile, []byte("not a directory"), 0o600); err != nil {
t.Fatalf("write runtime root file: %v", err)
}
if err := ensureSocketDir(filepath.Join(rootFile, "dws-event-test", "bus.sock"), false); err == nil || !strings.Contains(err.Error(), "runtime root is not a directory") {
t.Fatalf("non-directory runtime root error = %v", err)
}
mkdirSocketDir = func(string, os.FileMode) error { return wantErr }
if err := ensureSocketDir(filepath.Join(root, "mkdir-failure", "bus.sock"), true); !errors.Is(err, wantErr) {
t.Fatalf("socket directory creation error = %v", err)
}
mkdirSocketDir = oldMkdir
if err := ensureSocketDir(filepath.Join(root, "missing-socket-dir", "bus.sock"), false); err == nil || !errors.Is(err, os.ErrNotExist) {
t.Fatalf("missing socket directory error = %v", err)
}
withoutOwner := fileInfoWithoutOwner{FileInfo: rootInfo}
if err := validateSocketRuntimeRoot(root, withoutOwner, uint32(os.Geteuid())); err == nil || !strings.Contains(err.Error(), "owner") {
t.Fatalf("runtime root owner error = %v", err)
}
if err := validatePrivateSocketDir(root, withoutOwner, uint32(os.Geteuid())); err == nil || !strings.Contains(err.Error(), "owner") {
t.Fatalf("socket directory owner error = %v", err)
}
}
type fileInfoWithMode struct {
os.FileInfo
mode os.FileMode
}
func (f fileInfoWithMode) Mode() os.FileMode { return f.mode }
func (f fileInfoWithMode) IsDir() bool { return f.mode.IsDir() }
type fileInfoWithoutOwner struct{ os.FileInfo }
func (fileInfoWithoutOwner) Sys() any { return struct{}{} }
type stubNetListener struct {
close func() error
}
+98 -5
View File
@@ -16,9 +16,12 @@
package transport
import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"syscall"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
@@ -30,10 +33,13 @@ type unixListener struct {
}
var (
statSocket = os.Stat
removeSocket = os.Remove
listenUnix = net.Listen
chmodSocket = os.Chmod
statSocket = os.Stat
removeSocket = os.Remove
listenUnix = net.Listen
chmodSocket = os.Chmod
lstatSocketPath = os.Lstat
statSocketRuntimeRoot = os.Stat
mkdirSocketDir = os.Mkdir
)
func (u *unixListener) Accept() (net.Conn, error) { return u.l.Accept() }
@@ -56,11 +62,95 @@ func checkSocketPath(path string) error {
return nil
}
// ensureSocketDir makes the socket's immediate parent an owner-only
// directory and rejects unsafe pre-existing paths. The parent of that
// directory must itself either be private to the effective user (for
// XDG_RUNTIME_DIR and macOS temporary roots) or sticky (for Linux /tmp), so
// another user cannot rename the private directory out from under us.
func ensureSocketDir(path string, create bool) error {
if !filepath.IsAbs(path) {
return fmt.Errorf("transport: unix socket path must be absolute: %s", path)
}
dir := filepath.Dir(path)
root := filepath.Dir(dir)
rootInfo, err := lstatSocketPath(root)
if err != nil {
return fmt.Errorf("transport: inspect socket runtime root %s: %w", root, err)
}
// macOS exposes the system /tmp as a root-owned symlink to /private/tmp.
// Follow only that well-known alias, then apply the same ownership/sticky
// validation to its target. Arbitrary runtime-root symlinks remain rejected.
if rootInfo.Mode()&os.ModeSymlink != 0 && filepath.Clean(root) == "/tmp" {
rootInfo, err = statSocketRuntimeRoot(root)
if err != nil {
return fmt.Errorf("transport: resolve socket runtime root %s: %w", root, err)
}
}
if err := validateSocketRuntimeRoot(root, rootInfo, uint32(os.Geteuid())); err != nil {
return err
}
if create {
if err := mkdirSocketDir(dir, config.DirPerm); err != nil && !errors.Is(err, os.ErrExist) {
return fmt.Errorf("transport: create socket directory %s: %w", dir, err)
}
}
dirInfo, err := lstatSocketPath(dir)
if err != nil {
return fmt.Errorf("transport: inspect socket directory %s: %w", dir, err)
}
return validatePrivateSocketDir(dir, dirInfo, uint32(os.Geteuid()))
}
func validateSocketRuntimeRoot(path string, info os.FileInfo, effectiveUID uint32) error {
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("transport: socket runtime root is not a directory: %s", path)
}
owner, err := fileOwnerUID(info)
if err != nil {
return fmt.Errorf("transport: inspect socket runtime root owner %s: %w", path, err)
}
privateOwnerRoot := owner == effectiveUID && info.Mode().Perm()&0o022 == 0
stickyRoot := info.Mode()&os.ModeSticky != 0
if !privateOwnerRoot && !stickyRoot {
return fmt.Errorf("transport: socket runtime root is neither private nor sticky: %s", path)
}
return nil
}
func validatePrivateSocketDir(path string, info os.FileInfo, effectiveUID uint32) error {
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("transport: socket directory is not a directory: %s", path)
}
owner, err := fileOwnerUID(info)
if err != nil {
return fmt.Errorf("transport: inspect socket directory owner %s: %w", path, err)
}
if owner != effectiveUID {
return fmt.Errorf("transport: socket directory %s is owned by uid %d, want %d", path, owner, effectiveUID)
}
if perm := info.Mode().Perm(); perm != config.DirPerm {
return fmt.Errorf("transport: socket directory %s has permissions %04o, want %04o", path, perm, config.DirPerm)
}
return nil
}
func fileOwnerUID(info os.FileInfo) (uint32, error) {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, errors.New("stat result does not expose an owner uid")
}
return stat.Uid, nil
}
func listen(path string) (Listener, error) {
if err := checkSocketPath(path); err != nil {
return nil, err
}
// Stale socket cleanup. Caller holds bus.lock so this is race-safe.
if err := ensureSocketDir(path, true); err != nil {
return nil, err
}
// The private per-user parent excludes other users. The caller's bus.lock
// serializes stale-socket cleanup for processes using the same WorkDir.
if _, err := statSocket(path); err == nil {
if err := removeSocket(path); err != nil {
return nil, fmt.Errorf("transport: remove stale socket %s: %w", path, err)
@@ -82,5 +172,8 @@ func dial(path string) (net.Conn, error) {
if err := checkSocketPath(path); err != nil {
return nil, err
}
if err := ensureSocketDir(path, false); err != nil {
return nil, err
}
return net.Dial("unix", path)
}
+138 -5
View File
@@ -21,12 +21,32 @@ import (
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
)
func shortSecureTempDir(t *testing.T) string {
t.Helper()
tempRoot, err := filepath.EvalSymlinks("/tmp")
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
dir, err := os.MkdirTemp(tempRoot, "dws-et-")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
if err := os.Chmod(dir, 0o700); err != nil {
t.Fatalf("chmod temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
return dir
}
func TestListen_DialRoundtrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "bus.sock")
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
l, err := Listen(path)
if err != nil {
t.Fatalf("Listen: %v", err)
@@ -87,7 +107,7 @@ func TestListen_DialRoundtrip(t *testing.T) {
}
func TestListen_StaleSocketCleanup(t *testing.T) {
path := filepath.Join(t.TempDir(), "bus.sock")
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
// Pre-create a stale file at path (not a valid socket).
if err := os.WriteFile(path, []byte("stale"), 0o600); err != nil {
t.Fatalf("pre-create: %v", err)
@@ -99,8 +119,121 @@ func TestListen_StaleSocketCleanup(t *testing.T) {
defer l.Close()
}
func TestCrossPlatformCoverageListenCreatesPrivateSocketDirectory(t *testing.T) {
dir := filepath.Join(shortSecureTempDir(t), "dws-event-test")
path := filepath.Join(dir, "bus.sock")
l, err := Listen(path)
if err != nil {
t.Fatalf("Listen: %v", err)
}
defer l.Close()
st, err := os.Stat(dir)
if err != nil {
t.Fatalf("stat socket directory: %v", err)
}
if mode := st.Mode().Perm(); mode != 0o700 {
t.Fatalf("socket directory mode = %04o, want 0700", mode)
}
}
func TestCrossPlatformCoverageListenRejectsWorldAccessibleSocketDirectory(t *testing.T) {
dir := filepath.Join(shortSecureTempDir(t), "dws-event-test")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatalf("chmod: %v", err)
}
if _, err := Listen(filepath.Join(dir, "bus.sock")); err == nil || !strings.Contains(err.Error(), "want 0700") {
t.Fatalf("Listen error = %v, want 0700 directory rejection", err)
}
}
func TestCrossPlatformCoverageDialRejectsWorldAccessibleSocketDirectory(t *testing.T) {
dir := filepath.Join(shortSecureTempDir(t), "dws-event-test")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatalf("chmod: %v", err)
}
if _, err := Dial(filepath.Join(dir, "bus.sock")); err == nil || !strings.Contains(err.Error(), "want 0700") {
t.Fatalf("Dial error = %v, want 0700 directory rejection", err)
}
}
func TestCrossPlatformCoverageListenRejectsSymlinkSocketDirectory(t *testing.T) {
root := shortSecureTempDir(t)
target := filepath.Join(root, "target")
if err := os.Mkdir(target, 0o700); err != nil {
t.Fatalf("mkdir target: %v", err)
}
link := filepath.Join(root, "dws-event-test")
if err := os.Symlink(target, link); err != nil {
t.Fatalf("symlink: %v", err)
}
if _, err := Listen(filepath.Join(link, "bus.sock")); err == nil || !strings.Contains(err.Error(), "not a directory") {
t.Fatalf("Listen error = %v, want symlink directory rejection", err)
}
}
func TestCrossPlatformCoverageValidatePrivateSocketDirRejectsDifferentOwner(t *testing.T) {
dir := shortSecureTempDir(t)
st, err := os.Lstat(dir)
if err != nil {
t.Fatalf("lstat: %v", err)
}
otherUID := uint32(os.Geteuid() + 1)
if err := validatePrivateSocketDir(dir, st, otherUID); err == nil || !strings.Contains(err.Error(), "is owned by uid") {
t.Fatalf("validatePrivateSocketDir error = %v, want owner mismatch", err)
}
}
func TestCrossPlatformCoverageListenRejectsUntrustedRuntimeRoot(t *testing.T) {
root := filepath.Join(shortSecureTempDir(t), "untrusted")
if err := os.Mkdir(root, 0o700); err != nil {
t.Fatalf("mkdir root: %v", err)
}
if err := os.Chmod(root, 0o777); err != nil {
t.Fatalf("chmod root: %v", err)
}
dir := filepath.Join(root, "dws-event-test")
if err := os.Mkdir(dir, 0o700); err != nil {
t.Fatalf("mkdir socket dir: %v", err)
}
if _, err := Listen(filepath.Join(dir, "bus.sock")); err == nil || !strings.Contains(err.Error(), "neither private nor sticky") {
t.Fatalf("Listen error = %v, want untrusted runtime root rejection", err)
}
}
func TestCrossPlatformCoverageListenSharedWorkDirUsesLocalSecureRuntimeEndpoint(t *testing.T) {
root := shortSecureTempDir(t)
runtimeDir := filepath.Join(root, "runtime")
if err := os.Mkdir(runtimeDir, 0o700); err != nil {
t.Fatalf("mkdir runtime: %v", err)
}
t.Setenv("XDG_RUNTIME_DIR", runtimeDir)
sharedWorkDir := filepath.Join(root, "simulated-nfs", "events", "open", "personal_stream", "identity")
endpoint := dwsevent.IPCEndpoint(sharedWorkDir, "open", dwsevent.SourceKindPersonalStream, "identity")
if strings.HasPrefix(endpoint, sharedWorkDir) {
t.Fatalf("endpoint = %q, want socket outside shared WorkDir %q", endpoint, sharedWorkDir)
}
l, err := Listen(endpoint)
if err != nil {
t.Fatalf("Listen on local runtime endpoint: %v", err)
}
defer l.Close()
if mode, err := os.Stat(filepath.Dir(endpoint)); err != nil {
t.Fatalf("stat runtime socket directory: %v", err)
} else if mode.Mode().Perm() != 0o700 {
t.Fatalf("runtime socket directory mode = %04o, want 0700", mode.Mode().Perm())
}
}
func TestListen_CloseUnlinksSocket(t *testing.T) {
path := filepath.Join(t.TempDir(), "bus.sock")
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
l, err := Listen(path)
if err != nil {
t.Fatalf("Listen: %v", err)
@@ -114,7 +247,7 @@ func TestListen_CloseUnlinksSocket(t *testing.T) {
}
func TestDial_NoServerReturnsError(t *testing.T) {
path := filepath.Join(t.TempDir(), "nonexistent.sock")
path := filepath.Join(shortSecureTempDir(t), "nonexistent.sock")
if _, err := Dial(path); err == nil {
t.Fatal("Dial to nonexistent socket should error")
}
@@ -124,7 +257,7 @@ func TestDial_NoServerReturnsError(t *testing.T) {
// surfaces as io.EOF to the server's Reader — the EOF signal is what bus
// uses to unregister dead consumers (plan invariant #5).
func TestReader_HandlesPeerCloseEOF(t *testing.T) {
path := filepath.Join(t.TempDir(), "bus.sock")
path := filepath.Join(shortSecureTempDir(t), "bus.sock")
l, err := Listen(path)
if err != nil {
t.Fatalf("Listen: %v", err)
+13 -1
View File
@@ -853,6 +853,7 @@ func newAitableCommand() *cobra.Command {
dws aitable form [list|delete|update] 表单管理
dws aitable form field [list|update|hide] 表单字段管理
dws aitable form share [get|update|notify] 表单分享管理
dws aitable workflow [edit-example|create|update|enable|disable|get|list] 自动化工作流管理
dws aitable dashboard [get|create|update|delete|config-example] 仪表盘管理
dws aitable chart [get|create|update|delete|widgets-example] 图表管理
dws aitable export data 数据导出
@@ -3260,6 +3261,17 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
},
}
workflowEditExampleCmd := &cobra.Command{
Use: "edit-example",
Short: "获取工作流编辑文档与示例",
Long: `返回服务端提供的 AI 表格工作流编辑文档与示例。
可作为 workflow create / workflow update 的 workflow-dsl/v1 结构参考;此命令不需要 Base ID 或其他参数。`,
Example: ` dws aitable workflow edit-example`,
RunE: func(cmd *cobra.Command, args []string) error {
return callAitableTool("edit_workflow_example", map[string]any{})
},
}
workflowUpdateCmd := &cobra.Command{
Use: "update",
Short: "更新并发布已有自动化工作流",
@@ -4856,7 +4868,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
workflowListCmd.Flags().Int("limit", 0, "分页大小 [1, 100],不传走服务端默认 20")
workflowListCmd.Flags().Int("offset", 0, "分页偏移量,>= 0,不传走服务端默认 0")
workflowCmd.AddCommand(
workflowCreateCmd, workflowUpdateCmd,
workflowEditExampleCmd, workflowCreateCmd, workflowUpdateCmd,
workflowEnableCmd, workflowDisableCmd,
workflowGetCmd, workflowListCmd,
)
@@ -94,6 +94,23 @@ func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
}
}
func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil, "edit-example")
if err != nil {
t.Fatalf("workflow edit-example returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
}
call := caller.calls[0]
if call.productID != "aitable" || call.toolName != "edit_workflow_example" {
t.Fatalf("tool call = %s/%s, want aitable/edit_workflow_example", call.productID, call.toolName)
}
if len(call.args) != 0 {
t.Fatalf("tool args = %#v, want empty arguments", call.args)
}
}
func TestAitableWorkflowUpdateReadsDSLFile(t *testing.T) {
path := t.TempDir() + "/workflow.json"
if err := os.WriteFile(path, []byte(`{"version":"workflow-dsl/v1","name":"updated"}`), 0o600); err != nil {
+13 -249
View File
@@ -56,20 +56,11 @@ const maxConversationCategoryTitleRunes = 15
func validatedConversationCategoryTitle(raw string) (string, error) {
title := strings.TrimSpace(raw)
if title == "" {
return "", apperrors.NewValidation(
"--title 不能为空",
apperrors.WithReason("invalid_category_title"),
apperrors.WithHint("请提供 1 到 15 个字符的分组标题,并保持用户指定原文。"),
apperrors.WithActions("补充非空 --title", "运行当前命令 --help 查看示例"),
)
return "", apperrors.NewValidation("--title 不能为空")
}
if count := utf8.RuneCountInString(title); count > maxConversationCategoryTitleRunes {
return "", apperrors.NewValidation(
fmt.Sprintf("--title 当前 %d 个字符,最多 %d 个字符", count, maxConversationCategoryTitleRunes),
apperrors.WithReason("category_title_too_long"),
apperrors.WithHint("不得静默截断、缩写或改写用户指定名称;请让用户提供合法标题后重试。"),
apperrors.WithActions("请用户将标题缩短到 15 个字符以内", "使用用户确认后的标题原文重试"),
)
if utf8.RuneCountInString(title) > maxConversationCategoryTitleRunes {
return "", apperrors.NewValidation(fmt.Sprintf(
"--title 最多 %d 个字符", maxConversationCategoryTitleRunes))
}
return title, nil
}
@@ -340,25 +331,6 @@ func containsMessageMention(text, placeholder string) bool {
}
}
func chatGuidanceError(message, reason string, actions, examples []string) error {
return apperrors.NewValidation(
message,
apperrors.WithReason(reason),
apperrors.WithActions(actions...),
apperrors.WithExamples(examples...),
)
}
func isLikelyPlaceholderID(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized == "" ||
normalized == "0" ||
strings.Contains(normalized, "placeholder") ||
strings.HasPrefix(normalized, "test_") ||
strings.HasPrefix(normalized, "test-") ||
strings.HasPrefix(normalized, "<")
}
func resolveOpenDingTalkID(ctx context.Context, value string) (string, error) {
ids, err := resolveOpenDingTalkIDs(ctx, []string{value})
if err != nil {
@@ -1709,27 +1681,7 @@ func newChatCommand() *cobra.Command {
if atAll && !strings.Contains(text, "<@all>") {
text = "<@all> " + text
}
var addedMentions []string
for _, rawID := range atOpenIds {
id := strings.TrimSpace(rawID)
if id == "" {
continue
}
wrapped := "<@" + id + ">"
if !containsMessageMention(text, wrapped) && !containsMessageMention(text, "@"+id) {
addedMentions = append(addedMentions, wrapped)
}
}
if len(addedMentions) > 0 {
text = strings.Join(addedMentions, " ") + " " + text
fmt.Fprintf(
os.Stderr,
"错误信息:检测到 --at-open-dingtalk-ids,但正文缺少对应 @ 占位符;CLI 已自动补齐\n原因:钉钉群消息只有正文包含 <@openDingTalkId> 时才会真正展示 @ 提醒\n建议操作:\n1. 后续命令请在 --text 中显式写入对应占位符\n示例:\n1. dws chat message send --group <openConversationId> --at-open-dingtalk-ids %s --text %q --format json\n",
atOpenIdsStr,
text,
)
}
// 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 保持不变
// 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 不变
text = normalizeAtPlaceholders(text, atOpenIds, true)
// 群聊统一走 openDingTalkId @ 人接口。
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
@@ -2039,12 +1991,7 @@ func newChatCommand() *cobra.Command {
return fmt.Errorf("--sender-user-id and --sender-open-dingtalk-id are mutually exclusive, specify exactly one")
}
if senderUserID == "" && senderOpenDingTalkID == "" {
return chatGuidanceError(
"缺少消息发送者标识",
"list-by-sender 查询的是指定对方发送的消息,必须提供对方的 userId 或 openDingTalkId",
[]string{"使用 --sender-user-id 传入对方 userId", "或使用 --sender-open-dingtalk-id 传入对方 openDingTalkId"},
[]string{`dws chat message list-by-sender --sender-user-id <对方userId> --start "2026-07-14T00:00:00+08:00" --format json`},
)
return fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required")
}
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
if err != nil {
@@ -2235,25 +2182,6 @@ func newChatCommand() *cobra.Command {
# 查询单聊会话 ID: dws chat conversation-info --user <userId>
# 查询人员: dws contact user search --keyword "姓名" --format json`,
RunE: func(cmd *cobra.Command, args []string) error {
hasCondition := false
for _, name := range []string{
"query", "keyword", "user", "users", "userId", "sender-ids", "senders", "sender",
"at-ids", "conversation-ids", "groups", "group", "message-type",
"conversation-type", "search-conv-type", "start", "end",
} {
if value, _ := cmd.Flags().GetString(name); strings.TrimSpace(value) != "" {
hasCondition = true
break
}
}
if !hasCondition {
atMe, _ := cmd.Flags().GetBool("at-me")
hasCondition = atMe || cmd.Flags().Changed("only-robot") || cmd.Flags().Changed("only-robot-messages")
}
if !hasCondition {
return apperrors.NewValidation("at least one search condition is required")
}
toolArgs := map[string]any{}
// The CLI primary is --query; the IM MCP field is still named "keyword".
@@ -3048,7 +2976,6 @@ func newChatCommand() *cobra.Command {
chatCategoryDeleteCmd := &cobra.Command{
Use: "delete",
Short: "删除用户自定义会话分组",
Long: "删除用户自定义会话分组。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。",
Example: ` dws chat category delete --category-id <分组ID>
# 分组ID 可通过 dws chat category list 获取`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -3056,14 +2983,6 @@ func newChatCommand() *cobra.Command {
if categoryId == 0 {
return fmt.Errorf("flag --category-id is required")
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"删除会话分组不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标分组及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标会话分组", "获得用户确认后使用 --yes 执行"),
)
}
return callMCPToolOnServer("im", "delete_conv_category", map[string]any{
"categoryId": categoryId,
})
@@ -3139,16 +3058,6 @@ func newChatCommand() *cobra.Command {
if err != nil {
return fmt.Errorf("--category-ids: %w", err)
}
for _, categoryID := range categoryIds {
if categoryID > 0 && categoryID < 1000 {
return chatGuidanceError(
"会话分组 ID 看起来仍是示例占位值",
"--category-ids 必须来自 chat category list 返回的真实分组 ID;123、456 等短示例值不能直接用于移出操作",
[]string{"先查询当前用户的会话分组", "从结果读取真实 categoryId 后再执行移出"},
[]string{`dws chat category list --format json`, `dws chat category remove-conv --group <openConversationId> --category-ids <categoryId> --format json`},
)
}
}
return callMCPToolOnServer("im", "remove_conv_from_categories", map[string]any{
"openConversationId": groupID,
"categoryIds": categoryIds,
@@ -3220,16 +3129,6 @@ func newChatCommand() *cobra.Command {
return err
}
msgIds := parseCSVValues(mustGetFlag(cmd, "msg-ids"))
for _, msgID := range msgIds {
if isLikelyPlaceholderID(msgID) {
return chatGuidanceError(
"消息 ID 仍是占位符,无法查询真实消息",
"--msg-ids 必须来自消息列表返回的真实 openMsgId,test_msg_id_placeholder 等示例值不会命中消息",
[]string{"先执行 chat message list 获取目标消息", "从结果读取 openMsgId 后替换占位符"},
[]string{`dws chat message list-by-ids --msg-ids <openMsgId> --format json`},
)
}
}
if len(msgIds) > 50 {
return fmt.Errorf("--msg-ids 最多支持 50 条,当前 %d 条", len(msgIds))
}
@@ -3253,14 +3152,6 @@ func newChatCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "msg-id", "emoji"); err != nil {
return err
}
if msgID := mustGetFlag(cmd, "msg-id"); isLikelyPlaceholderID(msgID) {
return chatGuidanceError(
"消息 ID 仍是占位符,无法添加表情回应",
"--msg-id 必须是目标消息真实的 openMsgId,不能使用测试占位符",
[]string{"先拉取目标会话消息", "读取目标消息的 openMsgId 后重试"},
[]string{`dws chat message add-emoji --conversation-id <openConversationId> --msg-id <openMsgId> --emoji "赞" --format json`},
)
}
return callMCPToolOnServer("im", "add_emoji_reaction", map[string]any{
"openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"),
"openMsgId": mustGetFlag(cmd, "msg-id"),
@@ -3543,14 +3434,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
conversationID := mustGetFlag(cmd, "open-conversation-id")
messageID := mustGetFlag(cmd, "message-id")
outputPath := mustGetFlag(cmd, "output")
if isLikelyPlaceholderID(resourceID) || isLikelyPlaceholderID(messageID) {
return chatGuidanceError(
"媒体资源参数仍包含占位符",
"--resource-id 和 --message-id 必须来自同一条真实消息,不能使用 test-media 或 test_msg_id_placeholder",
[]string{"先拉取包含媒体的目标消息", "从同一条消息读取 mediaId、openMessageId 和 openConversationId"},
[]string{`dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/ --format json`},
)
}
switch resourceType {
case "mediaId":
@@ -3663,12 +3546,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
newOwner = newOwnerUserID
}
if newOwner == "" {
return chatGuidanceError(
"缺少新群主标识",
"--new-owner 接收新群主 openDingTalkId,--user 接收新群主 userId,二者必须选择一个",
[]string{"先查询新群主的人员标识", "使用 --new-owner 或 --user 之一"},
[]string{`dws chat group transfer-owner --group <openConversationId> --new-owner <openDingTalkId> --format json`},
)
return fmt.Errorf("flag --new-owner or --user is required")
}
if !isOpenDingTalkID(newOwner) {
return callMCPToolOnServer("im", "transfer_group_owner", map[string]any{
@@ -3818,36 +3696,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
return fmt.Errorf("flag --status is required (0=关闭, 1=开启)")
}
status, _ := cmd.Flags().GetInt("status")
settingKey := mustGetFlag(cmd, "setting-key")
validSettingKeys := map[string]bool{
"authority": true, "joinValidation": true, "onlyAdminCanAtAll": true,
"searchable": true, "addFriendForbidden": true, "toolbarStatus": true,
"pluginCustomizeVerify": true, "onlyAdminCanDING": true,
"allMembersCanCreateMcsConf": true, "onlyAdminCanSetMsgTop": true,
"onlyAdminCanPinMsg": true, "onlyAdminCanSendFile": true,
"allMembersCanCreateCalendar": true, "groupEmailDisabled": true,
"groupRedEnvelopeSwitch": true, "groupLiveAuthority": true,
"groupBillAuthority": true,
}
if !validSettingKeys[settingKey] {
return chatGuidanceError(
"不支持的群设置项:"+settingKey,
"--setting-key 必须使用当前接口支持的精确枚举值,on 不是设置项名称",
[]string{"从 --help 列表选择合法 setting-key", "开启或关闭通过 --status 1/0 表达"},
[]string{`dws chat group update-settings --group <openConversationId> --setting-key searchable --status 1 --format json`},
)
}
if status != 0 && status != 1 {
return chatGuidanceError(
"群设置值只能是 0 或 1",
"--status 表示开关状态:0=关闭,1=开启;其他整数不会被服务端接受",
[]string{"关闭设置时传 --status 0", "开启设置时传 --status 1"},
[]string{`dws chat group update-settings --group <openConversationId> --setting-key searchable --status 1 --format json`},
)
}
return callMCPToolOnServer("im", "update_group_settings", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"settingKey": settingKey,
"settingKey": mustGetFlag(cmd, "setting-key"),
"status": status,
})
},
@@ -3936,14 +3787,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "src-conversation-id", "msg-id", "dest-conversation-id"); err != nil {
return err
}
if msgID := mustGetFlag(cmd, "msg-id"); isLikelyPlaceholderID(msgID) {
return chatGuidanceError(
"转发消息 ID 仍是占位符",
"--msg-id 必须是源会话中真实消息的 openMessageId",
[]string{"先拉取源会话消息", "确认消息属于 --src-conversation-id 后读取 openMessageId"},
[]string{`dws chat message forward --src-conversation-id <源会话ID> --msg-id <openMessageId> --dest-conversation-id <目标会话ID> --format json`},
)
}
toolArgs := map[string]any{
"srcOpenCid": mustGetFlag(cmd, "src-conversation-id"),
"srcOpenMessageId": mustGetFlag(cmd, "msg-id"),
@@ -4088,21 +3931,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if !off {
muteTime, _ := cmd.Flags().GetInt64("mute-time")
if muteTime <= 0 {
return chatGuidanceError(
"禁言时必须提供 --mute-time",
"--mute-time 的单位是毫秒,仅支持 5 分钟、1 小时、1 天、7 天或 30 天对应的固定值",
[]string{"选择支持的毫秒值之一", "取消禁言时改用 --off"},
[]string{`dws chat group-mute-member --group <openConversationId> --user <userId> --mute-time 300000 --format json`},
)
}
validMuteTimes := map[int64]bool{300000: true, 3600000: true, 86400000: true, 604800000: true, 2592000000: true}
if !validMuteTimes[muteTime] {
return chatGuidanceError(
"不支持的禁言时长",
"--mute-time 使用毫秒且只支持 300000、3600000、86400000、604800000、2592000000;300 表示的时间不在支持范围内",
[]string{"5 分钟使用 300000", "从支持的五档时长中选择"},
[]string{`dws chat group-mute-member --group <openConversationId> --user <userId> --mute-time 300000 --format json`},
)
return fmt.Errorf("--mute-time is required when muting (supported: 300000/3600000/86400000/604800000/2592000000)")
}
toolArgs["muteTime"] = muteTime
}
@@ -4269,14 +4098,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "group", "role-id", "name"); err != nil {
return err
}
if roleID := mustGetFlag(cmd, "role-id"); isLikelyPlaceholderID(roleID) {
return chatGuidanceError(
"群身份 ID 仍是占位符",
"--role-id 必须来自 chat group-role list 返回的真实 openRoleId,0 不是有效群身份 ID",
[]string{"先列出目标群的群身份", "从结果读取 openRoleId 后重试"},
[]string{`dws chat group-role list --group <openConversationId> --format json`, `dws chat group-role update --group <openConversationId> --role-id <openRoleId> --name "新名称" --format json`},
)
}
return callMCPToolOnServer("im", "update_custom_group_role", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"openRoleId": mustGetFlag(cmd, "role-id"),
@@ -4571,28 +4392,9 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "src-conversation-id", "msg-ids", "dest-conversation-id"); err != nil {
return err
}
msgIDs := parseCSVValues(mustGetFlag(cmd, "msg-ids"))
if len(msgIDs) < 2 {
return chatGuidanceError(
"合并转发至少需要两条消息",
"--msg-ids 只有一条消息时不构成合并转发;单条消息应使用 message forward",
[]string{"提供至少两个来自同一源会话的 openMessageId", "只有一条时改用 message forward"},
[]string{`dws chat message combine-forward --src-conversation-id <源会话ID> --msg-ids <id1>,<id2> --dest-conversation-id <目标会话ID> --format json`},
)
}
for _, msgID := range msgIDs {
if isLikelyPlaceholderID(msgID) {
return chatGuidanceError(
"合并转发消息列表包含占位符",
"--msg-ids 必须全部是源会话中的真实 openMessageId",
[]string{"先拉取源会话消息", "选择至少两个真实消息 ID"},
[]string{`dws chat message combine-forward --src-conversation-id <源会话ID> --msg-ids <id1>,<id2> --dest-conversation-id <目标会话ID> --format json`},
)
}
}
toolArgs := map[string]any{
"srcOpenCid": mustGetFlag(cmd, "src-conversation-id"),
"srcOpenMessageIds": msgIDs,
"srcOpenMessageIds": parseCSVValues(mustGetFlag(cmd, "msg-ids")),
"destOpenCid": mustGetFlag(cmd, "dest-conversation-id"),
}
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
@@ -4628,14 +4430,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "src-msg-id", "src-conversation-id", "src-thread-id", "dest-conversation-id"); err != nil {
return err
}
if msgID := mustGetFlag(cmd, "src-msg-id"); isLikelyPlaceholderID(msgID) {
return chatGuidanceError(
"话题转发的源消息 ID 仍是占位符",
"--src-msg-id 必须来自源话题中的真实 openMessageId,并与源会话和 thread-id 对应",
[]string{"先拉取源会话的话题消息", "从同一条消息读取 openMessageId 和 openConvThreadId"},
[]string{`dws chat message forward-topic --src-msg-id <openMessageId> --src-conversation-id <源会话ID> --src-thread-id <openConvThreadId> --dest-conversation-id <目标会话ID> --format json`},
)
}
toolArgs := map[string]any{
"srcOpenMessageId": mustGetFlag(cmd, "src-msg-id"),
"srcOpenConversationId": mustGetFlag(cmd, "src-conversation-id"),
@@ -4914,21 +4708,12 @@ status 可选值:
if err != nil {
return fmt.Errorf("--record-id must be a valid integer: %w", err)
}
status := mustGetFlag(cmd, "status")
if status != "AuditApprove" && status != "AuditDelete" {
return chatGuidanceError(
"不支持的入群审批状态:"+status,
"当前服务端仅接受大小写完全一致的 AuditApprove 或 AuditDelete;AuditRefuse 和 approve 均不可用",
[]string{"通过申请使用 AuditApprove", "拒绝或删除申请使用 AuditDelete,并可补充 --description"},
[]string{`dws chat group audit-join-validation --group <openConversationId> --record-id <recordId> --applicant <userId> --inviter <userId> --status AuditApprove --format json`},
)
}
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"applyRecordId": recordID,
"applicantUid": mustGetFlag(cmd, "applicant"),
"inviterUid": mustGetFlag(cmd, "inviter"),
"status": status,
"status": mustGetFlag(cmd, "status"),
}
if v, _ := cmd.Flags().GetString("description"); v != "" {
toolArgs["auditDescription"] = v
@@ -5054,7 +4839,7 @@ status 可选值:
chatClearMessagesCmd := &cobra.Command{
Use: "clear-messages",
Short: "清空当前用户指定会话的聊天记录",
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
- 群聊:dws chat search --query "群名"
@@ -5066,14 +4851,6 @@ status 可选值:
if convID == "" {
return fmt.Errorf("flag --conversation-id is required\n hint: dws chat clear-messages --conversation-id <openConversationId>")
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"清空会话聊天记录不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标会话及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标会话", "获得用户确认后使用 --yes 执行"),
)
}
return callMCPToolOnServer("im", "clear_conversation_messages", map[string]any{
"openConversationId": convID,
})
@@ -5416,14 +5193,6 @@ status 可选值:
if err := validateRequiredFlags(cmd, "group", "notice-id"); err != nil {
return err
}
if noticeID := mustGetFlag(cmd, "notice-id"); isLikelyPlaceholderID(noticeID) {
return chatGuidanceError(
"群公告 ID 仍是占位符",
"--notice-id 必须来自 chat group notice list 返回的真实 dataId,0 不是有效公告 ID",
[]string{"先查询目标群公告列表", "从结果读取 dataId 后重试;查询操作不需要 --dry-run"},
[]string{`dws chat group notice list --group <openConversationId> --format json`, `dws chat group notice get --group <openConversationId> --notice-id <dataId> --format json`},
)
}
return callMCPToolOnServer("im", "get_group_notice", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"dataId": mustGetFlag(cmd, "notice-id"),
@@ -5485,12 +5254,7 @@ status 可选值:
target, _ := cmd.Flags().GetString("target")
receiver, _ := cmd.Flags().GetString("receiver")
if target == "" && receiver == "" {
return chatGuidanceError(
"缺少群邀请链接的接收目标",
"--target 表示接收分享的目标会话,--receiver 表示接收分享的单聊用户,二者必须选择一个",
[]string{"分享到群或会话时使用 --target", "分享到个人时使用 --receiver openDingTalkId"},
[]string{`dws chat group share-invite --source <源群ID> --target <目标会话ID> --format json`},
)
return fmt.Errorf("--target or --receiver is required")
}
if target != "" && receiver != "" {
return fmt.Errorf("--target and --receiver are mutually exclusive")
@@ -114,20 +114,6 @@ func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) {
for _, wrap := range []bool{true, false} {
_ = normalizeAtPlaceholders("hello @u1 <@u2>", []string{"", "u1", "u2"}, wrap)
}
for _, tc := range []struct {
value string
want bool
}{
{value: "0", want: true},
{value: "test_msg_id_placeholder", want: true},
{value: "test-media", want: true},
{value: "<openMsgId>", want: true},
{value: "real-message-id", want: false},
} {
if got := isLikelyPlaceholderID(tc.value); got != tc.want {
t.Fatalf("isLikelyPlaceholderID(%q) = %v, want %v", tc.value, got, tc.want)
}
}
if got := NormalizeMessageMentions("hello @u1", []string{"u1"}, true, true); got != "<@all> hello <@u1>" {
t.Fatalf("current-user mention normalization = %q", got)
}
+2 -8
View File
@@ -63,14 +63,8 @@ func newChatMediaUploadCommand() *cobra.Command {
func chatMediaUploadDownlineError() error {
return apperrors.NewValidation(
"chat media upload 已下线,当前 CLI 不提供本地文件到 mediaId 的上传能力。"+
" 本地图片或文件请改用: "+chatMediaUploadReplacement+
"chat media upload 已下线,当前 CLI 不提供本地文件到 mediaId 的上传能力。" +
" 本地图片或文件请改用: " + chatMediaUploadReplacement +
";已有 mediaId 时可使用 dws chat message send --msg-type image --media-id <mediaId>。",
apperrors.WithReason("chat_media_upload_retired"),
apperrors.WithHint("本地图片、PDF、DOCX、XLSX 等统一通过 message send --msg-type file --file-path 发送;不要改用 drive upload。"),
apperrors.WithActions(
"本地文件:dws chat message send --msg-type file --file-path <本地路径>",
"已有图片 mediaId:dws chat message send --msg-type image --media-id <mediaId>",
),
)
}
-34
View File
@@ -16,40 +16,6 @@ import (
"github.com/spf13/cobra"
)
func TestChatConversationDestructiveCommandsRequireConfirmation(t *testing.T) {
for _, args := range [][]string{
{"category", "delete", "--category-id", "42"},
{"clear-messages", "--conversation-id", "cid-1"},
} {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand, args...)
if err == nil || !strings.Contains(err.Error(), "确认") {
t.Fatalf("chat %v error = %v, want confirmation error", args, err)
}
if len(caller.calls) != 0 {
t.Fatalf("chat %v made tool calls before confirmation: %#v", args, caller.calls)
}
}
}
func TestChatConversationDestructiveCommandsRunAfterConfirmation(t *testing.T) {
for _, tc := range []struct {
args []string
toolName string
}{
{[]string{"category", "delete", "--category-id", "42", "--yes"}, "delete_conv_category"},
{[]string{"clear-messages", "--conversation-id", "cid-1", "--yes"}, "clear_conversation_messages"},
} {
caller := &guardedMutationCaller{}
if err := executeGuardedMutationCommand(t, caller, newChatCommand, tc.args...); err != nil {
t.Fatalf("chat %v error = %v", tc.args, err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != tc.toolName {
t.Fatalf("chat %v calls = %#v, want one %s call", tc.args, caller.calls, tc.toolName)
}
}
}
type contractDefectCaller struct {
dryRun bool
calls []guardedMutationCall
+4 -2
View File
@@ -276,7 +276,8 @@ func defaultHTTPPutFile(ctx context.Context, url string, headers map[string]stri
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("OSS upload failed: HTTP %d: %s", resp.StatusCode, string(body))
// typed httpStatusError 供上层按 401/403 分支重取凭证
return fmt.Errorf("OSS upload failed: %w", &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)})
}
return nil
@@ -434,7 +435,8 @@ func defaultHTTPGetFile(ctx context.Context, url string, headers map[string]stri
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
// typed httpStatusError 供上层按 401/403 分支重取凭证
return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
}
outFile, err := docCreateDestination(destPath)
+136 -14
View File
@@ -476,7 +476,8 @@ func newDriveCommand() *cobra.Command {
--output 指定本地保存路径,可以是文件路径或目录。
如果指定目录,文件名从下载 URL 中自动推断。`,
Example: ` dws drive download --node <dentryUuid> --output ./report.pdf
dws drive download --node <dentryUuid> --output ~/downloads/`,
dws drive download --node <dentryUuid> --output ~/downloads/
dws drive download --node <dentryUuid> --output ./big.zip --part-size 32MB --parallel 8`,
RunE: func(cmd *cobra.Command, args []string) error {
fileID := flagOrFallback(cmd, "node", "file-id")
if fileID == "" {
@@ -492,6 +493,15 @@ func newDriveCommand() *cobra.Command {
argsMap["spaceId"] = v
}
// fail-fast:分片下载参数校验
dlOpts, err := driveDownloadOptionsFromFlags(cmd)
if err != nil {
return err
}
dlOpts.logf = func(format string, a ...any) {
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "下载钉盘文件")
deps.Out.PrintKeyValue("文件ID", fileID)
@@ -499,7 +509,7 @@ func newDriveCommand() *cobra.Command {
return nil
}
ctx := context.Background()
ctx := cmd.Context()
// Step 1: 获取下载 URL 和签名请求头
deps.Out.PrintInfo("[1/2] 获取下载链接...")
@@ -508,7 +518,7 @@ func newDriveCommand() *cobra.Command {
return err
}
resourceURL, dlHeaders, err := parseDownloadInfo(text)
resourceURL, dlHeaders, err := parseDriveDownloadInfo(text)
if err != nil {
return err
}
@@ -523,9 +533,34 @@ func newDriveCommand() *cobra.Command {
outputPath = filepath.Join(outputPath, filename)
}
// Step 2: HTTP GET 下载文件
// Step 2: 分片下载(自动分派 + 401/403 凭证刷新重试)
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil {
dlOpts.knownSize = parseDownloadFileSize(text)
dlOpts.nodeID = fileID
dlOpts.version = parseDownloadFileVersion(text)
fetchCred := func(fctx context.Context) (string, map[string]string, int, error) {
t, ferr := callMCPToolReturnText(fctx, "download_file", argsMap)
if ferr != nil {
return "", nil, 0, ferr
}
u, h, perr := parseDriveDownloadInfo(t)
if perr != nil {
return "", nil, 0, perr
}
return u, h, parseDownloadFileVersion(t), nil
}
if err := driveTransferDownload(ctx, fetchCred, resourceURL, dlHeaders, outputPath, dlOpts); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
partSize, _ := cmd.Flags().GetString("part-size")
noResume, _ := cmd.Flags().GetBool("no-resume")
if partSize != "" && !noResume {
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断,已保存断点(可重新执行相同命令续传)\n")
} else {
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断\n")
}
cmd.SilenceErrors = true
return err
}
return err
}
@@ -564,6 +599,15 @@ func newDriveCommand() *cobra.Command {
return fmt.Errorf("flag --output is required")
}
// fail-fast:分片下载参数校验
dlOpts, err := driveDownloadOptionsFromFlags(cmd)
if err != nil {
return err
}
dlOpts.logf = func(format string, a ...any) {
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
}
if deps.Caller.DryRun() {
deps.Out.PrintKeyValue("操作", "下载文件历史版本")
deps.Out.PrintKeyValue("节点ID", fileID)
@@ -572,16 +616,17 @@ func newDriveCommand() *cobra.Command {
return nil
}
ctx := context.Background()
ctx := cmd.Context()
deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...")
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
dlArgsMap := map[string]any{
"nodeId": fileID,
"version": versionNum,
})
}
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", dlArgsMap)
if err != nil {
return err
}
resourceURL, dlHeaders, err := parseDownloadInfo(text)
resourceURL, dlHeaders, err := parseDriveDownloadInfo(text)
if err != nil {
return err
}
@@ -593,7 +638,32 @@ func newDriveCommand() *cobra.Command {
outputPath = filepath.Join(outputPath, filename)
}
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
if err := httpGetFile(ctx, resourceURL, dlHeaders, outputPath); err != nil {
dlOpts.knownSize = parseDownloadFileSize(text)
dlOpts.nodeID = fileID
dlOpts.version = versionNum
fetchCred := func(fctx context.Context) (string, map[string]string, int, error) {
t, ferr := callMCPToolReturnTextOnServer(fctx, "drive", "download_file_version", dlArgsMap)
if ferr != nil {
return "", nil, 0, ferr
}
u, h, perr := parseDriveDownloadInfo(t)
if perr != nil {
return "", nil, 0, perr
}
return u, h, parseDownloadFileVersion(t), nil
}
if err := driveTransferDownload(ctx, fetchCred, resourceURL, dlHeaders, outputPath, dlOpts); err != nil {
if errors.Is(err, context.Canceled) || ctx.Err() == context.Canceled {
partSize, _ := cmd.Flags().GetString("part-size")
noResume, _ := cmd.Flags().GetBool("no-resume")
if partSize != "" && !noResume {
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断,已保存断点(可重新执行相同命令续传)\n")
} else {
fmt.Fprintf(cmd.ErrOrStderr(), "\n[INFO] 下载中断\n")
}
cmd.SilenceErrors = true
return err
}
return err
}
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
@@ -709,10 +779,16 @@ func newDriveCommand() *cobra.Command {
driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
driveDownloadVersionCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
driveDownloadVersionCmd.Flags().Int("version", 0, "历史版本号 (必填,正整数,从 drive list --versions 获取)")
driveDownloadVersionCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
driveDownloadVersionCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
driveDownloadVersionCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
driveDownloadVersionCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
for _, alias := range []string{"url", "id", "node-id", "doc-id", "file-id"} {
driveDownloadVersionCmd.Flags().String(alias, "", "")
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
@@ -2006,13 +2082,13 @@ func uploadToDrive(ctx context.Context, filePath, fileName string, fileSize int6
if err != nil {
return err
}
resourceURL, uploadID, headers, err := parseDriveUploadInfo(text)
// HTTP PUT 上传文件(OSS 与中心协议同路径;headers 透传,401/403 重取凭证重试一次)
uploadID, err := driveUploadPut(ctx, text, func(rctx context.Context) (string, error) {
return callMCPToolReturnTextOnServer(rctx, "drive", "get_upload_info", step1Args)
}, filePath, fileSize)
if err != nil {
return err
}
if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil {
return err
}
commitArgs := map[string]any{
"fileName": fileName,
@@ -2219,3 +2295,49 @@ func isPermissionCLIError(err error) bool {
var patErr *PATError
return errors.As(err, &patErr)
}
// parseDriveDownloadInfo 从 drive 的 download_file 返回里取下载 URL 与请求头。
// drive 返回形如 {"result":{"downloadUrl":"https://..."}};OSS 预签名 URL 自带签名参数、
// 无额外请求头;中心协议(httpToCenterWithToken)返回的 headers 含 dentry-token,
// 需原样透传。对历史字段名(resourceUrl / resourceUrls[].url)做 fallback。
func parseDriveDownloadInfo(text string) (string, map[string]string, error) {
var data map[string]any
if err := json.Unmarshal([]byte(text), &data); err != nil {
return "", nil, fmt.Errorf("解析 download_file 返回失败: %w", err)
}
if result, ok := data["result"].(map[string]any); ok {
data = result
}
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
}
}
}
dlURL, _ := data["downloadUrl"].(string)
if dlURL == "" {
dlURL, _ = data["resourceUrl"].(string)
}
if dlURL == "" {
if arr, ok := data["resourceUrls"].([]any); ok && len(arr) > 0 {
if first, ok := arr[0].(map[string]any); ok {
dlURL, _ = first["url"].(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 dlURL == "" {
return "", nil, fmt.Errorf("download_file 未返回下载链接(downloadUrl 为空)")
}
return dlURL, headers, nil
}
+847
View File
@@ -0,0 +1,847 @@
package helpers
// ──────────────────────────────────────────────────────────
// drive 传输增强:中心协议(token 化凭证)+ 分片下载(Range)
//
// 下载:文件大小 ≥ 2×part-size 时自动分片并发下载(对齐 aws s3 cp /
// ossutil 惯例),断点续传默认开启(<dest>.dwspart 临时文件 + checkpoint
// 元信息),服务端不支持 Range 时自动回退整流;401/403(凭证过期)自动
// 重新调用 MCP 取新凭证后续传。
// 上传:中心协议(uploadType=httpToCenterWithToken)与 OSS 走同一 PUT
// 路径(URL 服务端拼好、headers 透传、客户端零追加),401/403 重取凭证
// 重试一次;服务端超限错误补充可读提示。
// ──────────────────────────────────────────────────────────
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/spf13/cobra"
)
const (
driveDownloadDefaultPartSize = 16 * 1024 * 1024 // --part-size 默认 16MB
driveDownloadMinPartSize = 1 * 1024 * 1024
driveDownloadMaxPartSize = 1024 * 1024 * 1024
driveDownloadDefaultParallel = 4
driveDownloadMaxParallel = 8
// 单分片常规失败指数退避重试次数(不含 401/403 凭证刷新)。
driveDownloadPartRetries = 3
// 单分片 401/403 凭证刷新重试上限(防止无效凭证死循环)。
driveDownloadPartAuthRetries = 2
drivePartFileSuffix = ".dwspart"
drivePartMetaSuffix = ".dwspart.meta"
driveCheckpointVersion = 1
uploadTypeCenterToken = "httpToCenterWithToken"
driveTransferBodyErrCap = 2048 // 错误响应 body 截断长度
)
// driveRangeClient 分片下载/探测专用 HTTP 客户端。
// 整流路径仍走可注入的 httpGetFile,保持既有测试注入点不变。
var driveRangeClient = &http.Client{Timeout: 10 * time.Minute}
// errCredentialRefreshVersionUnknown 凭证刷新后无法验证文件版本一致性(双方之一
// version=0),激进策略要求清空已完成分片从头下载。
var errCredentialRefreshVersionUnknown = errors.New("凭证刷新后无法验证文件版本一致性,需从头下载")
// Testable OS operation hooks (package-level for coverage injection).
var (
driveJsonMarshal = json.Marshal
driveOsRename = os.Rename
driveFileTruncate = (*os.File).Truncate
driveFileSync = (*os.File).Sync
driveFileStat = (*os.File).Stat
)
// ──────────────────────────────────────────────────────────
// HTTP 状态错误
// ──────────────────────────────────────────────────────────
// httpStatusError 表示非 2xx 的 HTTP 响应,供上层按状态码分支(401/403 重取凭证等)。
type httpStatusError struct {
StatusCode int
Body string
}
func (e *httpStatusError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body)
}
// isAuthStatusError 判断错误是否为 401/403(凭证过期/无效)。
// 兼容 typed httpStatusError 与文本形态(测试注入或历史包装的 "HTTP 401/403" 错误)。
func isAuthStatusError(err error) bool {
if err == nil {
return false
}
var se *httpStatusError
if errors.As(err, &se) {
return se.StatusCode == http.StatusUnauthorized || se.StatusCode == http.StatusForbidden
}
msg := err.Error()
return strings.Contains(msg, "HTTP 401") || strings.Contains(msg, "HTTP 403")
}
// ──────────────────────────────────────────────────────────
// 参数解析
// ──────────────────────────────────────────────────────────
// parsePartSize 解析 --part-size 的人类可读值(16MB、512KB、1GB;纯数字按字节)。
func parsePartSize(s string) (int64, error) {
v := strings.TrimSpace(strings.ToUpper(s))
if v == "" {
return 0, fmt.Errorf("--part-size 不能为空(示例: 16MB、512KB、1GB)")
}
unit := int64(1)
switch {
case strings.HasSuffix(v, "GB"):
unit, v = 1<<30, strings.TrimSuffix(v, "GB")
case strings.HasSuffix(v, "MB"):
unit, v = 1<<20, strings.TrimSuffix(v, "MB")
case strings.HasSuffix(v, "KB"):
unit, v = 1<<10, strings.TrimSuffix(v, "KB")
case strings.HasSuffix(v, "G"):
unit, v = 1<<30, strings.TrimSuffix(v, "G")
case strings.HasSuffix(v, "M"):
unit, v = 1<<20, strings.TrimSuffix(v, "M")
case strings.HasSuffix(v, "K"):
unit, v = 1<<10, strings.TrimSuffix(v, "K")
case strings.HasSuffix(v, "B"):
v = strings.TrimSuffix(v, "B")
}
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil || n <= 0 {
return 0, fmt.Errorf("--part-size 格式非法: %q(示例: 16MB、512KB、1GB)", s)
}
size := n * unit
if size < driveDownloadMinPartSize || size > driveDownloadMaxPartSize {
return 0, fmt.Errorf("--part-size 取值范围 %s - %s,当前值: %s",
formatByteSize(driveDownloadMinPartSize), formatByteSize(driveDownloadMaxPartSize), s)
}
return size, nil
}
func formatByteSize(n int64) string {
switch {
case n >= 1<<30 && n%(1<<30) == 0:
return fmt.Sprintf("%dGB", n/(1<<30))
case n >= 1<<20 && n%(1<<20) == 0:
return fmt.Sprintf("%dMB", n/(1<<20))
case n >= 1<<10 && n%(1<<10) == 0:
return fmt.Sprintf("%dKB", n/(1<<10))
default:
return fmt.Sprintf("%dB", n)
}
}
// driveDownloadOptions 分片下载选项(由 drive download 的 flag 解析而来)。
type driveDownloadOptions struct {
partSize int64
parallel int
resume bool
knownSize int64 // MCP 返回的 fileSize;未知(0)或小于阈值时直接整流
nodeID string // 节点唯一标识(dentryUuid),用于生成 checkpoint 指纹
version int // 文件版本号;0 表示最新版
logf func(format string, args ...any)
}
// driveDownloadOptionsFromFlags 解析并校验 --part-size / --parallel / --no-resume。
func driveDownloadOptionsFromFlags(cmd *cobra.Command) (driveDownloadOptions, error) {
raw, _ := cmd.Flags().GetString("part-size")
partSize, err := parsePartSize(raw)
if err != nil {
return driveDownloadOptions{}, err
}
parallel, _ := cmd.Flags().GetInt("parallel")
if parallel < 1 || parallel > driveDownloadMaxParallel {
return driveDownloadOptions{}, fmt.Errorf("--parallel 取值范围 1-%d,当前值: %d", driveDownloadMaxParallel, parallel)
}
noResume, _ := cmd.Flags().GetBool("no-resume")
return driveDownloadOptions{partSize: partSize, parallel: parallel, resume: !noResume}, nil
}
// parseDownloadFileSize 从 download_file 返回中提取 fileSize(缺失/非法返回 0)。
func parseDownloadFileSize(text string) int64 {
var data map[string]any
if json.Unmarshal([]byte(text), &data) != nil {
return 0
}
if r, ok := data["result"].(map[string]any); ok {
data = r
}
switch v := data["fileSize"].(type) {
case float64:
return int64(v)
case string:
n, _ := strconv.ParseInt(v, 10, 64)
return n
}
return 0
}
// parseDownloadFileVersion 从 MCP download_file 响应中提取文件当前版本号。
// 返回 0 表示未获取到(兼容旧版 MCP 不返回 version 的场景)。
func parseDownloadFileVersion(text string) int {
var data map[string]any
if json.Unmarshal([]byte(text), &data) != nil {
return 0
}
if r, ok := data["result"].(map[string]any); ok {
data = r
}
switch v := data["version"].(type) {
case float64:
return int(v)
case string:
n, _ := strconv.Atoi(v)
return n
}
return 0
}
// ──────────────────────────────────────────────────────────
// 凭证状态(分片过程共享,401/403 时 single-flight 刷新)
// ──────────────────────────────────────────────────────────
// driveCredentialFetcher 重新调用 MCP 获取下载 URL + headers(含 dentry-token)。
type driveCredentialFetcher func(ctx context.Context) (url string, headers map[string]string, version int, err error)
type driveCredentialState struct {
mu sync.Mutex
fetch driveCredentialFetcher
url string
headers map[string]string
gen int
initialVersion int // 首次获取的文件版本号;0 表示未知(兼容旧 MCP)
}
func (cs *driveCredentialState) current() (string, map[string]string, int) {
cs.mu.Lock()
defer cs.mu.Unlock()
return cs.url, cs.headers, cs.gen
}
// refresh 重取凭证。仅当调用方持有的 generation 仍是最新时才真正重取
// (其他并发分片已刷新过则直接复用新凭证,避免重复 MCP 调用)。
func (cs *driveCredentialState) refresh(ctx context.Context, gen int) error {
cs.mu.Lock()
defer cs.mu.Unlock()
if cs.gen > gen {
return nil // 已被其他分片刷新
}
if cs.fetch == nil {
return fmt.Errorf("下载凭证已过期且无法自动刷新")
}
url, headers, version, err := cs.fetch(ctx)
if err != nil {
return err
}
// 版本校验
if cs.initialVersion > 0 && version > 0 {
if version != cs.initialVersion {
// 双方版本已知且不同 → 文件已被覆盖,终止下载防止数据不一致
return fmt.Errorf("下载凭证刷新后文件版本已变更(%d → %d),终止下载以防数据不一致", cs.initialVersion, version)
}
// 版本一致,正常继续
} else {
// 激进策略:版本不可验证(至少一方为 0),更新凭证但返回特殊错误
// 让上层清空已完成分片从头下载
cs.url, cs.headers = url, headers
cs.gen++
return errCredentialRefreshVersionUnknown
}
cs.url, cs.headers = url, headers
cs.gen++
return nil
}
// ──────────────────────────────────────────────────────────
// 下载入口:整流 / 分片自动分派
// ──────────────────────────────────────────────────────────
// driveTransferDownload 下载入口:按文件大小自动选择整流或分片下载。
// - 文件大小未知(MCP 未返回 fileSize)或 < 2×partSize → 直接整流
// (复用可注入的 httpGetFile,保持存量行为与测试注入边界不变,
// 401/403 时重取凭证重试一次);
// - 已知大小 ≥ 2×partSize → 首请求以 Range: bytes=0-0 探测:206 且解析出
// 总长 → 分片下载;服务端返回 200(不支持 Range)→ 直接消费该响应整流落盘。
func driveTransferDownload(ctx context.Context, fetch driveCredentialFetcher, rawURL string, headers map[string]string, destPath string, opts driveDownloadOptions) error {
if opts.partSize <= 0 {
opts.partSize = driveDownloadDefaultPartSize
}
if opts.parallel <= 0 {
opts.parallel = driveDownloadDefaultParallel
}
threshold := 2 * opts.partSize
if opts.knownSize < threshold {
// 含 knownSize==0(大小未知):不发起额外探测请求,保持存量整流行为
return downloadSingleWithAuthRetry(ctx, fetch, rawURL, headers, destPath)
}
creds := &driveCredentialState{fetch: fetch, url: rawURL, headers: headers, initialVersion: opts.version}
totalSize, fullResp, err := probeRangeSupport(ctx, creds)
if err != nil {
return err
}
if fullResp != nil {
// 服务端不支持 Range(忽略探测请求头返回 200 全量流):直接消费落盘
defer fullResp.Body.Close()
return writeStreamToFile(fullResp.Body, destPath)
}
curURL, curHeaders, _ := creds.current()
if totalSize <= 0 || totalSize < threshold {
// 总长未知(Content-Range 异常)或小于阈值:整流下载
return downloadSingleWithAuthRetry(ctx, func(fctx context.Context) (string, map[string]string, int, error) {
if fetch == nil {
return "", nil, 0, fmt.Errorf("下载凭证已过期且无法自动刷新")
}
return fetch(fctx)
}, curURL, curHeaders, destPath)
}
return downloadRangedParts(ctx, creds, destPath, totalSize, opts)
}
// downloadSingleWithAuthRetry 整流下载;401/403(凭证过期)时重新调用 MCP
// 获取新 URL+token 重试一次,仍失败走既有错误路径。
func downloadSingleWithAuthRetry(ctx context.Context, fetch driveCredentialFetcher, urlStr string, headers map[string]string, destPath string) error {
err := httpGetFile(ctx, urlStr, headers, destPath)
if err == nil || !isAuthStatusError(err) || fetch == nil {
return err
}
newURL, newHeaders, _, ferr := fetch(ctx)
if ferr != nil {
return err
}
return httpGetFile(ctx, newURL, newHeaders, destPath)
}
// probeRangeSupport 发送 Range: bytes=0-0 探测请求验证 206/Content-Range。
// 返回 (totalSize, nil, nil) 表示支持 Range 且已知总长;
// 返回 (0, resp, nil) 表示服务端忽略 Range 返回 200 全量响应(调用方直接消费);
// 401/403 时刷新凭证重试一次。
func probeRangeSupport(ctx context.Context, creds *driveCredentialState) (int64, *http.Response, error) {
for attempt := 0; ; attempt++ {
urlStr, headers, gen := creds.current()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return 0, nil, err
}
for k, v := range headers {
req.Header.Set(k, v)
}
req.Header.Set("Range", "bytes=0-0")
resp, err := driveRangeClient.Do(req)
if err != nil {
return 0, nil, err
}
switch {
case resp.StatusCode == http.StatusPartialContent:
total, perr := parseContentRangeTotal(resp.Header.Get("Content-Range"))
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if perr != nil {
return 0, nil, nil // Content-Range 异常:总长未知,回退整流
}
return total, nil, nil
case resp.StatusCode == http.StatusOK:
return 0, resp, nil
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap))
resp.Body.Close()
if attempt > 0 {
return 0, nil, fmt.Errorf("下载凭证刷新后仍鉴权失败")
}
if rerr := creds.refresh(ctx, gen); rerr != nil && !errors.Is(rerr, errCredentialRefreshVersionUnknown) {
return 0, nil, fmt.Errorf("重新获取下载凭证失败: %w (原错误: %v)",
rerr, &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)})
}
// errCredentialRefreshVersionUnknown 在探测阶段无需处理(尚无已完成分片),
// 凭证已更新,循环继续用新凭证重试探测。
default:
body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap))
resp.Body.Close()
return 0, nil, &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
}
}
}
// parseContentRangeTotal 从 "bytes 0-0/12345" 解析总长;"*" 视为未知。
func parseContentRangeTotal(cr string) (int64, error) {
idx := strings.LastIndex(cr, "/")
if idx < 0 || idx == len(cr)-1 {
return 0, fmt.Errorf("非法 Content-Range: %q", cr)
}
totalStr := cr[idx+1:]
if totalStr == "*" {
return 0, fmt.Errorf("Content-Range 总长未知: %q", cr)
}
total, err := strconv.ParseInt(totalStr, 10, 64)
if err != nil || total <= 0 {
return 0, fmt.Errorf("非法 Content-Range 总长: %q", cr)
}
return total, nil
}
// parseContentRange 解析 "bytes <start>-<end>/<total>" 格式的 Content-Range 头。
// 返回值 start、end 为字节偏移(闭区间),total 为文件总长("*" 视为 -1)。
func parseContentRange(header string) (start, end, total int64, err error) {
if header == "" {
return 0, 0, 0, fmt.Errorf("Content-Range 为空")
}
// 去掉 "bytes " 前缀
const prefix = "bytes "
if !strings.HasPrefix(header, prefix) {
return 0, 0, 0, fmt.Errorf("非法 Content-Range 前缀: %q", header)
}
rest := header[len(prefix):] // e.g. "0-1048575/104857600"
// 按 "/" 分割范围与总长
slashIdx := strings.LastIndex(rest, "/")
if slashIdx < 0 || slashIdx == len(rest)-1 {
return 0, 0, 0, fmt.Errorf("非法 Content-Range 格式: %q", header)
}
rangePart := rest[:slashIdx] // "0-1048575"
totalPart := rest[slashIdx+1:] // "104857600" 或 "*"
// 解析 total
if totalPart == "*" {
total = -1
} else {
total, err = strconv.ParseInt(totalPart, 10, 64)
if err != nil || total <= 0 {
return 0, 0, 0, fmt.Errorf("非法 Content-Range 总长: %q", header)
}
}
// 按 "-" 分割 start 和 end
dashIdx := strings.Index(rangePart, "-")
if dashIdx < 0 {
return 0, 0, 0, fmt.Errorf("非法 Content-Range 区间: %q", header)
}
start, err = strconv.ParseInt(rangePart[:dashIdx], 10, 64)
if err != nil || start < 0 {
return 0, 0, 0, fmt.Errorf("非法 Content-Range start: %q", header)
}
end, err = strconv.ParseInt(rangePart[dashIdx+1:], 10, 64)
if err != nil || end < start {
return 0, 0, 0, fmt.Errorf("非法 Content-Range end: %q", header)
}
return start, end, total, nil
}
func writeStreamToFile(r io.Reader, destPath string) error {
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
if _, err := io.Copy(out, r); err != nil {
return err
}
return nil
}
// ──────────────────────────────────────────────────────────
// 分片切分与 checkpoint
// ──────────────────────────────────────────────────────────
type driveDownloadPart struct {
index int
offset int64
length int64
}
// splitDownloadParts 按 partSize 等长切片,末片为余量。
func splitDownloadParts(totalSize, partSize int64) []driveDownloadPart {
if totalSize <= 0 || partSize <= 0 {
return nil
}
count := int((totalSize + partSize - 1) / partSize)
parts := make([]driveDownloadPart, 0, count)
for i := 0; i < count; i++ {
offset := int64(i) * partSize
length := partSize
if offset+length > totalSize {
length = totalSize - offset
}
parts = append(parts, driveDownloadPart{index: i, offset: offset, length: length})
}
return parts
}
// driveDownloadCheckpoint 断点续传元信息,随每个分片完成原子落盘。
type driveDownloadCheckpoint struct {
Version int `json:"version"`
Fingerprint string `json:"fingerprint"`
TotalSize int64 `json:"totalSize"`
PartSize int64 `json:"partSize"`
Completed []bool `json:"completed"`
}
// driveDownloadFingerprint 基于节点 ID + 版本号 + 文件总长 + 资源 URL 计算指纹。
// version>0 时只取 URL path(重签名不影响 checkpoint 复用);version==0(最新版)时
// 取完整 path+query——中心协议相同 path 可能对应不同实际版本,query 中的签名/token
// 标识了具体资源快照,防止错误复用旧 checkpoint。
// resourceURL 为空时不影响其他字段的指纹计算(安全降级)。
func driveDownloadFingerprint(nodeID string, version int, totalSize int64, resourceURL string) string {
urlComponent := ""
if resourceURL != "" {
if u, err := url.Parse(resourceURL); err == nil && u != nil {
if version == 0 {
// 最新版:含 query 以区分不同签名(不同实际版本)
urlComponent = u.RequestURI()
} else {
// 指定版本:只取 path,重签名不应废弃 checkpoint
urlComponent = u.Path
}
}
}
sum := sha256.Sum256([]byte(fmt.Sprintf("%s|%d|%d|%s", nodeID, version, totalSize, urlComponent)))
return hex.EncodeToString(sum[:])
}
// loadDriveDownloadCheckpoint 读取并校验 checkpoint;任一字段不匹配返回 nil(从头下载)。
func loadDriveDownloadCheckpoint(metaPath, fingerprint string, totalSize, partSize int64, partCount int) *driveDownloadCheckpoint {
data, err := os.ReadFile(metaPath)
if err != nil {
return nil
}
var cp driveDownloadCheckpoint
if err := json.Unmarshal(data, &cp); err != nil {
return nil
}
if cp.Version != driveCheckpointVersion || cp.Fingerprint != fingerprint ||
cp.TotalSize != totalSize || cp.PartSize != partSize || len(cp.Completed) != partCount {
return nil
}
return &cp
}
// save 原子写入(临时文件 + rename),避免中断产生半截 checkpoint。
func (cp *driveDownloadCheckpoint) save(metaPath string) error {
data, err := driveJsonMarshal(cp)
if err != nil {
return err
}
tmp := metaPath + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return driveOsRename(tmp, metaPath)
}
// ──────────────────────────────────────────────────────────
// 分片下载引擎
// ──────────────────────────────────────────────────────────
// downloadRangedParts 并发分片下载到 <dest>.dwspart,全部完成后校验总长并
// 原子重命名为 destPath、清理 checkpoint;中途失败保留分片产物供断点续传。
func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destPath string, totalSize int64, opts driveDownloadOptions) error {
parts := splitDownloadParts(totalSize, opts.partSize)
partPath := destPath + drivePartFileSuffix
metaPath := destPath + drivePartMetaSuffix
// 取首次凭证 URL 用于指纹计算,防止同大小文件覆盖后 checkpoint 错误复用
initialURL, _, _ := creds.current()
fingerprint := driveDownloadFingerprint(opts.nodeID, opts.version, totalSize, initialURL)
var cp *driveDownloadCheckpoint
if opts.resume {
cp = loadDriveDownloadCheckpoint(metaPath, fingerprint, totalSize, opts.partSize, len(parts))
// 分片数据文件缺失或长度不符时 checkpoint 作废,从头下载
if cp != nil {
if fi, err := os.Stat(partPath); err != nil || fi.Size() != totalSize {
cp = nil
}
}
} else {
// --no-resume:清理历史断点产物,从头下载且不写 checkpoint
_ = os.Remove(partPath)
_ = os.Remove(metaPath)
}
if cp == nil {
cp = &driveDownloadCheckpoint{
Version: driveCheckpointVersion,
Fingerprint: fingerprint,
TotalSize: totalSize,
PartSize: opts.partSize,
Completed: make([]bool, len(parts)),
}
}
f, err := os.OpenFile(partPath, os.O_RDWR|os.O_CREATE, 0o644)
if err != nil {
return fmt.Errorf("创建分片临时文件失败: %w", err)
}
if err := driveFileTruncate(f, totalSize); err != nil {
f.Close()
return fmt.Errorf("预分配分片临时文件失败: %w", err)
}
remaining := 0
for _, p := range parts {
if !cp.Completed[p.index] {
remaining++
}
}
if opts.logf != nil {
if remaining < len(parts) {
opts.logf("断点续传: 共 %d 分片(%s/片,并发 %d),已完成 %d,续传 %d",
len(parts), formatByteSize(opts.partSize), opts.parallel, len(parts)-remaining, remaining)
} else {
opts.logf("分片下载: 共 %d 分片(%s/片,并发 %d)", len(parts), formatByteSize(opts.partSize), opts.parallel)
}
}
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
var (
mu sync.Mutex // 保护 cp 与 checkpoint 落盘
wg sync.WaitGroup
errOnce sync.Once
firstErr error
)
fail := func(err error) {
errOnce.Do(func() {
firstErr = err
cancel()
})
}
jobs := make(chan driveDownloadPart)
workers := opts.parallel
if workers > remaining {
workers = remaining
}
if workers < 1 {
workers = 1
}
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for part := range jobs {
if runCtx.Err() != nil {
return
}
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
fail(fmt.Errorf("分片 %d/%d 下载失败: %w", part.index+1, len(parts), err))
return
}
mu.Lock()
cp.Completed[part.index] = true
var saveErr error
if opts.resume {
saveErr = cp.save(metaPath)
}
mu.Unlock()
if saveErr != nil {
fail(fmt.Errorf("写入下载断点信息失败: %w", saveErr))
return
}
}
}()
}
dispatch:
for _, part := range parts {
if cp.Completed[part.index] {
continue
}
select {
case jobs <- part:
case <-runCtx.Done():
break dispatch
}
}
close(jobs)
wg.Wait()
if firstErr != nil {
f.Close()
if errors.Is(firstErr, errCredentialRefreshVersionUnknown) {
// 激进策略:版本不可验证,清空 checkpoint 和分片文件防止错误续传;
// 用户重跑时将自然从头下载。
_ = os.Remove(metaPath)
_ = os.Remove(partPath)
}
return firstErr // 其他错误保留 .dwspart 与 checkpoint,重跑同一命令自动续传
}
if err := driveFileSync(f); err != nil {
f.Close()
return err
}
fi, statErr := driveFileStat(f)
f.Close()
if statErr != nil {
return statErr
}
if fi.Size() != totalSize {
return fmt.Errorf("下载完成但文件长度不符: got %d, want %d", fi.Size(), totalSize)
}
if err := driveOsRename(partPath, destPath); err != nil {
return fmt.Errorf("重命名下载文件失败: %w", err)
}
_ = os.Remove(metaPath)
return nil
}
// downloadOnePart 下载单个分片:常规失败指数退避重试 driveDownloadPartRetries 次;
// 401/403 触发凭证 single-flight 刷新(不计入常规重试,上限 driveDownloadPartAuthRetries),
// 刷新后用新凭证续传,不重下其他已完成分片。
func downloadOnePart(ctx context.Context, creds *driveCredentialState, f *os.File, part driveDownloadPart, totalSize int64) error {
attempt := 0
authRetries := 0
backoff := 500 * time.Millisecond
for {
urlStr, headers, gen := creds.current()
err := fetchRangeInto(ctx, urlStr, headers, f, part, totalSize)
if err == nil {
return nil
}
if ctx.Err() != nil {
return err
}
if isAuthStatusError(err) && authRetries < driveDownloadPartAuthRetries {
authRetries++
if rerr := creds.refresh(ctx, gen); rerr != nil {
return fmt.Errorf("重新获取下载凭证失败: %w (原错误: %v)", rerr, err)
}
continue // 凭证刷新不计常规重试、不退避
}
attempt++
if attempt > driveDownloadPartRetries {
return err
}
select {
case <-time.After(backoff):
case <-ctx.Done():
return err
}
backoff *= 2
}
}
// fetchRangeInto 拉取 [offset, offset+length) 区间并写入文件对应偏移。
func fetchRangeInto(ctx context.Context, urlStr string, headers map[string]string, f *os.File, part driveDownloadPart, expectedTotal int64) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return err
}
for k, v := range headers {
req.Header.Set(k, v)
}
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", part.offset, part.offset+part.length-1))
resp, err := driveRangeClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusPartialContent {
body, _ := io.ReadAll(io.LimitReader(resp.Body, driveTransferBodyErrCap))
return &httpStatusError{StatusCode: resp.StatusCode, Body: string(body)}
}
// 校验 Content-Range 响应区间与请求分片一致,防止代理/服务端返回错位数据
cr := resp.Header.Get("Content-Range")
if cr == "" {
return fmt.Errorf("分片响应缺少 Content-Range 头,无法验证数据偏移一致性")
}
{
crStart, crEnd, crTotal, crErr := parseContentRange(cr)
if crErr != nil {
return fmt.Errorf("Content-Range 解析失败: %w", crErr)
}
wantStart := part.offset
wantEnd := part.offset + part.length - 1
if crStart != wantStart || crEnd != wantEnd {
return fmt.Errorf("Content-Range 区间不匹配: 响应 %d-%d, 期望 %d-%d",
crStart, crEnd, wantStart, wantEnd)
}
if crTotal > 0 && expectedTotal > 0 && crTotal != expectedTotal {
return fmt.Errorf("Content-Range 总长不匹配: 响应 %d, 期望 %d", crTotal, expectedTotal)
}
}
n, err := io.Copy(io.NewOffsetWriter(f, part.offset), io.LimitReader(resp.Body, part.length))
if err != nil {
return err
}
if n != part.length {
return fmt.Errorf("分片长度不符: got %d, want %d", n, part.length)
}
return nil
}
// ──────────────────────────────────────────────────────────
// 上传:中心协议识别 + 401/403 重试 + 超限可读提示
// ──────────────────────────────────────────────────────────
// parseDriveUploadType 提取 get_upload_info 返回中的 uploadType(可选字段)。
// "httpToCenterWithToken" 表示中心协议;存量 OSS 返回无该字段,返回空串。
func parseDriveUploadType(text string) string {
var data map[string]any
if json.Unmarshal([]byte(text), &data) != nil {
return ""
}
if r, ok := data["result"].(map[string]any); ok {
data = r
}
t, _ := data["uploadType"].(string)
return t
}
// decorateUploadSizeError 为服务端上传超限错误补充可读提示。
// 不做本地文件大小上限校验(上限为服务端动态权益值,本地硬编码会漂移)。
func decorateUploadSizeError(err error, uploadType string) error {
var se *httpStatusError
if !errors.As(err, &se) {
return err
}
if se.StatusCode == http.StatusRequestEntityTooLarge ||
(uploadType == uploadTypeCenterToken && likelySizeLimitBody(se.Body)) {
return fmt.Errorf("%w\n提示: 文件大小可能超出空间容量或上传上限,请确认文件大小、清理空间或联系管理员调整容量后重试", err)
}
return err
}
func likelySizeLimitBody(body string) bool {
b := strings.ToLower(body)
if strings.Contains(b, "超限") || strings.Contains(b, "超出") || strings.Contains(b, "容量") {
return true
}
return strings.Contains(b, "size") && (strings.Contains(b, "limit") || strings.Contains(b, "exceed") || strings.Contains(b, "over"))
}
// driveUploadPut 解析上传凭证并执行 HTTP PUT;401/403(token 过期)时通过 refetch
// 重新获取凭证重试一次。返回最终生效凭证的 uploadId(凭证刷新后以新值为准)。
// 中心协议(uploadType=httpToCenterWithToken)与 OSS 走同一路径:resourceUrl 为
// 服务端拼好的完整 PUT URL(客户端零追加),headers(含 dentry-token)原样透传。
func driveUploadPut(ctx context.Context, credText string, refetch func(context.Context) (string, error), filePath string, fileSize int64) (string, error) {
resourceURL, uploadID, headers, err := parseDriveUploadInfo(credText)
if err != nil {
return "", err
}
uploadType := parseDriveUploadType(credText)
putErr := httpPutFile(ctx, resourceURL, headers, filePath, fileSize)
if putErr != nil && isAuthStatusError(putErr) && refetch != nil {
text2, rerr := refetch(ctx)
if rerr == nil {
if url2, id2, headers2, perr := parseDriveUploadInfo(text2); perr == nil {
uploadID = id2
uploadType = parseDriveUploadType(text2)
putErr = httpPutFile(ctx, url2, headers2, filePath, fileSize)
}
}
}
if putErr != nil {
return "", decorateUploadSizeError(putErr, uploadType)
}
return uploadID, nil
}
File diff suppressed because it is too large Load Diff
+9 -2
View File
@@ -14,7 +14,6 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -670,7 +669,15 @@ func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
// suggestForBusinessError returns a user-facing suggestion for known business
// error patterns in a parsed JSON body, or "" if no specific suggestion applies.
func suggestForBusinessError(body map[string]any) string {
return apperrors.SuggestBusinessHint(body)
msg := ""
if v, ok := body["errorMsg"].(string); ok {
msg = v
} else if v, ok := body["message"].(string); ok {
msg = v
} else if v, ok := body["error"].(string); ok {
msg = v
}
return suggestForBusinessErrorText(msg)
}
// confirmDelete is a convenience wrapper around cmdutil.ConfirmDelete that
+5 -17
View File
@@ -62,18 +62,13 @@ var ConversationSetTop = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
{Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId(最多 10 个)"},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat-ids", Type: shortcut.FlagStringSlice, Desc: "--conversation-ids 的兼容别名", Hidden: true},
{Name: "off", Type: shortcut.FlagBool, Desc: "取消置顶(不传则设置置顶)"},
{Name: "top", Type: shortcut.FlagBool, Default: "true", Desc: "置顶状态兼容参数", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"conversation-id", "conversation-ids", "open-conversation-id", "chat-id", "chat-ids"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"off", "top"}},
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"conversation-id", "conversation-ids"}},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"conversation-id", "conversation-ids", "open-conversation-id", "chat-id", "chat-ids"},
Flags: []string{"conversation-id", "conversation-ids"},
Description: "会话 ID 去重后必须为 1-10 个",
},
},
@@ -90,10 +85,6 @@ var ConversationSetTop = shortcut.Shortcut{
},
Execute: func(rt *shortcut.RuntimeContext) error {
ids := conversationSetTopIDs(rt)
top := !rt.Bool("off")
if rt.Changed("top") {
top = rt.Bool("top")
}
items := make([]shortcutBatchWrite, 0, len(ids))
for _, id := range ids {
items = append(items, shortcutBatchWrite{
@@ -101,7 +92,7 @@ var ConversationSetTop = shortcut.Shortcut{
arguments: map[string]any{
"openConversationId": id,
"cid": id,
"top": top,
"top": !rt.Bool("off"),
},
})
}
@@ -111,11 +102,8 @@ var ConversationSetTop = shortcut.Shortcut{
func conversationSetTopIDs(rt *shortcut.RuntimeContext) []string {
values := append([]string{}, rt.StrSlice("conversation-ids")...)
values = append(values, rt.StrSlice("chat-ids")...)
for _, name := range []string{"conversation-id", "open-conversation-id", "chat-id"} {
if value := rt.Str(name); value != "" {
values = append(values, value)
}
if value := rt.Str("conversation-id"); value != "" {
values = append(values, value)
}
return uniqueShortcutStrings(values)
}
+10 -37
View File
@@ -69,25 +69,15 @@ var ChatMembersGet = shortcut.Shortcut{
Intent: "当你已有若干成员的 openDingTalkId、需要批量获取他们在该群内的详情(群昵称、角色等)时使用;只读,需传群 openConversationId 和成员 openDingTalkId 列表。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"},
{Name: "group", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 openDingTalkId 列表(必填)"},
{Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "--users 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "group", "chat-id", "conversation-id", "open-conversation-id"}},
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"users", "open-dingtalk-ids"}},
{Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 openDingTalkId 列表", Required: true},
},
Tips: []string{`dws chat +chat-members-get --id <openConversationId> --users odid1,odid2`},
Execute: func(rt *shortcut.RuntimeContext) error {
conversationID := rt.StrFirst("id", "group", "chat-id", "conversation-id", "open-conversation-id")
return rt.CallMCP("list_group_member_by_ids", map[string]any{
"openConversationId": conversationID,
"cid": conversationID,
"memberOpenDingTalkIds": rt.StrSliceFirst("users", "open-dingtalk-ids"),
"openConversationId": rt.Str("id"),
"cid": rt.Str("id"),
"memberOpenDingTalkIds": rt.StrSlice("users"),
})
},
}
@@ -132,22 +122,14 @@ var ChatInviteURL = shortcut.Shortcut{
Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{Name: "expires-seconds", Type: shortcut.FlagInt, Desc: "链接有效期(秒),0 表示永久"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "id", "chat-id", "conversation-id", "open-conversation-id"}},
},
Tips: []string{`dws chat +chat-invite-url --group <openConversationId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
conversationID := rt.StrFirst("group", "id", "chat-id", "conversation-id", "open-conversation-id")
params := map[string]any{
"openConversationId": conversationID,
"cid": conversationID,
"openConversationId": rt.Str("group"),
"cid": rt.Str("group"),
}
if rt.Changed("expires-seconds") {
params["expiresSeconds"] = rt.Int("expires-seconds")
@@ -570,20 +552,11 @@ var ChatBots = shortcut.Shortcut{
Intent: "当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "id", "chat-id", "conversation-id", "open-conversation-id"}},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
},
Tips: []string{`dws chat +chat-bots --group <openConversationId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{
"openConversationId": rt.StrFirst("group", "id", "chat-id", "conversation-id", "open-conversation-id"),
})
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": rt.Str("group")})
if err != nil {
return err
}
+10 -31
View File
@@ -147,23 +147,14 @@ var MessagesRecall = shortcut.Shortcut{
Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息,需传会话 openConversationId 和消息 openMessageId。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId(必填)"},
{Name: "group", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId(必填)"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "--msg-id 的兼容别名", Hidden: true},
{Name: "open-message-id", Type: shortcut.FlagString, Desc: "--msg-id 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"conversation-id", "group", "chat-id", "open-conversation-id"}},
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"msg-id", "message-id", "open-message-id"}},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
},
Tips: []string{`dws chat +messages-recall --conversation-id <openConversationId> --msg-id <openMessageId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("recall_message", map[string]any{
"openConversationId": rt.StrFirst("conversation-id", "group", "chat-id", "open-conversation-id"),
"openMessageId": rt.StrFirst("msg-id", "message-id", "open-message-id"),
"openConversationId": rt.Str("conversation-id"),
"openMessageId": rt.Str("msg-id"),
})
},
}
@@ -538,30 +529,26 @@ var MessagesMget = shortcut.Shortcut{
Intent: "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: append([]shortcut.Flag{
{Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID(必填)"},
{Name: "message-id", Type: shortcut.FlagStringSlice, Desc: "--msg-ids 的兼容别名", Hidden: true},
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "--msg-ids 的兼容别名", Hidden: true},
{Name: "open-message-ids", Type: shortcut.FlagStringSlice, Desc: "--msg-ids 的兼容别名", Hidden: true},
{Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID", Required: true},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
}, MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"msg-ids", "message-id", "message-ids", "open-message-ids"}},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"msg-ids", "message-id", "message-ids", "open-message-ids"},
Flags: []string{"msg-ids"},
Description: "--msg-ids 去重后必须包含 1-50 条消息 ID",
},
}, MessageResourceDownloadConstraints()...),
Tips: []string{`dws chat +messages-mget --msg-ids msgId1,msgId2`},
Validate: func(rt *shortcut.RuntimeContext) error {
ids := messageMgetIDs(rt)
ids := uniqueShortcutStrings(rt.StrSlice("msg-ids"))
if len(ids) < 1 || len(ids) > 50 {
return fmt.Errorf("--msg-ids 去重后必须包含 1-50 条消息 ID,当前 %d 条", len(ids))
}
return ValidateMessageResourceDownload(rt)
},
Execute: func(rt *shortcut.RuntimeContext) error {
ids := messageMgetIDs(rt)
ids := uniqueShortcutStrings(rt.StrSlice("msg-ids"))
data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{"openMsgIds": ids})
if err != nil {
return err
@@ -594,10 +581,6 @@ var MessagesMget = shortcut.Shortcut{
},
}
func messageMgetIDs(rt *shortcut.RuntimeContext) []string {
return uniqueShortcutStrings(rt.StrSliceFirst("msg-ids", "message-id", "message-ids", "open-message-ids"))
}
// MessageResourceDownloadFlags returns the common opt-in resource workflow used
// by message list, search, mget, @me and thread-reading Shortcuts.
func MessageResourceDownloadFlags() []shortcut.Flag {
@@ -850,15 +833,11 @@ var MessagesQuerySendStatus = shortcut.Shortcut{
Intent: "当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "open-task-id", Type: shortcut.FlagString, Desc: "发送消息时返回的 openTaskId(必填)"},
{Name: "task-id", Type: shortcut.FlagString, Desc: "--open-task-id 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"open-task-id", "task-id"}},
{Name: "open-task-id", Type: shortcut.FlagString, Desc: "发送消息时返回的 openTaskId", Required: true},
},
Tips: []string{`dws chat +messages-query-send-status --open-task-id <openTaskId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.StrFirst("open-task-id", "task-id")})
return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.Str("open-task-id")})
},
}
@@ -129,72 +129,6 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
wantTool: "query_msg_read_status",
wantArgs: map[string]any{"openConversationId": "cid-1"},
},
{
name: "chat update id and title aliases",
argv: []string{"chat", "+chat-update", "--open-conversation-id", "cid-1", "--title", "新群名", "--yes"},
wantProduct: "chat",
wantTool: "update_group_name",
wantArgs: map[string]any{"openconversation_id": "cid-1", "group_name": "新群名"},
},
{
name: "member get id and users aliases",
argv: []string{"chat", "+chat-members-get", "--conversation-id", "cid-1", "--open-dingtalk-ids", "D1,D2"},
wantProduct: "im",
wantTool: "list_group_member_by_ids",
wantArgs: map[string]any{
"openConversationId": "cid-1",
"memberOpenDingTalkIds": []string{"D1", "D2"},
},
},
{
name: "invite url id alias",
argv: []string{"chat", "+chat-invite-url", "--chat-id", "cid-1"},
wantProduct: "im",
wantTool: "get_group_invite_url",
wantArgs: map[string]any{"openConversationId": "cid-1"},
},
{
name: "chat bots id alias",
argv: []string{"chat", "+chat-bots", "--id", "cid-1"},
wantProduct: "bot",
wantTool: "list_group_bots",
wantArgs: map[string]any{"openConversationId": "cid-1"},
},
{
name: "recall message aliases",
argv: []string{"chat", "+messages-recall", "--chat-id", "cid-1", "--message-id", "msg-1", "--yes"},
wantProduct: "im",
wantTool: "recall_message",
wantArgs: map[string]any{"openConversationId": "cid-1", "openMessageId": "msg-1"},
},
{
name: "message mget message id alias",
argv: []string{"chat", "+messages-mget", "--message-id", "msg-1,msg-2"},
wantProduct: "im",
wantTool: "list_messages_by_ids",
wantArgs: map[string]any{"openMsgIds": []string{"msg-1", "msg-2"}},
},
{
name: "send status task id alias",
argv: []string{"chat", "+messages-query-send-status", "--task-id", "task-1"},
wantProduct: "im",
wantTool: "query_message_send_status",
wantArgs: map[string]any{"openTaskId": "task-1"},
},
{
name: "conversation top aliases",
argv: []string{"chat", "+conversation-set-top", "--open-conversation-id", "cid-1", "--top=false", "--yes"},
wantProduct: "im",
wantTool: "set_top_conversation",
wantArgs: map[string]any{"openConversationId": "cid-1", "top": false},
},
{
name: "favorite list limit alias",
argv: []string{"chat", "+flag-list", "--limit", "7"},
wantProduct: "im",
wantTool: "list_message_favorites",
wantArgs: map[string]any{"size": "7"},
},
{
name: "at all mute matches live MCP schema",
argv: []string{"chat", "+conversation-mute-at-all", "--conversation-id", "cid-1", "--yes"},
@@ -256,7 +190,7 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
t.Fatalf("call = %s/%s, want %s/%s", fake.product, fake.tool, tc.wantProduct, tc.wantTool)
}
for key, want := range tc.wantArgs {
if got := fake.args[key]; !reflect.DeepEqual(got, want) {
if got := fake.args[key]; got != want {
t.Errorf("%s = %#v, want %#v", key, got, want)
}
}
+6 -18
View File
@@ -119,24 +119,14 @@ var ChatUpdate = shortcut.Shortcut{
Intent: "当你只需要修改群名称时使用;这是 lark-cli +chat-update 的诚实子集,只接受群 openConversationId 和新名称。修改群 description、个人备注、群昵称或其他群设置时不要使用。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "name", Type: shortcut.FlagString, Desc: "新的群名称(必填)"},
{Name: "title", Type: shortcut.FlagString, Desc: "--name 的兼容别名", Hidden: true},
{Name: "new-title", Type: shortcut.FlagString, Desc: "--name 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "id", "chat-id", "conversation-id", "open-conversation-id"}},
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"name", "title", "new-title"}},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "新的群名称", Required: true},
},
Tips: []string{`dws chat +chat-update --group <openConversationId> --name "新群名"`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("update_group_name", map[string]any{
"openconversation_id": rt.StrFirst("group", "id", "chat-id", "conversation-id", "open-conversation-id"),
"group_name": rt.StrFirst("name", "title", "new-title"),
"openconversation_id": rt.Str("group"),
"group_name": rt.Str("name"),
})
},
}
@@ -401,8 +391,6 @@ var FlagList = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "数字分页游标,首次传 0"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,范围 1-100"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "--size 的兼容别名", Hidden: true},
{Name: "max", Type: shortcut.FlagInt, Desc: "--size 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{{
Kind: shortcut.ConstraintCustom,
@@ -414,7 +402,7 @@ var FlagList = shortcut.Shortcut{
if rt.Int("cursor") < 0 {
return apperrors.NewValidation("--cursor 必须大于等于 0")
}
if size := rt.IntFirst("size", "limit", "max"); size < 1 || size > 100 {
if size := rt.Int("size"); size < 1 || size > 100 {
return apperrors.NewValidation("--size 必须在 1-100 之间")
}
return nil
@@ -422,7 +410,7 @@ var FlagList = shortcut.Shortcut{
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("list_message_favorites", map[string]any{
"cursor": rt.Int("cursor"),
"size": strconv.Itoa(rt.IntFirst("size", "limit", "max")),
"size": strconv.Itoa(rt.Int("size")),
})
},
}
+1 -30
View File
@@ -96,17 +96,6 @@ func (rt *RuntimeContext) StrSlice(name string) []string {
return v
}
// StrSliceFirst returns the first non-empty string-slice value across a
// primary flag and its compatibility aliases.
func (rt *RuntimeContext) StrSliceFirst(names ...string) []string {
for _, name := range names {
if v := rt.StrSlice(name); hasNonEmptyString(v) {
return v
}
}
return nil
}
// Changed reports whether the user explicitly set the flag on the command line.
func (rt *RuntimeContext) Changed(name string) bool {
f := rt.cmd.Flags().Lookup(name)
@@ -369,27 +358,9 @@ func shortcutLongHelp(s Shortcut) string {
if len(s.Constraints) == 0 {
return long
}
publicFlags := make(map[string]bool, len(s.Flags))
for _, flag := range s.Flags {
publicFlags[flag.Name] = !flag.Hidden
}
lines := make([]string, 0, len(s.Constraints))
for _, constraint := range s.Constraints {
visible := constraint
visible.Flags = make([]string, 0, len(constraint.Flags))
for _, flag := range constraint.Flags {
if publicFlags[flag] {
visible.Flags = append(visible.Flags, flag)
}
}
if len(visible.Flags) == 0 ||
(visible.Kind != ConstraintCustom && len(visible.Flags) < 2) {
continue
}
lines = append(lines, " - "+constraintHelp(visible))
}
if len(lines) == 0 {
return long
lines = append(lines, " - "+constraintHelp(constraint))
}
return long + "\n\n参数约束:\n" + strings.Join(lines, "\n")
}
-15
View File
@@ -16,7 +16,6 @@ package shortcut
import (
"bytes"
"os"
"reflect"
"strings"
"testing"
@@ -138,7 +137,6 @@ func TestCrossPlatformCoverageSchemaConstraintCollapsesHiddenAliases(t *testing.
cmd := mount(Shortcut{
Service: "chat",
Command: "+search",
Intent: "搜索消息",
Flags: []Flag{
{Name: "query", Type: FlagString},
{Name: "keyword", Type: FlagString, Hidden: true},
@@ -167,11 +165,6 @@ func TestCrossPlatformCoverageSchemaConstraintCollapsesHiddenAliases(t *testing.
if raw := cmd.Annotations["dws.schema.constraints"]; raw != "" {
t.Fatalf("hidden compatibility alias leaked into public Schema constraints: %q", raw)
}
for _, hidden := range []string{"--keyword", "--legacy-id"} {
if strings.Contains(cmd.Long, hidden) {
t.Fatalf("hidden compatibility alias leaked into long help: %q", cmd.Long)
}
}
}
func TestCrossPlatformCoverageAliasAndAIMessageTagHelpers(t *testing.T) {
@@ -186,8 +179,6 @@ func TestCrossPlatformCoverageAliasAndAIMessageTagHelpers(t *testing.T) {
Flags: []Flag{
{Name: "query", Type: FlagString},
{Name: "keyword", Type: FlagString},
{Name: "ids", Type: FlagStringSlice},
{Name: "legacy-ids", Type: FlagStringSlice},
{Name: "limit", Type: FlagInt, Default: "20"},
{Name: "size", Type: FlagInt},
aiTag,
@@ -202,12 +193,6 @@ func TestCrossPlatformCoverageAliasAndAIMessageTagHelpers(t *testing.T) {
if got := rt.StrFirst("query", "keyword"); got != "树莓派" {
t.Fatalf("StrFirst() = %q, want 树莓派", got)
}
if err := cmd.Flags().Set("legacy-ids", "a,b"); err != nil {
t.Fatal(err)
}
if got := rt.StrSliceFirst("ids", "legacy-ids"); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Fatalf("StrSliceFirst() = %#v, want [a b]", got)
}
if got := rt.IntFirst("limit", "size"); got != 20 {
t.Fatalf("IntFirst() default = %d, want 20", got)
}
@@ -218,20 +218,6 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
wantTool: "search_messages",
wantArgs: map[string]any{"openConversationIds": []string{"cid-1"}, "keyword": "树莓派"},
},
{
name: "dm name alias",
argv: []string{"chat", "+dm", "--name", "张三", "--text", "你好", "--yes"},
wantProduct: "chat",
wantTool: "send_personal_message",
wantArgs: map[string]any{"receiverOpenDingTalkId": "open1"},
},
{
name: "chat members list id alias",
argv: []string{"chat", "+chat-members-list", "--chat-id", "cid-1", "--member-types", "user"},
wantProduct: "chat",
wantTool: "get_group_members",
wantArgs: map[string]any{"openconversation_id": "cid-1"},
},
}
for _, tc := range tests {
+2 -7
View File
@@ -40,21 +40,16 @@ var DM = shortcut.Shortcut{
"内部先按姓名搜通讯录解析出唯一用户,并用其 openDingTalkId 发送,姓名匹配到多人时会列出候选让你区分。会真实发出消息。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "to", Type: shortcut.FlagString, Desc: "收件人姓名/花名(必填)"},
{Name: "name", Type: shortcut.FlagString, Desc: "--to 的兼容别名", Hidden: true},
{Name: "keyword", Type: shortcut.FlagString, Desc: "--to 的兼容别名", Hidden: true},
{Name: "to", Type: shortcut.FlagString, Desc: "收件人姓名/花名", Required: true},
{Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true},
shortcut.AIMessageTagFlag(),
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"to", "name", "keyword"}},
},
Tips: []string{`dws chat +dm --to 张三 --text "周报发我一下"`},
Execute: func(rt *shortcut.RuntimeContext) error {
text := rt.Str("text")
// Step 1 — resolve the recipient name to a unique userId.
user, err := resolveOpenDingTalkUser(rt, rt.StrFirst("to", "name", "keyword"))
user, err := resolveOpenDingTalkUser(rt, rt.Str("to"))
if err != nil {
return err
}
+2 -5
View File
@@ -109,20 +109,17 @@ var ChatMembersList = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群名称(与 --conversation-id 二选一)"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "群 openConversationId(与 --group 二选一)"},
{Name: "id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "member-types", Type: shortcut.FlagStringSlice, Desc: "成员类型:user,bot;不传则同时返回"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "chat-id", "open-conversation-id"}},
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id"}},
},
Tips: []string{
`dws chat +chat-members-list --group "项目冲刺"`,
`dws chat +chat-members-list --conversation-id <openConversationId> --member-types user,bot`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "id", "chat-id", "open-conversation-id"))
groupID := strings.TrimSpace(rt.Str("conversation-id"))
groupName := strings.TrimSpace(rt.Str("group"))
if groupID == "" {
resolved, err := resolveGroupName(rt, groupName)
+3 -32
View File
@@ -28,7 +28,7 @@ import (
// NewShortcutCommand builds the `dws shortcut` management command tree:
//
// dws shortcut list [--service x] [--compact] # list built-in shortcuts
// dws shortcut list [--service x] # list built-in shortcuts
// dws shortcut stats [--top N] # high-frequency usage aggregation
// dws shortcut stats --purge # clear the usage log
func NewShortcutCommand() *cobra.Command {
@@ -51,7 +51,6 @@ func newListCommand() *cobra.Command {
RunE: func(cmd *cobra.Command, _ []string) error {
svc, _ := cmd.Flags().GetString("service")
includeHidden, _ := cmd.Flags().GetBool("all")
compact, _ := cmd.Flags().GetBool("compact")
rows := make([]shortcutListRow, 0)
for _, s := range shortcut.All() {
if svc != "" && s.Service != svc {
@@ -62,37 +61,19 @@ func newListCommand() *cobra.Command {
}
rows = append(rows, newShortcutListRow(s))
}
payload := map[string]any{
return output.WriteCommandPayload(cmd, map[string]any{
"catalog": "shortcut",
"runtime_schema": true,
"count": len(rows),
"shortcuts": rows,
}
if compact {
compactRows := make([]shortcutCompactListRow, 0, len(rows))
for _, row := range rows {
compactRows = append(compactRows, row.compact())
}
payload["compact"] = true
payload["shortcuts"] = compactRows
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
}, output.FormatJSON)
},
}
cmd.Flags().String("service", "", "只列出指定服务的 shortcut")
cmd.Flags().Bool("all", false, "包含当前未进入公开 Catalog、但仍可直接调用的 shortcut")
cmd.Flags().Bool("compact", false, "仅返回发现和安全决策所需字段;选中后再查 leaf Schema 或完整 catalog")
return cmd
}
type shortcutCompactListRow struct {
CLIPath string `json:"cli_path"`
Risk string `json:"risk"`
Confirmation string `json:"confirmation"`
Description string `json:"description"`
Intent string `json:"intent,omitempty"`
}
type shortcutListRow struct {
Service string `json:"service"`
Command string `json:"command"`
@@ -113,16 +94,6 @@ type shortcutListRow struct {
Reviewed bool `json:"reviewed"`
}
func (r shortcutListRow) compact() shortcutCompactListRow {
return shortcutCompactListRow{
CLIPath: r.CLIPath,
Risk: r.Risk,
Confirmation: r.Confirmation,
Description: r.Description,
Intent: r.Intent,
}
}
func newShortcutListRow(s shortcut.Shortcut) shortcutListRow {
product := s.Product
if product == "" {
-51
View File
@@ -75,57 +75,6 @@ func TestCrossPlatformCoverageShortcutListFiltersHiddenAndService(t *testing.T)
}
}
func TestCrossPlatformCoverageShortcutListCompactPublishesDiscoveryContract(t *testing.T) {
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+messages-send",
Description: "发送一条消息",
Intent: "用户要求发送消息",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "text", Required: true},
},
Tips: []string{"dws chat +messages-send --text hello"},
})
cmd := newListCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{"--service", "chat", "--compact"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var payload struct {
Compact bool `json:"compact"`
Count int `json:"count"`
Shortcuts []map[string]any `json:"shortcuts"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if !payload.Compact || payload.Count != 1 || len(payload.Shortcuts) != 1 {
t.Fatalf("unexpected compact payload: %#v", payload)
}
row := payload.Shortcuts[0]
for key, want := range map[string]any{
"cli_path": "chat +messages-send",
"description": "发送一条消息",
"intent": "用户要求发送消息",
"risk": "write",
"confirmation": "user_required",
} {
if got := row[key]; got != want {
t.Fatalf("compact row %s = %#v, want %#v", key, got, want)
}
}
for _, omitted := range []string{"flags", "constraints", "examples", "primary", "reviewed"} {
if _, ok := row[omitted]; ok {
t.Fatalf("compact row unexpectedly contains %q: %#v", omitted, row)
}
}
}
func TestCrossPlatformCoverageShortcutListRowPublishesCompleteContract(t *testing.T) {
row := newShortcutListRow(shortcut.Shortcut{
Service: "chat",
+5 -5
View File
@@ -50,13 +50,13 @@ PRODUCT_START = "<!-- VISIBLE_SHORTCUTS_START -->"
PRODUCT_END = "<!-- VISIBLE_SHORTCUTS_END -->"
# Large, high-frequency product skills should route known intents directly and
# keep their full shortcut inventory in the Runtime Shortcut Catalog. Curated
# entries may additionally be queried through leaf Schema. Add services here
# only after verifying that the product skill has its own reviewed routing
# keep their full shortcut inventory in Runtime Catalog/Schema. Add services
# here only after verifying that the product skill has its own reviewed routing
# section and intent table; compacting a sparse skill without an alternative
# route would make its shortcuts harder to discover.
COMPACT_PRODUCT_SERVICES = {"chat"}
def md_escape(value: Any) -> str:
text = str(value or "")
return text.replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ")
@@ -138,9 +138,9 @@ def compact_product_section(service: str, rows: list[dict[str, Any]]) -> str:
return f"""{PRODUCT_START}
## Shortcut 发现(按需)
`{md_escape(service)}` 当前有 {len(rows)} 条公开 shortcut。完整清单保留在 Runtime Shortcut Catalog;已完成 Schema curation 的子集可通过 leaf Schema 查询。高频产品根 Skill 不重复展开完整清单。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
`{md_escape(service)}` 当前有 {len(rows)} 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service {md_escape(service)} --compact --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service {md_escape(service)} --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
{PRODUCT_END}"""
+1 -1
View File
@@ -21,7 +21,7 @@ shortcut_rows="$(
awk '
/<!-- VISIBLE_SHORTCUTS_START -->/ { in_block = 1; next }
/<!-- VISIBLE_SHORTCUTS_END -->/ { in_block = 0 }
in_block && /^\|[[:space:]]*`/ { count++ }
in_block && /^\| `dws chat \+/ { count++ }
END { print count + 0 }
' "$chat_skill"
)"
+4 -5
View File
@@ -1,8 +1,7 @@
---
name: dws
description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件、实时监听群成员加入、群成员退出、群改名和群解散时使用。
metadata:
cli_version: ">=1.0.15"
cli_version: ">=1.0.15"
---
# 钉钉全产品 Skill
@@ -22,15 +21,15 @@ metadata:
- 危险操作必须先向用户确认,用户同意后才加 `--yes` 执行
- 单次批量操作不超过 30 条记录
- 所有命令必须**严格遵循**对应产品参考文档里面规定的参数格式(如:如果有参数值,则参数和参数值之间至少用一个空格隔开)
- **CLI 路径必须独立可用**:不要假定用户环境安装了 Python。优先使用 `dws` 原生命令或 Shortcut;[scripts/](./scripts/) 仅在对应运行时已确认可用且能明显简化翻页、轮询或批量操作时作为可选加速项。脚本不可用时直接执行同场景的原生命令流程,不得把缺少 Python 当成能力阻塞
- **脚本优先**:[scripts/](./scripts/) 下的 `python scripts/<name>.py` 已封装翻页/轮询/批量逻辑,遇到对应场景(如 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等)**优先调用脚本**而非手写多步命令。脚本均支持 `--dry-run` 预览、`--format json` 输出,失败时回退到手动步骤
- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,必须走 `dws event consume ... --flatten` 长连接,不要写脚本轮询消息历史
## Shortcut 与原子命令的使用原则
`shortcut` 是对常用操作的高层封装,适合优先承担用户意图;产品参考文档和本 skill 负责判断意图、风险、跨产品流程和复杂参数,CLI 帮助负责声明当前版本真正可调用的命令。
- 先按产品参考、意图表和 recipe 路由。精确 recipe 或对应解释器(如 `python3`)已可用的专用脚本优先;解释器不可用时直接跳过脚本,不得阻塞任务。否则用户意图可由可见 shortcut 满足时,优先使用 `dws <service> +<verb> ... --format json`,不要手写等价的多步原子命令。
- 公开内建 shortcut 以 `dws shortcut list --service <service> --format json` 为动态 catalog;已进入 Runtime Schema 的 leaf 用 `dws schema --cli-path "<service> +<verb>" --format json` 读取 Agent 选择、参数、跨参数约束、risk/confirmation 与接口语义。若 leaf 暂未进入 Schema,则使用 catalog 中同一 `cli_path` 的完整契约。
- 先按产品参考、意图表和 recipe 路由。存在精确覆盖场景的专用脚本/recipe 时继续遵循“脚本优先”;否则用户意图可由可见 shortcut 满足时,优先使用 `dws <service> +<verb> ... --format json`,不要手写等价的多步原子命令。
- 公开内建 shortcut 同时进入 Runtime Schema。用 `dws schema --cli-path "<service> +<verb>" --format json` 读取 Agent 选择、参数、跨参数约束、risk/confirmation 与接口语义;`dws shortcut list --service <service> --format json` 只作为轻量批量发现入口。
- 真正组装参数前用叶子帮助 `dws <service> +<verb> --help` 核对当前 Cobra 接受的 flags。父级 `dws <service> --help` 只能发现子命令,不能替代叶子参数帮助。
- shortcut catalog 中 `confirmation=user_required` 时,必须先获得用户确认,确认后才加 `--yes`;`not_required` 不额外确认。
- 如果 shortcut 不在 help / list 中,改用产品参考里的原子命令、脚本或标准流程;不要猜测未展示的 `+` 命令。
@@ -4,10 +4,10 @@
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| query-group-chat | 1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>" --direction newer --format json`<br>3. **翻页**:`hasMore=true` 时取本页最后一条消息的 `createTime` 作为下一次 `--time`,保持原 `--direction`,禁止自造 cursor/time;直到 `hasMore=false`<br>4. `--direction older` 拉给定时间**之前**的消息<br>5. 合并结果;用户要求导出时由 Agent 将结构化输出写入目标文件 |
| query-private-chat | 1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId` / `openDingTalkId`<br>2. `chat message list --user <userId> --time "<yyyy-MM-dd HH:mm:ss>" --direction newer --format json`;跨组织时可用 `--open-dingtalk-id`<br>3. **翻页**:同 query-group-chat<br>4. 合并结果后总结或按用户要求保存 |
| query-group-chat | **优先**:`chat_export_messages.py`(开源版未引入;可手动用 `dws chat message list` 翻页后写入文件)(自动搜群+翻页+导出)<br>备选:1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`<br>4. `--forward=false` 拉给定时间**之前**的消息<br>5. 合并全部消息后总结 |
| query-private-chat | **优先**:`chat_history_with_user.py`(开源版未引入;可手动用 `dws chat search` + `dws chat message list` 组合)(自动搜人+翻页+导出)<br>备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `chat message list --user <userId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:同 query-group-chat<br>4. 合并全部消息后总结 |
| escalate-ding | 三级升级:<br>1. `ding message send --robot-code <robotCode> --type app --users <userId> --content "<内容>"`(必填项见 [ding.md](../products/ding.md))<br>2. `chat message send --group <openConversationId> --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](../products/chat.md))<br>3. `todo task create --title "<标题>" --executors <userId> --priority 40` 建紧急待办<br>前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` |
| send-by-bot | 1. `chat bot search` → 取 `robotCode`<br>2. `chat search --query "<群名>"` → 取每个 `openConversationId`<br>3. 单群用 `chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "<标题>" --text "<内容>"`<br>4. 多群时先一次性确认全部目标与内容,再对每个已确认群逐一执行同一命令;汇总每群成功/失败结果 |
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat +chat-messages --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat +messages-send --as user --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--chat-id <openConversationId>` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
| send-by-bot | **多群批量优先**:`bot_broadcast.py`(开源版未引入;可手动用 `dws chat message send-by-bot` 多次调用)<br>单群:1. `chat bot search` → 取 `robotCode`<br>2. `chat search --query "<群名>"` → 取 `openConversationId`<br>3. `chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "<标题>" --text "<内容>"` |
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId> --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
| search-common-group | `chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0`(`--match-mode AND`=全在/`OR`=任一在,翻页:`hasMore=true` 时用 `nextCursor`)<br>用户说"我和XX的共同群" → nicks 包含"我"时,需先 `contact user get-self` 取自己昵称再拼接 |
| focus-messages | **零参数一行命令**:`chat message list-focused --limit 50`(拉特别关注人发的消息聚合)<br>触发 query:`"我特别关注的人最近发了什么消息"`、`"关注的人最近聊了啥"`、`"星标联系人最近的动态"`<br>**强消歧**:query 含动词【发/说/聊/讲】或名词【消息/聊天/动态】 → **必须**走本命令,**不要**先去拉 `contact relation list-my-followings`;仅当用户终点是"人员列表"(如"我关注了谁")才走 `relation list-my-followings`(详见 [contact.md](../products/contact.md#意图判断) 易混淆硬规则)<br>翻页:`hasMore=true` 时用 `nextCursor` 作为下次 `--cursor`<br>按人精控(可选):先 `contact relation list-my-followings` 取 `openDingTalkId`,再 `chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start <ISO> --end <ISO>` |
@@ -105,6 +105,7 @@
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `workflow edit-example` | 获取编辑文档与 DSL 示例 | 无 | create/update 前优先调用,内容由服务端提供 |
| `workflow create` | 创建并发布工作流 | `--base-id` `--dsl` | `--dsl` 为完整 workflow-dsl/v1;非幂等,不自动重试 |
| `workflow update` | 更新并发布工作流 | `--base-id` `--workflow-id` `--dsl` | 全量替换,先 get 留底;检查 `data.valid/issues` |
| `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` |
@@ -7,6 +7,7 @@
| 命令 | 用途 |
|------|------|
| `workflow edit-example` | 获取工作流编辑文档与 workflow-dsl/v1 示例 |
| `workflow create` | 创建并发布自动化工作流 |
| `workflow update` | 更新并发布已有自动化工作流 |
| `workflow list` | 列出 Base 下所有工作流(含状态/创建人/最后修改时间),支持分页 |
@@ -14,11 +15,11 @@
| `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) |
| `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) |
> 所有子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
> `workflow edit-example` 无参数;其他子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
## DSL 入参格式与最小 Demo
`workflow create/update` 的 `--dsl` 接收完整的 `workflow-dsl/v1` JSON object,不是局部 patch。支持内联 JSON、`@文件路径` 或 `-` 从 stdin 读取。
先运行 `workflow edit-example` 获取服务端提供的最新编辑文档和示例。`workflow create/update` 的 `--dsl` 接收完整的 `workflow-dsl/v1` JSON object,不是局部 patch。支持内联 JSON、`@文件路径` 或 `-` 从 stdin 读取。
复杂工作流应先用 `table get` / `field get` / `view list` 确认真实 `sheetId`、`fieldId`、`viewId`,并检查所有 `next`、`loopEntry`、branch `to` 和 ref。下面是一个不依赖数据表字段的最小定时消息工作流:
@@ -53,6 +54,14 @@ create 和 update 都必须同时满足 `status=success`、`data.valid=true`、`
## 命令详情
### workflow edit-example — 获取编辑文档与示例
```bash
dws aitable workflow edit-example --format json
```
该命令无业务参数,调用 `aitable/edit_workflow_example` 返回服务端提供的工作流编辑文档和示例。创建或更新复杂工作流前优先调用它,避免依赖可能过期的本地 DSL 结构。
### workflow create — 创建并发布工作流
```bash
+14 -28
View File
@@ -4,26 +4,24 @@
## Shortcut 优先路由
精确脚本 / recipe 未覆盖时,常见 Agent 意图优先使用公开 `+` Shortcut;下面的原子命令章节保留给需要特定原始返回结构、兼容参数或 Shortcut 未覆盖字段的场景。先尝试 `dws shortcut list --service chat --compact --format json` 动态发现;当前 CLI 报 `unknown flag: --compact` 时,立即去掉 `--compact` 重试。选中后读取 `dws schema --cli-path "<cli_path>" --format json`;若 leaf 暂未进入 Schema,则以完整 Catalog 中同一 `cli_path` 的契约为准,最后用 `dws <cli_path> --help` 核对真实 flags。
常见 Agent 意图优先使用公开 `+` Shortcut;下面的原子命令章节保留给需要特定原始返回结构、兼容参数或 Shortcut 未覆盖字段的场景。执行前用 `dws schema --cli-path "chat +<shortcut>" --format json` 读取最终参数、约束和确认语义。
| 意图 | 首选 |
|---|---|
| 以 current-user / bot / webhook 身份发消息 | `dws chat +messages-send --as <identity> ...` |
| 拉取单个群聊或单聊的消息 | `dws chat +chat-messages ...` |
| 按关键词、发送者、@对象、会话、类型或时间组合搜索 | `dws chat +search-msg ...` |
| 查询 @ 我的消息 | `dws chat +at-me ...` |
| 查询 @我的消息 | `dws chat +at-me ...` |
| 根据消息 ID 批量取详情与 reaction | `dws chat +messages-mget ...` |
| 读取已知 thread/topic 的全部回复 | `dws chat +thread-replies ...` |
| 下载单个 mediaId/fileId | `dws chat +messages-resource-download ...` |
| 创建并按需立即更新流式卡片 | `dws chat +messages-send-card ...` |
- `+messages-send` 只暴露下层真实支持的身份能力:user 支持文本/Markdown、已有 mediaId 图片、本地文件和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。
- `+messages-send` 自动规范化并补齐对应身份的 @ 占位符。声明 `--at-*` / `--at-all` 即可,不要为统一 Shortcut 手工拼 `@10`。
- `+messages-send` 会自动规范化并补齐 @ 占位符。user 使用 `<@id>` / `<@all>`;bot/webhook 使用 `@id` / `@手机号` / `@all`。声明 `--at-*` / `--at-all` 即可,不要为统一 Shortcut 手工拼 `@10`。
- `+search-msg --page-all` 连续翻页并默认按消息 ID 批量富化;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger。
- `+at-me`、`+chat-messages`、`+messages-mget`、`+search-msg`、`+thread-replies` 可用 `--download-resources` 下载资源。引用、回复、合并转发中的资源使用结果 `resourceRefs` 自带的子消息 `messageId`;仅当子消息缺会话 ID 时继承父消息 `openConversationId`。
- 上述五个查询 Shortcut 与 `+messages-resource-download` 都是 `read/not_required`,不要添加 `--yes`。下载只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
- 下载器只接受经审查的钉钉与公网 OSS HTTPS 地址并逐跳校验重定向;跨主机时不会转发下层提供的请求头。新官方域名被拒绝时记录错误中的 host 供审查,不要放宽为任意 HTTPS。
- `+messages-send-card` 的目标为 `--group`、`--receiver`、`--receiver-open-dingtalk-id` 三选一;传 `--content` 时创建并立即更新,不传时返回 `bizId` 供 `message update-card` 使用。
- 上述五个查询 Shortcut 与 `+messages-resource-download` 都沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。下载只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
- 下载器仅接受经审查的钉钉与公网 OSS HTTPS 地址并逐跳校验重定向;跨主机时不会转发下层提供的请求头。新官方域名被拒绝时记录错误中的 host 供审查,不要放宽为任意 HTTPS。
### group (群组管理)
@@ -1171,10 +1169,6 @@ Flags:
#### 下载消息中的资源(图片/视频/语音等)到本地
读取消息时优先在 `+at-me`、`+chat-messages`、`+messages-mget`、`+search-msg`、`+thread-replies` 上加 `--download-resources --output-dir ./downloads`。只下载单个已知资源时优先用 `+messages-resource-download`:mediaId 必须同时传 `--message-id` 和 `--open-conversation-id`,fileId 不需要消息上下文。输出必须是工作目录内相对路径且不能包含 `..`;默认不覆盖,用户明确要求时才传 `--overwrite`。该 Shortcut 暂无 leaf Schema 时按完整 Catalog + 叶子 Help 核对。
以下原子命令保留给需要原始接口行为的场景。
下载聊天消息中的图片、视频、语音等资源到本地文件。流程:先获取下载 URL,再 HTTP GET 下载。
```
Usage:
@@ -1545,12 +1539,10 @@ Flags:
Usage:
dws chat category delete [flags]
Example:
# 先向用户确认,确认后执行
dws chat category delete --category-id <分组ID> --yes
dws chat category delete --category-id <分组ID>
# 分组ID 可通过 dws chat category list 获取
Flags:
--category-id int 会话分组 ID (必填)
--yes 跳过运行时确认;只能在用户明确确认后传入
```
#### 重命名会话分组
@@ -1779,18 +1771,15 @@ Flags:
Usage:
dws chat clear-messages [flags]
Example:
# 先向用户说明目标会话和影响范围,确认后执行
dws chat clear-messages --conversation-id <openConversationId> --yes
dws chat clear-messages --id <openConversationId> --yes
dws chat clear-messages --conversation-id <openConversationId>
dws chat clear-messages --id <openConversationId>
Flags:
--conversation-id string 会话 openConversationId (必填,支持群聊/单聊)
--id string --conversation-id 的别名
--chat string --conversation-id 的别名
--yes 跳过运行时确认;只能在用户明确确认后传入
注意:
- 仅清空当前用户视角的消息,不影响其他成员
- 仍属于高风险操作;未确认时不得传 --yes 或执行
- openConversationId 可通过 chat search(群聊)或 chat conversation-info(单聊)获取
```
@@ -2212,7 +2201,7 @@ dws chat message send --group <openConversationId> --msg-type image --media-id "
群聊传 --group,单聊传 --receiver,二者互斥。
**注意:本节原子 send-card 必须和 update-card 搭配使用。** 创建卡片时无需传入内容,后续通过 update-card 更新内容,最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。若使用 `+messages-send-card --content ...`,Shortcut 会在创建后立即完成一次更新;不传 `--content` 时仍返回 `bizId` 供本节 update-card 使用。
**注意:send-card 必须和 update-card 搭配使用。** 创建卡片时无需传入内容,后续通过 update-card 更新内容,最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。
flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。
```
Usage:
@@ -2331,15 +2320,12 @@ Flags:
- `chat group-mute-member` 指定群成员禁言,需传 --group、--user/--users(userId,逗号分隔)、--mute-time(毫秒,仅禁言时必填,支持 300000/3600000/86400000/604800000/2592000000),传 --off 解除禁言;CLI 会自动把 userId 解析成 openDingTalkId 再调用,直接传 userId 即可;禁言群主会被服务端拒绝
- `chat group set-admin` 设置/取消群管理员,需传 --group(openConversationId)、--user/--users(userId,逗号分隔),默认设为管理员,传 --off 取消
## 可选自动化脚本
## 自动化脚本
这些脚本仅用于已确认安装 Python 3 的环境,不是默认或唯一执行路径;无
Python 环境时直接使用上文的 `dws` Shortcut / 原子命令。
| 脚本 | 可选场景 | 用法 |
|------|----------|------|
| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python3 scripts/chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` |
| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` |
| 脚本 | 场景 | 用法 |
|------|------|------|
| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` |
| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` |
## 相关产品
+9
View File
@@ -202,14 +202,23 @@ Usage:
Example:
dws drive download --node <dentryUuid> --output ./report.pdf
dws drive download --node <dentryUuid> --output ~/downloads/
dws drive download --node <dentryUuid> --output ./big.zip --part-size 32MB --parallel 8
Flags:
--node string 文件 ID (dentryUuid) (必填)
--output string 本地保存路径 (必填),可以是文件路径或目录;如果指定目录,文件名从下载 URL 中自动推断
--space-id string 文件所属空间 ID (可选)
--part-size string 分片下载的分片大小,支持 KB/MB/GB 单位,范围 1MB-1GB (默认 16MB)
--parallel int 分片下载并发数,范围 1-8 (默认 4)
--no-resume 关闭断点续传,忽略历史下载进度从头下载 (默认开启续传)
```
> **注意**:`--output` 是必填参数,不传会报错。
> **大文件分片下载**:
> - 大文件自动分片并发下载,小文件整流下载,行为对用户透明,无需任何额外操作。
> - 断点续传默认开启:下载中断后重跑同一命令会自动跳过已完成部分继续下载(`<目标文件>.dwspart` 为临时进度文件,下载完成后自动清理);不需要续传时加 `--no-resume`。
> - 下载凭证过期会自动刷新并继续下载,已完成的部分不会重下;单个分片失败会自动重试,无需手动处理。
### 创建文件夹
```
+10 -15
View File
@@ -3,19 +3,19 @@
用机器人向多个群批量发送相同消息(如日报提醒)
用法:
python3 scripts/bot_broadcast.py \
python bot_broadcast.py \
--robot-code <ROBOT_CODE> \
--chats "conv_id1,conv_id2,conv_id3" \
--title "日报提醒" \
--text "请大家今天下班前提交日报"
python3 scripts/bot_broadcast.py \
python bot_broadcast.py \
--robot-code <ROBOT_CODE> \
--chats-file groups.txt \
--title "周会通知" \
--text "明天下午3点周会"
python3 scripts/bot_broadcast.py --dry-run ...
python bot_broadcast.py --dry-run ...
"""
import sys
@@ -40,19 +40,14 @@ def run_dws(
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
data = json.loads(result.stdout)
if isinstance(data, dict) and data.get('success') is False:
detail = data.get('errorMsg') or data.get('message') or '未知错误'
print(f" ✗ 业务调用失败:{detail}")
return None
return data
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def run(argv: Optional[List[str]] = None) -> int:
def main():
parser = argparse.ArgumentParser(
description='向多个群批量发送机器人消息'
)
@@ -69,7 +64,7 @@ def run(argv: Optional[List[str]] = None) -> int:
'--text', required=True, help='消息内容 Markdown'
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args(argv)
args = parser.parse_args()
chat_ids: List[str] = []
if args.chats:
@@ -79,13 +74,13 @@ def run(argv: Optional[List[str]] = None) -> int:
p = Path(args.chats_file)
if not p.exists():
print(f"错误:文件不存在: {p}")
return 1
sys.exit(1)
chat_ids = [line.strip() for line in
p.read_text(encoding='utf-8').splitlines()
if line.strip() and not line.startswith('#')]
if not chat_ids:
print('错误:需要 --chats 或 --chats-file')
return 1
sys.exit(1)
print(f"📢 批量发送消息到 {len(chat_ids)} 个群")
print(f" 标题: {args.title}")
@@ -110,8 +105,8 @@ def run(argv: Optional[List[str]] = None) -> int:
fail += 1
print(f"\n完成: 成功 {success}, 失败 {fail}")
return 0 if fail == 0 else 1
sys.exit(0 if fail == 0 else 1)
if __name__ == '__main__':
sys.exit(run())
main()
+104 -215
View File
@@ -3,12 +3,12 @@
导出群聊消息到 JSON 文件(从指定时间点拉取)
用法:
python3 scripts/chat_export_messages.py \
python chat_export_messages.py \
--group <openconversation_id> \
--time "2026-03-10 00:00:00" \
--output messages.json
python3 scripts/chat_export_messages.py \
python chat_export_messages.py \
--query "项目冲刺" \
--time "2026-03-10 00:00:00" \
--no-forward --limit 100
@@ -16,51 +16,11 @@
import sys
import json
import datetime
import subprocess
import argparse
from typing import List, Any, Optional
class ScriptError(RuntimeError):
"""可预期的脚本执行错误。"""
exit_code = 1
class AmbiguousTargetError(ScriptError):
"""搜索结果不唯一,需要调用方消歧。"""
exit_code = 2
def normalize_boundary_time(value: Any) -> str:
"""将消息时间边界规范为 CLI 接受的 yyyy-MM-dd HH:mm:ss 或原字符串。"""
if value is None or isinstance(value, bool):
return ''
if isinstance(value, (int, float)):
timestamp = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return ''
try:
timestamp = float(text)
except ValueError:
return text
else:
return str(value).strip()
if timestamp > 10_000_000_000:
timestamp /= 1000
try:
dt = datetime.datetime.fromtimestamp(
timestamp, tz=datetime.timezone(datetime.timedelta(hours=8))
)
except (OSError, OverflowError, ValueError) as exc:
raise ScriptError(f'无效的消息时间边界:{value}') from exc
return dt.strftime("%Y-%m-%d %H:%M:%S")
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
@@ -72,19 +32,14 @@ def run_dws(
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
)
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
raise ScriptError(f'执行 dws 失败:{exc}') from exc
if result.returncode != 0:
detail = result.stderr.strip() or f'退出码 {result.returncode}'
raise ScriptError(f'dws 命令失败:{detail}')
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ScriptError(f'dws 返回的不是合法 JSON:{exc}') from exc
if isinstance(data, dict) and data.get('success') is False:
detail = data.get('errorMsg') or data.get('message') or '未知错误'
raise ScriptError(f'dws 业务调用失败:{detail}')
return data
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def search_group(
@@ -96,17 +51,14 @@ def search_group(
], dry_run=dry_run)
if dry_run:
return '<CONV_ID>'
if not data:
return None
if isinstance(data, list):
groups = data
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
groups = (
inner.get('value')
or inner.get('groups')
or inner.get('items')
or []
)
groups = inner.get('items', inner.get('groups', []))
elif isinstance(inner, list):
groups = inner
else:
@@ -114,64 +66,16 @@ def search_group(
else:
groups = []
if not groups:
raise ScriptError(f'未找到群聊:{query}')
exact = [
item for item in groups
if isinstance(item, dict)
and str(item.get('title') or item.get('name') or '').strip().casefold()
== query.strip().casefold()
]
candidates = exact if exact else groups
if len(candidates) != 1:
rendered = []
for item in candidates:
if not isinstance(item, dict):
continue
name = item.get('title') or item.get('name') or '未知'
conv_id = item.get('openConversationId') or item.get('id') or '无ID'
rendered.append(f'{name} ({conv_id})')
detail = ';'.join(rendered) or f'{len(candidates)} 个候选'
raise AmbiguousTargetError(
f'群名“{query}”匹配到多个候选,请指定 --group:{detail}'
)
g = candidates[0]
print(f"未找到群聊: {query}")
return None
g = groups[0]
name = g.get('title') or g.get('name', '未知')
conv_id = g.get('openConversationId') or g.get('id')
if not conv_id:
raise ScriptError(f'群聊“{name}”缺少 openConversationId')
print(f" 找到群聊: {name} ({conv_id})")
return conv_id
def parse_message_page(data: Any) -> tuple[List[Any], bool]:
"""提取消息页和 hasMore。"""
if isinstance(data, list):
return data, False
if not isinstance(data, dict):
return [], False
inner = data.get('result', data)
if isinstance(inner, dict):
messages = inner.get('messages', [])
return messages if isinstance(messages, list) else [], bool(
inner.get('hasMore', False)
)
if isinstance(inner, list):
return inner, False
return [], False
def message_identity(message: Any) -> Optional[str]:
if not isinstance(message, dict):
return None
value = (
message.get('openMessageId')
or message.get('openMsgId')
or message.get('msgId')
)
return str(value) if value else None
def run(argv: Optional[List[str]] = None) -> int:
def main():
parser = argparse.ArgumentParser(
description='导出群聊消息到 JSON'
)
@@ -191,115 +95,100 @@ def run(argv: Optional[List[str]] = None) -> int:
)
parser.add_argument('--output', default='', help='输出文件')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args(argv)
args = parser.parse_args()
try:
conv_id = args.group
if not conv_id:
if not args.query:
raise ScriptError('需要 --group 或 --query 参数')
print(f'🔍 搜索群聊: {args.query}')
conv_id = search_group(args.query, args.dry_run)
conv_id = args.group
if not conv_id:
if not args.query:
print('错误:需要 --group 或 --query 参数')
sys.exit(1)
print(f'🔍 搜索群聊: {args.query}')
conv_id = search_group(args.query, args.dry_run)
if not conv_id and not args.dry_run:
sys.exit(1)
print(f'📥 拉取消息 (起始: {args.time})...')
all_messages: List[Any] = []
seen_ids = set()
current_time = args.time
direction = 'older' if args.no_forward else 'newer'
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
has_more = False
print(f'📥 拉取消息 (起始: {args.time})...')
all_messages: List[Any] = []
current_time = args.time
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list',
'--group', conv_id or '<CONV_ID>',
'--time', current_time,
'--direction', direction,
'--format', 'json',
]
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list',
'--group', conv_id or '<CONV_ID>',
'--time', current_time,
'--format', 'json',
]
if args.no_forward:
cmd_args.append('--forward=false')
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 使用末条消息 createTime')
return 0
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time')
return
page_msgs, has_more = parse_message_page(data)
if not page_msgs:
if has_more:
raise ScriptError('服务端返回 hasMore=true,但本页没有消息')
break
if not data:
break
for message in page_msgs:
if not isinstance(message, dict):
raise ScriptError('消息列表包含非对象条目,无法安全导出')
identity = message_identity(message)
if identity and identity in seen_ids:
continue
if identity:
seen_ids.add(identity)
all_messages.append(message)
remaining -= 1
if remaining <= 0:
break
page += 1
if not has_more or remaining <= 0:
break
last_msg = page_msgs[-1]
if not isinstance(last_msg, dict):
raise ScriptError('末条消息不是对象,无法取得翻页时间边界')
boundary_time = normalize_boundary_time(
last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time')
)
if not boundary_time:
raise ScriptError('hasMore=true,但末条消息缺少 createTime')
if boundary_time == current_time:
raise ScriptError('分页边界没有推进,已停止以避免重复循环')
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if has_more and page >= max_pages and remaining > 0:
raise ScriptError(f'达到最大分页数 {max_pages},结果不完整')
if not all_messages:
print('未拉取到消息')
return 0
if args.output:
with open(args.output, 'w', encoding='utf-8') as file:
json.dump(all_messages, file, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
# 兼容两种结构: 顶层 messages / {result: {messages, hasMore}}
if isinstance(data, list):
page_msgs = data
has_more = False
else:
for message in all_messages:
sender = (
message.get('sender')
or message.get('senderNick')
or '未知'
)
text = message.get('content') or message.get('text', '')
time_str = (
message.get('createTime')
or message.get('createAt')
or message.get('time', '')
)
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
return 0
except ScriptError as exc:
print(f'错误:{exc}', file=sys.stderr)
return exc.exit_code
except OSError as exc:
print(f'错误:无法写入输出文件:{exc}', file=sys.stderr)
return 1
container = data
inner = data.get('result')
if isinstance(inner, dict):
container = inner
page_msgs = container.get('messages')
if page_msgs is None and isinstance(inner, list):
page_msgs = inner
if not isinstance(page_msgs, list):
page_msgs = []
has_more = bool(container.get('hasMore', False))
if not page_msgs:
break
all_messages.extend(page_msgs)
remaining -= len(page_msgs)
page += 1
if not has_more:
break
last_msg = page_msgs[-1]
boundary_time = (last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time', ''))
if not boundary_time or boundary_time == current_time:
break
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if not all_messages:
print('未拉取到消息')
return
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(all_messages, f, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for m in all_messages:
# 实际字段: sender/createTime/content (兼容旧字段名)
sender = (m.get('sender') or m.get('senderNick')
or m.get('senderOpenDingTalkId', '未知'))
text = m.get('content') or m.get('text', '')
time_str = (m.get('createTime') or m.get('createAt')
or m.get('time', ''))
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
if __name__ == '__main__':
sys.exit(run())
main()
+108 -216
View File
@@ -3,63 +3,23 @@
查询与某人的单聊聊天记录
用法:
python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"
python3 scripts/chat_history_with_user.py --user <userId> --time "2026-03-10 00:00:00" --limit 50
python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-01 00:00:00" --output history.json
python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"
python chat_history_with_user.py --user <userId> --time "2026-03-10 00:00:00" --limit 50
python chat_history_with_user.py --name "张三" --time "2026-03-01 00:00:00" --output history.json
工作流:
1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user)
2. 调用 chat message list-direct --user <userId> 拉取单聊消息
2. 调用 chat message list --user <userId> 拉取单聊消息
3. 输出到终端或导出为 JSON 文件
"""
import sys
import json
import datetime
import subprocess
import argparse
from typing import List, Any, Optional
class ScriptError(RuntimeError):
"""可预期的脚本执行错误。"""
exit_code = 1
class AmbiguousTargetError(ScriptError):
"""搜索结果不唯一,需要调用方消歧。"""
exit_code = 2
def normalize_boundary_time(value: Any) -> str:
"""将消息时间边界规范为 CLI 接受的 yyyy-MM-dd HH:mm:ss 或原字符串。"""
if value is None or isinstance(value, bool):
return ''
if isinstance(value, (int, float)):
timestamp = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return ''
try:
timestamp = float(text)
except ValueError:
return text
else:
return str(value).strip()
if timestamp > 10_000_000_000:
timestamp /= 1000
try:
dt = datetime.datetime.fromtimestamp(
timestamp, tz=datetime.timezone(datetime.timedelta(hours=8))
)
except (OSError, OverflowError, ValueError) as exc:
raise ScriptError(f'无效的消息时间边界:{value}') from exc
return dt.strftime("%Y-%m-%d %H:%M:%S")
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
@@ -72,19 +32,14 @@ def run_dws(
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
)
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
raise ScriptError(f'执行 dws 失败:{exc}') from exc
if result.returncode != 0:
detail = result.stderr.strip() or f'退出码 {result.returncode}'
raise ScriptError(f'dws 命令失败:{detail}')
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ScriptError(f'dws 返回的不是合法 JSON:{exc}') from exc
if isinstance(data, dict) and data.get('success') is False:
detail = data.get('errorMsg') or data.get('message') or '未知错误'
raise ScriptError(f'dws 业务调用失败:{detail}')
return data
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def search_user(
@@ -97,80 +52,30 @@ def search_user(
], dry_run=dry_run)
if dry_run:
return '<USER_ID>'
if not data:
return None
# 解析返回结构
users = data
if isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
users = (
inner.get('value')
or inner.get('users')
or inner.get('list')
or inner.get('items')
or []
)
users = (inner.get('users', [])
or inner.get('list', []))
elif isinstance(inner, list):
users = inner
else:
users = []
if not users or not isinstance(users, list):
raise ScriptError(f'未找到用户:{name}')
exact = [
item for item in users
if isinstance(item, dict)
and str(item.get('name') or item.get('nick') or '').strip().casefold()
== name.strip().casefold()
]
candidates = exact if exact else users
if len(candidates) != 1:
rendered = []
for item in candidates:
if not isinstance(item, dict):
continue
user_name = item.get('name') or item.get('nick') or '未知'
user_id = item.get('userId') or item.get('userid') or '无ID'
rendered.append(f'{user_name} ({user_id})')
detail = ';'.join(rendered) or f'{len(candidates)} 个候选'
raise AmbiguousTargetError(
f'姓名“{name}”匹配到多个候选,请指定 --user:{detail}'
)
u = candidates[0]
print(f"未找到用户: {name}")
return None
u = users[0]
user_name = u.get('name') or u.get('nick', '未知')
user_id = u.get('userId') or u.get('userid', '')
if not user_id:
raise ScriptError(f'用户“{user_name}”缺少 userId')
print(f" 找到用户: {user_name} ({user_id})")
return user_id
def parse_message_page(data: Any) -> tuple[List[Any], bool]:
"""提取消息页和 hasMore。"""
if isinstance(data, list):
return data, False
if not isinstance(data, dict):
return [], False
inner = data.get('result', data)
if isinstance(inner, dict):
messages = inner.get('messages', [])
return messages if isinstance(messages, list) else [], bool(
inner.get('hasMore', False)
)
if isinstance(inner, list):
return inner, False
return [], False
def message_identity(message: Any) -> Optional[str]:
if not isinstance(message, dict):
return None
value = (
message.get('openMessageId')
or message.get('openMsgId')
or message.get('msgId')
)
return str(value) if value else None
def run(argv: Optional[List[str]] = None) -> int:
def main():
parser = argparse.ArgumentParser(
description='查询与某人的单聊聊天记录'
)
@@ -191,113 +96,100 @@ def run(argv: Optional[List[str]] = None) -> int:
)
parser.add_argument('--output', default='', help='导出到 JSON 文件')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args(argv)
args = parser.parse_args()
try:
user_id = args.user
if not user_id:
print(f'🔍 搜索用户: {args.name}')
user_id = search_user(args.name, args.dry_run)
# 1. 获取 userId
user_id = args.user
if not user_id:
print(f'🔍 搜索用户: {args.name}')
user_id = search_user(args.name, args.dry_run)
if not user_id and not args.dry_run:
sys.exit(1)
print(f'📥 拉取与 {user_id} 的聊天记录 (起始: {args.time})...')
all_messages: List[Any] = []
seen_ids = set()
current_time = args.time
direction = 'older' if args.no_forward else 'newer'
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
has_more = False
# 2. 拉取单聊消息(自动翻页)
print(f'📥 拉取与 {user_id} 的聊天记录 (起始: {args.time})...')
all_messages: List[Any] = []
current_time = args.time
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list-direct',
'--user', user_id or '<USER_ID>',
'--time', current_time,
'--direction', direction,
'--format', 'json',
]
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list',
'--user', user_id or '<USER_ID>',
'--time', current_time,
'--format', 'json',
]
if args.no_forward:
cmd_args.append('--forward=false')
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 使用末条消息 createTime')
return 0
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time')
return
page_msgs, has_more = parse_message_page(data)
if not page_msgs:
if has_more:
raise ScriptError('服务端返回 hasMore=true,但本页没有消息')
break
if not data:
break
for message in page_msgs:
if not isinstance(message, dict):
raise ScriptError('消息列表包含非对象条目,无法安全导出')
identity = message_identity(message)
if identity and identity in seen_ids:
continue
if identity:
seen_ids.add(identity)
all_messages.append(message)
remaining -= 1
if remaining <= 0:
break
page += 1
if not has_more or remaining <= 0:
break
last_msg = page_msgs[-1]
if not isinstance(last_msg, dict):
raise ScriptError('末条消息不是对象,无法取得翻页时间边界')
boundary_time = normalize_boundary_time(
last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time')
)
if not boundary_time:
raise ScriptError('hasMore=true,但末条消息缺少 createTime')
if boundary_time == current_time:
raise ScriptError('分页边界没有推进,已停止以避免重复循环')
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if has_more and page >= max_pages and remaining > 0:
raise ScriptError(f'达到最大分页数 {max_pages},结果不完整')
if not all_messages:
print('未拉取到消息')
return 0
if args.output:
with open(args.output, 'w', encoding='utf-8') as file:
json.dump(all_messages, file, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
# 兼容两种结构: 顶层 messages / {result: {messages, hasMore}}
if isinstance(data, list):
page_msgs = data
has_more = False
else:
for message in all_messages:
sender = (
message.get('sender')
or message.get('senderNick')
or '未知'
)
text = message.get('content') or message.get('text', '')
time_str = (
message.get('createTime')
or message.get('createAt')
or message.get('time', '')
)
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
return 0
except ScriptError as exc:
print(f'错误:{exc}', file=sys.stderr)
return exc.exit_code
except OSError as exc:
print(f'错误:无法写入输出文件:{exc}', file=sys.stderr)
return 1
container = data
inner = data.get('result')
if isinstance(inner, dict):
container = inner
page_msgs = container.get('messages')
if page_msgs is None and isinstance(inner, list):
page_msgs = inner
if not isinstance(page_msgs, list):
page_msgs = []
has_more = bool(container.get('hasMore', False))
if not page_msgs:
break
all_messages.extend(page_msgs)
remaining -= len(page_msgs)
page += 1
if not has_more:
break
last_msg = page_msgs[-1]
boundary_time = (last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time', ''))
if not boundary_time or boundary_time == current_time:
break
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if not all_messages:
print('未拉取到消息')
return
# 3. 输出结果
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(all_messages, f, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for m in all_messages:
# 实际字段: sender/createTime/content (兼容旧字段名)
sender = (m.get('sender') or m.get('senderNick')
or m.get('senderOpenDingTalkId', '未知'))
text = m.get('content') or m.get('text', '')
time_str = (m.get('createTime') or m.get('createAt')
or m.get('time', ''))
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
if __name__ == '__main__':
sys.exit(run())
main()
@@ -134,6 +134,7 @@ Flags:
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `workflow edit-example` | 获取编辑文档与 DSL 示例 | 无 | create/update 前优先调用,内容由服务端提供 |
| `workflow create` | 创建并发布自动化工作流 | `--base-id` `--dsl` | 按子文档 Demo 组装 DSL;必须检查返回的 `data.valid` / `issues`;create 不自动重试 |
| `workflow update` | 更新并发布已有自动化工作流 | `--base-id` `--workflow-id` `--dsl` | 先 get 留底;提交完整目标 DSL;必须检查 `data.valid` / `issues` |
| `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` |
@@ -7,6 +7,7 @@
| 命令 | 用途 |
|------|------|
| `workflow edit-example` | 获取工作流编辑文档与 workflow-dsl/v1 示例 |
| `workflow create` | 创建并发布自动化工作流 |
| `workflow update` | 更新并发布已有自动化工作流 |
| `workflow list` | 列出 Base 下所有工作流(含状态/创建人/最后修改时间),支持分页 |
@@ -14,15 +15,15 @@
| `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) |
| `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) |
> 所有子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
> `workflow edit-example` 无参数;其他子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
## DSL 入参格式与最小 Demo
`workflow create/update` 的 `--dsl` 接收钉钉 AI 表格 `workflow-dsl/v1` JSON object。当前同步范围只包含 create/update,没有新增 DSL 文档子命令;其他 Agent 可以直接使用下面的最小 Demo 理解调用格式。
先运行 `workflow edit-example` 获取服务端提供的最新编辑文档和示例。`workflow create/update` 的 `--dsl` 接收钉钉 AI 表格 `workflow-dsl/v1` JSON object。
复杂工作流还应注意:
1. 如果 Agent 运行环境直接提供 AI 表格 MCP 的 `get_workflow_dsl_docs`,可用它获取最新 DSL Guide、Schema 和示例。
1. 使用 `workflow edit-example` 获取最新 DSL Guide、结构和示例。
2. 涉及数据表、字段或视图的节点,先用 `table get` / `field get` / `view list` 确认真实 `sheetId`、`fieldId`、`viewId`。
3. create 和 update 都提交完整的 workflow-dsl/v1 JSON object,并检查所有 `next`、`loopEntry`、branch `to` 和 ref。
@@ -82,6 +83,14 @@ create 和 update 都必须同时满足 `status=success`、`data.valid=true`、`
## 命令详情
### workflow edit-example — 获取编辑文档与示例
```bash
dws aitable workflow edit-example --format json
```
该命令无业务参数,调用 `aitable/edit_workflow_example` 返回服务端提供的工作流编辑文档和示例。创建或更新复杂工作流前优先调用它,避免依赖可能过期的本地 DSL 结构。
### workflow create — 创建并发布工作流
```bash
+90 -103
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-chat
description: 钉钉群聊与消息。Use when 用户提到 发消息/单聊/群聊/建群/搜群或消息/聊天记录/群成员管理/回复转发撤回/@消息/表情回应/图片文件与资源下载/机器人群发/Webhook通知/互动卡片/收藏与Pin/消息或会话置顶/未读与红点/已读状态/会话分类。不做紧急 DING/短信/电话(走 dingtalk-ding)、邮件(走 dingtalk-mail)、班级群(走 dingtalk-misc);找人本身走 dingtalk-contact 或 dingtalk-aisearch。命令前缀:dws chat。
description: 钉钉群聊与消息。Use when 用户提到 发消息/编辑或撤回消息/单聊/群聊/建群/普通群升级外部群/群昵称/会话分组/群成员管理/@消息/搜索聊天记录/话题回复/收藏消息/机器人群发/Webhook通知/发送或下载消息图片与文件。不做紧急 DING/短信/电话(走 dingtalk-misc)、邮件(走 dingtalk-mail)、班级群(走 dingtalk-misc)。命令前缀:dws chat。
metadata:
cli_version: ">=0.2.14"
category: product
@@ -11,139 +11,126 @@ metadata:
# 钉钉群聊 / 消息 Skill
## Preconditions
## 前置条件 — 执行操作前必读
> **CRITICAL — Before any `dws` operation, MUST fully read [`dws-shared`](../dws-shared/SKILL.md).** It defines the global execution contract, safety floor, and on-demand shared-reference routing. Do not preload all of its references.
> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dws-shared`](../dws-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。
> Command reference: [chat.md](references/chat.md); emoji list: [chat-emoji-list.md](references/chat-emoji-list.md).
> 命令参考:[chat.md](references/chat.md);表情:[chat-emoji-list.md](references/chat-emoji-list.md);剧本:[01-messaging.md](references/01-messaging.md)。
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcut 发现(按需)
`chat` 当前有 97 条公开 shortcut。完整清单保留在 Runtime Shortcut Catalog;已完成 Schema curation 的子集可通过 leaf Schema 查询。高频产品根 Skill 不重复展开完整清单。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
`chat` 当前有 97 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service chat --compact --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service chat --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
<!-- VISIBLE_SHORTCUTS_END -->
## 加载与路由顺序
## IM Shortcut 优先路由
This is the only Shortcut entry point for multi/chat; `references/` documents atomic commands only. Use `exact recipe/runnable script > matching public Shortcut > atomic command`; skip an optional script if its interpreter is unavailable.
- 统一发送优先用 `chat +messages-send`:通过 `--as user|bot|webhook` 选择身份,并只传该身份真实支持的目标、内容和凭据。仅在需要原子命令的特定返回结构或兼容参数时,才降级到 `chat message send` / `send-by-bot` / `send-by-webhook`。
- 消息查询按意图选择:指定会话用 `+chat-messages`,跨维度过滤/全量翻页用 `+search-msg`,@我用 `+at-me`,已知消息 ID 批量富化用 `+messages-mget`,已知 thread/topic ID 读取回复用 `+thread-replies`。
- 查询结果中的 `resourceRefs` 是可继续执行的资源上下文。引用、回复或合并转发中的子消息必须使用该子消息返回的 `messageId`;子消息缺会话 ID 时才继承父消息的 `openConversationId`。
- 上述五个查询 Shortcut 与 `+messages-resource-download` 都沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。下载只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
- `+messages-send` 会按身份规范化 @ 占位符:user 使用 `<@id>` / `<@all>`,bot/webhook 使用 `@id` / `@手机号` / `@all`;只需声明 `--at-*` / `--at-all`,缺失占位符会自动补齐。
- `+messages-send --as user --user <userId>` 会通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId,所有内容类型与 `--dry-run` 都使用同一只读解析链路;已有 openDingTalkId 时直接传 `--open-dingtalk-id`。
- 流式卡片优先用 `+messages-send-card`:群聊传 `--group`,单聊 userId 传 `--receiver` 并由 CLI 通过通讯录关键词搜索做 userId 精确匹配,已有 openDingTalkId 则必须显式传 `--receiver-open-dingtalk-id`;三者严格三选一,禁止根据首字母猜身份类型。
1. 已知高频意图:直接使用“核心意图与执行骨架”,命中后照抄参数名,不先调用 `--help`。
2. 已有匹配 Shortcut:直接执行;参数、约束或安全不确定时才读 leaf Schema。
3. 仅当前 Cobra flags 不确定时读 leaf `--help`。
4. 现有路由无法定位低频能力时才查 Runtime Shortcut Catalog;select a real `cli_path` and never guess names.
5. 没有 Shortcut 时,按需读取对应 branch reference,进入原子命令。
## 意图表
For `confirmation=user_required`, confirm before adding `--yes`. On source conflict, use the safer interpretation and report it.
| 用户说 | 命令 |
|--------|------|
| "发消息给张三" | `dws chat +messages-send --as user --open-dingtalk-id <id> --text "<内容>"` |
| "发到XX群" | `dws chat +chat-search --query "<群名>"` → `dws chat +messages-send --as user --chat-id <openConversationId> --text "<内容>"` |
| "建群" / "拉人进群" | `dws chat group create` / `dws chat group members add` |
| "改群名" / "踢人" | `dws chat group rename` / `dws chat group members remove --yes`(踢人不可逆,先确认目标) |
| "@我消息" | `dws chat +at-me` |
| "查群聊记录" | `dws chat +chat-messages --group <openConversationId>` |
| "收藏/取消收藏这条消息" | `dws chat +flag-create` / `dws chat +flag-cancel` |
| "查看我收藏的消息" | `dws chat +flag-list` |
| "用机器人发消息" | `dws chat +messages-send --as bot --robot-code <code> --chat-id <id> --text "<内容>"` |
| "Webhook 推一条" | `dws chat +messages-send --as webhook --webhook-token <token> --text "<内容>"` |
| "发送流式卡片" | `dws chat +messages-send-card --group <openConversationId> --content "<内容>"`;单聊 userId 改用 `--receiver`,openDingTalkId 改用 `--receiver-open-dingtalk-id` |
| "撤回消息" | `dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>` |
| "标记未读 / 清除红点 / 全部已读" | `dws chat mark-unread` / `dws chat clear-red-point` / `dws chat clear-all-red-point` |
| "置顶某条消息 / 取消消息置顶" | `dws chat message set-top-msg` / `dws chat message unset-top-msg` |
| "我加入的所有群 / 全部群列表" | `dws chat group list-all` |
## 核心对象与 ID
## 标准 SOP(必遵流程)
| 对象 | 核心标识与边界 |
|---|---|
| 人员 | 姓名必须解析成唯一真实的 `userId` / `openDingTalkId`,不能把名称当 ID |
| 会话 | 使用真实 `openConversationId` / cid;群名只用于 Shortcut 目标解析 |
| 消息 | 使用真实 `openMessageId` / msgId,并保持与身份和会话一致 |
| 发送任务 | `openTaskId` 只用于查询发送状态,不能替代消息 ID |
| Thread | thread/topic ID 必须绑定真实会话,不跨会话复用 |
| 身份 | current-user、app-bot、Webhook 是不同操作者,不能自动互换 |
| 状态 | 收藏、消息置顶、消息 Pin、会话置顶作用于不同对象 |
> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 ID。每条命令必须带 `--format json`,执行后必须按"解析"步取真实字段(`openDingTalkId` / `openConversationId` / `openMessageId`)。
## 核心意图与执行骨架
### SOP-1 发消息(send-message)
Prefer the exact Shortcut below; otherwise use the atomic fallback. Apply shared `--format json` and take downstream IDs from actual output. Every Chat workflow must work without Python.
**触发**:发消息/单聊/通知某人/发到群里。
| 用户意图 | 精确 Shortcut 骨架 / 原子回退 | 必须保留的执行边界 |
|---|---|---|
| 姓名发单聊 / 群名发群消息 | `+dm --to <姓名> --text <内容>` / `+send-to-group --group <群名> --text <内容>` | Resolve one real person or cid; mentions/`@all` use `+messages-send` |
| user / bot / webhook 发消息 | `+messages-send --as user|bot|webhook` / 对应 `message send*` | Identity determines the operator; use the matching target and content flags |
| 建群 / 改群名 / 拉人 | `+chat-create --name <群名> --users <uid,...>` / `+chat-update --group <cid> --name <新群名>` / `group members add` | Resolve every member; extract the new cid before follow-up actions |
| 列成员 / 批查成员 | `+chat-members-list --group <群名>` 或 `--conversation-id <cid>` / `+chat-members-get --id <cid> --users <odid,...>` | Stop on ambiguous group names; keep user and bot bucket failures |
| 拉会话消息 / 查某人记录 | `+chat-messages` / `message list` | Select one group or DM target; use the requested or explicitly narrowed time boundary |
| 搜消息 / 查 @ 我 | `+search-msg --query <关键词>`;全局 `+at-me --days <N>`;群内 `+search-msg --at-me --group <cid>` | Add only real filters; never invent group flags for `+at-me` |
| 消息详情 / 撤回 / 发送状态 | `+messages-mget --msg-ids <mid,...>` / `+messages-recall --conversation-id <cid> --msg-id <mid>` / `+messages-query-send-status --open-task-id <tid>` | Recall only when explicit; use IDs from the same identity and conversation |
| 群邀请链接 / 群机器人 | `+chat-search --query <群名>` → `+chat-invite-url --group <cid>` / `+chat-bots --group <cid>` | Reuse the resolved cid; do not search again by a guessed name |
| 会话置顶 / 收藏列表 | `+conversation-set-top --conversation-id <cid> [--off]` / `+flag-list --size <1-100>` | Do not confuse conversation top with message top, Pin, or favorite |
| 群消息完整导出 | `+chat-messages`; save merged JSON if requested | Follow pagination to completion; report partial results as incomplete |
| 机器人多群广播 | Call `+messages-send-by-bot` once per resolved group | Confirm recipients once; preserve one body and return a per-group ledger |
1. **解析收件人(必须)**:人名 → 先 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 取 `openDingTalkId`(优先)或 `userId`;群名 → 先 `dws chat search --query "<群名>" --format json` 取 `openConversationId`。
2. **执行(必须)**:单聊 `dws chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>" --format json`(只有拿不到 `openDingTalkId` 时才用 `--user <userId>`);群聊 `dws chat message send --group <openConversationId> --text "<内容>" --format json`。
3. **验证(必须)**:发送接口成功时可能只返回 `result.openTaskId`,它可用于确认提交成功,但**不是撤回所需消息 ID**。需要撤回时必须按 SOP-7 用带 `--time` 的 `message list` 回查,取得真实 `openMessageId`;返回非 `success` 必须如实报错,不要谎报已发。
## 统一发送
**禁止**:把人名/群名直接当 ID 传入、跳过 `aisearch person`/`chat search` 解析、跳过 `--format json`、未发送成功就答复"已发送"。
身份决定真实操作者、可见范围和可用能力;同一目标使用 user、bot 或 webhook 时结果和权限可能不同,禁止自动切换身份重试。Before sending, verify identity, target, body, title, mentions, message type, and attachment path; ask if ambiguous. Reuse the same `--idempotency-key` on retry.
### SOP-2 建群(create-group)
```bash
dws chat +messages-send --as user --chat-id <openConversationId> --text "内容" --idempotency-key <key> --format json
dws chat +messages-send --as user --open-dingtalk-id <openDingTalkId> --msg-type file --file ./report.pdf --idempotency-key <key> --format json
dws chat +messages-send --as bot --robot-code <robotCode> --chat-id <openConversationId> --markdown "## 标题
**触发**:建群/拉人进群/新建讨论组。
正文" --format json
dws chat +messages-send --as webhook --webhook-token <token> --title "告警" --text "内容" --at-all --format json
```
1. **解析成员(必须)**:对每个成员 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 取 `userId`,多人英文逗号拼接。
2. **执行(必须)**:`dws chat group create --name "<群名>" --users <userId1,userId2,...> --format json`;外部群加 `--type EXTERNAL`,话题圈加 `--thread`。
3. **验证(必须)**:从返回取 `openConversationId`,可用 `dws chat search --query "<群名>" --format json` 复核。
- `user` supports text, Markdown, existing mediaId images, and local files. Audio/video use `--file` and are sent as files.
- `bot` supports group or batch-DM text/Markdown. A webhook token selects its chat. Neither identity supports files.
- Pass only required `--at-*` / `--at-all`; never construct `@10` manually. Use real `U+000A` newlines and a blank line between Markdown paragraphs.
**禁止**:跳过成员 userId 解析直接传姓名、编造 `openConversationId`。
## 查询、资源与卡片
### SOP-3 Webhook 推送(send-by-webhook)
- `+search-msg` 只搜索消息内容;禁止用于按群名找群或解析群 CID。
- `+search-msg --page-all` paginates and enriches in batches; use it only when complete pagination is required.
- Preserve primary results on partial failure and return a per-item ledger; never claim an incomplete result is complete.
- If a sender name is absent, keep the real sender ID; do not guess a name or expand into an unrequested directory lookup.
- A missing optional enrichment is not a primary-query failure; record enrichment failures in the ledger.
- `+at-me`、`+chat-messages`、`+messages-mget`、`+search-msg`、`+thread-replies` support `--download-resources --output-dir ./downloads [--overwrite]`; resource download is opt-in.
- For nested resources, prefer the child `messageId` in `resourceRefs`; inherit the parent only if the chat ID is missing.
- These queries and `+messages-resource-download` are `read/not_required`; never pass `--yes`. Keep output relative, inside the working directory, without `..`; add `--overwrite` only when requested.
- Accept reviewed DingTalk/public OSS HTTPS URLs only. Validate every redirect and never forward headers across hosts.
- `+messages-send-card` 的 `--group`、`--receiver`、`--receiver-open-dingtalk-id` are mutually exclusive. With `--content`, update immediately; otherwise return `bizId`. The final `+messages-update-card` call uses `--flow-status 3`.
**触发**:用机器人群 webhook 推一条消息。
## 低频原子路由
1. **执行(必须)**:`dws chat message send-by-webhook --token <webhookToken> --title "<标题>" --text "<内容>" --format json`。
2. **@ 人(必须)**:需要 @ 时,`--text` 中**必须**先包含对应 `@userId` / `@手机号` / `@10`,再配合 `--at-users` / `--at-mobiles` / `--at-all`;否则 @ 不生效。
Use this section only when no public Shortcut matches. If sibling commands are ambiguous, read [intent-guide.md](references/intent-guide.md), locate the leaf in [chat.md](references/chat.md#命令索引表), then load only the matching branch reference.
**禁止**:只传 `--at-users` 而 `--text` 里不含 `@<标识>`。
```text
dws chat
├── message # send, list, search, reply, forward, recall, cards, reactions
├── group* / group-role # chats, members, settings, mute, notice, roles
├── bot # bot search
├── category # conversation categories
├── search # chat and common-chat search
├── conversation-info / list-* # conversation info and lists
├── mute* / hide / set-top
├── mark-* / clear-* # read/unread, red dots, message clearing
├── text / chmod / data-auth
└── scripts
```
### SOP-4 共同群查询(search-common-group)
Branch references: [消息](references/chat/chat-message.md), [群与成员](references/chat/chat-group.md), [机器人与 Webhook](references/chat/chat-bot.md), [会话状态与分组](references/chat/chat-conversation.md).
**触发**:"我和 XX 的共同群"。
| 用户意图 | 原子回退 | 关键边界 |
|---|---|---|
| 收藏 / 取消收藏 / 收藏列表 | `message add-favorite` / `remove-favorite` / `list-favorites` | Changes personal favorites; does not recall |
| 编辑已发送消息 | `message edit` | Edits the existing message; does not resend |
| 普通群升级为外部群 | `group upgrade-to-external` | 不可逆; read leaf confirmation before execution |
| 设置或清除群昵称 | `group update-nick` | Omit `--nick` to clear the current user's chat nickname |
| 查会话所属分组 / 批量查分组 | `category list-by-conv` / `batch-info` | Different from listing every category |
| 特别关注人的消息 | `message list-focused` | Lists messages; “我关注了谁” routes to `dingtalk-contact` |
| 我和某人的共同群 | `search-common` | For “我”, get self nickname via `contact user get-self`; paginate by `nextCursor` |
| 群公告 / 群身份 | `group notice *` / `group-role *` | Chat notices differ from company notices; identity is not admin role |
| 消息置顶 / Pin / 会话置顶 | `message set-top-msg` / `set-pin-msg` / `chat set-top` | Three different target types |
| 查未读会话 / 谁看了消息 | `message list-unread-conversations` / `message read-status` | First lists chats; second lists readers of one message |
| 标未读 / 标已读 / 清红点 / 全部已读 | `mark-unread` / `mark-read` / `clear-red-point` / `clear-all-red-point` | Mutates chat/message state; not read-only |
| 行为授权 / 跨组织聊天数据授权 | `chmod` / `data-auth cross-org` | Grants action or cross-org read access; executes neither target action |
| 退群 / 解散群 | `group quit` / `group dismiss` | First exits current user; second dismisses the entire chat |
1. **取昵称(必须)**:先 `dws contact user get-self --format json` 取自己昵称;对方昵称从历史/上下文取,拿不到必须先问用户。
2. **执行(必须)**:`dws chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0 --format json`;`hasMore=true` 时**必须**用 `nextCursor` 翻页,不要停在第一页。
## 错误恢复与按需 Reference
**禁止**:跳过昵称解析、忽略 `hasMore` 不翻页。
- If a leaf is missing or parameters are invalid, follow the established Catalog → Schema → `--help` order and correct once.
- Re-extract every downstream ID from actual output; never repair an ID by guessing.
- For complex messaging, read [01-messaging.md](references/01-messaging.md); for onboarding, read [01-onboarding.md](references/workflows/01-onboarding.md).
- On command error, read [chat-error-recovery.md](references/chat-error-recovery.md) and correct once.
- Stop and report insufficient permission, unresolved ambiguity, no result after re-search, or contract conflict.
### SOP-5 红点 / 未读管理(manage-red-point)
**触发**:标记未读/清除红点/全部已读。
1. **执行(必须)**:标记某会话未读 `dws chat mark-unread --conversation-id <openConversationId> --format json`;清除某会话红点 `dws chat clear-red-point --conversation-id <openConversationId> --format json`;全部已读 `dws chat clear-all-red-point --format json`。
2. **取会话 ID(必须)**:`openConversationId` 拿不准时先 `dws chat group list-all --format json` 或 `dws chat search --query "<群名>" --format json`,**禁止**编造。
**禁止**:未确认会话就批量"全部已读"(破坏性,必须先与用户确认)。
### SOP-6 特别关注消息(focus-messages)
**触发**:"特别关注的人最近发了什么/聊了什么"。
1. **执行(必须)**:`dws chat message list-focused --limit 50 --format json`,直接基于返回答复。
2. **边界(必须)**:只有用户终点是"我关注了谁"这种**人员列表**时,才切 `dingtalk-contact` 关系查询。
**禁止**:用普通 `message list` 冒充 focused、把人员列表需求硬塞进 chat。
### SOP-7 拉取 / 撤回消息(list-or-recall-message)
**触发**:查某个群或单聊的聊天记录、撤回某条消息。
1. **定位会话(必须)**:群名先 `dws chat search --query "<群名>" --format json` 取 `openConversationId`;单聊对象先解析真实 `userId` 或 `openDingTalkId`。
2. **拉取消息(必须)**:`dws chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>" --direction older --format json`;单聊将 `--group` 换成 `--user` 或 `--open-dingtalk-id`。`--time` 是必填参数,必须来自用户时间范围或明确收敛后的边界。
3. **撤回(必须)**:仅在用户明确要求撤回时,从消息列表 `result.messages[].openMessageId` 取真实 ID,执行 `dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId> --format json`。
**禁止**:省略 `--time`、用发送返回的 `clientMsgId` 代替 `openMessageId`、只传 `--client-msg-id`、编造会话或消息 ID。
## 跨产品协作
- 收件人是人名 → use `dingtalk-contact` or `dingtalk-aisearch` to obtain one real `openDingTalkId` / `userId`.
- 要发本地图片/文件 → use `dws chat message send --msg-type file --file-path <本地路径>`. Images are downloadable attachments, not inline. Use `--msg-type image --media-id` only with an existing valid mediaId; DWS cannot convert local files to mediaId.
- 紧急升级(应用内/短信/电话)→ route to `dingtalk-ding`.
- 发邮件 → route to `dingtalk-mail`.
- 收件人是人名 → 先用 `dingtalk-contact` 或 `dingtalk-aisearch` 拿 `openDingTalkId` / `userId`
- 要发图片/文件 → 先 `dt_media_upload` 上传 → `python scripts/extract_media_id.py "<URL>"` 提取 mediaId → 再用 `--media-id`
- 紧急升级(应用内/短信/电话)→ 切到 `dingtalk-misc`(`references/ding.md`)
- 发邮件 → 切到 `dingtalk-mail`
## 局部意图与短流程
- [局部意图消歧](references/intent-guide.md);[短流程](references/lite-recipes.md)。
@@ -4,10 +4,10 @@
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| query-group-chat | 1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>" --direction newer --format json`<br>3. **翻页**:`hasMore=true` 时取本页最后一条消息的 `createTime` 作为下一次 `--time`,保持原 `--direction`,禁止自造 cursor/time;直到 `hasMore=false`<br>4. `--direction older` 拉给定时间**之前**的消息<br>5. 合并结果;用户要求导出时由 Agent 将结构化输出写入目标文件 |
| query-private-chat | 1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId` / `openDingTalkId`<br>2. `chat message list --user <userId> --time "<yyyy-MM-dd HH:mm:ss>" --direction newer --format json`;跨组织时可用 `--open-dingtalk-id`<br>3. **翻页**:同 query-group-chat<br>4. 合并结果后总结或按用户要求保存 |
| query-group-chat | **优先**:`python scripts/chat_export_messages.py --query "<群名>" --time "<yyyy-MM-dd HH:mm:ss>" [--no-forward] [--limit N] [--output messages.json]`(自动搜群+翻页+导出)<br>备选:1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`<br>4. `--forward=false` 拉给定时间**之前**的消息<br>5. 合并全部消息后总结 |
| query-private-chat | **优先**:`python scripts/chat_history_with_user.py --name "<姓名>" --time "<yyyy-MM-dd HH:mm:ss>" [--no-forward] [--limit N] [--output messages.json]`(自动搜人+翻页+导出)<br>备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `chat message list --user <userId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:同 query-group-chat<br>4. 合并全部消息后总结 |
| escalate-ding | 三级升级:<br>1. `ding message send --robot-code <robotCode> --type app --users <userId> --content "<内容>"`(必填项见 `dingtalk-misc` 的 `references/ding.md`)<br>2. `chat message send --group <openConversationId> --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](./chat.md))<br>3. `todo task create --title "<标题>" --executors <userId> --priority 40` 建紧急待办<br>前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` |
| send-by-bot | 1. `chat bot search` → 取 `robotCode`<br>2. `chat search --query "<群名>"` → 取每个 `openConversationId`<br>3. 单群用 `chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "<标题>" --text "<内容>"`<br>4. 多群时先一次性确认全部目标与内容,再对每个已确认群逐一执行同一命令;汇总每群成功/失败结果 |
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `aisearch person --keyword "<姓名>" --dimension name` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId>` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
| send-by-bot | **多群批量优先**:`python scripts/bot_broadcast.py --robot-code <robotCode> --chats <id1>,<id2> --title "<标题>" --text "<内容>"`<br>单群:1. `chat bot search` → 取 `robotCode`<br>2. `chat search --query "<群名>"` → 取 `openConversationId`<br>3. `chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "<标题>" --text "<内容>"` |
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `aisearch person --keyword "<姓名>" --dimension name` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId> --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
| search-common-group | `chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0`(`--match-mode AND`=全在/`OR`=任一在,翻页:`hasMore=true` 时用 `nextCursor`)<br>用户说"我和XX的共同群" → nicks 包含"我"时,需先 `contact user get-self` 取自己昵称再拼接 |
| focus-messages | **零参数一行命令**:`chat message list-focused --limit 50`(拉特别关注人发的消息聚合)<br>触发 query:`"我特别关注的人最近发了什么消息"`、`"关注的人最近聊了啥"`、`"星标联系人最近的动态"`<br>**强消歧**:query 含动词【发/说/聊/讲】或名词【消息/聊天/动态】 → **必须**走本命令,**不要**先去拉 `contact relation list-my-followings`;仅当用户终点是"人员列表"(如"我关注了谁")才走 `relation list-my-followings`(详见 `dingtalk-contact` 易混淆硬规则)<br>翻页:`hasMore=true` 时用 `nextCursor` 作为下次 `--cursor`<br>按人精控(可选):先 `contact relation list-my-followings` 取 `openDingTalkId`,再 `chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start <ISO> --end <ISO>` |
@@ -1,46 +0,0 @@
# chat 高频错误与自纠指南
> 本文件是 `dingtalk-chat` 的局部错误契约,与 `dws-shared` 的全局错误规则配合使用。
> 原则:可自纠的错误给出一次明确修正;不可自纠的错误立即停止并报告用户。
## 错误读取顺序
chat 命令由 Agent 执行时统一加 `--format json`。失败时优先读取结构化错误中的 `code`、`message`、`hint`、`actions` 和 `available_flags`;如果当前版本只返回纯文本,则保留原始错误并按同样顺序判断。
## 可自行修复的错误
| 错误表现 | 根因 | Agent 自纠动作 |
|---|---|---|
| `--group` 与单聊收件人参数同时传入 | 单聊/群聊参数互斥 | 删除错误目标;群聊保留 `--group`,单聊保留 `--user` 或 `--open-dingtalk-id` |
| `openConversationId is required` / 群不存在 | 群 ID 错误或缺失 | 执行 `chat search --query "<群名>" --format json`,从返回中取真实 `openConversationId` |
| `userId is required` / 收件人无效 | 未先把人名解析为 ID | 执行 `aisearch person` 或 `contact user search`,取 `userId` / `openDingTalkId` |
| 群成员命令提示 flag 不存在 | 把消息参数用于成员管理 | 改用 `group members --id <openConversationId>`;不要臆造 `members list` |
| 消息搜索结果不符合关键词条件 | 错用了按时间拉取 | 改用 `message search-advanced` |
| `confirmation_required` | 危险操作尚未通过运行时确认 | 停止并向用户说明目标与影响;只有用户明确同意后才用原命令加 `--yes` 重试一次 |
| 消息 ID / 卡片 bizId 无效 | 使用了占位符或产品对象 ID | 从源会话消息或 `message send-card` 的真实返回中重新提取,不自行构造 |
| Webhook Token 无效 | token 错误或失效 | 停止并报告用户,要求确认 Webhook Token |
| 添加/移除群成员失败 | ID 错误或无权限 | 先确认成员 ID;当前用户无管理权限则停止并报告 |
| 机器人无法添加到群 | 当前用户非群管理员或机器人不可用 | 停止并报告用户,不改用普通用户身份代发 |
| 撤回消息失败 | message key 错误或已超时 | 停止并报告用户 |
## 需用户介入的错误
遇到以下情况不得自行重试或猜测:
- 权限不足(`PermissionDenied`)。
- 资源不存在且重新搜索也无结果。
- 配额超限(例如群成员达到上限)。
- 搜索返回多个同名用户或群,目标存在歧义。
- 不可逆操作尚未获得用户明确确认。
- Schema 与叶子 Help 冲突;采用更安全解释并报告契约漂移,不尝试绕过。
报告时包含原始错误、已验证的目标与仍缺少的信息。
## 跨步骤数据传递
1. `chat search --format json` 返回的 `openConversationId` 直接传给下一步群聊参数。
2. `contact user search` / `aisearch person` 返回的 `openDingTalkId`、`userId` 分别传给对应 flag。
3. `chat message list` / `search` 返回的 `openMessageId` 必须与所属 `openConversationId` 同源,再用于回复、转发、收藏、表情或置顶。
4. `chat message send-card` 创建成功后返回的 `bizId` 只用于后续 `message update-card`。
不要自行构造 ID,也不要假设不同命令返回的字段可以互换。
+46 -93
View File
@@ -4,20 +4,40 @@
> 命令别名:`dws im` 等价于 `dws chat`。
## Shortcut 优先路由
常见 Agent 意图优先使用公开 `+` Shortcut;原子命令保留给需要特定原始返回结构、兼容参数或 Shortcut 未覆盖字段的场景。执行前用 `dws schema --cli-path "chat +<shortcut>" --format json` 读取最终参数、约束和默认确认语义。
| 意图 | 首选 |
|---|---|
| 以 current-user / bot / webhook 身份发消息 | `dws chat +messages-send --as <identity> ...` |
| 拉取单个群聊或单聊的消息 | `dws chat +chat-messages ...` |
| 按关键词、发送者、@对象、会话、类型或时间组合搜索 | `dws chat +search-msg ...` |
| 查询 @我的消息 | `dws chat +at-me ...` |
| 根据消息 ID 批量取详情与 reaction | `dws chat +messages-mget ...` |
| 读取已知 thread/topic 的全部回复 | `dws chat +thread-replies ...` |
| 下载单个 mediaId/fileId | `dws chat +messages-resource-download ...` |
- `+messages-send` 只暴露下层真实支持的身份能力,并自动规范化、补齐对应身份的 @ 占位符。
- `+search-msg --page-all` 连续翻页并默认按消息 ID 批量富化;续页或富化失败会保留已取得结果并返回逐项失败 ledger。
- 五个查询 Shortcut 的 `--download-resources` 沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。引用、回复、合并转发中的资源使用 `resourceRefs` 自带的子消息 `messageId`;仅当子消息缺会话 ID 时继承父消息 `openConversationId`。
- `+messages-resource-download` 同样无需交互确认,但只允许工作目录内相对路径、默认拒绝覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
- 下载器只接受经审查的钉钉与公网 OSS HTTPS 地址并逐跳校验重定向;跨主机时不会转发下层提供的请求头。
## 适用范围与安全硬约束
`chat` 覆盖钉钉会话、群聊、群成员、会话消息、机器人消息、Webhook、会话状态和群身份管理。
**发消息前参数审查(必须执行)**:
- 发消息类原子命令包括 `message send`、`send-by-bot`、`send-by-webhook`、`send-card`、`reply`、`forward`。
- 发消息类命令包括 `chat message send`、`send-by-bot`、`send-by-webhook`、`send-card`、`reply`、`forward`。
- 执行前必须逐项核对接收对象、群/人、消息内容、@ 对象、消息类型、附件路径,确认全部来自用户原始需求。
- 用户没说发给谁、群名/人名可能匹配多个对象、消息文本由 Agent 组织、是否 @ 某人不明确时,必须先确认,严禁自行补全。
- 重试 `message send` 时复用同一个 `--uuid`,避免重复投递。
- 重试发送建议复用同一个 `--uuid`,避免重复投递;不传 `--uuid` 时每次调用都视为新消息。
**文件、图片、音频、视频发送硬规则**:
- 发送本地文件、音频、视频使用 `message send --msg-type file|audio|video --file-path <本地路径>`;CLI 完成上传和发送,`audio` / `video` 底层按 `file` 处理。
- 新场景发送本地文件、音频、视频统一用 `dws chat message send --msg-type file|audio|video --file-path <本地路径>`,CLI 内部完成上传和发送;`audio` / `video` 底层按 `file` 链路处理。
- 不要先走 `dt_media_upload`、`extract_media_id.py`、`drive upload`;`--msg-type image --media-id` 只保留给已有 mediaId 的旧链路。
**Markdown 换行硬规则**:
@@ -62,7 +82,7 @@ dws chat bot --help
| 群成员昵称 | `dws chat group members --id <openConversationId> --format json` | 返回中包含成员在群里的昵称/展示名;不要只查 contact 个人名 |
| 消息 `openMessageId` / `openMsgId` | `dws chat message list ... --format json` | 撤回、已读状态、回复、转发、表情回应 |
| 话题 `openConvThreadId` | `dws chat message list ... --format json` | 拉话题回复或往话题内回复;禁止自行拼接 |
| 卡片 `bizId` | `dws chat message send-card ... --format json` | `message update-card --biz-id` |
| 卡片 `bizId` | `dws chat message send-card ... --format json` | `update-card --biz-id` |
## 易混淆路由
@@ -70,14 +90,14 @@ dws chat bot --help
|------------|----------|------------|
| “置顶会话” | `chat set-top` 或 `chat list-top-conversations` | 不要用 `message set-top-msg` |
| “置顶某条消息” | `chat message set-top-msg` | 不要用 `chat set-top` |
| “机器人发消息” | `chat message send-by-bot` | 不要用当前用户身份代发 |
| “机器人发消息” | `chat message send-by-bot` | 不要用当前用户身份 `message send` 代发 |
| “给机器人发单聊” | `chat bot find` 取 openDingTalkId → `message send --open-dingtalk-id` | 不要用 `bot search`,它没有 openDingTalkId |
| “发图片/文件/音频/视频” | `message send --msg-type file|audio|video --file-path`;已有图片 mediaId 才用 `image` | 不要先 `drive upload` 或 `dt_media_upload` |
| “特别关注的人最近发了什么” | `message list-focused` | 不要先查 `contact relation list-my-followings` |
| “某人发给我的消息” | `message list-by-sender` | 不要只查单聊,结果应覆盖群聊+单聊 |
| “搜索消息” | `message search-advanced` | 不要在可组合条件下退回简单 `message search` |
| “消息转待办” | 先用 `message list` / `search-advanced` 取消息内容,再调用 `dws todo task create` | 不要在 chat 内寻找“转待办”命令 |
| “消息转日程” | 先用 `message list` / `search-advanced` 取消息内容,再调用 `dws calendar event create` | 不要在 chat 内寻找“转日程”命令 |
| “搜索消息” | 首选 `message search-advanced` | 不要在可组合条件下退回简单 `message search` |
| “消息转待办” | 先用 `chat message list/search-advanced` 取消息内容,再调用 `dws todo task create` | 不要在 chat 内寻找“转待办”命令 |
| “消息转日程” | 先用 `chat message list/search-advanced` 取消息内容,再调用 `dws calendar event create` | 不要在 chat 内寻找“转日程”命令 |
| “共同群” | `chat search-common --nicks` | 不要分别搜索群再手工求交 |
| “拉取群文件 / 群文件列表” | `chat conversation-info --group <openConversationId>` 取 `spaceId` → `dws drive list --space-id <spaceId>` | 不要用 `chat file upload`,它只负责上传 |
| “清空会话聊天记录” | `chat clear-messages` | 不要理解为删除群消息;它只影响当前用户视角 |
@@ -87,11 +107,11 @@ dws chat bot --help
执行任何发送类命令前,至少检查:
1. 接收对象明确:群聊必须有 `openConversationId`,单聊必须有 `userId` 或 `openDingTalkId`。
2. 身份明确:当前用户、应用机器人、Webhook 分别使用 `message send`、`send-by-bot`、`send-by-webhook`。
2. 身份明确:用户身份发送用 `message send`,机器人身份发送用 `send-by-bot`,Webhook 用 `send-by-webhook`。
3. 内容明确:消息正文、标题、附件、卡片内容都能从用户原始需求中找到依据。
4. @ 对象明确:`--at-all`、`--at-user-ids`、`--at-open-dingtalk-ids` 不可自行追加。
5. 文件路径明确:使用 `--file-path`,并确认路径是用户提供或当前任务生成的目标文件。
6. 重试策略明确:`message send` 复用同一个 `--uuid`,避免重复投递。
5. 文件路径明确:本地文件、音频、视频必须传 `--msg-type file|audio|video --file-path`,并确认路径是用户提供或当前任务生成的目标文件。
6. 重试策略明确:失败重试时复用同一个 `--uuid`,避免重复投递。
## 高频一跳命令
@@ -110,11 +130,11 @@ dws chat bot --help
| 发位置/名片 | `dws chat message send --group <openConversationId> --msg-type location ...` / `--msg-type profile --contact-id <openDingTalkId>` |
| 拉群消息 | `dws chat message list --group <openConversationId> --time "2026-03-10 00:00:00" --direction older --format json` |
| 搜消息 | `dws chat message search-advanced --query "关键词" --start <ISO> --end <ISO> --format json` |
| 查 @ 我的消息 | `dws chat message list-mentions --start <ISO> --end <ISO> --format json` |
| 查 @ 我的消息 | `dws chat message search-advanced --at-me --start <ISO> --end <ISO> --format json` |
| 查未读会话 | `dws chat message list-unread-conversations --count 20 --format json` |
| 撤回自己消息 | `dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId> --format json` |
| 查我的机器人 | `dws chat bot search --name "机器人名" --format json` |
| 机器人发群消息 | `dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "标题" --text "## 标题<br><br>正文" --format json` |
| 机器人发群消息 | `dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "标题" --text "## 标题\n\n正文" --format json` |
| Webhook 告警 | `dws chat message send-by-webhook --token <token> --title "告警" --text "内容" --format json` |
| 查单聊会话 ID | `dws chat conversation-info --user <userId> --format json` |
| 查群钉盘空间 | `dws chat conversation-info --group <openConversationId> --format json` |
@@ -134,15 +154,14 @@ dws chat bot --help
|------|------|----------|------|
| `message send` | 当前用户发群聊/单聊文本、Markdown、图片、文件、音频、视频、位置、名片 | `--group` / `--user` / `--open-dingtalk-id` 三选一;文本用 `--text`,本地文件/音视频用 `--msg-type file|audio|video --file-path` | [chat-message](chat/chat-message.md#发送消息) |
| `message list` | 拉取群聊或单聊消息 | `--time` + `--group` / `--user` / `--open-dingtalk-id` 三选一 | [chat-message](chat/chat-message.md#拉取消息) |
| `message list-direct` | 拉取与指定用户的单聊消息 | `--time` + `--user` / `--open-dingtalk-id` 二选一 | [chat-message](chat/chat-message.md#拉取消息) |
| `message list-all` | 指定时间范围内拉取当前用户全部会话消息 | `--start` `--end` `--limit` `--cursor` | [chat-message](chat/chat-message.md#拉取消息) |
| `message list-by-sender` | 跨单聊和群聊查指定发送者消息 | `--sender-user-id` / `--sender-open-dingtalk-id` 二选一 | [chat-message](chat/chat-message.md#拉取消息) |
| `message list-mentions` | 查 @ 我的消息 | 可选 `--group`、`--start`、`--end` | [chat-message](chat/chat-message.md#拉取消息) |
| `message list-focused` | 查特别关注人消息 | 无 | [chat-message](chat/chat-message.md#拉取消息) |
| `message list-unread-conversations` | 获取未读会话列表 | 可选 `--count` | [chat-conversation](chat/chat-conversation.md#会话列表与红点) |
| `message search-advanced` | 原子多维度搜索回退;支持消息类型、会话类型、机器人消息过滤 | 至少一个搜索条件 | [chat-message](chat/chat-message.md#搜索消息) |
| `message search-advanced` | 多维度搜索消息,首选;支持消息类型、会话类型、机器人消息过滤 | 至少一个搜索条件 | [chat-message](chat/chat-message.md#搜索消息) |
| `message search` | 简单关键词搜索消息 | `--query` | [chat-message](chat/chat-message.md#搜索消息) |
| `message read-status` | 查询消息已读/未读状态 | `--conversation-id` `--message-id` | [chat-message](chat/chat-message.md#消息状态与撤回) |
| `message read-status` | 查询消息已读/未读状态 | `--group` `--message-id` | [chat-message](chat/chat-message.md#消息状态与撤回) |
| `message query-send-status` | 查询发送任务状态 | `--open-task-id` | [chat-message](chat/chat-message.md#消息状态与撤回) |
| `message recall` | 撤回当前用户消息;群主/管理员可撤回群内他人消息 | `--conversation-id` `--msg-id` | [chat-message](chat/chat-message.md#消息状态与撤回) |
| `message edit` | 编辑已发送消息内容 | `--conversation-id` `--msg-id`,`--text` / `--content` 二选一 | [chat-message](chat/chat-message.md#消息状态与撤回) |
@@ -217,7 +236,7 @@ dws chat bot --help
| `mute-at-all` / `mute-red-envelope` | 关闭/恢复 @所有人或红包通知 | `--conversation-id` | [chat-conversation](./chat/chat-conversation.md#会话置顶与通知) |
| `mark-unread` / `mark-read` | 标记会话未读 / 标记消息已读 | 会话 ID;`mark-read` 还需消息 ID | [chat-conversation](./chat/chat-conversation.md#已读未读与清理) |
| `clear-red-point` / `clear-all-red-point` | 清除单会话/全部红点 | 单会话需 `--conversation-id` | [chat-conversation](./chat/chat-conversation.md#会话列表与红点) |
| `clear-messages` | 清空当前用户视角会话消息 | `--conversation-id`;确认后传 `--yes` | [chat-conversation](./chat/chat-conversation.md#已读未读与清理) |
| `clear-messages` | 清空当前用户视角会话消息 | `--conversation-id` | [chat-conversation](./chat/chat-conversation.md#已读未读与清理) |
| `category list-by-conv` | 拉取指定会话所属的用户自定义会话分组 | `--group` | [chat-conversation](./chat/chat-conversation.md#会话分组) |
| `category batch-info` | 批量拉取用户自定义会话分组信息 | `--category-ids` | [chat-conversation](./chat/chat-conversation.md#会话分组) |
| `category *` | 自定义会话分组和智能分组管理 | 视子命令而定 | [chat-conversation](./chat/chat-conversation.md#会话分组) |
@@ -229,25 +248,25 @@ dws chat bot --help
| 用户说 | 路由 |
|--------|------|
| “发群消息 / 发到某群” | `chat message send --group`,先用 `chat search` 找 `openConversationId` |
| “发单聊 / 发给某人” | `chat message send --user/--open-dingtalk-id`,人员信息见 `dingtalk-contact` |
| “发图片 / 发文件 / 发截图 / 发音频 / 发视频” | `chat message send --msg-type file|audio|video --file-path <本地路径>` |
| “发单聊 / 发给某人” | `chat message send --user` 或 `--open-dingtalk-id`,人员信息见 `dingtalk-contact` |
| “发图片 / 发文件 / 发截图 / 发音频 / 发视频” | `chat message send --msg-type file|audio|video --file-path <本地路径>`;`audio/video` 底层按 `file` 发送 |
| “发位置 / 分享地址” | `chat message send --msg-type location`,需经用户确认经纬度和地址名 |
| “发联系人名片 / 分享联系人” | `chat message send --msg-type profile --contact-id <openDingTalkId>` |
| “机器人发消息 / 机器人群发” | 单次用 `chat message send-by-bot`;多群广播优先专用脚本 |
| “机器人发消息 / 机器人群发” | `chat message send-by-bot`,不要用用户身份代发 |
| “撤回我发的消息” | `chat message recall` |
| “撤回机器人发的消息” | `chat message recall-by-bot` |
| “查某群聊天记录” | 单次用 `chat message list --group`;完整导出优先专用脚本 |
| “查和某人的单聊记录” | 单次用 `chat message list-direct --user/--open-dingtalk-id`;完整历史优先专用脚本 |
| “查某群聊天记录” | `chat message list --group` |
| “查和某人的单聊记录” | `chat message list --user` 或 `--open-dingtalk-id` |
| “某人发给我的消息 / 指定发送者消息” | `chat message list-by-sender`,跨单聊和群聊 |
| “我今天/最近所有消息” | `chat message list-all --start <ISO> --end <ISO>` |
| “@我的消息” | `chat message list-mentions`;组合过滤用 `chat message search-advanced --at-me` |
| “@我的消息” | `chat message list-mentions` 或 `search-advanced --at-me` |
| “特别关注的人最近发了什么” | `chat message list-focused`,不要先查关注人员列表 |
| “搜索消息里的关键词 / 多维度搜索” | `chat message search-advanced` |
| “搜索消息里的关键词 / 多维度搜索” | 首选 `chat message search-advanced` |
| “只搜文件消息 / 只搜群聊 / 只看机器人消息” | `chat message search-advanced --message-type file --search-conv-type group_chat --only-robot-messages` 按需组合 |
| “翻译这段文字” | `chat text translate --query <文本> --to <语言代码>` |
| “把这条消息转待办 / 消息里提到的事项建待办” | 先取消息内容和相关人员,再按 `dingtalk-todo` 调 `dws todo task create`;标题、执行人、截止时间必须来自消息或用户确认 |
| “把这条消息转日程 / 消息里约的会建日程” | 先取消息内容、时间、地点、参会人,再按 `dingtalk-calendar` 调 `dws calendar event create`;时间不明确必须先确认 |
| “话题回复 / 往话题里回复” | `message list-topic-replies` 读取;发送回复用 `message send --group <openConvThreadId>` |
| “话题回复 / 往话题里回复” | 先 `message list` 获取 `openConvThreadId`,再 `list-topic-replies` 或 `message send --group <openConvThreadId>` |
| “消息已读未读 / 谁看了消息” | `chat message read-status` |
| “置顶某条消息 / 取消消息置顶” | `chat message set-top-msg` / `unset-top-msg` |
@@ -290,7 +309,7 @@ dws chat bot --help
| “隐藏会话” | `chat hide` |
| “清红点 / 全部已读” | `chat clear-red-point` / `chat clear-all-red-point` |
| “标记未读 / 标记已读” | `chat mark-unread` / `chat mark-read` |
| “清空聊天记录” | `chat clear-messages`;按 leaf Schema 的确认语义执行 |
| “清空聊天记录” | `chat clear-messages` |
| “会话分组” | `chat category *` |
| “智能分组 / 按关键词或成员自动分组” | `chat category create-smart` |
| “群文件 / 拉取群文件列表” | `chat conversation-info --group <openConversationId>` 取 `spaceId`,再按 `dingtalk-drive` 调 `dws drive list --space-id <spaceId>` |
@@ -332,78 +351,12 @@ dws chat bot --help
完整字段传递表见 [chat-workflows](./chat/chat-workflows.md#上下文传递表)。
## 注意事项
- **发消息前参数审查(必须执行)**:
- 发消息(`chat message send`、`send-by-bot`、`send-by-webhook`、`send-card`、`reply`、`forward`)是严肃操作,一旦发错人/发错群会导致严重问题,因此在执行发送之前,agent 必须对所有参数进行内部审查
- 审查方式:将即将发送的**全部参数**(收件人/群、消息内容、@对象、消息类型等)与用户的**原始需求**逐一对比,确认每个参数都能从原始需求中找到明确依据
- 如果存在任何不明确、有歧义或原始需求中未提及的参数(例如:用户没说发给谁、没说发到哪个群、消息内容与用户意图有出入、不确定是否需要 @某人等),**必须先向用户确认**,严禁自行假设或补全
- 典型需要确认的场景:用户只说了"发个消息"但没指定群/人;用户的描述可匹配多个群或多个联系人;消息文本由 agent 组织而非用户原文提供时需确认措辞
- uuid 幂等参数(发消息最佳实践):
- 发消息时建议始终带上 `--uuid` 参数,传入用户自行生成的唯一标识(如 UUID v4),用于幂等控制
- 如果发送失败需要重试,重试时 `--uuid` 必须与首次发送保持一致,服务端据此去重,避免重复发消息
- 如果不传 `--uuid`,每次调用都视为新消息,重试可能导致消息重复发送
- 此参数适用于 `chat message send`(群聊和单聊均支持)
- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取
- `chat message send` 推荐用 `--text` 传消息内容(含换行或特殊字符时必须使用),也支持一个位置参数;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;纯文本/Markdown 单聊传 `--user` 时直接走 userId 发送能力;`--at-all`、`--at-open-dingtalk-ids` 仅在 `--group` 群聊时生效;本地图片/文件/音视频统一用 `--msg-type file --file-path`,其中图片是可下载附件;`--msg-type image --media-id` 仅用于上游已经提供有效 mediaId 的内联图片
- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor
- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一
- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识;`--sender-user-id`(userId)与 `--sender-open-dingtalk-id`(openDingTalkId)二选一;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数
- `chat message search` 按关键词搜索消息内容,`--query` 必填,可选 `--group` 限定搜索某个会话;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`(默认 100)/`--cursor`
- `chat message read-status` 查询指定消息的已读/未读状态,仅消息发送者可查询自己发出的消息;`--conversation-id`、`--message-id` 必填;目标用户 userId 用 `--user`/`--users`,openDingTalkId 用 `--target-open-dingtalk-ids`,不传则查所有接收者
- `chat search-common` 搜索共同群,`--nicks` 传人员昵称(逗号分隔),`--match-mode` AND/OR 控制匹配逻辑,分页用 `--limit`(默认 20)/`--cursor`
- `chat list-top-conversations` 只拉取置顶会话列表,分页用 `--limit`(默认 1000)/`--cursor`;置顶或取消置顶某条消息使用 `chat message set-top-msg` / `unset-top-msg`,不要混用
- `--user` 和 `--open-dingtalk-id` 本质上都是发起单聊操作,只是用户标识格式不同:userId 为企业内部应用常用标识,openDingTalkId 为三方应用或跨组织场景下的用户标识,服务端对两种 ID 的解析逻辑不同
- `--time` 格式: `yyyy-MM-dd HH:mm:ss`,为拉取消息的起始时间点;`--direction` 控制方向(newer=从给定时间往现在拉,older=从给定时间往以前拉),`--limit` 控制数量
- `chat search` 挂在 `chat` 下(非 `chat group` 下),路径为 `dws chat search`
- `send-by-bot` 群聊传 `--group`,单聊传 `--users` 或 `--open-dingtalk-ids`,与 `--group` 互斥且必选其一;群聊时可选 `--at-user-ids` @指定成员(传 userId 列表)或 `--at-open-dingtalk-ids` @指定成员(传 openDingtalkId 列表),content 中需包含对应 @标识;`--at-all` @所有人;群聊场景如果返回"机器人不存在"错误,需先通过 `chat group members add-bot --id <openConversationId> --robot-code <robot-code>` 将机器人邀请进群后再发送
- `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回)
- `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--text` 中包含 `@userId` 或 `@手机号` 才能生效;`--at-all` @所有人时需在 `--text` 中包含 `@10`
- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 覆盖某人全部身份(传空 --role-ids 则清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user <userId>`
- 消息**换行符**(`send` / `send-by-bot` / `send-by-webhook` 的 `--text`)有两层要求:(1) 必须是**真实换行符** `U+000A`,不是字面量 `\n`;(2) Markdown 规范下单换行不生效,需用空行 `\n\n`(段落分隔)或行尾两空格 + 换行 / `<br>`(硬换行)
- `chat group transfer-owner` 转让群主,需传 --group(openConversationId);新群主 userId 用 `--user`,openDingTalkId 用 `--new-owner`
- `chat group invite-url` 获取群邀请链接,需传 --group(openConversationId),可选 --expires-seconds 指定有效期(秒,0=永久)
- `chat group quit` 退出群聊,需传 --group(openConversationId)
- `chat group update-icon` 更新群头像,需传 --group(openConversationId)和由可信上游提供的有效 --icon-media-id(mediaId);DWS CLI 不能从本地图片生成该 ID
- `chat group update-settings` 更新群设置,需传 --group(openConversationId)、--setting-key(设置项 key)、--status(0=关闭 1=开启)
- `chat message send-card` 的 `--group`、`--receiver`、`--receiver-open-dingtalk-id` 三选一;创建后用 `message update-card --biz-id` 更新内容
- `chat message update-card` 流式更新卡片内容,需传 --biz-id(创建卡片返回的业务 ID)、--content、--flow-status
- `chat message list-by-ids` 根据消息 ID 批量查询,--msg-ids 逗号分隔,最多 50 条
- `chat message add-emoji` / `remove-emoji` 需传 --group(openConversationId)、--msg-id(openMsgId)、--emoji(表情名称)
- `chat message add-text-emotion` / `remove-text-emotion` 需传 --group、--msg-id、--emotion-id、--emotion-name、--text、--background-id,六个参数全部必填
- `chat message create-text-emotion` 创建文字表情模板,返回 emotionId;--background-id 可选,不传由服务端默认分配
- `chat category list` 无需参数;`category list-conversations` 需传 --category-id(通过 category list 获取)
- `chat mute` 默认开启免打扰,传 --off 关闭;--conversation-id / --id / --chat 三个别名均可用于传入会话 ID
- `chat message reply` 引用回复消息(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段)、--ref-msg-id(被引用消息 openMessageId)、--ref-sender(被引用消息发送者 openDingTalkId)、--text(回复内容);目前回复类型仅支持 text
- `chat message forward` 转发单条消息(**源/目标会话均支持单聊/群聊**,常见组合:群→群、群→单、单→群、单→单),需传 --src-conversation-id(源会话 openConversationId)、--msg-id(源消息 openMessageId)、--dest-conversation-id(目标会话 openConversationId)
- `chat set-top` 设置/取消会话置顶(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段),默认置顶,传 --off 取消
- `chat message reply` 以当前用户身份引用回复,与 `chat message send` 的用户身份发送语义一致
- **如何获取 openConversationId**(如果上层已有则直接使用,不必再查):
- 群聊:`dws chat search --query "群名"`
- 单聊:`dws chat conversation-info --user <userId>` 或 `dws chat conversation-info --open-dingtalk-id <openDingTalkId>`(人员信息可通过 `dws aisearch person --keyword "姓名" --dimension name` 获取)
- `chat group-mute` 全员禁言/取消全员禁言,需传 --group(openConversationId),默认禁言,传 --off 取消
- `chat group-mute-member` 指定群成员禁言,需传 --group、--user/--users(userId,逗号分隔,CLI 自动解析为 openDingTalkId)、--mute-time(毫秒,仅禁言时必填,支持 300000/3600000/86400000/604800000/2592000000),传 --off 解除禁言;禁言群主会被服务端拒绝
- `chat group set-admin` 设置/取消群管理员,需传 --group(openConversationId)、--user/--users(userId,逗号分隔),默认设为管理员,传 --off 取消
## 可选自动化脚本
这些脚本仅用于已确认安装 Python 3 的环境,不是默认或唯一执行路径;无
Python 环境时直接使用上文的 `dws` 原生命令。
| 脚本 | 可选场景 | 用法 |
|------|----------|------|
| [chat_export_messages.py](../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python3 scripts/chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` |
| [chat_history_with_user.py](../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` |
| [bot_broadcast.py](../scripts/bot_broadcast.py) | 使用同一机器人向多个群批量发送消息 | `python3 scripts/bot_broadcast.py --robot-code <code> --chats <id1>,<id2> --title "通知" --text "内容"` |
## 相关产品
- [contact](../../dingtalk-contact/references/contact.md) — 搜索人员,获取 `userId` / `openDingTalkId`。
- [todo](../../dingtalk-todo/references/todo.md) — 消息转待办时使用;从 chat 消息内容提取标题、执行人、截止时间后调用 `dws todo task create`。
- [calendar](../../dingtalk-calendar/references/calendar.md) — 消息转日程时使用;从 chat 消息内容提取标题、开始/结束时间、地点、参会人后调用 `dws calendar event create`。
- [drive](../../dingtalk-drive/references/drive.md) — 云盘/群文件管理;拉群文件先用 `chat conversation-info` 取 `spaceId`,再用 `dws drive list --space-id <spaceId>`;chat 纯文件/音视频发送使用 `chat message send --msg-type file|audio|video --file-path`。
- [drive](../../dingtalk-drive/references/drive.md) — 云盘/群文件管理;拉群文件先用 `chat conversation-info` 取 `spaceId`,再用 `dws drive list --space-id <spaceId>`;chat 纯文件/音视频发送优先用 `chat message send --msg-type file|audio|video --file-path`。
- [aisearch](../../dingtalk-aisearch/references/aisearch.md) — 可用于人员、行为、历史信息搜索,但消息发送与会话管理仍走 `chat`。
- [ding](../../dingtalk-misc/references/ding.md) — DING 通知与升级提醒,不等价于群聊消息。
@@ -8,8 +8,7 @@
## 必读约束
- 用户明确要求“用机器人/机器人身份/robot”发送时使用 `chat message send-by-bot`;严禁改用当前用户身份。
- 用户明确要求 Webhook 时使用 `chat message send-by-webhook`。
- 用户明确要求“用机器人/机器人身份/robot”发送时,必须用 `chat message send-by-bot`,严禁改用 `chat message send`。
- `chat bot search` 只返回我创建的机器人,没有 `openDingTalkId`;给机器人发单聊必须用 `chat bot find`。
- 机器人发群消息前需确认机器人已在群中;报“机器人不存在”时先 `group members add-bot`。
- `send-by-bot --text` 支持 Markdown;需要稳定换行时用空行分隔段落。若以转义形式组织文本,写 `\n\n`,不要只写 `\n`。
@@ -38,11 +37,7 @@ dws chat bot find --query "日报" --limit 20 --cursor <nextCursor>
```bash
# 群聊
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "日报" --text "## 今日完成
- 事项 A
- 事项 B"
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "日报" --text "## 今日完成\n\n- 事项 A\n\n- 事项 B"
# 单聊 userId
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
@@ -110,11 +105,7 @@ dws chat group members remove-bot --id <openConversationId> --bot-id <openBotId>
```bash
dws chat bot search --name "日报" --format json
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "日报" --text "## 今日完成
- 事项 A
- 事项 B" --format json
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "日报" --text "## 今日完成\n\n- 事项 A\n\n- 事项 B" --format json
dws chat message recall-by-bot --robot-code <robot-code> --group <openConversationId> --keys <processQueryKey> --format json
```
@@ -10,8 +10,7 @@
- 会话状态类命令通常需要 `openConversationId`。群聊可由 `chat search` 获取,单聊可由 `chat conversation-info --user/--open-dingtalk-id` 获取。
- `set-top` 是会话置顶;`message set-top-msg` 是会话内消息置顶,二者不能混用。
- `clear-messages` 只清空当前用户视角的消息,不影响其他成员,但属于高风险操作。必须先向用户说明目标会话和影响范围,获得明确确认后才传 `--yes`。
- `category delete` 会删除会话分组,同样必须先确认,确认后才传 `--yes`;不得因为 Schema 示例未展示 `--yes` 就跳过运行时确认。
- `clear-messages` 只清空当前用户视角的消息,不影响其他成员。
- 智能分组规则中的成员使用 openDingTalkId;如果用户只给姓名,先用 `aisearch person --dimension name` 获取。
## 命令明细
@@ -62,13 +61,12 @@ dws chat hide --conversation-id <openConversationId>
|------|------|----------|
| `mark-unread` | 标记指定会话为未读 | `--conversation-id` |
| `mark-read` | 将指定消息及之前消息标记为已读 | `--conversation-id` `--message-id` |
| `clear-messages` | 清空当前用户指定会话的消息 | `--conversation-id`;确认后加 `--yes` |
| `clear-messages` | 清空当前用户指定会话的消息 | `--conversation-id` |
```bash
dws chat mark-unread --conversation-id <openConversationId>
dws chat mark-read --conversation-id <openConversationId> --message-id <openMessageId>
# 按 leaf Schema 的确认语义执行
dws chat clear-messages --conversation-id <openConversationId> --yes
dws chat clear-messages --conversation-id <openConversationId>
```
### 会话分组
@@ -81,7 +79,7 @@ dws chat clear-messages --conversation-id <openConversationId> --yes
| `category batch-info` | 批量拉取用户自定义会话分组信息 | `--category-ids` |
| `category create` | 创建会话分组 | `--title` |
| `category create-smart` | 创建智能会话分组,可按群名称关键词和群内成员匹配 | `--name`,可选 `--keywords` `--members` |
| `category delete` | 删除会话分组 | `--category-id`;确认后加 `--yes` |
| `category delete` | 删除会话分组 | `--category-id` |
| `category rename` | 修改分组名称 | `--category-id` `--title` |
| `category add-conv` | 将会话加入分组 | `--group` `--category-ids` |
| `category remove-conv` | 将会话移出分组 | `--group` `--category-ids` |
@@ -93,8 +91,6 @@ dws chat category batch-info --category-ids 123,456
dws chat category create --title "工作群"
dws chat category create-smart --name "重点群" --keywords "重点,项目" --members openDingTalkId1,openDingTalkId2
dws chat category add-conv --group <openConversationId> --category-ids 123,456
# 按 leaf Schema 的确认语义执行
dws chat category delete --category-id <categoryId> --yes
```
`create-smart` 中 `--keywords` 是群名称关键词列表,`--members` 是群内成员 openDingTalkId 列表;两者可单独使用,也可组合使用。
@@ -140,5 +136,5 @@ dws chat category create-smart --name "重点群" --keywords "重点" --members
- 用户说“置顶消息”:用 `message set-top-msg`,不是 `chat set-top`。
- 用户说“置顶会话”:用 `chat set-top` 或 `list-top-conversations`。
- 单聊没有会话 ID:先 `conversation-info --user` 或 `--open-dingtalk-id`。
- 清空聊天记录或删除会话分组前必须确认目标和影响范围;未确认时不得传 `--yes` 或执行。
- 清空聊天记录前必须确认目标会话;该操作只影响当前用户视角。
- 智能分组没有匹配条件:至少确认分组名称;关键词和成员规则不明确时先向用户确认,不要自行猜成员。
@@ -128,9 +128,7 @@ dws chat group user-settings set --items '[{"openConversationId":"cid1","top":tr
```bash
dws chat group notice create --group <openConversationId> --content "今晚 22 点系统维护,请提前保存工作内容"
dws chat group notice create --group <openConversationId> --content "# 重要通知
请大家查收" --sticky --send-ding
dws chat group notice create --group <openConversationId> --content "# 重要通知\n\n请大家查收" --sticky --send-ding
dws chat group notice create --group <openConversationId> --content "明早九点例会" --run-at "2026-07-03T09:00:00+08:00"
dws chat group notice list --group <openConversationId> --limit 20 --cursor <nextPageCursor>
dws chat group notice get --group <openConversationId> --notice-id <dataId>
@@ -197,9 +195,7 @@ dws chat group members add --id <openConversationId> --users userId3,userId4 --f
```bash
dws chat group share-invite --source <sourceOpenConversationId> --target <targetOpenConversationId> --format json
dws chat group notice create --group <openConversationId> --content "# 项目公告
请大家关注最新安排" --send-ding --format json
dws chat group notice create --group <openConversationId> --content "# 项目公告\n\n请大家关注最新安排" --send-ding --format json
```
### 设置管理员并禁言成员
@@ -10,11 +10,11 @@
- 发消息前必须核对接收对象、消息内容、@ 对象、附件路径和消息类型;不明确时先问用户。
- `--group`、`--user`、`--open-dingtalk-id` 通常互斥,群聊用 `--group`,单聊用 `--user` 或 `--open-dingtalk-id`。
- 发送本地文件、音频、视频使用 `chat message send --msg-type file|audio|video --file-path <path>`。`audio` / `video` 底层按 `file` 链路发送;已有 mediaId 的图片可用 `--msg-type image --media-id`。
- 发送本地文件、音频、视频统一用 `chat message send --msg-type file|audio|video --file-path <path>`;`audio` / `video` 底层按 `file` 链路发送;旧图片链路 `--msg-type image --media-id` 仅在已有 mediaId 时使用。
- 发送位置消息前必须确认纬度、经度、地址名称;地图缩略图需先通过旧媒体上传链路拿到 mediaId。
- 分享联系人名片前必须确认联系人 `openDingTalkId`,不要把 userId 直接当 `--contact-id`。
- 消息内容按 Markdown 渲染,换行必须是真实换行符;需要换行效果时用空行、行尾两个空格或 `<br>`。
- `message send` 建议带 `--uuid`;失败重试复用同一个值。
- 建议发送时带 `--uuid`,失败重试复用同一个值。
## 命令明细
@@ -72,7 +72,6 @@ dws chat message send --group <openConversationId> --msg-type profile --contact-
| 命令 | 用途 | 示例与要点 |
|------|------|------------|
| `message list` | 拉取指定群聊或单聊消息 | `dws chat message list --group <cid> --time "2025-03-01 00:00:00" --direction older`;目标三选一,`--direction newer/older` 优先于旧 `--forward` |
| `message list-direct` | 拉取与指定用户的单聊消息 | `--user` / `--open-dingtalk-id` 二选一,`--time` 必填 |
| `message list-all` | 时间范围内全部会话消息 | `dws chat message list-all --start <ISO> --end <ISO> --limit 100 --cursor 0`;四个参数每次请求都传,翻页用 `nextCursor` |
| `message list-by-sender` | 查指定发送者消息 | `--sender-user-id` 与 `--sender-open-dingtalk-id` 二选一,跨单聊+群聊 |
| `message list-mentions` | 查 @ 我的消息 | 可传 `--group` 限定群,不传查全部 |
@@ -90,7 +89,7 @@ dws chat message send --group <openConversationId> --msg-type profile --contact-
### 搜索消息
组合搜索使用 `message search-advanced`。它是 `message search` 的严格超集。
优先使用 `message search-advanced`,它是 `message search` 的严格超集。
```bash
dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
@@ -157,10 +156,10 @@ dws chat message edit --group <openConversationId> --msg-id <openMessageId> --co
话题完整读取流程:
1. 已知 thread/topic ID 时执行 `message list-topic-replies --group <openConversationId> --topic-id <topicId>`。
2. 未知 ID 时先拉主消息;如果返回 `openConvThreadId`,再将其作为 `--topic-id`。
1. `dws chat message list --group <openConversationId> --time ...` 获取话题主消息。
2. 如果返回 `openConvThreadId`,执行 `dws chat message list-topic-replies --group <openConversationId> --topic-id <openConvThreadId>`。
原子卡片接口先创建,再用返回的 `bizId` 继续流式更新:
流式卡片必须 `send-card` 与 `update-card` 搭配:
```bash
dws chat message send-card --group <openConversationId>
@@ -223,7 +222,7 @@ dws chat text translate --query "Bonjour" --to ja_JP
### 文件与媒体
使用原子下载接口 `dws chat message download-media`:
#### `dws chat message download-media`
```bash
dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/
@@ -233,7 +232,7 @@ dws chat message download-media --type mediaId --resource-id <mediaId> --message
## 常见工作流
### 原子回退:群聊发文字与文件
### 群聊发文字与文件
```bash
dws chat search --query "项目冲刺" --format json
@@ -263,7 +262,7 @@ dws chat message search-advanced --message-type file --search-conv-type group_ch
- 发送目标不唯一:先确认群/人;群用 `chat search`,单聊用 `aisearch person` + `conversation-info`。
- `unknown flag`:立即执行对应命令 `--help`,不要猜参数。
- 文件/音视频发送失败:确认本地路径可读且在允许的工作目录范围内,并使用 `message send --msg-type file|audio|video --file-path`。
- 文件/音视频发送失败:确认本地路径可读;新链路使用 `--msg-type file|audio|video --file-path`。
- 位置消息参数不完整:先确认经纬度、地址名称和缩略图 mediaId。
- 名片发送失败:确认 `--contact-id` 是 openDingTalkId,不是 userId。
- 话题回复缺失:检查是否只拉了主消息,需继续用 `list-topic-replies`。
@@ -63,12 +63,8 @@ dws chat message send --open-dingtalk-id <openDingTalkId> --text "这是本周
# 查我的机器人,提取 robotCode
dws chat bot search --name "日报" --format json
# 机器人发群消息,提取 processQueryKey;命令中使用真实空行,模型内部转义表示才写 \n\n
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "日报" --text "## 今日完成
- 事项 A
- 事项 B" --format json
# 机器人发群消息,提取 processQueryKey;Markdown 稳定换行用 \n\n,不要只用 \n
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "日报" --text "## 今日完成\n\n- 事项 A\n\n- 事项 B" --format json
# 撤回机器人消息
dws chat message recall-by-bot --robot-code <robot-code> --group <openConversationId> --keys <processQueryKey> --format json
@@ -96,13 +92,13 @@ dws chat message send-by-bot --robot-code <robot-code> --group <openConversation
--title "提醒" --text "@openDingtalkId1 @openDingtalkId2 请查收本周报告" --format json
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> \
--at-all --title "通知" --text "@all 请所有人注意" --format json
--at-all --title "通知" --text "请所有人注意" --format json
```
### Webhook 告警
```bash
dws chat message send-by-webhook --token <webhook-token> --title "告警" --text "@10 CPU 超 90%" --at-all --format json
dws chat message send-by-webhook --token <webhook-token> --title "告警" --text "CPU 超 90% @10" --at-all --format json
```
### 话题完整读取与回复
@@ -120,9 +116,7 @@ dws chat message send --group <openConvThreadId> --text "回复话题内容" --f
dws chat group share-invite --source <sourceOpenConversationId> --target <targetOpenConversationId> --format json
# 发布群公告,Markdown 段落换行用空行
dws chat group notice create --group <openConversationId> --content "# 重要通知
请大家查收" --send-ding --format json
dws chat group notice create --group <openConversationId> --content "# 重要通知\n\n请大家查收" --send-ding --format json
# 修改公告前先列表查询 notice-id/dataId
dws chat group notice list --group <openConversationId> --format json
@@ -141,12 +135,12 @@ dws chat text translate --query "你好世界" --to en_US --format json
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `chat search` | `openConversationId` | `message send`、`message list`、`group members`、`set-top`、`group-mute` |
| `chat search` | `openConversationId` | `message send/list`、`group members`、`set-top`、`group-mute` |
| `chat group create` | `openConversationId` | 新群后续发消息、成员管理、群设置 |
| `chat group get-by-group-id` | `openConversationId` | 将数字群号转为可用会话 ID |
| `chat conversation-info` | `openConversationId` | 单聊消息搜索、转发、会话状态操作 |
| `aisearch person` | `userId` | `message send --user`、`send-by-bot --users/--at-user-ids`、`list-by-sender --sender-user-id` |
| `aisearch person` | `openDingTalkId` | `message send --open-dingtalk-id`、`send-by-bot --open-dingtalk-ids/--at-open-dingtalk-ids` |
| `aisearch person` | `userId` | `message send --user`、`send-by-bot --users`、`--at-user-ids`、`list-by-sender --sender-user-id` |
| `aisearch person` | `openDingTalkId` | `message send --open-dingtalk-id`、`--at-open-dingtalk-ids`、`send-by-bot --open-dingtalk-ids` |
| `aisearch person` | `userId` | 可替代人员搜索结果,继续用于 userId 参数 |
| `chat bot search` | `robotCode` | `send-by-bot`、`recall-by-bot` |
| `chat bot find` | `openDingTalkId` | 给机器人发单聊 |
@@ -166,17 +160,15 @@ dws chat text translate --query "你好世界" --to en_US --format json
| `chat message list/search-advanced` | `openMessageId` / `openMsgId` | `list-emotion-replies --msg-ids` |
| `chat category list` | `categoryId` | `category list-conversations/add-conv/remove-conv --category-id(s)` |
| `chat category create-smart` | 智能分组 ID/规则结果 | 后续查看或调整智能分组 |
| `chat message send-card` | `bizId` | `message update-card --biz-id` |
| `chat message send-card` | `bizId` | `update-card --biz-id` |
## 可选自动化脚本
## 自动化脚本
这些脚本仅用于已确认安装 Python 3 的环境;无 Python 环境时使用本页的
`dws` 原生命令流程。
| 脚本 | 可选场景 | 用法 |
|------|----------|------|
| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python3 scripts/chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` |
| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` |
| 脚本 | 场景 | 用法 |
|------|------|------|
| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` |
| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` |
| [extract_media_id.py](../../scripts/extract_media_id.py) | 旧链路:从 dt_media_upload URL 提取 mediaId | 新场景不要用,文件/音视频直接 `--msg-type file|audio|video --file-path` |
## 常见错误与回退
@@ -184,4 +176,4 @@ dws chat text translate --query "你好世界" --to en_US --format json
- `chat bot search` 没有 openDingTalkId:改用 `chat bot find`。
- 群号是纯数字:先 `chat group get-by-group-id`。
- 翻页不要自造 cursor:使用响应中的 `nextCursor` 原值。
- 文件/音视频发送不要走旧上传链路;使用 `message send --msg-type file|audio|video --file-path`。
- 文件/音视频发送不要走旧上传链路:优先 `message send --msg-type file|audio|video --file-path`;`audio/video` 底层按 `file` 发送。
@@ -1,71 +0,0 @@
# Workflow:新人入职群聊接待
目标:完成“查人 → 拉群 → 发欢迎消息 → 创建待办 → 预约会议”的闭环。所有 Agent 调用统一补充 `--format json`。
## 触发语
- “给新人 XXX 办理入职”
- “欢迎 XXX 加入团队,拉他进项目群并发 onboarding”
- “给 XXX 建入职待办和欢迎会”
## 步骤
### 1. 获取新人身份
```bash
dws aisearch person --keyword "<新人姓名>" --dimension name --format json
# 或
dws contact user search --query "<新人姓名>" --format json
```
提取 `openDingTalkId` 和 `userId`。返回多条时让用户确认;返回零条时停止并报告。
### 2. 搜索目标群并添加成员
```bash
dws chat search --query "<项目群名>" --format json
dws chat group members add --id <openConversationId> --users <userId> --format json
```
群不存在时重新核对群名;无权限时停止并报告群管理员。
### 3. 发送欢迎消息
```bash
dws chat message send --group <openConversationId> \
--title "欢迎 <姓名> 加入" \
--text "欢迎 <姓名> 加入项目组!入职待办和欢迎会已安排。" \
--at-all \
--format json
```
### 4. 创建入职待办
```bash
dws todo task create --title "<姓名> 入职待办:完成环境配置" \
--executors <userId> \
--priority 40 \
--due-time "<3 天后 18:00>" \
--format json
```
### 5. 预约欢迎会议
```bash
dws calendar event create --summary "<姓名> 入职欢迎会" \
--start-time "<明天 14:00>" \
--end-time "<明天 15:00>" \
--attendees <userId> \
--format json
```
## 验收
完成后汇报:
- 已拉入的群名和 `openConversationId`。
- 欢迎消息发送结果。
- 待办 ID 和截止时间。
- 会议 ID 和时间。
任一步失败时保留已完成结果,并说明失败步骤与是否需要回滚或人工处理;不要把中间步骤成功误报为整个 Workflow 完成。
@@ -3,19 +3,19 @@
用机器人向多个群批量发送相同消息(如日报提醒)
用法:
python3 scripts/bot_broadcast.py \
python bot_broadcast.py \
--robot-code <ROBOT_CODE> \
--chats "conv_id1,conv_id2,conv_id3" \
--title "日报提醒" \
--text "请大家今天下班前提交日报"
python3 scripts/bot_broadcast.py \
python bot_broadcast.py \
--robot-code <ROBOT_CODE> \
--chats-file groups.txt \
--title "周会通知" \
--text "明天下午3点周会"
python3 scripts/bot_broadcast.py --dry-run ...
python bot_broadcast.py --dry-run ...
"""
import sys
@@ -40,19 +40,14 @@ def run_dws(
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
data = json.loads(result.stdout)
if isinstance(data, dict) and data.get('success') is False:
detail = data.get('errorMsg') or data.get('message') or '未知错误'
print(f" ✗ 业务调用失败:{detail}")
return None
return data
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def run(argv: Optional[List[str]] = None) -> int:
def main():
parser = argparse.ArgumentParser(
description='向多个群批量发送机器人消息'
)
@@ -69,7 +64,7 @@ def run(argv: Optional[List[str]] = None) -> int:
'--text', required=True, help='消息内容 Markdown'
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args(argv)
args = parser.parse_args()
chat_ids: List[str] = []
if args.chats:
@@ -79,13 +74,13 @@ def run(argv: Optional[List[str]] = None) -> int:
p = Path(args.chats_file)
if not p.exists():
print(f"错误:文件不存在: {p}")
return 1
sys.exit(1)
chat_ids = [line.strip() for line in
p.read_text(encoding='utf-8').splitlines()
if line.strip() and not line.startswith('#')]
if not chat_ids:
print('错误:需要 --chats 或 --chats-file')
return 1
sys.exit(1)
print(f"📢 批量发送消息到 {len(chat_ids)} 个群")
print(f" 标题: {args.title}")
@@ -110,8 +105,8 @@ def run(argv: Optional[List[str]] = None) -> int:
fail += 1
print(f"\n完成: 成功 {success}, 失败 {fail}")
return 0 if fail == 0 else 1
sys.exit(0 if fail == 0 else 1)
if __name__ == '__main__':
sys.exit(run())
main()
@@ -3,12 +3,12 @@
导出群聊消息到 JSON 文件(从指定时间点拉取)
用法:
python3 scripts/chat_export_messages.py \
python chat_export_messages.py \
--group <openconversation_id> \
--time "2026-03-10 00:00:00" \
--output messages.json
python3 scripts/chat_export_messages.py \
python chat_export_messages.py \
--query "项目冲刺" \
--time "2026-03-10 00:00:00" \
--no-forward --limit 100
@@ -22,42 +22,11 @@ import argparse
from typing import List, Any, Optional
class ScriptError(RuntimeError):
"""可预期的脚本执行错误。"""
exit_code = 1
class AmbiguousTargetError(ScriptError):
"""搜索结果不唯一,需要调用方消歧。"""
exit_code = 2
def normalize_boundary_time(value: Any) -> str:
"""将消息时间边界规范为 CLI 接受的 yyyy-MM-dd HH:mm:ss 或原字符串。"""
if value is None or isinstance(value, bool):
return ''
if isinstance(value, (int, float)):
timestamp = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return ''
try:
timestamp = float(text)
except ValueError:
return text
else:
return str(value).strip()
if timestamp > 10_000_000_000:
timestamp /= 1000
try:
dt = datetime.datetime.fromtimestamp(
timestamp, tz=datetime.timezone(datetime.timedelta(hours=8))
)
except (OSError, OverflowError, ValueError) as exc:
raise ScriptError(f'无效的消息时间边界:{value}') from exc
def timestamp_to_datetime(ts_ms: int) -> str:
"""将毫秒时间戳转换为 yyyy-MM-dd HH:mm:ss 格式"""
dt = datetime.datetime.fromtimestamp(
ts_ms / 1000, tz=datetime.timezone(datetime.timedelta(hours=8))
)
return dt.strftime("%Y-%m-%d %H:%M:%S")
@@ -72,19 +41,14 @@ def run_dws(
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
)
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
raise ScriptError(f'执行 dws 失败:{exc}') from exc
if result.returncode != 0:
detail = result.stderr.strip() or f'退出码 {result.returncode}'
raise ScriptError(f'dws 命令失败:{detail}')
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ScriptError(f'dws 返回的不是合法 JSON:{exc}') from exc
if isinstance(data, dict) and data.get('success') is False:
detail = data.get('errorMsg') or data.get('message') or '未知错误'
raise ScriptError(f'dws 业务调用失败:{detail}')
return data
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def search_group(
@@ -96,6 +60,11 @@ def search_group(
], dry_run=dry_run)
if dry_run:
return '<CONV_ID>'
if not data:
return None
if isinstance(data, dict) and data.get('success') is False:
print(f"搜索失败: {data.get('errorMsg', '未知错误')}", file=sys.stderr)
return None
if isinstance(data, list):
groups = data
elif isinstance(data, dict):
@@ -114,64 +83,16 @@ def search_group(
else:
groups = []
if not groups:
raise ScriptError(f'未找到群聊:{query}')
exact = [
item for item in groups
if isinstance(item, dict)
and str(item.get('title') or item.get('name') or '').strip().casefold()
== query.strip().casefold()
]
candidates = exact if exact else groups
if len(candidates) != 1:
rendered = []
for item in candidates:
if not isinstance(item, dict):
continue
name = item.get('title') or item.get('name') or '未知'
conv_id = item.get('openConversationId') or item.get('id') or '无ID'
rendered.append(f'{name} ({conv_id})')
detail = ';'.join(rendered) or f'{len(candidates)} 个候选'
raise AmbiguousTargetError(
f'群名“{query}”匹配到多个候选,请指定 --group:{detail}'
)
g = candidates[0]
print(f"未找到群聊: {query}")
return None
g = groups[0]
name = g.get('title') or g.get('name', '未知')
conv_id = g.get('openConversationId') or g.get('id')
if not conv_id:
raise ScriptError(f'群聊“{name}”缺少 openConversationId')
print(f" 找到群聊: {name} ({conv_id})")
return conv_id
def parse_message_page(data: Any) -> tuple[List[Any], bool]:
"""提取消息页和 hasMore。"""
if isinstance(data, list):
return data, False
if not isinstance(data, dict):
return [], False
inner = data.get('result', data)
if isinstance(inner, dict):
messages = inner.get('messages', [])
return messages if isinstance(messages, list) else [], bool(
inner.get('hasMore', False)
)
if isinstance(inner, list):
return inner, False
return [], False
def message_identity(message: Any) -> Optional[str]:
if not isinstance(message, dict):
return None
value = (
message.get('openMessageId')
or message.get('openMsgId')
or message.get('msgId')
)
return str(value) if value else None
def run(argv: Optional[List[str]] = None) -> int:
def main():
parser = argparse.ArgumentParser(
description='导出群聊消息到 JSON'
)
@@ -191,115 +112,119 @@ def run(argv: Optional[List[str]] = None) -> int:
)
parser.add_argument('--output', default='', help='输出文件')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args(argv)
args = parser.parse_args()
try:
conv_id = args.group
if not conv_id:
if not args.query:
raise ScriptError('需要 --group 或 --query 参数')
print(f'🔍 搜索群聊: {args.query}')
conv_id = search_group(args.query, args.dry_run)
conv_id = args.group
if not conv_id:
if not args.query:
print('错误:需要 --group 或 --query 参数')
sys.exit(1)
print(f'🔍 搜索群聊: {args.query}')
conv_id = search_group(args.query, args.dry_run)
if not conv_id and not args.dry_run:
sys.exit(1)
print(f'📥 拉取消息 (起始: {args.time})...')
all_messages: List[Any] = []
seen_ids = set()
current_time = args.time
direction = 'older' if args.no_forward else 'newer'
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
has_more = False
print(f'📥 拉取消息 (起始: {args.time})...')
all_messages: List[Any] = []
current_time = args.time
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list',
'--group', conv_id or '<CONV_ID>',
'--time', current_time,
'--direction', direction,
'--format', 'json',
]
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list',
'--group', conv_id or '<CONV_ID>',
'--time', current_time,
'--format', 'json',
]
if args.no_forward:
cmd_args.append('--forward=false')
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 使用末条消息 createTime')
return 0
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time')
return
page_msgs, has_more = parse_message_page(data)
if not page_msgs:
if has_more:
raise ScriptError('服务端返回 hasMore=true,但本页没有消息')
break
if not data:
break
for message in page_msgs:
if not isinstance(message, dict):
raise ScriptError('消息列表包含非对象条目,无法安全导出')
identity = message_identity(message)
if identity and identity in seen_ids:
continue
if identity:
seen_ids.add(identity)
all_messages.append(message)
remaining -= 1
if remaining <= 0:
break
page += 1
if isinstance(data, dict) and data.get('success') is False:
print(f"拉取失败: {data.get('errorMsg', '未知错误')}", file=sys.stderr)
break
if not has_more or remaining <= 0:
break
if isinstance(data, list):
page_msgs = data
has_more = False
next_cursor = None
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
page_msgs = inner.get('messages', [])
has_more = inner.get('hasMore', False)
next_cursor = inner.get('nextCursor')
elif isinstance(inner, list):
page_msgs = inner
has_more = False
next_cursor = None
else:
page_msgs = []
has_more = False
next_cursor = None
else:
page_msgs = []
has_more = False
next_cursor = None
if not page_msgs:
break
all_messages.extend(page_msgs)
remaining -= len(page_msgs)
page += 1
if not has_more:
break
# 翻页:优先用 nextCursor(毫秒精度,转为时间字符串),降级用最后消息的 createTime
if next_cursor:
current_time = timestamp_to_datetime(next_cursor)
else:
last_msg = page_msgs[-1]
if not isinstance(last_msg, dict):
raise ScriptError('末条消息不是对象,无法取得翻页时间边界')
boundary_time = normalize_boundary_time(
boundary_time = (
last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time')
or last_msg.get('time', '')
)
if not boundary_time:
raise ScriptError('hasMore=true,但末条消息缺少 createTime')
if boundary_time == current_time:
raise ScriptError('分页边界没有推进,已停止以避免重复循环')
if not boundary_time or boundary_time == current_time:
break
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if has_more and page >= max_pages and remaining > 0:
raise ScriptError(f'达到最大分页数 {max_pages},结果不完整')
if not all_messages:
print('未拉取到消息')
return
if not all_messages:
print('未拉取到消息')
return 0
if args.output:
with open(args.output, 'w', encoding='utf-8') as file:
json.dump(all_messages, file, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for message in all_messages:
sender = (
message.get('sender')
or message.get('senderNick')
or '未知'
)
text = message.get('content') or message.get('text', '')
time_str = (
message.get('createTime')
or message.get('createAt')
or message.get('time', '')
)
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
return 0
except ScriptError as exc:
print(f'错误:{exc}', file=sys.stderr)
return exc.exit_code
except OSError as exc:
print(f'错误:无法写入输出文件:{exc}', file=sys.stderr)
return 1
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(all_messages, f, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for m in all_messages:
sender = (
m.get('sender') or m.get('senderNick') or '未知'
)
text = m.get('content') or m.get('text', '')
time_str = (
m.get('createTime') or m.get('createAt')
or m.get('time', '')
)
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
if __name__ == '__main__':
sys.exit(run())
main()
@@ -3,13 +3,13 @@
查询与某人的单聊聊天记录
用法:
python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"
python3 scripts/chat_history_with_user.py --user <userId> --time "2026-03-10 00:00:00" --limit 50
python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-01 00:00:00" --output history.json
python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"
python chat_history_with_user.py --user <userId> --time "2026-03-10 00:00:00" --limit 50
python chat_history_with_user.py --name "张三" --time "2026-03-01 00:00:00" --output history.json
工作流:
1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user)
2. 调用 chat message list-direct --user <userId> 拉取单聊消息
2. 调用 chat message list --user <userId> 拉取单聊消息
3. 输出到终端或导出为 JSON 文件
"""
@@ -21,42 +21,11 @@ import argparse
from typing import List, Any, Optional
class ScriptError(RuntimeError):
"""可预期的脚本执行错误。"""
exit_code = 1
class AmbiguousTargetError(ScriptError):
"""搜索结果不唯一,需要调用方消歧。"""
exit_code = 2
def normalize_boundary_time(value: Any) -> str:
"""将消息时间边界规范为 CLI 接受的 yyyy-MM-dd HH:mm:ss 或原字符串。"""
if value is None or isinstance(value, bool):
return ''
if isinstance(value, (int, float)):
timestamp = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return ''
try:
timestamp = float(text)
except ValueError:
return text
else:
return str(value).strip()
if timestamp > 10_000_000_000:
timestamp /= 1000
try:
dt = datetime.datetime.fromtimestamp(
timestamp, tz=datetime.timezone(datetime.timedelta(hours=8))
)
except (OSError, OverflowError, ValueError) as exc:
raise ScriptError(f'无效的消息时间边界:{value}') from exc
def timestamp_to_datetime(ts_ms: int) -> str:
"""将毫秒时间戳转换为 yyyy-MM-dd HH:mm:ss 格式"""
dt = datetime.datetime.fromtimestamp(
ts_ms / 1000, tz=datetime.timezone(datetime.timedelta(hours=8))
)
return dt.strftime("%Y-%m-%d %H:%M:%S")
@@ -72,19 +41,14 @@ def run_dws(
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
)
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
raise ScriptError(f'执行 dws 失败:{exc}') from exc
if result.returncode != 0:
detail = result.stderr.strip() or f'退出码 {result.returncode}'
raise ScriptError(f'dws 命令失败:{detail}')
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ScriptError(f'dws 返回的不是合法 JSON:{exc}') from exc
if isinstance(data, dict) and data.get('success') is False:
detail = data.get('errorMsg') or data.get('message') or '未知错误'
raise ScriptError(f'dws 业务调用失败:{detail}')
return data
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def search_user(
@@ -97,80 +61,30 @@ def search_user(
], dry_run=dry_run)
if dry_run:
return '<USER_ID>'
if not data:
return None
# 解析返回结构
users = data
if isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
users = (
inner.get('value')
or inner.get('users')
or inner.get('list')
or inner.get('items')
or []
)
users = (inner.get('users', [])
or inner.get('list', []))
elif isinstance(inner, list):
users = inner
else:
users = []
if not users or not isinstance(users, list):
raise ScriptError(f'未找到用户:{name}')
exact = [
item for item in users
if isinstance(item, dict)
and str(item.get('name') or item.get('nick') or '').strip().casefold()
== name.strip().casefold()
]
candidates = exact if exact else users
if len(candidates) != 1:
rendered = []
for item in candidates:
if not isinstance(item, dict):
continue
user_name = item.get('name') or item.get('nick') or '未知'
user_id = item.get('userId') or item.get('userid') or '无ID'
rendered.append(f'{user_name} ({user_id})')
detail = ';'.join(rendered) or f'{len(candidates)} 个候选'
raise AmbiguousTargetError(
f'姓名“{name}”匹配到多个候选,请指定 --user:{detail}'
)
u = candidates[0]
print(f"未找到用户: {name}")
return None
u = users[0]
user_name = u.get('name') or u.get('nick', '未知')
user_id = u.get('userId') or u.get('userid', '')
if not user_id:
raise ScriptError(f'用户“{user_name}”缺少 userId')
print(f" 找到用户: {user_name} ({user_id})")
return user_id
def parse_message_page(data: Any) -> tuple[List[Any], bool]:
"""提取消息页和 hasMore。"""
if isinstance(data, list):
return data, False
if not isinstance(data, dict):
return [], False
inner = data.get('result', data)
if isinstance(inner, dict):
messages = inner.get('messages', [])
return messages if isinstance(messages, list) else [], bool(
inner.get('hasMore', False)
)
if isinstance(inner, list):
return inner, False
return [], False
def message_identity(message: Any) -> Optional[str]:
if not isinstance(message, dict):
return None
value = (
message.get('openMessageId')
or message.get('openMsgId')
or message.get('msgId')
)
return str(value) if value else None
def run(argv: Optional[List[str]] = None) -> int:
def main():
parser = argparse.ArgumentParser(
description='查询与某人的单聊聊天记录'
)
@@ -191,113 +105,119 @@ def run(argv: Optional[List[str]] = None) -> int:
)
parser.add_argument('--output', default='', help='导出到 JSON 文件')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args(argv)
args = parser.parse_args()
try:
user_id = args.user
if not user_id:
print(f'🔍 搜索用户: {args.name}')
user_id = search_user(args.name, args.dry_run)
# 1. 获取 userId
user_id = args.user
if not user_id:
print(f'🔍 搜索用户: {args.name}')
user_id = search_user(args.name, args.dry_run)
if not user_id and not args.dry_run:
sys.exit(1)
print(f'📥 拉取与 {user_id} 的聊天记录 (起始: {args.time})...')
all_messages: List[Any] = []
seen_ids = set()
current_time = args.time
direction = 'older' if args.no_forward else 'newer'
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
has_more = False
# 2. 拉取单聊消息(自动翻页)
print(f'📥 拉取与 {user_id} 的聊天记录 (起始: {args.time})...')
all_messages: List[Any] = []
current_time = args.time
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list-direct',
'--user', user_id or '<USER_ID>',
'--time', current_time,
'--direction', direction,
'--format', 'json',
]
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list',
'--user', user_id or '<USER_ID>',
'--time', current_time,
'--format', 'json',
]
if args.no_forward:
cmd_args.extend(['--forward', 'false'])
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 使用末条消息 createTime')
return 0
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time')
return
page_msgs, has_more = parse_message_page(data)
if not page_msgs:
if has_more:
raise ScriptError('服务端返回 hasMore=true,但本页没有消息')
break
if not data:
break
for message in page_msgs:
if not isinstance(message, dict):
raise ScriptError('消息列表包含非对象条目,无法安全导出')
identity = message_identity(message)
if identity and identity in seen_ids:
continue
if identity:
seen_ids.add(identity)
all_messages.append(message)
remaining -= 1
if remaining <= 0:
break
page += 1
if isinstance(data, dict) and data.get('success') is False:
print(f"拉取失败: {data.get('errorMsg', '未知错误')}", file=sys.stderr)
break
if not has_more or remaining <= 0:
break
if isinstance(data, list):
page_msgs = data
has_more = False
next_cursor = None
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
page_msgs = inner.get('messages', [])
has_more = inner.get('hasMore', False)
next_cursor = inner.get('nextCursor')
elif isinstance(inner, list):
page_msgs = inner
has_more = False
next_cursor = None
else:
page_msgs = []
has_more = False
next_cursor = None
else:
page_msgs = []
has_more = False
next_cursor = None
if not page_msgs:
break
all_messages.extend(page_msgs)
remaining -= len(page_msgs)
page += 1
if not has_more:
break
# 翻页:优先用 nextCursor(毫秒精度,转为时间字符串),降级用最后消息的 createTime
if next_cursor:
current_time = timestamp_to_datetime(next_cursor)
else:
last_msg = page_msgs[-1]
if not isinstance(last_msg, dict):
raise ScriptError('末条消息不是对象,无法取得翻页时间边界')
boundary_time = normalize_boundary_time(
boundary_time = (
last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time')
or last_msg.get('time', '')
)
if not boundary_time:
raise ScriptError('hasMore=true,但末条消息缺少 createTime')
if boundary_time == current_time:
raise ScriptError('分页边界没有推进,已停止以避免重复循环')
if not boundary_time or boundary_time == current_time:
break
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if has_more and page >= max_pages and remaining > 0:
raise ScriptError(f'达到最大分页数 {max_pages},结果不完整')
if not all_messages:
print('未拉取到消息')
return
if not all_messages:
print('未拉取到消息')
return 0
if args.output:
with open(args.output, 'w', encoding='utf-8') as file:
json.dump(all_messages, file, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for message in all_messages:
sender = (
message.get('sender')
or message.get('senderNick')
or '未知'
)
text = message.get('content') or message.get('text', '')
time_str = (
message.get('createTime')
or message.get('createAt')
or message.get('time', '')
)
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
return 0
except ScriptError as exc:
print(f'错误:{exc}', file=sys.stderr)
return exc.exit_code
except OSError as exc:
print(f'错误:无法写入输出文件:{exc}', file=sys.stderr)
return 1
# 3. 输出结果
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(all_messages, f, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for m in all_messages:
sender = (
m.get('sender') or m.get('senderNick') or '未知'
)
text = m.get('content') or m.get('text', '')
time_str = (
m.get('createTime') or m.get('createAt')
or m.get('time', '')
)
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
if __name__ == '__main__':
sys.exit(run())
main()
@@ -170,14 +170,23 @@ Usage:
Example:
dws drive download --node <dentryUuid> --output ./report.pdf
dws drive download --node <dentryUuid> --output ~/downloads/
dws drive download --node <dentryUuid> --output ./big.zip --part-size 32MB --parallel 8
Flags:
--node string 文件 ID (dentryUuid) (必填)
--output string 本地保存路径 (文件路径或目录,不传则保存到当前目录);如果指定目录,文件名从下载 URL 中自动推断 (可选)
--space-id string 文件所属空间 ID (可选)
--part-size string 分片下载的分片大小,支持 KB/MB/GB 单位,范围 1MB-1GB (默认 16MB)
--parallel int 分片下载并发数,范围 1-8 (默认 4)
--no-resume 关闭断点续传,忽略历史下载进度从头下载 (默认开启续传)
```
> **提示**:`--output` 为可选参数,不传则保存到当前目录,文件名从下载 URL 中自动推断。
> **大文件分片下载**:
> - 大文件自动分片并发下载,小文件整流下载,行为对用户透明,无需任何额外操作。
> - 断点续传默认开启:下载中断后重跑同一命令会自动跳过已完成部分继续下载(`<目标文件>.dwspart` 为临时进度文件,下载完成后自动清理);不需要续传时加 `--no-resume`。
> - 下载凭证过期会自动刷新并继续下载,已完成的部分不会重下;单个分片失败会自动重试,无需手动处理。
### 创建文件夹
```
@@ -526,6 +535,7 @@ Flags:
> **两步下载流程**:先调用 MCP 工具获取历史版本下载 URL 和签名头,再 HTTP GET 下载文件内容到本地。
> `--output` 指定目录时,优先从文件信息中获取原始文件名,获取不到时从下载 URL 推断。
> 历史版本下载同样支持 `--part-size` / `--parallel` / `--no-resume` 分片下载参数,行为与最新版下载一致。
#### 回滚文件到指定历史版本
-460
View File
@@ -1,460 +0,0 @@
#!/usr/bin/env python3
"""Regression tests for the Chat Skill's bundled Python scripts."""
import contextlib
import importlib.util
import io
import json
import re
import types
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
MULTI_ROOT = ROOT / 'skills' / 'multi' / 'dingtalk-chat'
MONO_ROOT = ROOT / 'skills' / 'mono'
SCRIPT_NAMES = (
'chat_export_messages.py',
'chat_history_with_user.py',
'bot_broadcast.py',
)
def load_script(name: str):
path = MULTI_ROOT / 'scripts' / f'{name}.py'
spec = importlib.util.spec_from_file_location(f'chat_skill_{name}', path)
if spec is None or spec.loader is None:
raise RuntimeError(f'cannot load {path}')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class ChatSkillScriptPathTest(unittest.TestCase):
def test_shortcuts_use_progressive_discovery_in_multi_skill(self):
text = (MULTI_ROOT / 'SKILL.md').read_text(encoding='utf-8')
block = text.split('<!-- VISIBLE_SHORTCUTS_START -->', 1)[1].split(
'<!-- VISIBLE_SHORTCUTS_END -->', 1
)[0]
catalog = json.loads(
(ROOT / 'docs' / 'shortcut-public-catalog.json').read_text(
encoding='utf-8'
)
)
chat_rows = [
row for row in catalog['results'] if row.get('service') == 'chat'
]
schema_catalog = json.loads(
(
ROOT
/ 'internal'
/ 'cli'
/ 'schema_catalog'
/ 'tools'
/ 'chat.json'
).read_text(encoding='utf-8')
)
schema_shortcuts = [
path
for path in schema_catalog['tools']
if path.startswith('chat.shortcut_')
]
exclusions = json.loads(
(
ROOT / 'internal' / 'cli' / 'schema_command_exclusions.json'
).read_text(encoding='utf-8')
)
pending_shortcuts = next(
group['commands']
for group in exclusions['groups']
if group['id'] == 'chat-shortcuts-pending-schema-curation'
)
self.assertEqual(97, len(chat_rows))
self.assertEqual(47, len(schema_shortcuts))
self.assertEqual(50, len(pending_shortcuts))
self.assertEqual(
len(chat_rows), len(schema_shortcuts) + len(pending_shortcuts)
)
self.assertIn('## Shortcut 发现(按需)', block)
self.assertIn('`chat` 当前有 97 条公开 shortcut', block)
self.assertIn(
'完整清单保留在 Runtime Shortcut Catalog;已完成 Schema curation '
'的子集可通过 leaf Schema 查询。',
block,
)
self.assertNotIn('Runtime Catalog 与 Schema', block)
self.assertIn(
'dws shortcut list --service chat --compact --format json',
block,
)
self.assertNotRegex(block, r'(?m)^\|\s*`\+[a-z0-9-]+`')
self.assertNotRegex(block, r'(?m)^\|\s*`dws chat \+[a-z0-9-]+`')
self.assertNotIn('dws chat +', block)
for required in (
'This is the only Shortcut entry point for multi/chat',
'exact recipe/runnable script > matching public Shortcut > atomic command',
'dws shortcut list --service chat --compact --format json',
'never guess names',
'confirmation=user_required',
'--idempotency-key',
'--download-resources',
'--receiver-open-dingtalk-id',
):
self.assertIn(required, text)
def test_chat_references_are_atomic_only(self):
references = list((MULTI_ROOT / 'references').rglob('*.md'))
self.assertTrue(references)
for path in references:
text = path.read_text(encoding='utf-8')
with self.subTest(path=path.relative_to(MULTI_ROOT)):
self.assertNotRegex(text, r'(?i)\bshortcut\b')
self.assertNotRegex(text, r'\+[a-z][a-z0-9-]+')
chat_ref = (
MULTI_ROOT / 'references' / 'chat.md'
).read_text(encoding='utf-8')
message_ref = (
MULTI_ROOT / 'references' / 'chat' / 'chat-message.md'
).read_text(encoding='utf-8')
conversation_ref = (
MULTI_ROOT / 'references' / 'chat' / 'chat-conversation.md'
).read_text(encoding='utf-8')
for required in (
'message send --group',
'message search-advanced',
'message send-by-bot',
'message send-by-webhook',
):
self.assertIn(required, chat_ref)
for required in (
'message send-card',
'message update-card',
'--flow-status',
'message download-media',
'--file-path',
):
self.assertIn(required, message_ref)
for required in (
'clear-messages',
'category delete',
'按 leaf Schema 的确认语义执行',
):
self.assertIn(required, conversation_ref)
def test_documented_chat_commands_do_not_pass_literal_newline_escapes(self):
docs = [MULTI_ROOT / 'SKILL.md']
docs.extend((MULTI_ROOT / 'references').rglob('*.md'))
offenders = []
for path in docs:
for line_number, line in enumerate(
path.read_text(encoding='utf-8').splitlines(),
start=1,
):
if 'dws chat ' in line and r'\n' in line:
offenders.append(
f'{path.relative_to(ROOT)}:{line_number}: {line}'
)
self.assertEqual([], offenders)
def test_markdown_python_links_resolve(self):
link_re = re.compile(r'\[[^\]]+\.py\]\(([^)]+\.py)\)')
docs = list(MULTI_ROOT.rglob('*.md'))
docs.extend((MONO_ROOT / 'references').rglob('*.md'))
missing = []
for doc in docs:
text = doc.read_text(encoding='utf-8')
for target in link_re.findall(text):
resolved = (doc.parent / target).resolve()
if not resolved.is_file():
missing.append(f'{doc.relative_to(ROOT)} -> {target}')
self.assertEqual([], missing)
def test_optional_python_invocations_use_python3_and_skill_relative_paths(self):
docs = {
MULTI_ROOT: (
MULTI_ROOT / 'SKILL.md',
MULTI_ROOT / 'references' / '01-messaging.md',
MULTI_ROOT / 'references' / 'chat.md',
MULTI_ROOT / 'references' / 'chat' / 'chat-workflows.md',
),
MONO_ROOT: (
MONO_ROOT / 'references' / 'best_practices' / '01-messaging.md',
MONO_ROOT / 'references' / 'products' / 'chat.md',
),
}
bad_python = re.compile(r'(?<![A-Za-z0-9_])python\s+')
invocation = re.compile(r'python3\s+(scripts/[A-Za-z0-9_.-]+\.py)')
errors = []
for skill_root, paths in docs.items():
for doc in paths:
text = doc.read_text(encoding='utf-8')
if bad_python.search(text):
errors.append(f'{doc.relative_to(ROOT)} uses bare python')
for target in invocation.findall(text):
if not (skill_root / target).is_file():
errors.append(
f'{doc.relative_to(ROOT)} -> {target} is missing'
)
self.assertEqual([], errors)
def test_chat_workflows_do_not_require_python(self):
multi_skill = (MULTI_ROOT / 'SKILL.md').read_text(encoding='utf-8')
multi_recipes = (
MULTI_ROOT / 'references' / '01-messaging.md'
).read_text(encoding='utf-8')
mono_recipes = (
MONO_ROOT / 'references' / 'best_practices' / '01-messaging.md'
).read_text(encoding='utf-8')
self.assertRegex(multi_skill, r'must work without\s+Python')
self.assertNotRegex(multi_skill, r'\|\s*“[^”]+”\s*\|\s*`python3 ')
for recipes in (multi_recipes, mono_recipes):
self.assertNotRegex(recipes, r'\*\*优先\*\*[^\\n]*python3')
self.assertIn('chat message list', recipes)
self.assertIn('chat message send-by-bot', recipes)
def test_mono_and_multi_script_copies_match(self):
bad_python = re.compile(r'(?<![A-Za-z0-9_])python\s+')
for name in SCRIPT_NAMES:
multi_script = MULTI_ROOT / 'scripts' / name
self.assertEqual(
multi_script.read_bytes(),
(MONO_ROOT / 'scripts' / name).read_bytes(),
name,
)
self.assertIsNone(
bad_python.search(multi_script.read_text(encoding='utf-8')),
name,
)
class ChatExportMessagesTest(unittest.TestCase):
def setUp(self):
self.module = load_script('chat_export_messages')
def run_script(self, argv, responses):
calls = []
def fake_run_dws(args, dry_run=False):
self.assertFalse(dry_run)
calls.append(args)
response = responses.pop(0)
if isinstance(response, Exception):
raise response
return response
stdout = io.StringIO()
stderr = io.StringIO()
with mock.patch.object(self.module, 'run_dws', fake_run_dws):
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(
stderr
):
code = self.module.run(argv)
return code, calls, stdout.getvalue(), stderr.getvalue()
def test_paginates_with_last_message_time_and_deduplicates(self):
responses = [
{
'result': {
'value': [
{
'title': '项目群',
'openConversationId': 'cid-project',
}
]
}
},
{
'result': {
'messages': [
{
'openMessageId': 'm1',
'createTime': '2026-07-01 10:00:00',
'content': 'one',
},
{
'openMessageId': 'm2',
'createTime': '2026-07-01 11:00:00',
'content': 'two',
},
],
'hasMore': True,
'nextCursor': 'opaque-not-a-time',
}
},
{
'result': {
'messages': [
{
'openMessageId': 'm2',
'createTime': '2026-07-01 11:00:00',
'content': 'two',
},
{
'openMessageId': 'm3',
'createTime': '2026-07-01 12:00:00',
'content': 'three',
},
],
'hasMore': False,
}
},
]
code, calls, stdout, stderr = self.run_script(
['--query', '项目群', '--time', '2026-07-01 09:00:00'],
responses,
)
self.assertEqual(0, code, stderr)
second_page = calls[2]
self.assertEqual(
'2026-07-01 11:00:00',
second_page[second_page.index('--time') + 1],
)
self.assertEqual(
'newer', second_page[second_page.index('--direction') + 1]
)
self.assertNotIn('opaque-not-a-time', second_page)
self.assertIn('合计: 3 条消息', stdout)
def test_ambiguous_group_requires_explicit_id(self):
responses = [
{
'result': {
'value': [
{'title': '项目一群', 'openConversationId': 'cid-1'},
{'title': '项目二群', 'openConversationId': 'cid-2'},
]
}
}
]
code, calls, _, stderr = self.run_script(
['--query', '项目', '--time', '2026-07-01 09:00:00'],
responses,
)
self.assertEqual(2, code)
self.assertEqual(1, len(calls))
self.assertIn('请指定 --group', stderr)
def test_command_failure_is_not_reported_as_empty_result(self):
error = self.module.ScriptError('upstream failed')
code, _, stdout, stderr = self.run_script(
['--group', 'cid-project', '--time', '2026-07-01 09:00:00'],
[error],
)
self.assertEqual(1, code)
self.assertNotIn('未拉取到消息', stdout)
self.assertIn('upstream failed', stderr)
def test_successful_empty_page_returns_zero(self):
code, _, stdout, stderr = self.run_script(
['--group', 'cid-project', '--time', '2026-07-01 09:00:00'],
[{'result': {'messages': [], 'hasMore': False}}],
)
self.assertEqual(0, code, stderr)
self.assertIn('未拉取到消息', stdout)
class ChatHistoryWithUserTest(unittest.TestCase):
def setUp(self):
self.module = load_script('chat_history_with_user')
def test_ambiguous_user_requires_explicit_id(self):
calls = []
def fake_run_dws(args, dry_run=False):
calls.append(args)
return {
'result': {
'users': [
{'name': '张三(研发)', 'userId': 'u-1'},
{'name': '张三(销售)', 'userId': 'u-2'},
]
}
}
stdout = io.StringIO()
stderr = io.StringIO()
with mock.patch.object(self.module, 'run_dws', fake_run_dws):
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(
stderr
):
code = self.module.run(
['--name', '张三', '--time', '2026-07-01 09:00:00']
)
self.assertEqual(2, code)
self.assertEqual(1, len(calls))
self.assertIn('请指定 --user', stderr.getvalue())
def test_no_forward_uses_public_older_direction(self):
calls = []
responses = [
{
'result': {
'messages': [
{
'openMessageId': 'm1',
'createTime': '2026-07-01 08:00:00',
'content': 'hello',
}
],
'hasMore': False,
}
}
]
def fake_run_dws(args, dry_run=False):
calls.append(args)
return responses.pop(0)
stdout = io.StringIO()
with mock.patch.object(self.module, 'run_dws', fake_run_dws):
with contextlib.redirect_stdout(stdout):
code = self.module.run(
[
'--user',
'u-1',
'--time',
'2026-07-01 09:00:00',
'--no-forward',
]
)
self.assertEqual(0, code)
self.assertEqual(
'older', calls[0][calls[0].index('--direction') + 1]
)
self.assertNotIn('--forward', calls[0])
class BotBroadcastTest(unittest.TestCase):
def test_business_failure_counts_as_failure(self):
module = load_script('bot_broadcast')
completed = types.SimpleNamespace(
returncode=0,
stdout='{"success":false,"errorMsg":"robot rejected"}',
stderr='',
)
stdout = io.StringIO()
with mock.patch.object(module.subprocess, 'run', return_value=completed):
with contextlib.redirect_stdout(stdout):
code = module.run(
[
'--robot-code',
'robot',
'--chats',
'cid-1',
'--title',
'title',
'--text',
'text',
]
)
self.assertEqual(1, code)
self.assertIn('业务调用失败', stdout.getvalue())
if __name__ == '__main__':
unittest.main()
-30
View File
@@ -1,30 +0,0 @@
package unit_test
import (
"os/exec"
"path/filepath"
"runtime"
"testing"
)
func TestChatSkillPythonScripts(t *testing.T) {
python, err := exec.LookPath("python3")
if err != nil {
t.Skip("python3 is not installed")
}
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed")
}
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
cmd := exec.Command(
python,
"test/scripts/chat_skill_scripts_test.py",
)
cmd.Dir = root
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("chat skill Python tests failed: %v\n%s", err, output)
}
}