钉钉CLI
推荐 第三方 via GitHub管理钉钉产品能力 (AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/工作台/开放平台文档等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)时使用。
DingTalk-Real-AI v1.0.47
环境安装
使用前需安装 dws 工具 (手动安装或直接发给Agent):
macOS / Linux:
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
Windows(PowerShell):
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
Copyright 2026 Alibaba Group
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
DingTalk Workspace CLI (dws)
Copyright 2026 Alibaba Group
This product includes software developed at
DingTalk (https://www.dingtalk.com/).
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.
---
name: dws
description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库时使用。
cli_version: ">=1.0.15"
---
# 钉钉全产品 Skill
通过 `dws` 命令管理钉钉产品能力。
> ⚠️ **命令可用性可能因企业服务发现配置而异**。本文档列出的命令基于 dws envelope schema 与本仓库 v1.0.30 实测,但部分命令的 cobra 子命令暴露与否还取决于你的企业 MCP gateway 是否注册了对应 tool。如果跑某条命令报 `unknown command` 或 fall back 到父级 help,说明当前账号企业未开通该能力。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
## 严格禁止 (NEVER DO)
- 不要使用 dws 命令以外的方式操作(禁止 curl、HTTP API、浏览器)
- 不要编造 UUID、ID 等标识符,必须从命令返回中提取
- 不要编造 URL、Email、手机号等结构化信息,必须从命令返回中提取或由用户明确提供
- 不要猜测字段名/参数值,操作前必须先查询确认
- 禁止编造命令路径、子命令或 flag;产品参考缺失、路径/flag 不确定,或报 `unknown command` / `unknown flag` 时,必须先运行对应层级的 `dws <path> --help` 查证后再执行或重试
## 严格要求 (MUST DO)
- DWS 命令合法性协议:执行 `dws` 前必须用当前 skill 资料确认命令;产品参考已覆盖时直接按参考执行,缺失或不确定时必须先用 `--help` 查证
- 所有命令必须加 `--format json` 以获取可解析输出
- 危险操作必须先向用户确认,用户同意后才加 `--yes` 执行
- 单次批量操作不超过 30 条记录
- 所有命令必须**严格遵循**对应产品参考文档里面规定的参数格式(如:如果有参数值,则参数和参数值之间至少用一个空格隔开)
- **脚本优先**:[scripts/](./scripts/) 下的 `python scripts/<name>.py` 已封装翻页/轮询/批量逻辑,遇到对应场景(如 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等)**优先调用脚本**而非手写多步命令。脚本均支持 `--dry-run` 预览、`--format json` 输出,失败时回退到手动步骤
- **业务域最佳实践优先**:文档类多步任务先读 [04-document.md](./references/best_practices/04-document.md);AI 表格读取/统计/写入/导入导出先读 [06-data-analytics.md](./references/best_practices/06-data-analytics.md)。本仓库只迁入这些业务域 best practices,不引入其它产品行动指南。
- 知识库容器只用 `dws wiki space/member`;知识库内文件/文档的浏览、搜索、读取、创建、移动、复制统一切到 `dws doc`。`workspaceId` 只能传给 `wiki --workspace`、`doc --workspace` 或 `doc search --workspace-ids`,禁止传给 `doc list --folder`,也不要使用不存在的 `--space-id`。
- 找群 / 找人 / 找数据在当前组织没命中、且 `dws profile list` 显示 ≥2 个组织时,对每个组织带一次性 `--profile <corpId>` 各搜一遍;命中即用,全部组织都没有才追问用户。禁止在当前组织搜不到就判定「不存在」或直接甩给用户选。
## 开放平台文档 RAG / 错误码排查
- 任何产品执行中,只要用户问开放平台 API、接口参数、字段含义、权限点、回调、SDK、配额、错误码,或命令返回上游 OpenAPI/SDK 错误,必须先用 `dws devdoc article search --query "<关键词>" --format json` 做官方文档 RAG。
- 查询词优先保留原始 API 名、能力名、权限点、完整错误码和 message;首轮形如 `errcode <code> <message>`,无结果再换 `<产品/场景> <错误码>`、`<接口名> 参数`。
- 本地 CLI 错误(如 `unknown command` / `unknown flag` / 认证 / recovery)仍按本文件「错误处理」执行;`devdoc` 用于开放平台业务错误码和接口语义排查。
- `devdoc` 只查钉钉开放平台开发者文档,不查业务数据;排查结论必须基于命中条目的标题、摘要或链接,不能编造错误原因或不存在的命令。
## 产品总览
> 若用户意图涉及多步操作、汇总/整理/归纳/分析、文档创建后写入内容、知识库内文档处理、AI 表格批量读写/统计/导入导出,**先匹配下方「行动指南」**;仅当行动指南无匹配且明确是单一产品单步操作时,按本表路由。
| 产品 | 用途 | 参考文件 |
|-------------------|------------------------------------------------------|----------------------------------------------------------------|
| `aisearch` | AI搜问(搜人首选):按姓名/部门/职位/职责/上级/下级/手机号/工号维度找人,"谁负责 XX/XX 的负责人/某事项/某项目的人"统一走本产品 | [aisearch.md](./references/products/aisearch.md) |
| `aitable` | AI表格:Base/数据表/字段/记录/视图/附件/图表/仪表盘/导入导出/模板搜索 | [aitable.md](./references/products/aitable.md) |
| `attendance` | 考勤:考勤组与规则查询(rules)/个人打卡详情(record get)/批量班次查询(shift list)/考勤统计摘要(summary),仅此 4 个命令组 | [attendance.md](./references/products/attendance.md) |
| `calendar` | 日历:日历列表/日程/参与者/附件/响应/会议室/闲忙查询/时间建议 | [calendar.md](./references/products/calendar.md) |
| `chat` | 群聊与机器人:搜索群/建群/群成员管理/改群名/消息发送(文本/Markdown/图片/文件)/拉取消息/@我/特别关注/机器人群发/单聊/撤回/转发/引用回复/Webhook/**查询**已有机器人 | [chat.md](./references/products/chat.md) |
| `contact` | 通讯录:用户查询(当前用户/搜索/详情/手机号)/花名册档案(学历/家庭/银行卡/合同)/离职员工查询(姓名/时间范围/部门)/部门查询(搜索/详情/子部门/成员)/特别关注列表(按角色/职责找人请走 `aisearch`) | [contact.md](./references/products/contact.md) |
| `dev` | 开放平台开发者:**新建/配置机器人(建号)**、建联调试(把机器人接到本地 agent 的 Stream)、应用生命周期(创建/更新/删除/凭证/权限/成员/事件订阅)、开放平台文档搜索 | [dev.md](./references/products/dev.md) |
| `devdoc` | 开放平台文档:搜索开发文档 | [devdoc.md](./references/products/devdoc.md) |
| `ding` | DING消息:发送/撤回(应用内/短信/电话) | [ding.md](./references/products/ding.md) |
| `doc` | 钉钉文档:搜索/浏览/读写/块级编辑/评论/文件创建/复制/移动/重命名/**删除/导出 docx/权限管理/媒体上传下载**;创建/编辑先按渐进式 doc 子文档与 JSONML 工作流决策 | [doc.md](./references/products/doc.md) |
| `drive` | 钉钉云盘:文件列表/元数据/文件夹/上传(两步)/下载 | [drive.md](./references/products/drive.md) |
| `minutes` | AI听记:听记列表/摘要/关键词/转写/待办/思维导图/发言人/发言人段落总结/热词/录音控制/成员权限/上传 | [minutes.md](./references/products/minutes.md) |
| `oa` | OA审批:待处理/详情/同意/拒绝/撤销/记录/已发起/任务/转交/评论/抄送 | [oa.md](./references/products/oa.md) |
| `report` | 日志:按模版创建/收件箱/已发送/模版查看/详情/已读统计 | [report.md](./references/products/report.md) |
| `mail` | 邮箱:邮箱地址查询/邮件搜索(KQL)/邮件详情/发送邮件 | [mail.md](./references/products/mail.md) |
| `sheet` | 在线电子表格(axls):工作表 CRUD/区域读写/CSV 批量写入/行列增删/合并/查找替换/筛选视图/全局筛选/排序/下拉列表/浮动图片/导出(两步) | [sheet.md](./references/products/sheet.md) |
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 | [wiki.md](./references/products/wiki.md) |
## 核心流程(每次请求必须执行,不得跳过)
作为一个智能助手,你的首要任务是**理解用户的真实、完整意图**,不是简单执行第一条看起来相关的命令。在选择 `dws` 产品命令前,必须按以下流程执行:
0. **URL 预检**:输入含 `alidocs.dingtalk.com` URL 时,必须先读取 [url-patterns.md](./references/url-patterns.md) 的「alidocs URL 分流决策」,识别 URL 是文档、文件夹、知识库、表格、分享短链还是其它格式,再选择对应产品。含 `shanji.dingtalk.com` URL 时直接路由到 `minutes`。
1. **意图拆解**:判断用户请求是否包含多个时序步骤(如“创建文件夹,然后创建文档并写入内容”“读文档,然后总结并评估”“建 AI 表格,再加字段和记录”)。若是,拆成多个子意图,按顺序执行,前一步产出的 `workspaceId` / `nodeId` / `baseId` / `tableId` 必须作为后一步输入。
2. **行动指南优先匹配**:将用户意图或拆解后的子意图与下方「行动指南」逐行做语义比对。命中文档知识或 AI 表格数据任一行时,必须先读取对应 best practice,再读取对应产品参考文件执行;文档知识场景还必须进入 [doc.md](./references/products/doc.md) 的渐进式文档索引按需加载子文件。
3. **recipe 分级执行**:当前开源版只迁入业务域 full recipe(`04-document.md`、`06-data-analytics.md`),未迁入悟空完整 `lite-recipes.md`。因此命中下方任一业务域行动指南时,一律按 full recipe 处理:先读行动指南,再读产品参考,不要只凭主 skill 摘要直接执行。文档创建/更新/块级编辑按 `doc.md` 前置条件继续读取 `doc/style/doc-create-workflow.md`、`doc/style/doc-update-workflow.md`、`doc/format/doc-jsonml-schema.md` 或 `doc/format/doc-jsonml-cookbook.md`,优先保真使用 JSONML。
4. **Fallback 单产品路由**:仅当行动指南未命中,且用户意图明确是单一产品单步操作时,才按「产品总览」和「意图判断决策树」选择产品,并读取对应 `references/products/*.md`。
5. **追问**:以上步骤都无法判断时,主动追问用户澄清,严禁猜测命令、flag、URL、ID 或字段名。
## 多组织处理
dws 可同时登录多个钉钉组织,一个 profile = 一个已登录组织(corp)。当前 profile 决定本次命令用哪个组织的身份(corpId / userId 按当前 profile 自动注入,不是只支持单组织)。
**触发条件(命中任一即进入本节)**:
- 显式:用户提到 切换 / 换 / 跨组织、另一个钉钉、别的公司、看登录了哪些组织、当前是哪个组织、某人 / 某群 / 某数据在别的组织
- 隐式(最常见、易漏):在当前组织读 / 搜没找到目标(群 / 人 / 数据),且 `dws profile list` 显示已登录 ≥2 个组织 —— 别急着判「不存在」,按下方跨组织铁律去其他组织找
- 需要跨多个组织汇总 / 对比数据
- 用户问认证状态 / 登录了哪些组织 / 主组织是哪个
**不触发**:只登录 1 个组织时,按当前组织正常处理,不带 `--profile`,不进本节。
命令:
- `dws profile list` — 列出已登录组织(主 / 当前标记、状态、有效期),只读元数据
- `dws profile switch <名称|corpId|->` — 持久切换当前组织;`-` 切回上一个;无参数在交互终端弹选择器(非交互须显式传参)。`dws profile use` 是其别名
- 全局 `--profile <名称|corpId>` — 单次指定本命令用哪个组织,一次性、不改当前组织
- `dws auth login` — 再登一个组织即新增 profile(自动从授权账号取 corpId / corpName);同组织重复 login = 刷新
- `dws auth status [--profile <名称>]` — 查看认证状态
多组织数据聚合步骤:`dws profile list` 拿到所有已登录组织,对每个组织带 `--profile <corpId>` 各取一次数,合并并标注来源组织;某组织失败则标「该组织暂不可用」并继续返回其余。
安全护栏:
- 只有 `dws profile list` 显示 ≥2 个组织才启用上面的跨组织逻辑;单组织直接按当前组织走,不带 `--profile`。
- 自动跨组织只对「读 / 搜」。写 / 发 / 删 / 撤回等操作默认只在当前组织做;确需带 `--profile` 跨组织写时,必须先与用户确认目标组织。
- 持久切换 `dws profile switch`(改默认组织)按写操作对待:未经用户明确要求不得执行。跨组织找数一律用一次性 `--profile`,不改当前组织。
## 行动指南(优先匹配)
> 将用户意图与下表做**语义比对**,不要求字面包含关键词。命中后必须读取该行动指南文件,并按其中固定路线执行;多个场景同时命中时,按下方「消歧规则」选择。
| # | 场景 | 触发关键词 / 能力范围 | 行动指南 |
|---|------|----------------------|----------|
| 4 | 文档知识 | 搜索/浏览/读取/创建/更新/迁移/模板复用/导出钉钉文档;知识库内文档处理;文件夹下创建文档;块级编辑;JSONML 保真改写;图片/附件/PDF/Excel/PPT 嵌入正文;读文档后总结/评估/对照 | [04-document.md](./references/best_practices/04-document.md) |
| 6 | AI 表格数据 | AI 表格读取/统计/写入记录/更新记录/字段/视图/导入导出/模板建表/主文档读取 | [06-data-analytics.md](./references/best_practices/06-data-analytics.md) |
### 消歧规则
- "工作台应用/app001/appXYZ/钉钉工作台上有哪些应用" → `workbench app`。
- "开放平台接口文档/API 怎么调用/错误码/字段说明" → `devdoc`。
- "MCP 服务/connector/创建工具/HSF 映射/上架 MCP" → MCP 平台配置流程。
- 只说"应用"且缺少上下文时,先结合当前对话判断;仍不明确时追问是工作台应用、文档能力还是 MCP 工具。
- "知识库空间/成员管理" → `wiki` 产品参考;"知识库里的文档/文件/内容" → #4 文档知识,再切 `doc`。
- "创建表格/在线电子表格/单元格" → `sheet`;"AI 表格/多维表/base/记录/字段/视图" → #6 AI 表格数据。
- "写文档/读文档/总结文档/插入图片附件/块级编辑/JSONML 保真改写" → #4 文档知识,不要停在 `doc create` 或 `doc block --help`;创建/编辑必须继续按 [doc.md](./references/products/doc.md) 加载渐进式子文档和 JSONML workflow。
- "导出文档后归档/上传" → 先 #4 完成 `doc export`,再按产品路由继续 `drive upload` 等后续动作。
## 意图判断决策树
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/某职责/某角色(主管/管理员/财务/HR 等)由谁担任/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`(按角色或职责找人用 `aisearch person --dimension duty`)
用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable`
用户提到"考勤/打卡/排班" → `attendance`
用户提到"日程/日历/会议室/约会/时间建议" → `calendar`
用户提到"群聊/建群/群成员/群管理/发消息/发图片消息/发文件消息/发 Markdown 消息/截图发钉钉/转发消息/引用回复/@我/特别关注消息/机器人发消息/Webhook/机器人群发/机器人单聊/通知" → `chat`(仅 IM 操作;创建/配置/建联机器人走 `dev`,见下)
用户提到"创建机器人/新建机器人/建机器人/配置机器人/机器人建号/建联/把机器人连到/接入 agent/opencode/claude/qoder/连接 agent/开放平台应用/开发者应用/app 创建/应用凭证/事件订阅/dws dev" → `dev`
用户提到"通讯录/同事/部门/组织架构/子部门/部门多少人/离职员工/离职名单/离职花名册/花名册/员工档案/学历/家庭/银行卡/紧急联系人/合同/特别关注/星标联系人" → `contact`(按角色/职责找人不在此,走 `aisearch`)
用户提到"开发/API/调用错误 文档" → `devdoc`
用户提到"DING/紧急消息/电话提醒" → `ding`
用户提到"钉钉文档/云文档/读写文档/知识库里的文档/浏览知识库内容/知识库内搜索文档/块级编辑/文档评论/文档复制移动" → `doc`
用户提到"云盘/文件存储/文件上传下载/文件夹" → `drive`
用户提到"听记/AI听记/会议纪要/转写/摘要/思维导图/发言人/热词" → `minutes`
用户提到"邮箱/邮件/发邮件/收邮件/搜邮件/查邮件/邮件草稿/转发邮件/回复邮件/邮件附件/抄送" → `mail`
用户提到"审批/请假/报销/出差/加班/同意/拒绝/撤销审批" → `oa`
用户提到"日志/日报/周报/日志统计/写日报/提交周报/发日志/填日志" → `report`
用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet`
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki`
用户提到"切换组织/换组织/跨组织/另一个钉钉/别的公司/多组织/看所有组织/profile/登录了哪些组织" → `profile`(见「多组织 / profile」节)
关键区分: **dev(创建/配置/建联机器人)** vs **chat(查询/发消息已有机器人)**。`dws chat bot search/find` 只查询机器人;**建号**(创建钉钉智能体机器人)走 `dws dev app robot submit`;**建联**(把机器人接到本地 agent 的 Stream)走 `dws dev connect`。凡是"创建机器人""建机器人""接入 agent""建联"一律路由到 `dev`,禁止走 `chat`。
关键区分: aitable(数据表格) vs todo(待办任务)
关键区分: report(钉钉日志/日报周报) vs todo(待办任务)
关键区分: chat send-by-bot(机器人身份发消息) vs send-by-webhook(自定义机器人Webhook告警)
关键区分: doc(钉钉文档/富文本协同) vs drive(钉钉云盘/二进制文件)
关键区分: wiki(知识库空间/成员管理) vs doc(知识库内文档内容读写)。用户要读/搜/列知识库里的文档时:先 `wiki space list/search` 拿 `workspaceId`,再 `doc list --workspace` / `doc search --workspace-ids` / `doc read --node`。
关键区分: oa tasks(审批 taskId,审批/拒绝用) vs oa list-pending(收件箱 processInstanceId,查看用)
> 更多易混淆场景见 [intent-guide.md](./references/intent-guide.md)
## 危险操作确认
以下操作为不可逆或高影响操作,执行前**必须先向用户展示操作摘要并获得明确同意**,同意后才加 `--yes` 执行。
| 产品 | 命令 | 说明 |
|------|------|------|
| `aitable` | `base delete` | 删除整个 AI 表格,含全部数据表和记录 |
| `aitable` | `table delete` | 删除数据表(含全部字段/视图/记录) |
| `aitable` | `field delete` | 删除字段(该列所有值同步清空) |
| `aitable` | `view delete` | 删除视图 |
| `aitable` | `record delete` | 删除记录(支持批量) |
| `aitable` | `chart delete` / `dashboard delete` | 删除图表/仪表盘 |
| `calendar` | `event delete` | 删除日程,所有参与者同步取消 |
| `calendar` | `participant delete` | 移除日程参与者 |
| `calendar` | `room delete` | 取消会议室预定 |
| `chat` | `group members remove` | 移除群成员 |
| `chat` | `message recall-by-bot` | 撤回机器人已发消息 |
| `doc` | `delete` | **删除整篇文档/文件**到回收站(与 `block delete` 不同,本命令删除整个 node) |
| `doc` | `block delete` | 删除文档单个块(不可恢复) |
| `doc` | `permission update` | 修改协作者权限(降权可能影响他人访问) |
| `ding` | `message recall` | 撤回已发 DING 消息 |
| `oa` | `approval revoke` | 撤销自己发起的审批实例 |
| `oa` | `approval reject` | 拒绝待审批(需加明确理由) |
| `todo` | `task delete` | 删除待办 |
| `minutes` | `replace-text` | 全文批量替换转写与摘要 |
| `auth` | `logout` | **默认退出所有已登录组织**;只退一个加 `--profile <名称\|corpId>`。注意:退主组织不会被拦,会静默把「主」改选为剩下第一个组织,退主前必须向用户确认 |
### 确认流程
```
Step 1 → 展示操作摘要(操作类型 + 目标对象 + 影响范围)
Step 2 → 用户明确回复确认(如 "确认" / "好的")
Step 3 → 加 --yes 执行命令
```
## 命令发现(flag / 参数以 binary 为准)
产品参考文档(`references/products/*.md`)里的 flag 列表是**便于理解用途的参考**,不是权威契约。参数名称、默认值、必填约束随服务发现动态变化,**以下两个命令的输出才是调用的事实源**:
```bash
# 1) 人读视图:看 Usage / Example / Flags
dws <command-path> --help
# 例:dws calendar event list --help
# 2) 机读视图:JSON Schema + flag 别名映射 + 必填字段
dws schema # 列出所有产品及工具
dws schema <product>.<canonical_name> # 规范路径(如 calendar.list_suggested_event_times)
dws schema "<product> <group> <cli_name>" # CLI 路径(如 "calendar event list")
dws schema <path> --jq '.tool.flag_overlay' # 只看 flag 别名
dws schema <path> --jq '.tool.required' # 只看必填字段
```
**何时用哪条路径:**
- 只需看某个命令怎么调用 → `dws <cmd> --help`
- 构造 `--params` / `--json` 时不确定字段类型、必填、别名 → `dws schema <path>`
- 参考文档和 `--help` 冲突时 → **以 `--help` / `dws schema` 为准**,文档视为过期
`dws schema` 输出的 `flag_overlay[key].alias` 就是实际生效的 flag 名(如 `attendeeUserIds → --attendee-user-ids`);`parameters[key]` 是原始 JSON Schema;`required` 是必填字段数组;`sensitive: true` 表示写/删操作,须先向用户确认再加 `--yes`。
## 错误处理
1. 遇到错误,加 `--verbose` 重试**一次**
2. 若 stderr 出现 `RECOVERY_EVENT_ID=<event_id>`,优先按 [recovery-guide.md](./references/recovery-guide.md) 执行 recovery 闭环
3. 仍然失败,**立即停止**并报告完整错误信息,禁止自行尝试替代方案或反复变通
4. **严禁**连续重试超过 3 次相同或类似的命令;如果 3 次仍失败,必须停止并报告
5. 报 `unknown command` / `unknown flag` 时,先运行对应层级 `dws <path> --help` 查证,再修正一次;不要把自然语言同义词直接当命令或 flag
6. 逐条命令多次失败时,检查 [scripts/](./scripts/) 是否有对应业务域脚本可降级使用
7. 认证失败时,参考 [global-reference.md](./references/global-reference.md) 中的认证章节处理
8. 各产品高频错误及排查流程见 [error-codes.md](./references/error-codes.md)
9. 遇到 [capability-limits.md](./references/capability-limits.md) 中列出的「已知不支持操作」时,**直接告知用户不支持并建议在钉钉客户端操作**,不要重试或变通
## 详细参考 (按需读取)
- [references/products/](./references/products/) — 各产品命令详细参考(flag 细节以 `--help` / `dws schema` 为准)
- [references/intent-guide.md](./references/intent-guide.md) — 意图路由指南(易混淆场景对照)
- [references/url-patterns.md](./references/url-patterns.md) — URL 格式规范 + alidocs URL 分流决策与类型探测流程(含钉盘 `document/edit|preview?dentryKey=` 链接)
- [references/global-reference.md](./references/global-reference.md) — 全局标志、认证、输出格式
- [references/field-rules.md](./references/field-rules.md) — AI表格字段类型规则
- [references/error-codes.md](./references/error-codes.md) — 错误码 + 调试流程
- [references/recovery-guide.md](./references/recovery-guide.md) — recovery 闭环、`RECOVERY_EVENT_ID`、`execute/finalize` 规范
- [scripts/](./scripts/) — 各产品批量/复合操作脚本(AI表格批量导入导出、AI应用创建轮询、日历、机器人消息、通讯录、考勤、日志、待办、文档创建并写入、钉盘目录树等)
- [references/products/aitable/](./references/products/aitable/) — AI表格细分章节(单元格值/字段属性/公式/筛选排序/导入导出/仪表盘/记录增删改查/错误恢复)
- [references/products/aitable-record-ops.md](./references/products/aitable-record-ops.md) — AI表格记录操作专项说明
- [references/capability-limits.md](./references/capability-limits.md) — 已知能力限制(doc/aitable/chat/minutes,遇到时直接告知用户不支持)
# 消息沟通
> lite recipe 见 [SKILL.md 速查表](../../SKILL.md)。
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| 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`(开源版未引入;可手动用 `aisearch person` + `dws chat message list-direct` 组合)(自动搜人+翻页+导出)<br>备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `chat message list-direct --user <userId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表(单聊专用;旧版 `list --user` 已停用)<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 | **多群批量优先**:`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>` |
# 任务管理
> **SKILL.md** 中 #2 仅内联 **lite**:`create-todo`、`list-todo`、`get-todo-detail`、`update-todo-status`、`query-todo-by-topic`。其中 `list-todo` 统一覆盖 open/completed/all(`--status false|true` 或不传),`update-todo-status` 统一覆盖 complete/reopen(`--status true|false`)。下列 recipe 已迁出速查表,命中时读本文件对应行。重型 **full** 见下表「行动指南」。命令细节见 [todo.md](../products/todo.md)。
## Recipe 速查(非 SKILL lite)
| Recipe | 步骤(命令均须 `--format json`,下略) |
|--------|----------------------------------------|
| `create-priority-todo` | 1. 确定执行者(同 [SKILL.md](../../SKILL.md) 中 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40>`(可选 `--due "<截止ISO>"`;10低/20普通/30较高/40紧急)→ 取 `todoTaskId` |
| `create-recurring-todo` | 1. 确定执行者(同 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId> --due "<首次截止ISO>" --recurrence "DTSTART:<UTC时间>\nRRULE:FREQ=DAILY;INTERVAL=1"`(`--due` 必填;仅支持按天循环,见 [todo.md](../products/todo.md))→ 取 `todoTaskId` |
| `reschedule-todo` | 1. `todo task list --status false` → 取 `todoTaskId`<br>2. `todo task update --task-id <todoTaskId> --due "<新截止时间>"` |
## Full / 组合(固定路线)
| Recipe | 行动指南(固定路线) |
|--------|---------------------|
| generate-progress-report | 1. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行<br>2. 交叉比对各源数据<br>3. `doc create --name "<报告名>" --content "<报告内容>"` |
| batch-create-todo | 1. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行 → 从结果提取任务条目<br>2. 每条:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>3. **优先**:将待办写入 `todos.json`(格式见 [todo_batch_create.py](../../scripts/todo_batch_create.py)),执行 `python scripts/todo_batch_create.py todos.json`<br>备选:逐条 `todo task create --title "<标题>" --executors <userId>` → 汇总回显<br>**单批超 30 条须用户确认** |
| assign-and-notify | 1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `todo task create --title "<标题>" --executors <userId>` → 取 `todoTaskId`<br>3. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --group <openConversationId> --text "<通知内容>"` 通知 |
# 会议管理(日程与会议室)
> **lite 速查表**含 `start-conference`、`list-today-meetings`、`check-users-busy`(见 [SKILL.md](../../SKILL.md) 中「Lite Recipe 清单」→ [lite-recipes.md](./lite-recipes.md))。列表类操作须遵循 [calendar.md](../products/calendar.md) **「CLI 命令树与黄金路径」**,禁止无子命令的 `dws calendar` 或臆造 `calendar list`(见该文 **「反模式(禁止)」**)。**`schedule-meeting` 不做内联**:须读本文件 **「两准则」「搜房失败硬门禁」** 及下表 **schedule-meeting** 行全文。
> **听记、会后待办、摘要分享** 见 [07-minutes.md](./07-minutes.md)。
## 日程与会议室两准则(强制)
1. **时段**:用户已明确会议起止时间 → **禁止**自动改期、禁止用闲忙结果或「推荐时段」覆盖用户给定时段;只能在此时段内建日程、订会议室;该时段内无可用或指定资源不可用 → **立刻如实告知**,不得偷偷换时间段再试。
2. **会议室**:用户点名具体会议室 → **禁止**换其他会议室;在用户给定时段内查无该房 → **立刻告知**。**用户未给出时段时,必须先显式向用户追问具体开始/结束时间;禁止默认用「当前时刻至当日 23:59:59」之类窗口代查。** **`calendar_schedule_meeting.py`**:仅需 `--title`、`--start`、`--end`;先创建日程,再邀请参会人,最后搜房/订房。未给会议室范围时,`--book-room` 为 **单次**无 `--group-id` 的 `room search --available`,取返回的**第一个**会议室并 `room add`;无结果则告警、不删日程。**若用户明确限定楼层/楼宇/园区/分组,应先用 `room list-groups` 解析允许的 `group-id`,再把这些 `group-id` 传给脚本 `--room-group-id`(或手工 `room search --group-id ...`);脚本只会在这些 group 内查找,若无空房则直接返回。对于同一地点(同园区/楼栋/楼层)的会议室,必须优先锁定最相关、最贴近该地点的承载 group;该 group 查无 roomId/空房,即可判定该地点当前时段无可订会议室,**不得**再去别的无关 group 继续碰运气,因为同一地点的会议室只会挂在其所属 group 下。** **在组织内、按早停规则已把应查的分组(或未限范围时的根目录一次查询)全部查完仍无可用会议室时,必须立即向用户说明「当前时段没有可预订的会议室」或「范围内未检索到可用会议室/资源」并收束,禁止继续扩区、换参重试或虚构有房。** 手工 `room search` **禁止**为试出空闲擅自改日或拉长时间窗。
### 会议室搜索早停
> 专用于 `calendar room list-groups` / `room search` / `room add`;与通用规范「无新参数不重复 search」一致。
**`room search --available`**(与传入的 `--start` / `--end` 配对):返回的是在**该整段时段内**可被预订的空闲会议室(不是「有一段空就算」);脚本与用户手工选房均应沿用同一时间窗,避免误以为分段凑满即等价于整段可用。
**`dws calendar room search` 合法参数**(与 [calendar.md](../products/calendar.md) 一致):仅 `--start`、`--end`、`--group-id`(可选)、`--available`(可选)、`--format json` 等;**禁止使用 `--query`**,否则会报 `unknown flag: --query`。
**地点归组早停**:若用户给的是同一地点范围(如“西溪园区 C6 楼 3-5 层”或具体楼层/楼栋),先用 `room list-groups` 找到**最相关的承载 group**(通常是该楼层;若楼层下无会议室则为直接挂会议室的上一级)。在这个最相关 group 下查不到有效 `rooms[].roomId` 或空房时,**不得**再跳去别的同级/异地 group 继续搜;同一地点的会议室不会散落在别的 group 里。只有用户明确放宽到别的楼层、楼栋或园区,才能重新解析新的 group 并继续。
**用户点名具体会议室(如「C6-4-06-N / 贡嘎山」)**:**不要**尝试 `room search --query "<名称>"`;**禁止**把用户原文(含「C6-4-06-N 贡嘎山」整句)或展示名当作 `room add --rooms` 的 `roomId`。用户输入**几乎从不会是**有效 `roomId`。须先 `dws calendar room list-groups` 定位所在楼层/分组的 `group-id`,再 `dws calendar room search --start "<ISO>" --end "<ISO>" --group-id <GROUP_ID> [--available] --format json`,在返回 `rooms[]` 中对 `roomName`、`name` 等与用户表述匹配,**仅**取 JSON 里的 `roomId`(典型为小写十六进制串,长度以返回为准),最后 `dws calendar room add --event <eventId> --rooms <roomId>`。该时段无匹配或房间忙 → 如实告知;**禁止**为通过校验而编造、拼接或猜测 `roomId`。
### 搜房失败硬门禁(园区/范围搜尽仍无 roomId)
在用户限定的园区、楼宇、楼层或评测固定分组内,已按早停规则**逐组 `room search` 查完**仍得不到任何有效 `rooms[].roomId`(或无任何空闲房)→ **立即停止**,向用户**明确报错/失败结论**(例如:该时段在指定范围内未检索到可预订会议室或无法获得 roomId),**本回合订房流程结束**。
**用户汇报硬门禁**:一旦触发上面的失败条件,**下一条对外输出必须直接面向用户汇报结果**,不得继续在会话里自言自语式地延长推理。允许的后续只有两类:
1. **失败汇报**:明确说明“指定范围/指定会议室在该时段未找到可预订会议室,因此当前无法完成预订”
2. **确认放宽条件**:仅在需要继续推进时,明确问用户是否放宽地点范围、改时间或接受不订会议室
以下表述/行为视为**违例**:继续写“让我再试一次”“也许是 Mock/测试环境”“可能存在预设 roomId 映射”“我去别的 group 看看”“我换个时间验证一下”“我先看看脚本/示例还能不能推断出 roomId”。
以下行为**一律禁止**(与是否「想多试一次」无关):编造/假设 `roomId` 格式做「预订测试」;在**没有**合法 `roomId` 时调用 `room add` 试探错误详情;拉 `event get` / 日程详情等试图**绕开** `room search` 推断 roomId;换无关园区、扩大关键词、换工具名做未经用户授权的新搜索。
**失败后强制回读**:若出现以下任一信号,下一步**必须重新读取本文件本节与 `schedule-meeting` recipe**,不得沿着当前假设继续试:
1. 连续 **2 次** `room search` 空结果/无 `roomId`
2. 任意一次 `roomId invalid`
3. 已开始尝试「换园区 / 换楼栋 / 看 event 详情 / 猜 roomId」
回读后只允许二选一:
1. **报错收束**:已搜尽允许范围/整园仍无 `roomId` 或无空房
2. **用户确认**:明确询问是否放宽范围、换时间,或接受不订会议室
| # | 规范 |
|---|------|
| 1 | **一键脚本**:`calendar_schedule_meeting.py` 做「建日程 → 加人 → 可选搜房/订房」;未限范围时可直接 `--book-room`,脚本按根目录单次 `room search --available` 订第一家。**若搜房失败,脚本应输出明确失败原因并返回非零退出码,促使上层立即向用户汇报,而不是继续试探。** |
| 2 | **要限范围/具名**:先 `list-groups` 解析允许的 `group-id`。若用户说的是同一地点(同园区/楼栋/楼层),应优先锁定**最相关的承载 group** 并只查它;该 group 无结果即可按该地点无房收束,不再试别的无关 group。仅当用户明确给出多个允许地点时,才分别对这些 group 各 **1 次** `room search --available` 再 `room add` |
| 3 | **禁止**:无新信息时反复 `--verbose`、反复切 `--available`、父组子组试探、在最相关 group 无结果后改搜别的同级/异地 group、超 100 条后仍根分组或未授权区域全量搜;**禁止**对 `room search` 使用不存在的 `--query` |
| 4 | **`roomId` 门禁**:`room add --rooms` **只能**填 `room search` 返回 JSON 中的 `rooms[].roomId`;**禁止**将用户说的会议室名、编号文案、或「假 UUID / 试数字」当作 `roomId` |
| 5 | **全量无结果即收束**:在用户允许的搜索范围内(含**整园/全 campus** 若用户或评测要求已逐组查尽)仍无任何可用会议室或有效 `roomId` → **直接报错/告知失败并结束订房**,且**下一条消息必须汇报给用户**;**不得**假设 ID、不得用 `room add` 试探、不得绕路查日程、不得继续自说自话分析 Mock/测试环境 |
| 6 | **失败触发回读**:连续 2 次空结果、任意一次 `roomId invalid`、或开始换园区/绕路时 → **必须回读本节**;回读后只允许「报错收束」或「向用户确认是否放宽条件」 |
| Recipe | 行动指南(固定路线) |
| ------------------ | ------------------- |
| schedule-meeting | **见上文「两准则」**、**「搜房失败硬门禁」**。**未给时段且仅说「发起/开个会」**→ 不走本 recipe,走 lite recipe `start-conference`。**未给时段但有预约意图**("安排""约""定"等词):追问具体开始/结束时间。**已有时段后**,按固定顺序执行:1. `dws calendar event create` 建日程;2. 有参会人则 `dws calendar participant add`;3. 再处理会议室。**无明确会议室范围**:可直接 `python scripts/calendar_schedule_meeting.py --title "<主题>" --start "<起始>" --end "<结束>" [--users <userIds>] [--book-room] [--dry-run]`。**有明确范围(某楼/层)**:先 `dws calendar room list-groups`,锁定该地点**最相关的承载 group**;若只有一个地点,`--room-group-id` 应只传这个最相关 group,**不要**把同楼内多个楼层 group 打包传入碰运气。只有用户明确给出多个允许地点时,才把这些 `group-id` 一并传给 `python scripts/calendar_schedule_meeting.py ... --book-room --room-group-id "<id1,id2,...>"`。**用户点名具体会议室**:须手工 `dws calendar room search --start "<ISO>" --end "<ISO>" --group-id <GROUP_ID> [--available] --format json`(**无** `--query`),在 JSON 中匹配名称取 **`rooms[].roomId` 唯一真值** → `dws calendar room add --event <eventId> --rooms <roomId>`;**不得**把用户输入的会议室名当 `roomId`。**一旦连续 2 次空结果 / 任意一次 `roomId invalid`**:**必须回读本节并立即收束判断**;若整园/限定范围内搜尽仍无 roomId 或无空房 → **下一条消息必须直接向用户汇报失败结论**;否则只能向用户确认是否放宽范围/改时间。**禁止**假设 roomId、禁止无 ID 调用 `room add`、禁止用日程详情绕路、禁止继续猜测 Mock/测试环境。细则见「会议室搜索早停」。 |
| reschedule-meeting | 1. `calendar event list --start "<起始ISO>" --end "<结束ISO>"` → 取 `eventId` 2. `calendar event update --id <eventId> --start "<新起始ISO>" --end "<新结束ISO>"` 更新时间 3. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --group <openConversationId> --text "<变更通知>"` 通知变更 |
# 文档知识
> lite recipe 见 [SKILL.md 速查表](../../../../SKILL.md#常用-recipe-速查表lite-recipe--可直接执行无需读行动指南文件)。
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| write-doc | 0. 阅读 [doc-create-workflow.md](../products/doc/style/doc-create-workflow.md) 的 §前置必读 + §关键词速查表,锁定文档类型和起稿路径:**决策型/含对比的知识沉淀型/用户要求美观 → JSONML 起稿**(`.json`);执行型/说明型 → Markdown 起稿(`.md`)<br>1. 按选定路径执行 doc-create-workflow.md(JSONML 路径有骨架范例可直接复制修改)<br>2. `doc create --content-file /tmp/<name>.json --content-format jsonml`(或 `.md` + `--content-format markdown`)<br>3. 大内容默认依赖 DWS 自动分片;只有 `CONTENT_TRUNCATED`、部分写入失败或回读发现缺失时,才按 workflow 的恢复流程手工补片<br>4. **回读校验(必须)**:所有写入完成后,执行 `doc read --node <nodeId>`,校验关键标题/段落/表格是否完整写入 |
| search-docs-and-share | 1. `doc search --query "<关键词>"` → 取 `nodeId` + 标题建索引(不读全文)<br>2. `doc read --node <nodeId>`(追问按需,最多 2 篇) |
| create-knowledge-base | 1. 创建知识库空间取 `WS_ID`<br>2. `doc create --name "<文档名>" --workspace <WS_ID>` → 取 `nodeId`<br>3. `doc list --workspace <WS_ID>` 确认 |
| migrate-doc | 1. `doc read --node <源nodeId>` → 取正文并写入临时文件 `<tmp>.md`<br>2. `doc create --name "<文档名>" --folder <DOC_FOLDER_NODE_ID> --content-file <tmp>.md` → 取新 `nodeId`(`--folder` 只传文档文件夹 nodeId / alidocs 文件夹 URL,不传数字 dentryId;正文 <200KB 单步到位)<br>2a. 若正文 >200KB:**必须先向用户提示截断风险**(详见下方「分块 append 截断风险提示」),用户确认后再执行:`doc create --name "<文档名>" --folder <DOC_FOLDER_NODE_ID>` → `nodeId` → 按段落切片 → 每片 `doc update --node <nodeId> --content-file <part> --mode append`<br>3. **回读校验**:`doc read --node <nodeId>` 校验内容完整性 |
| update-doc-section | 1. `doc search --query "<关键词>"` → 取 `nodeId`<br>2. **形态选择(按 [doc-update-workflow.md §1.3](../products/doc/style/doc-update-workflow.md) 优先级)**:目标段落含 callout / 分栏 / 颜色 / @人 / 附件 / 嵌套结构 → 走 `jsonml-node-edit`;纯文本替换且确认无富结构 → 继续本 recipe<br>3. `doc read --node <nodeId>` 定位目标章节<br>4. `doc update --node <nodeId> --content "<替换内容>" --mode overwrite`<br>5. **回读校验**:`doc read --node <nodeId>` 确认 overwrite 未被降级为 append、内容完整无截断<br>**overwrite 须用户确认**;完整改写流程见 [doc-update-workflow.md](../products/doc/style/doc-update-workflow.md) |
| rewrite-doc | 1. 阅读并执行 [doc-update-workflow.md](../products/doc/style/doc-update-workflow.md):先看 §1.3 编辑形态优先级(**JSONML 首选**),再按 §3 速查表选路径,跳 §4 对应小节执行<br>2. 单块改写 / 含富结构 → §4.4 路径 B;多处保真改写或改 root → §4.4 路径 A;纯文本骨架重写 → §4.5 markdown<br>3. 整篇 overwrite 前必须按 workflow §4.5 向用户提示风险并等待确认<br>4. **回读校验(必须)**:按 workflow §6 的校验要点逐项核查;@人、附件、图片等保真要素必须原样保留<br>**适用场景**:用户提供已有 nodeId/链接,需要改写、润色、章节补充、段落形态转换、整篇重写 |
| doc-to-message | 1. `doc read --node <nodeId>` → 取正文(大文档只摘要+链接)<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>`(备选) |
| lossless-doc-edit | 1. `doc read --node <nodeId> --content-format jsonml --output /tmp/doc.json` → 获取完整 JSONML 结构(输出含 `revision`,并发敏感时记下来;默认改写不需要)<br>2. 解析 JSON 文件,修改 `jsonml` 数组中的目标节点(节点结构参见 [doc-jsonml-schema.md](../products/doc/format/doc-jsonml-schema.md))<br>3. 将修改后的内容写回临时文件 `/tmp/doc_modified.json`,格式为 `{"jsonml": [...]}`<br>4. `doc update --node <nodeId> --content-file /tmp/doc_modified.json --content-format jsonml --mode overwrite`(默认不做并发检查;担心多 agent 同时改时加 `--revision <第 1 步拿到的 N>` 触发并发检查,版本不一致返回 `VersionConflict` 时回到第 1 步重读重写)<br>5. **回读校验**:`doc read --node <nodeId> --content-format jsonml` 确认写入成功<br>**适用场景**:保留样式、精准插入特定节点类型、改属性不动文本;普通文本编辑仍优先用 markdown 模式。完整 JSONML 改写流程见 [doc-update-workflow.md §4.4](../products/doc/style/doc-update-workflow.md) |
| jsonml-node-edit | 1. `doc block list --node <nodeId> --content-format jsonml` → 获取 JSONML 节点列表(含 uuid)<br>2. 根据 uuid 定位目标节点<br>3. `doc block list --node <nodeId> --content-format jsonml --block-id <uuid>` → 读取完整子树<br>4. 修改 JSONML 节点内容(节点结构参见 [doc-jsonml-schema.md](../products/doc/format/doc-jsonml-schema.md),可复制范例见 [doc-jsonml-cookbook.md](../products/doc/format/doc-jsonml-cookbook.md))<br>5. `doc block update --node <nodeId> --block-id <uuid> --content-format jsonml --element '<修改后的 JSONML>'` → 写回<br>**适用场景**:只改一个 block 的结构/样式,无需全文回写;写入端默认 normalize(自动补 uuid、裸字符串包成 canonical 文本),可用 `--no-fix-jsonml` 关闭全部修复;`--fix-jsonml` 额外启用 JSON 语法修复(推荐 agent 调用) |
---
## 分块 append 截断风险提示
### 触发条件
当内容总大小 **超过 200KB**,需要拆分为多片通过 `doc update --mode append` 分块写入时,**必须在执行前向用户发出截断风险提示**,等待用户确认后再继续。
### 提示话术(参考模板)
> 注意: 内容较长(约 {size}),需要分 {n} 片写入。分块 append 存在以下风险:
> - 部分片段可能写入失败但返回 success,导致文档**内容截断或缺失**
> - 片段之间的表格、代码块等跨块元素可能**被截断破坏**
> - 写入顺序异常可能导致**段落错乱**
>
> 建议:写入完成后我会回读校验文档完整性。是否继续?
### 执行规范
1. **提示时机**:在执行第一片 append **之前**提示,而非写入过程中
2. **分片原则**:按段落/标题边界切分,**禁止**在表格、代码块、列表内部截断
3. **逐片校验**(推荐):每写入一片后记录已写入的最后一个标题/段落标记,供最终回读时比对
4. **最终回读**(必须):所有片段写入完成后,执行 `doc read --node <nodeId>` 回读全文,逐片比对关键标记是否完整(详见下方「doc update 回读校验规范」)
5. **失败处理**:若回读发现缺失片段,向用户报告具体缺失位置,建议针对缺失部分单独重试 append
---
## doc update 回读校验规范
**所有 `doc update`(含 overwrite 和 append)执行后都必须回读校验**——返回 `success=true` 不等于内容真的写入完整。
完整规范(静默失败场景、校验流程、异常处理路径、"先清空再重建"命令样板)见 [doc-update-workflow.md §6「回读验收」](../products/doc/style/doc-update-workflow.md)。
最小流程:
```bash
dws doc update --node <nodeId> --content-file /tmp/new-content.md --mode overwrite
dws doc read --node <nodeId> # 校验关键标题、段落首句、表格、@人、附件
```
**禁止**在未回读的情况下向用户报告「已完成」。
# 工作汇报
> lite recipe 见 [SKILL.md 速查表](../../SKILL.md)。
## 路径分歧(先判定后选 recipe)
`dws report` 与 `dws doc` 是两个不同的产品,覆盖不同的"周报 / 日报"场景。在选 recipe 前先做一次判定:
| query 中是否含强信号 | 选哪个 recipe | 默认值 |
|---------------------|---------------|--------|
| 含「钉钉日志 / OA 周报 / 我的钉钉日志 / 日报模板 / 周报模板 / 提交日志 / 填模版」 | `submit-report`(走 dws report entry submit) | — |
| 含「在线文档 / 写一篇文档 / 整理成文档 / 文档保存」 | `generate-*-report`(走 dws doc create) | — |
| **无强信号**(如"写日报"、"写周报"、"整理本周工作")| `generate-*-report`(走 dws doc create) | 默认 |
注:
- "钉钉日志"在用户口语中多指 OA 周报应用,但偶有泛指日志/记录,必要时反问澄清。
- 仅当用户**明确**说"钉钉日志(OA 应用)"或类似强信号时才切到 `submit-report`;否则不要主动推荐 OA 日志路径——多数用户的"周报"实际期望是文档(可分享、可编辑、长文本)。
## Recipe 速查
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| query-report | **0. 前置判定**:query 含「查日志 / 看日志 / 我发过的日志 / 收到的日志 / 日志详情」且语义指向钉钉日志 OA 应用?是 → 直接走 `dws report`;否 → 先按 doc/report 分歧澄清<br>1. 用户说「我发过 / 我创建」→ `report outbox list --cursor 0 --size 20 --format json`;用户说「收到 / 别人发给我」→ `report inbox list --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --cursor 0 --size 20 --format json`<br>2. 时间 flag 只允许 `--start` / `--end`,禁止 `--start-date` / `--end-date` / `--date`;不要只传裸日期,必须展开完整 ISO;不要先查 `help`,不要预先登录;只有命令返回认证错误时才处理认证<br>3. 从列表返回中取 `reportId` 留给内部后续调用;如果用户已直接提供 `reportId`,跳过列表<br>4. 面向用户展示列表时必须基于 `result[]` 拼 Markdown 表:`日期 | 标题 | 发送人 | 状态 | 钉钉链接`;每条 `result[]` 都带这五个中文字段;不要把日志 ID 作为主列;已读状态字段缺失则不展示,不要编造<br>5. 用户要看正文时再执行 `report entry get --report-id <reportId> --format json`;用户要统计 / 已读情况时执行 `report entry stats --report-id <reportId> --format json`<br>**不要把 inbox list/outbox list 当正文接口**;查询正文必须补 `entry get`<br>**不要再生成** `report list` / `report sent` / `report detail` / `report stats`(deprecated alias,仍能跑但会打 stderr 警告) |
| generate-daily-report | 1. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行(时间=今日)<br>2. 交叉汇总并把日报内容写入临时文件 `<tmp>.md`(UTF-8,真实换行)<br>3. **创建文档**:`doc create --name "<日报名>" --content-file <tmp>.md`(> 200KB 按 [write-doc 兜底](./04-document.md) 走 create 空 → 循环 update) |
| generate-weekly-report | 1. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行(时间=本周)<br>2. 交叉对比并把周报内容写入临时文件 `<tmp>.md`<br>3. **创建文档**:`doc create --name "<周报名>" --content-file <tmp>.md`(兜底同上) |
| submit-report | **0. 前置判定**:query 含「钉钉日志 / OA 周报模板 / 我的钉钉日志」等强信号?是 → 继续;否 → 切换到 `generate-weekly-report` 或 `generate-daily-report`(走 dws doc)<br>1. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行(时间=当日)<br>2. `report template list --format json` → 取 `report_template_id`<br>3. `report template get --name "<模版名>" --format json` → 取 `result.report_template_fields[]`,每项含 `field_name`/`field_sort`/`field_type`<br>4. **把 contents 写入临时文件**(避免 shell 引号问题):每项含 `key`/`sort`/`content`/`contentType`/`type` 五个字段,**严格映射** `field_name → key`、`field_sort → sort`、`field_type → type`,再填 `content` 与 `contentType`<br>5. `report entry submit --template-id <id> --contents-file <tmp>.json --format json` → CLI 会在提交成功后自动反查详情并追加 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl` / `dingtalkOpenLink` 字段;取返回的 `reportId` 与钉钉打开链接<br>6. final reply 优先直接使用 `dingtalkOpenMarkdownLink`,让用户点击跳转钉钉客户端查看 / 修改;仅当 submit 返回中缺少 `dingtalkOpenUrl` 时,才手动执行 `report entry get --report-id <reportId> --format json` 补取 `result.url`,再包装成 `[在钉钉中查看日志](result.url)`<br>**不要走 doc 写文档**;**禁止跳过 2/3 步**直接 submit;**禁止把 raw `dingtalk://...` URL 直接粘到回复**,必须包成 markdown link<br>**不要再生成** `report template detail` / `report create`(deprecated alias,仍能跑但会打 stderr 警告) |
| generate-monthly-report | 1. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行(时间=当月)<br>2. `report outbox list --start "<月初ISO>" --end "<月末ISO>"` → 取当月已提交日志<br>3. 按周分段归纳并把月报内容写入临时文件 `<tmp>.md`<br>4. **创建文档**:`doc create --name "<月报名>" --content-file <tmp>.md`(兜底同上) |
| generate-topic-report | 1. 提取主题关键词;推断时间范围("最近"默认近 30 天)<br>2. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行<br>3. 按时间线排列,交叉归纳核心结论/决策/行动项/未解决问题/演进脉络,并把内容写入临时文件 `<tmp>.md`<br>4. **创建文档**:`doc create --name "<报告名>" --content-file <tmp>.md`(兜底同上) |
# 数据分析
> 本场景所有 recipe 均为 full。
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| read-aitable | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. `aitable field get --base-id <baseId> --table-id <tableId>` → 取 `fieldId`<br>3. **取记录(按场景分流)**:<br> • 数据统计/分析/全量汇总 → `aitable record query --base-id <baseId> --table-id <tableId> --all`(自动翻页,**禁止凭单页数据做统计**)<br> • 大表保险 → 加 `--page-limit 100`(默认 50 页/5000 条,0 = 无限制)<br> • 单纯预览前几条 → `aitable record query --base-id <baseId> --table-id <tableId> --limit 30`(不加 --all)<br> • 筛选时 `--filters` 格式见 [aitable-filter-sort.md](../products/aitable/aitable-filter-sort.md)<br>4. **检查输出契约**:`hasMore=true` 时数据被截断,必须用 `--cursor <X>` 续拉;`partial=true` 时表示中途某页失败(保留已拉数据,可重试)<br>5. 总结数据 |
| generate-data-report | 1. 同 read-aitable 步骤 1-3(**必须用 --all 防漏数据**)<br>2. 按[「多源并行采集」](_common/conventions.md#多源并行采集公共模式)执行 → 补充背景<br>3. `doc create --name "<报告名>" --content "<分析报告>"` |
| create-aitable-record | **写入路径分流**(关键决策):<br> • **追加到已有表**(场景:把这批数据加到「成员表」)→ `python scripts/import_records.py <baseId> <tableId> data.csv\|data.json [batch_size]`(走 `record create`,需要列名匹配字段名)<br> • **新建表**(场景:把这个 Excel 变成多维表)→ `python scripts/aitable_import_via_task.py <baseId> <file>` 或 `dws aitable import upload --base-id <baseId> --file ./x.xlsx` + `dws aitable import data --import-id <ID>`(CLI 已内置 OSS PUT 清空 Content-Type + 同步轮询,**禁止自己写 PUT**)<br> • **单条/少量手动**:1. `aitable base search` → `baseId`/`tableId`;2. `aitable field get` → `fieldId` 与类型;3. `aitable record create --records '[{"cells":{"<fieldId>":"值"}}]'` |
| update-aitable-record | 1. `aitable base search --query "<表格名>"` → 取 `baseId`/`tableId`<br>2. **取目标 record**:<br> • 已知少量 recordId → `aitable record query --record-ids <ID1,ID2>`<br> • 按条件批量改 → `aitable record query --base-id <baseId> --table-id <tableId> --filters '<JSON>' --all`(**用 --all 防止漏改**)<br>3. **先展示让用户确认要改的 record 列表**<br>4. `aitable record update --base-id <baseId> --table-id <tableId> --records '[{"recordId":"<recordId>","cells":{...}}]'`(单次 ≤30 条) |
| search-aitable-template | 1. `aitable template search --query "<关键词>"` → 取 `templateId`<br>2. 用户选定<br>3. `aitable base create --name "<表格名>" --template-id <templateId>` → 取 `baseId` |
| export-aitable-to-xlsx | 1. `aitable base search --query "<表格名>"` → 取 `baseId`<br>2. **按场景选 scope**:<br> • 全表+附件 → `aitable export data --base-id <baseId> --scope all --export-format excel_and_attachment --output ./<name>.xlsx`<br> • 单表(仅 xlsx)→ `--scope table --table-id <tableId> --export-format excel`<br> • 单视图 → `--scope view --table-id <tableId> --view-id <viewId>`<br>3. CLI 内置渐进式退避轮询 + 自动落盘,**不要自己写 GET downloadUrl**<br>4. 大表超时(默认 5 分钟):加 `--timeout-sec 900` 或拿到 `taskId` 后 `aitable export data --base-id <baseId> --task-id <ID> --output ./<name>.xlsx` 续等<br>5. 与悟空脚本路径并存:复杂场景(多 base 批量 / 按视图组合)请用 `python scripts/aitable_export_via_task.py <baseId> --scope all\|table\|view` |
| primary-doc-from-record | 当 AI 表格用文档作主键字段时,从 record 跳到其主文档:<br>1. `aitable record query` 取 `recordId`<br>2. `aitable base get-primary-doc-id --base-id <baseId> --table-id <tableId> --record-id <recordId>` → 取主键文档 `dentryUuid`<br>3. 凭 `dentryUuid` 调 `doc read --node <UUID>` 拿文档内容 |
# 听记与会后
> lite(`list-minutes`、`get-minutes-summary`、`get-minutes-transcription`)见 [SKILL.md](../../SKILL.md)。full recipe 见下表。
> 日程、订会议室、`schedule-meeting` 见 [03-meeting.md](./03-meeting.md)。产品命令见 [minutes.md](../products/minutes.md)。
### 听记列表参数速查
`list mine` / `list shared` / `list all` 均支持以下筛选参数(**有时间/关键词条件时优先使用服务端过滤;无筛选条件时可全量拉取**):
| 参数 | 说明 | 示例 |
|------|------|------|
| `--query "<关键词>"` | 服务端关键词搜索 | `--query "周会"` |
| `--start "<ISO-8601>"` | 开始时间(含时区) | `--start "2026-05-01T00:00:00+08:00"` |
| `--end "<ISO-8601>"` | 结束时间(含时区) | `--end "2026-05-25T23:59:59+08:00"` |
| `--limit <N>` | 每页返回条数(默认 10) | `--limit 20`(`--max` 为兼容别名) |
| `--cursor "<token>"` | 分页 token(首页留空) | `--cursor "abc123"`(`--next-token` 为兼容别名) |
**组合筛选示例**:
```bash
# 按时间范围
dws minutes list all --start "2026-04-01T00:00:00+08:00" --end "2026-04-30T23:59:59+08:00" --format json
# 关键词 + 时间范围
dws minutes list mine --query "需求评审" --start "2026-05-25T00:00:00+08:00" --end "2026-05-25T23:59:59+08:00" --format json
# 限制条数
dws minutes list mine --limit 5 --format json
```
| Recipe | 行动指南(固定路线) |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| meeting-followup | 1. **提取待办优先**:`minutes_extract_todos.py`(开源版未引入;可手动用 `dws minutes todo list` 拼装) → 获取行动项列表 无脚本时按[「多源并行采集」](./_common/conventions.md#多源并行采集公共模式)执行 2. 提取行动项 3. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId` → `todo task create --title "<行动项>" --executors <userId>`(每条行动项;批量时可写 JSON 调 `python scripts/todo_batch_create.py todos.json`) 4. `chat message send --group <openConversationId> --text "<通知内容>"` 通知已创建 |
| share-minutes | **拉摘要优先**:`minutes_recent_summary.py`(开源版未引入;可手动用 `dws minutes list` + `dws minutes summary get` 组合) → 获取近期听记摘要 备选手动:1. `minutes list mine` → 取 `taskUuid` 2. 用户选定 3. `minutes get summary --id <taskUuid>` → 取摘要 4. 单聊:`contact user search --query "<姓名>"` → 取 `openDingTalkId` → `chat message send --open-dingtalk-id <openDingTalkId> --text "<摘要内容>"`(推荐);群聊:`--group <openConversationId> --text "<摘要内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
| browse-minutes | 1. `minutes list all --query "<关键词>" --limit <N>` → 取全量 `taskUuid`(翻页直至无更多;无主题筛选用 `minutes list mine`;**有时间范围时加 `--start`/`--end` 服务端过滤**,如 `--start "2026-04-01T00:00:00+08:00" --end "2026-04-30T23:59:59+08:00"`) 2. **详情/元数据优先** `minutes get batch --ids <uuid1,uuid2,...>`(仅 API 上限时拆多批);**摘要专项**且无 batch 字段时再用并行 `get summary` 或 `**python scripts/minutes_recent_summary.py --limit <N>`** 3. 汇总展示;**同一用户诉求下避免**「半截 list → 再 list 剩余」「拆 4 条一批 summary 连跑多轮」等无必要拆分 |
| minutes-detail | 1. 确定 `taskUuid`:用户已提供 → 直接用;未提供 → `minutes list mine --limit 5` 让用户选(**有时间线索时加 `--start`/`--end`**,如「上周五的会」→ `--start "2026-05-23T00:00:00+08:00" --end "2026-05-23T23:59:59+08:00"`) 2. 并行拉取四维信息:`minutes get info --id <taskUuid>` `&` `minutes get summary --id <taskUuid>` `&` `minutes get keywords --id <taskUuid>` `&` `minutes get todos --id <taskUuid>` `& wait` 3. 整合输出:基础信息(标题、时间、参与人)→ AI 摘要 → 关键字 → 行动项/待办 4. **展示发言人列表**:从转写/info 提取所有发言人(含已标注姓名和匿名编号),列出每位发言人的发言次数和时长占比,引导用户:「是否需要查看某位发言人的详细内容总结?请输入姓名或编号」→ 用户选定 → 进入 `minutes-speaker-summarize` recipe 5.(可选)用户要求看原文 → `minutes get transcription --id <taskUuid>` |
| minutes-speaker-summarize | 1. 读取转写 → `minutes get transcription --id <uuid>` 2. 声纹标注检查:已标注 → 跳 Step 6;匿名编号 → 继续 3. 转写原文推断(称呼/自我介绍/上下文指代)→ 高置信度跳 Step 6 4. 并发身份推断:`calendar event list` + `participant list` 取日程参与人(最高优先) & `aisearch person` & `chat message list` & `doc search` `& wait`;未找到同时段日程 → 引导用户提供日程链接或参会人名单;两路以上一致才下结论 5. 置信度判断:>70% 直接输出;≤70% 展示 TOP3 候选让用户选(最多一次) 6. 结构化总结输出(核心观点 + 问题 + Action Item + 立场)→ 追问是否替换发言人标注。详见 [10-minutes-speaker-match.md](./10-minutes-speaker-match.md) |
| speaker-correct | 1. **查同时段日程**:从听记元数据获取录音开始时间 → `calendar event list`(前后 30 分钟内)→ 若匹配到日程则提取参会人名单并展示 2. **展示发言人状态表格**:列出所有发言人及标注状态(已识别 / 未标注 / 未标注,发言较少) 3. **让用户选择识别方式**:听音识人(裁剪代表性音频片段让用户辨认)/ 手动设置(用户直接告知对应关系)/ 智能匹配(结合参会人名单和发言内容推断) 4. **执行替换**:确认对应关系后,通讯录查询获取 dingUid → `dws contact user search --query "<姓名>" --format json` → 取 userId(长整型)→ `dws minutes speaker replace --id <taskUuid> --from "发言人X" --to "<姓名>" --target-uid <userId> --format json`;多个匹配时列出候选让用户选;无匹配时执行不带 `--target-uid` 的替换 5. 替换成功后追问「还有其他发言人需要帮你识别和替换吗?」详见 [11-minutes-speaker-correct.md](./11-minutes-speaker-correct.md) |
# 通讯录(组织架构)
> **SKILL.md** 中 #8 仅内联 2 条 **lite**:`get-contact-self`、`search-user`。下列 recipe、专用规则与消歧请在命中 #8 且**超出**上述 lite 时阅读本文。
> 产品命令见 [contact.md](../products/contact.md)。通用批量/并行见 [conventions.md](_common/conventions.md)。
## 专用规则(#8 非 lite 步骤必守)
- **角色/职责类查人优先 aisearch**:用户说"角色为XX的员工/XX角色的人员""所有主管/财务/HR/总经理""谁负责 XX"等角色/职责类查询时,**优先** `aisearch person --keyword "<角色或职责>" --dimension duty`(`contact label` 角色查询命令已下线,不再可用)。
- **脚本优先**:按部门拉成员**优先** `python scripts/contact_dept_members.py --query "<部门名>"`(`--dry-run` / `--format json`);失败再 `dept search` → `dept list-members --depts`。
- **详情链路**:用户要子部门、职位、联系方式、汇报关系等,在 `user search` 之后**必须**再 `contact user get --ids <userId>`;禁止仅用 search 的浅表字段交差。
- **`user get` 后部门仍空**:不得过早结束或只建议用户去 App;须在 CLI 能力内尝试 **用户点名的部门** `dept search` + `dept list-members` 等与 `userId` 交叉核对,再结构化汇总「返回中有哪些字段 / 哪些为空及可能原因」。
- **多命中**:`user search` 或 `dept search` 多条时须列候选(姓名、title、部门线索)请用户确认,禁止默认猜一人。具体消歧流程:
1. 从搜索结果中提取所有同名/多命中用户的 `userId`
2. 调用 `contact user get --ids userId1,userId2,...` 获取每人详情(含 `depts` 部门列表、职位等)
3. 将「姓名 + 部门 + 职位」列表展示给用户,请用户确认选择哪一位
4. 使用用户确认的 `userId` 继续后续操作
> **根因**:`user search` 和 `aisearch person` 均不返回部门信息,无法仅凭搜索结果区分同名用户。**必须**追加 `contact user get` 获取部门信息才能消歧。
- **批量**:多个 `userId` 用 `contact user get --ids id1,id2,...`;多部门成员列表按需并行,遵守单次批量上限与 [conventions.md](_common/conventions.md)。
- **子部门枚举**:已知父部门 `deptId` 时**优先** `contact dept list-children --dept <父deptId>` 直接拿到完整子部门列表;只知道部门名时先 `contact dept search --query "<父部门名>"` 取 `deptId` 再 `list-children`;用户明示的子部门名(无需枚举)可直接 `dept search` 命中。多子部门展开见 `explore-subdepts-and-members`。
## 与其他场景消歧
- **按角色/职位类型查人(主管/管理员/财务等)** → 优先 `aisearch person --dimension duty`(按职责维度找人);`contact label` 角色查询命令已下线。
- **搜人/找人/找同事/查工号/查手机号** → 首选 **`aisearch person`**(AI 语义搜索,支持姓名/部门/职责/上下级/手机号/工号维度),见 `aisearch`(开源版未引入,悟空内部产品)。
- **需要 userId 做后续操作 / 按手机号查 / 按 userId 查详情** → `contact`(精确查询)。
- **纯查部门与子部门成员 / 验证归属 / 组织关系** → `contact`。
- **终点是发消息、待办、日程** → 先用 `search-person` 或 `search-user` 取 `userId`,再进入 #1 / #2 / #3。
- **联系客户 + 发邮件** → 先用 `contact` 取 `orgAuthEmail`,再走 [mail.md](../products/mail.md)。
## Recipe 速查(本表步骤,非 SKILL lite)
| Recipe | 步骤 |
|--------|------|
| `lookup-role-members` | 1. `aisearch person --keyword "<角色或职责>" --dimension duty` → 按职责维度找人(`contact label` 已下线) |
| `search-user-by-mobile` | 1. `contact user search-mobile --mobile "<手机号>"` → 按需 `contact user get --ids <userId>` |
| `lookup-dept-id` | 1. `contact dept search --query "<部门关键词>"` → 回显 `deptId`(多命中须消歧) |
| `list-subdepts` | 1. 已有父 `deptId` → `contact dept list-children --dept <父deptId>` 直接取直属子部门列表<br>2. 只有部门名 → 先 `lookup-dept-id` 取 `deptId`,再 `list-children` |
| `list-dept-members` | 1. **优先** `python scripts/contact_dept_members.py --query "<部门名>"`<br>2. 备选:`lookup-dept-id` → `contact dept list-members --depts <deptId>`<br>3. 若要每人档案字段:对 `userId` 批量 `contact user get --ids …` |
| `list-multi-dept-members` | 1. 对每个部门名 `contact dept search --query "<名>"` → 各 `deptId`<br>2. `contact dept list-members --depts <id1>,<id2>,...`(多部门并行/批量见 conventions)<br>3. 需要档案再 `contact user get --ids …` |
| `verify-user-dept` | 1. `contact dept search --query "<部门名>"` → `deptId`<br>2. `contact dept list-members --depts <deptId>` 中匹配姓名;或先 `search-user` lite 再 `user get` 核对部门字段 |
## Full / 多步组合
| Recipe | 行动指南(固定路线) |
|--------|---------------------|
| explore-subdepts-and-members | 1. 取父部门 `deptId`:用户给了 ID 直接用;只给名字则 `contact dept search --query "<父部门名>"` → 父 `deptId`(多命中先消歧)<br>2. **优先** `contact dept list-children --dept <父deptId>` 拿到全部直属子 `deptId` 列表;若用户只点名了部分子部门,则改为对每个子部门名 `contact dept search --query "<子部门名>"`<br>3. 对子 `deptId`:`contact dept list-members --depts <id1>,<id2>,...`(多部门按 conventions **并行/批量**)<br>4. 若还要成员详情:汇总 `userId` → `contact user get --ids …`(≤30 条/批,超出分批 + 用户确认) |
| verify-user-in-dept | 同速查表 `verify-user-dept`;多轮对话中用户追加「是否在某部门」时叠加本路线 |
| cross-level-dept-members | 1. `contact dept search --query "<父部门关键词>"` → 父 `deptId`<br>2. **优先** `contact dept list-children --dept <父deptId>` 枚举全部直属子 `deptId`;用户已点名的子部门则用 `dept search` 精确命中<br>3. 需要逐层下钻时,对上一步拿到的子 `deptId` 继续 `dept list-children` 递归(注意控制深度,避免一次拉太多)<br>4. `contact dept list-members --depts <id1,id2,…>` → 按需 `user get` |
| user-detail-organization | 1. `contact user search --query "<关键词>"` → `userId`(多结果先消歧)<br>2. **必须** `contact user get --ids <userId>`<br>3. 若用户同时给出部门语境:叠加 `verify-user-dept` |
| batch-users-by-keyword | 1. `contact user search --query "<职位或技能关键词>"` → 多条<br>2. 提取 `userId`(≤30)→ `contact user get --ids …`<br>3. 结果过多时汇总或请用户收窄 |
# 邮件
> **SKILL.md** 中 #9 内联 4 条 **lite**:`mail-list-mailbox`、`mail-search`、`mail-send`、`mail-reply-forward`,见 [lite-recipes.md](./lite-recipes.md)。下列 recipe、专用规则与消歧请在命中 #9 且**超出**上述 lite 时阅读本文。
> 产品命令见 [mail.md](../products/mail.md)。通用批量/并行见 [conventions.md](./_common/conventions.md)。
## 专用规则(#9 非 lite 步骤必守)
- **KQL 语法强制**:邮件搜索的查询条件**只能**通过 `--query` 参数以 KQL 语法传入(如 `subject:周报`),**禁止臆造** `--subject`、`--sender`、`--from-address` 等不存在的 flag。详见 [mail.md](../products/mail.md) 中 KQL 查询字段说明。
- **邮箱地址前置**:大部分邮件命令需要 `--email` 或 `--from` 参数,执行前**必须**先通过 `mail mailbox list` 获取当前用户邮箱,禁止猜测邮箱地址。
- **查找他人邮箱**:需要获取某人邮箱地址时,**不要用 `mailbox list`**(只返回自己的),必须走三路并发查询流程(见 [mail.md](../products/mail.md) 中「查找他人邮箱地址」章节)。
- **附件下载三步走**:先 `message search` 搜索邮件获取 messageId,再 `attachment list` 获取附件 ID 和文件名,最后逐个 `attachment download` 下载。**不存在 `download_batch` / `download_all` 等批量下载命令,禁止编造**。
- **危险操作确认**:`batch-delete` 执行前必须向用户确认,同意后加 `--yes`。
## 与其他场景消歧
- **"给某人发邮件"**(只知姓名不知邮箱)→ 先走「查找他人邮箱地址」三路并发,再 `mail-send`。
- **"找某人邮箱"**(终点是获取邮箱地址)→ 三路并发查询,不走 `mail-search`。
- **"搜某人发的邮件"**(终点是邮件内容)→ `mail-search`,KQL 用 `from:xxx`。
- **"催+邮件"** → `mail-send` 发催促邮件,不是 #1 消息。
- **"邮件+待办"** → 先 `mail-search` 找邮件内容,再走 #2 创建待办。
## Recipe 速查(本表步骤,非 SKILL lite)
| Recipe | 步骤 |
|--------|------|
| `mail-get` | `mail message get --email <邮箱> --id <messageId>` → 查看邮件完整内容(含正文) |
| `mail-folder-list` | `mail folder list --email <邮箱>` → 列举文件夹;`--folder-id <id>` 查子文件夹 |
| `mail-tag-list` | `mail tag list --email <邮箱>` → 列举邮件标签 |
| `mail-thread-get` | `mail thread get --email <邮箱> --id <conversationId>` → 获取会话(邮件线程)详情 |
| `mail-attachment-list` | `mail attachment list --email <邮箱> --id <messageId>` → 列举指定邮件的附件 |
| `mail-attachment-download` | 1. `mail attachment list --email <邮箱> --id <messageId>` → 取附件 `id` 和 `name`<br>2. `mail attachment download --email <邮箱> --message-id <messageId> --attachment-id <attachmentId> --name <文件名>` |
| `mail-batch-move` | `mail message batch-move --email <邮箱> --ids <id1,id2,...> --folder <folderId>`(常用 folderId: 2=收件箱, 6=已删除) |
| `mail-batch-delete` | `mail message batch-delete --email <邮箱> --ids <id1,id2,...> --yes`(**危险操作,须先确认**) |
| `mail-draft-create` | `mail draft create --from <邮箱> --subject "<标题>"` → 取 `messageId`(可选 `--to`、`--body`、`--cc`) |
| `mail-draft-update` | `mail draft update --from <邮箱> --id <draftId> --subject "<新标题>"`(可选 `--body`、`--to`、`--cc`) |
| `mail-draft-send` | `mail draft send --from <邮箱> --id <draftId>` |
## Full / 多步组合
| Recipe | 行动指南(固定路线) |
|--------|---------------------|
| search-and-download-attachment | 1. `mail mailbox list` → 取邮箱<br>2. `mail message search --email <邮箱> --query "<KQL>" --size 20` → 取 `messageId` 列表<br>3. 对每封邮件执行 `mail attachment list --email <邮箱> --id <messageId>` → 列出附件取 `id` 和 `name`<br>4. 对每个附件逐个执行 `mail attachment download --email <邮箱> --message-id <messageId> --attachment-id <attachmentId> --name <文件名>`(**仅支持逐个下载,不存在批量下载命令**) |
| search-reply-forward | 1. `mail mailbox list` → 取邮箱<br>2. `mail message search --email <邮箱> --query "<KQL>" --size 10` → 取 `messageId`<br>3. 展示搜索结果供用户选择<br>4. 按用户指示执行 reply / reply-all / forward(参见 lite `mail-reply-forward`) |
| batch-mail-cleanup | 1. `mail mailbox list` → 取邮箱<br>2. `mail message search --email <邮箱> --query "<KQL>" --size 100` → 取多个 `messageId`<br>3. 展示列表供用户确认<br>4. `mail message batch-move --email <邮箱> --ids <id1,id2,...> --folder 6 ` 移到已删除;或 `batch-delete` 永久删除 |
| send-to-person-by-name | 1. `mail mailbox list` → 取发件邮箱<br>2. 走「查找他人邮箱地址」三路并发查询获取收件人邮箱(见 [mail.md](../products/mail.md))<br>3. `mail message send --from <发件邮箱> --to <收件邮箱> --subject "<标题>" --body "<内容>"` |
# 听记发言人智能匹配
> 从钉钉听记中定向提取并总结指定人物的发言内容。
> 产品命令见 [minutes.md](../products/minutes.md)、`aisearch`(开源版未引入,悟空内部产品)、[chat.md](../products/chat.md)、[doc.md](../products/doc.md)。
| Recipe | 行动指南(固定路线) |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| minutes-speaker-summarize | 1. **读取转写**:有 URL → 提取 `taskUuid` → `minutes get transcription --id <uuid>`;有时间/关键词 → `minutes list all` 筛选 → 获取转写;无信息 → 必须询问 2. **声纹标注检查**:已标注真实姓名 → 直接跳 Step 6;仅匿名编号(发言人1/2/3)→ 继续 3. **转写原文推断**:利用称呼、自我介绍、上下文指代、发言顺序推断,高置信度 → 跳 Step 6;无法确定 → 继续 4. **并发身份推断**:`aisearch person --keyword "<姓名>" --dimension name` & `chat message list` 取近期 IM & `doc search --keyword "<姓名>"` 前 3 篇 `& wait`;两路以上一致才下结论 5. **置信度判断**:>70% → 直接输出;≤70% → 展示 TOP3 候选音频片段让用户选(最多一次,"都不是"则终止) 6. **结构化总结输出**:核心观点 + 问题/关注点 + Action Item + 立场态度;输出后追问是否替换发言人标注 |
---
## 触发条件
**TRIGGER** when ANY of the following are true:
- 用户提供了听记来源(URL / 时间 / 关键词可定位)**且**指定了一个具体人名(花名/真名/职位称谓均可),目标是获取该人在会议中说了什么
- 用户从 `minutes-detail` 的发言人列表中选定某位发言人,要求查看其内容总结
- 用户贴了听记链接,发言人未完全识别(存在匿名编号),需要做批量智能匹配
**DO NOT TRIGGER** when:
- 用户只要整体纪要,未指定特定人物 → 用 `minutes get summary`
- 用户想纠错发言人标注 → 告知在听记详情页 GUI 操作
- 用户想提取待办 → `meeting-followup` recipe
---
## 详细步骤
### Step 1:读取听记转写
- 有 URL → 从 URL 提取 `taskUuid` → `dws minutes get transcription --id <uuid>`
- 有时间/关键词 → `dws minutes list all` 筛选 → 获取转写
- 什么都没给 → **必须询问**
### Step 2:声纹标注检查
- **已标注真实姓名** → 直接跳 Step 6
- **仅有匿名编号**(发言人1/2/3)→ 继续 Step 3
### Step 3:转写原文推断(优先)
充分利用转写文本中的称呼、自我介绍、上下文指代、发言顺序、内容特征进行推断。能从原文高置信度确定发言人 → 直接跳 Step 6。无法确定 → 继续 Step 4。
### Step 4:并发身份推断
**同时**发起多路查询,不串行等待:
| 优先级 | 路径 | 做什么 |
| ------ | ------------------ | ---------------------------------------------------------------------------------------------------- |
| ① 最高 | 日程参与人 | 从听记 info 提取会议时间 → `dws calendar event list --start <会议开始-30min> --end <会议结束+30min>` → 匹配同时段日程 → `dws calendar participant list --event <eventId>` → 获取参与人姓名列表,与转写中匿名发言人做数量/顺序对照 |
| ② 高 | 通讯录 + 组织架构 | `dws aisearch person --keyword <姓名> --dimension name` → 部门/职级/上级/汇报关系 |
| ③ 高 | 聊天记录 | `dws chat message list` 获取与目标人的近期 IM 消息 → 工作内容/语言风格/职责线索 |
| ④ 兜底 | 本人文档 | `dws doc search --keyword <姓名>` 取前 3 篇标题+摘要 → 角色精确信号 |
**日程参与人匹配规则**(路径①专用):
- 日程参与人数 = 转写发言人数 → 高置信度,按发言顺序/内容特征一一对应
- 日程参与人数 > 转写发言人数 → 有人未发言,需结合角色推断缩小范围
- 日程参与人数 < 转写发言人数 → 有人未在日程中,需补充其他路径
- **未找到同时段日程** → 主动引导用户:「未找到与该听记时间匹配的日程,是否可以提供会议对应的日程链接或参会人员名单?这将帮助更准确地识别发言人」
**判定规则**:
- 日程参与人 + 通讯录两路信号一致 → 确认身份,置信度最高
- 通讯录 + 聊天两路信号一致 → 确认角色,置信度高,通常无需再查文档
- 仅一路可用或信号矛盾 → 补查文档兜底,两路以上一致才下结论
> 部门名 ≠ 角色,不能用「产品设计部」直接推断是产品设计师;聊天记录往往最直接反映日常工作内容。
**文档类型与角色对照**(仅在文档兜底时参考):
- PRD/需求文档/商业化方案 → 产品经理
- 视觉规范/原型说明/交互评审 → 产品设计师
- 技术方案/架构设计 → 研发工程师
- 分析报告/底表说明 → 数据分析师
### Step 5:置信度分支
基于 Step 4 角色,在转写中匹配发言模式(产品经理提需求/定优先级,研发讲技术约束,管理者高占比做决策,设计师讲交互细节)。
#### 分支 A:置信度 > 70% → 直接输出总结
无需任何确认,直接进入 Step 6。
#### 分支 B:置信度 ≤ 70% → TOP3 候选音频(仅一次)
列出置信度最高的 TOP3 候选发言人(必须来自不同发言人,不重复),每人附一段代表性转写片段:
> 以下是本次会议中可能是 [人名] 的候选,请确认:
>
> **候选 1**(置信度最高)
> 对应文字:"[转写文本片段]"
>
> **候选 2**
> 对应文字:"[转写文本片段]"
>
> **候选 3**
> 对应文字:"[转写文本片段]"
>
> 请问 [人名] 是哪一位?(1 / 2 / 3 / 都不是)
- 用户选 1/2/3 → 进入 Step 6
- 用户说"都不是" → 告知无法确认,建议在听记详情页手动标注,**不再重复**
### Step 6:总结输出
提取该发言人的全部发言,结合会议上下文综合总结:
```
[人名] 在本次会议中的发言总结
**核心观点**
- 观点1
- 观点2
**提出的问题 / 关注点**(如有)
- ...
**Action Item / 承诺事项**(如有)
- ...
**立场 / 态度**(如有明确倾向)
- ...
```
输出完成后追问:
> 要在本篇听记里把「发言人 X」替换成「[人名]」吗?替换后系统会记住声纹特征,下次会议自动识别。[替换] [暂不]
---
## 核心约束
- **禁止暴露 Speaker_X 编号**:追问中用「发言人 X」等用户可感知的描述,不暴露内部格式
- **禁止碰运气播音频**:必须先做身份预推断,再定向选片段
- **声纹库命中时直接输出**:不做任何额外确认
- **置信度 > 70% 直接输出**:不需要确认
- **音频确认最多一次**:用户说"都不是"则终止,不重复
- **先完成任务,后引导替换**:总结输出完成后再追问替换,不打断主流程
---
## 常见陷阱
- 部门名 ≠ 角色:不能用部门名称代替角色判断
- 聊天记录是快速判断工作内容的捷径,优先于文档
- 单篇文档不够:至少看 3 篇,单篇可能是跨角色协作产出
- 访谈记录 PM 和 UXR 都会写,不能单独判断角色
- TOP3 候选必须来自不同发言人,展示前做去重检查
# 听记发言人识别与标注 (speaker-correct)
> 触发条件:用户提供了一篇钉钉听记(URL 或 ID),表达了识别/标注/替换发言人的意图。即使用户只说"帮我标注发言人"也应触发本技能。
## 执行流程
### Step 1:查同时段日程,获取参会人名单
1. 从听记元数据获取录音**开始时间**(精确到分钟)。
2. 调用日历接口查询该时间点前后 30 分钟内的日程:`dws calendar event list --start <T-30min> --end <T+30min> --format json`
3. 若匹配到日程,提取参会人名单(姓名/花名),输出:
> 根据录音时间,匹配到日程「{日程名称}」,参会人为:{姓名1}、{姓名2}、{姓名3}...
> 未识别的发言人大概率来自以上名单。
4. 未匹配到日程 → 跳过,直接进入 Step 2。
### Step 2:展示发言人状态表格
列出所有发言人及其当前标注状态:
| # | 当前标注 | 状态 |
|---|---------|------|
| 1 | 张三 | 已识别 |
| 2 | 发言人 2 | 未标注 |
| 3 | 发言人 3 | 未标注 |
| 4 | 发言人 4 | 未标注,发言较少 |
### Step 3:让用户选择识别方式
```
你希望如何识别未标注的发言人?
A. 听音识人 — 为每位未识别发言人裁剪一段音频片段,你听后辨认是谁
B. 手动设置 — 直接告诉我对应关系,如「发言人2:王五, 发言人3:赵六」
C. 智能匹配 — 提供参会人名单或其他信息,我来尝试推断
```
**等待用户选择后再执行后续操作,不提前切片。**
---
#### 选项 A:听音识人
为每位未标注发言人裁剪一段代表性音频片段。
**片段选择策略:**
1. 语义质量过滤:排除口吃/重复词段落、文本 < 8 字、纯单字回应、句意不完整段落
2. 综合评分排序:`score = 时长(秒) x 语义完整度系数`,取最高分段落,截取 `min(段落时长, 10秒)`,不足 5 秒的跳过
3. 兜底(发言较少):合并该发言人所有 >= 1 秒的片段;合并后仍不足 3 秒则放弃,告知用户建议手动标注
切片完成后告知用户可以听取,等待用户回复对应关系。
#### 选项 B:手动设置
等待用户输入对应关系(如「发言人2:王五, 发言人3:赵六」),收到后进入 Step 4 批量替换。
#### 选项 C:智能匹配
若 Step 1 已获取参会人名单,结合发言内容特征尝试推断;否则请用户补充名单后推断。
---
### Step 4:通讯录查询 + 执行替换
确认对应关系后,**必须先查询通讯录获取 dingUid**:
1. 调用 `dws contact user search --query "<姓名>" --format json`
2. 从返回结果提取 `userId`(长整型数字,即 dingUid)
3. 根据匹配结果执行替换:
| 情况 | 处理 |
|------|------|
| 唯一匹配(如 userId=123456789) | `dws minutes speaker replace --id <taskUuid> --from "发言人X" --to "<姓名>" --target-uid 123456789 --format json` |
| 多个匹配 | 列出候选(姓名 + 部门 + userId)让用户选择后执行 |
| 无匹配 | 提示通讯录未找到,执行不带 `--target-uid` 的替换:`dws minutes speaker replace --id <taskUuid> --from "发言人X" --to "<姓名>" --format json` |
### Step 5:追问
替换成功后:
> 还有其他发言人需要我帮你识别和替换吗?
---
## 注意事项
- **不透出执行过程**:不向用户展示时间戳、ffmpeg 命令、评分逻辑等内部细节
- **不做身份画像分析**:不提供语言风格、决策权等文字画像
- **串行处理**:逐条执行裁剪命令,不使用 shell `&` 并行
- **全已识别情况**:若所有发言人均已识别,直接告知「所有发言人已识别,无需修改」
- **通讯录查询必跑**:替换前必须调用 `dws contact user search` 获取 userId,严禁跳过
## 异常处理
| 情况 | 处理方式 |
|------|----------|
| 无法获取原始音频 | 告知权限不足或链接失效,建议用户本地提供或选择手动设置 |
| 未匹配到日程 | 跳过参会人提示,直接展示发言人表格 |
| 通讯录查不到目标人 | 执行不带 `--target-uid` 的替换,告知用户仅修改显示名 |
| 未找到未识别发言人 | 告知用户所有发言人均已识别 |
| speaker replace 报权限错误 | 告知用户需听记创建者/协作者权限,引导联系原作者 |
# 业务域通用规范
> 仅服务本仓库已迁入的文档与 AI 表格行动指南。安全门控、危险操作确认、`--format json` 等已在根 [SKILL.md](../../../SKILL.md) 中定义,此处不重复。
## 批量查询规范
| # | 规范 |
|---|------|
| 1 | **并行查详情**:拿到多个 ID 后,用 `&` 合并到同一条 Shell 命令并行执行 + `wait`,**严禁逐条串行** |
| 2 | **翻页**:分页接口须拉全直至无更多 |
| 3 | **优先批量 API**:有批量接口则用批量;无则按 #1 并行 |
| 4 | **列表少轮次**:带条件搜索/列表 → 一次采全详情;**禁止**无新参数时重复同一 `list` / `search` |
## 多源并行采集(公共模式)
> recipe 引用方式:`按「多源并行采集」执行(关键词=<X>,时间=<Y>至<Z>)`。
- 同条 Shell:`&` 并行 + `wait`;分页须采全。
- 只保留与主题相关的数据,无关丢弃。
- 有批量详情接口优先;否则并行拉详情(见上表 #1)。
- 具体采哪些产品列表由对应 **行动指南 recipe** 与 [SKILL 产品参考](../../../SKILL.md) 决定;不要引入本文档未覆盖的产品路线。
## 字段术语与 ID 传递
> list 返回 JSON 后,必须提取下表字段传给后续命令。**禁止用其他字段替代。**
| 字段 | 来源 | 传递给 |
|------|------|--------|
| `nodeId` | `doc search` | `doc read/update/copy/move/rename --node` |
| `nodeId` | `doc list` 中的 folder 类型节点 / `doc folder create` | `doc list --folder`、`doc create --folder`、`doc upload --folder`、`doc copy/move --folder` |
| `baseId` / `tableId` | `aitable base search` | `aitable record query --base-id --table-id` |
| `dentryUuid` | `drive list` / `drive mkdir` | `drive info/download --file-id`、`drive list/mkdir/upload --parent-id` |
| `workspaceId` | `wiki space search/list/create` | `doc list/search/create --workspace`、`wiki member * --workspace` |
**ID 边界硬约束**:遇到 `drive --parent-id`、`doc --folder`、`doc --node` 时,只能使用 `dentryUuid` / `nodeId` / 文档 URL。若当前上下文只有数字型 `dentryId`,必须先重新 `drive list` / `doc list` / `doc search` 获取正确 ID,不能把该数字直接代入后续命令。
# Recipe 规范
> 每个 recipe 是一个 **SKILL.md**,格式与产品 skill 同等身份。
## 架构三层
```
Layer 1: _common/conventions.md → 全局共享(认证、安全规则、字段术语)
相当于 gws-shared/SKILL.md
Layer 2: products/*.md → 产品能力(命令用法、flags、示例)
相当于 gws-gmail/SKILL.md, gws-docs/SKILL.md
Layer 3: recipe-xxx/SKILL.md → 组合配方(固定步骤 + 声明依赖)
相当于 recipe-send-team-announcement/SKILL.md
```
## Recipe SKILL.md 格式
```yaml
---
name: recipe-<verb>-<object>
description: "一句话描述"
metadata:
category: "recipe"
domain: "<领域编号>"
requires:
bins:
- dws
skills:
- <product-1>
- <product-2>
---
```
**正文**只包含:
1. 标题 + 一句话描述
2. `> **PREREQUISITE:** ...` 提示加载哪些 skill
3. `## Steps` — 编号步骤,每步一条 `dws` 命令
**不包含**(这些留给 conventions.md 和 best_practice.md):
- 安全门控/决策分支/踩坑提醒
- 失败处理策略
- 数据佐证
## 命名规则
```
recipe-<verb>-<object>[-<modifier>]
```
| 动词 | 含义 | 示例 |
|------|------|------|
| send | 发送 | recipe-send-message |
| create | 创建 | recipe-create-todo |
| query | 查询 | recipe-query-doc |
| write | 写入 | recipe-write-doc |
| resolve | 解析 | recipe-resolve-contact |
| generate | 生成 | recipe-generate-daily-report |
| share | 分享 | recipe-share-doc-and-notify |
## 路由规则
Agent 识别意图后:
1. 匹配到 recipe → 加载 recipe 声明的 skills → 按 Steps 执行
2. 无匹配 recipe → 走领域 best_practice.md 策略指南
3. 只需单个命令 → 直接查 products/*.md
# Lite Recipe 完整步骤
> 核心流程步骤 3 判定为 lite 后,按本文件中对应 recipe 的步骤**直接执行**。
> 所有命令均须加 `--format json`(下文省略)。
## #1 消息沟通
所有消息沟通相关的命令详情、参数说明、意图路由和复合工作流,请查阅 [chat.md](../products/chat.md)。
## #2 任务管理
### create-todo
1. 确定执行者:指定姓名 → `aisearch person --keyword "<姓名>" --dimension name` → `userId`;未指定 → `contact user get-self` → `userId`;多人 → 逐个搜索逗号拼接。
2. 创建:`todo task create --title "<标题>" --executors <userId>[,<userId2>...]`(可选 `--due "<截止ISO>"`)→ `todoTaskId`
### todo-query-ops
- 查询:`todo task list [--status false|true]`(不传=全部)
- 详情:`todo task get --task-id <id>`
- 完成/重开:`todo task done --task-id <id> --status <true|false>`
- 按主题筛选:list 后按标题关键词过滤
## #3 会议日程
### list-today-meetings
**优先**:`python scripts/calendar_today_agenda.py [today|tomorrow|week]`
备选:`dws calendar event list --start "<今日起始ISO>" --end "<今日结束ISO>"`(须加 `--format json`)
### check-users-busy
查询多人在某时段内的闲忙(**busy**,不是用 `event list` 扫日程):
1. 解析用户:对每个姓名执行 `aisearch person --keyword "<姓名>" --dimension name` → `userId`;多人将 `userId` 用英文逗号拼接(无空格或按 [calendar.md](../products/calendar.md) `busy search` 要求)。
2. 确认时段:用户须给出或可收敛为明确的 `--start` / `--end`(ISO-8601);若未给出,**先追问**起止时间,禁止用任意默认全天窗口代替用户意图。
3. 执行:`dws calendar busy search --users <userId1,userId2,...> --start "<ISO>" --end "<ISO>" --format json`
详见 [calendar.md](../products/calendar.md) 中「查询用户闲忙状态」。
### start-conference
> 触发:「发起会议」「开个会」「创建会议」且**没有给出具体时间** → 直接执行,无需追问。
`conference start [--title "<主题>"]`
- 用户给了主题 → 加 `--title`;没给 → 省略(系统用默认标题)
- 有具体时间(如"明天3点开会")→ 不走此 recipe,走 03-meeting.md 的 `schedule-meeting`
### invite-participant
1. 查人:`contact user search --query "<姓名>"` → `openDingTalkId`、`nick`
2. 获取会议 ID:`conference get-id` → `conferenceId`
3. 邀请:`conference member invite --conference-id <conferenceId> --nicks "<nick>" --open-dingtalk-ids "<openDingTalkId>"`
### share-screen
- 共享屏幕:`conference share start`
- 停止共享:`conference share stop`
## #4 文档知识
### query-doc
1. `doc search --query "<关键词>"` → `nodeId`
2. `doc read --node <nodeId>`(按需;大文档只抽章节)
### list-folder-docs
`doc list --workspace <WS_ID>` 或 `--folder <FOLDER_ID>`
## #5 工作汇报
### query-report-list
1. 收到的日志:先把用户时间词转成起止时间,再执行 `report inbox list --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --cursor 0 --size 20 --format json`。
2. 我发过的日志:`report outbox list --cursor 0 --size 20 --format json`;如用户指定时间,补 `--start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>"`。
3. 面向用户时必须基于 `result[]` 拼 Markdown 表,表头固定为 `日期 | 标题 | 发送人 | 状态 | 钉钉链接`;每条 `result[]` 都会带这五个中文字段,不要把 `reportId` / `日志ID` 作为主列。
4. 用户要看某条正文时,再用内部保留的 `reportId` 执行 `report entry get --report-id <reportId> --format json`。
时间 flag 硬约束:只允许 `--start` / `--end`;禁止 `--start-date` / `--end-date` / `--date`。不要只传 `2026-05-04`,必须展开成 `2026-05-04T00:00:00+08:00` 这种完整 ISO。
硬约束:`report inbox list` 是收到的日志(别人发给我),`report outbox list` 是我创建/发出的日志(我发给别人)。不要混淆方向;不要回答"API 不支持收到的日志"。
> 旧命令兼容:`report list` / `report inbox` / `report sent` / `report created` / `report detail` / `report stats` 仍可执行,但已 deprecated,stderr 会打废弃提醒,新计划一律使用 `inbox list` / `outbox list` / `entry get` / `entry stats`。
禁止:不要先查 help,不要为了格式化列表创建脚本。`report inbox` 可作为兼容入口使用,但新计划优先写规范命令 `report inbox list --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --cursor 0 --size 20 --format json`。
### check-report-read-status
`report entry stats --report-id <reportId>` → 已读/未读
## #7 听记与会后
> 产品命令完整参考见 [minutes.md](../products/minutes.md)。full recipe 见 [07-minutes.md](./07-minutes.md)。
### minutes-query(查询与获取)
**列表查询**(`list` 后**必须**跟 scope:`mine`/`shared`/`all`,默认补 `all`):
```bash
# 我可访问的所有听记(默认)
dws minutes list all --format json
# 按关键词服务端搜索(严禁全量拉取后本地 grep)
dws minutes list all --query "周会" --format json
# 按时间范围筛选(ISO-8601 格式)
dws minutes list mine --start "2026-05-01T00:00:00+08:00" --end "2026-05-25T23:59:59+08:00" --format json
# 关键词 + 时间组合
dws minutes list all --query "需求评审" --start "2026-05-25T00:00:00+08:00" --end "2026-05-25T23:59:59+08:00" --format json
# 限制条数
dws minutes list mine --limit 5 --format json
# 共享给我的听记
dws minutes list shared --query "ROI" --format json
```
| 参数 | 说明 |
|------|------|
| `--query "<关键词>"` | 服务端关键词搜索 |
| `--start "<ISO-8601>"` | 开始时间 |
| `--end "<ISO-8601>"` | 结束时间 |
| `--limit <N>` | 每页条数,默认 10(`--max` 为兼容别名) |
| `--cursor "<token>"` | 分页 token,首页留空(`--next-token` 为兼容别名) |
**获取详情**:
- 批量基础信息:`minutes get batch --ids <uuid1,uuid2,...>`
- 单篇摘要:`minutes get summary --id <taskUuid>`
- 转写原文(自动翻页):`minutes get transcription --id <taskUuid>`(返回 `nextToken` 时用 `--next-token <token>` 继续)
- 关键词:`minutes get keywords --id <taskUuid>`
- 待办事项:`minutes get todos --id <taskUuid>`
- 基础信息:`minutes get info --id <taskUuid>`
- 音频地址:`minutes get audio --id <taskUuid>`
> `--id`/`--uuid`/`--task-uuid` 三者等价。推荐 `--id`。
### minutes-edit(编辑与替换)
- **替换转写文字**:`minutes replace-text --id <taskUuid> --search "旧文字" --replace "新文字"`
- 执行前检查特殊字符(引号/书名号/括号等),若包含先提示用户确认去除
- 替换成功后追问是否加热词:`minutes hot-word add --words "新文字"`
- **替换发言人**:先通讯录查 dingUid → `minutes speaker replace --id <taskUuid> --from "发言人X" --to "姓名" --target-uid <userId>`
- 查询 dingUid:`contact user search --query "姓名" --format json` → 取 `userId`
- 多个匹配 → 列出候选让用户选;无匹配 → 不带 `--target-uid` 执行
- **修改标题**:`minutes update title --id <taskUuid> --title "新标题"`
- **修改摘要**:`minutes update summary --id <taskUuid> --content "新内容"`
- **热词管理**:`minutes hot-word add --words "词1,词2"` / `minutes hot-word list`
- **思维导图**:`minutes mind-graph create --id <taskUuid>` → `mind-graph status --id <taskUuid>` 轮询至完成
### minutes-permission(权限管理)
- 添加成员:`minutes permission add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy 4`
- 需先通过 `contact user search` 获取目标 userId
- policy:0=不可见 / 1=仅查看 / 2=查看+下载 / 3=查看+下载+编辑 / 4=全部权限
- 移除成员:`minutes permission remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>`
### minutes-upload(音频上传)
```bash
# 创建上传会话
dws minutes upload create --file-name "meeting.mp3" --file-size 61565431 --format json
# 上传完成后确认
dws minutes upload complete --session-id <sid> --format json
# 取消上传
dws minutes upload cancel --session-id <sid> --format json
```
### 最佳实践案例速查(详见 [minutes.md](../products/minutes.md))
| 案例 | 场景 | 正确链路 |
|------|------|----------|
| 案例 1 | 听记 URL + 创建思维导图 | 提取 taskUuid → `mind-graph create` → `mind-graph status` 轮询;**禁止**走 app-development 或前端库 |
| 案例 2 | 替换文字后未引导热词 | 检查特殊字符 → `replace-text` → 追问加热词 `hot-word add` |
| 案例 3 | 查听记拉了不必要的转写 | 用户只要列表 → `list` 即可,**不要**自动拉 `get transcription` |
| 案例 4 | 拉完转写只输出时间线原文 | 拉完后追问按发言人聚类 → 引导匹配 → 调用 `speaker replace` 写回 |
| 案例 5 | 查某人说了什么不引导替换 | 推断发言人 → **用户确认** → 结构化总结 → 引导 `speaker replace` |
| 案例 6 | 通讯录+部门+转写三路印证 | Step 3 画像 + Step 4 `contact user search` 并发 → 置信度 ≥70% → 确认 → 替换 |
| 案例 7 | grep 花名误判未参会 | **禁止**在转写文本里 grep 人名判参会;**必须**调 `contact user search` |
| 案例 8 | 听记类 query 不走 dws | **禁止**用 session_search/browser_use/activity:search 替代 dws;模糊请求先 `list mine` |
### 间接意图识别铁律
query 未提"听记"但任务产出依赖会议讨论内容时(报告/总结/日报/复盘/商业分析/市场感知),听记采集是**必跑前置步骤**:
1. **铁律 A**:任务含"会议/讨论/沟通"信息需求 → `dws minutes list` 必跑
2. **铁律 B**:用户说"文档啥也没有" → 听记优先级更高(唯一结构化数据源)
3. **铁律 C**:多源聚合场景 → 每个被提及的数据源都必须有采集动作,听记侧 0 调用 = 严重失败
## #8 通讯录
### get-contact-self
`contact user get-self` → 当前用户 userId、部门、主管等
### search-person
**搜人首选入口**。凡是“找人/搜人/找同事/谁负责/上级/下级/负责人/团队成员”均优先用 `aisearch person`:
1. 从用户问题中提取 keyword(人名/业务关键词)和 dimension(维度),规则见 `aisearch`(开源版未引入,悟空内部产品)。
2. `aisearch person --keyword "<关键词>" --dimension <维度>`
3. 结果中提取 `userId` 和 `title`(姓名)展示给用户。
4. 若需要 userId 做后续操作(发消息/建待办),可直接使用结果中的 `userId`。
5. **重名消歧**:多人同名时禁止默认选第一个,须追加 `contact user get --ids` 获取部门/职位后请用户确认,详见 [08-directory.md](./08-directory.md)「多命中」。
### search-user
仅在以下**精确查询**场景使用,搜人请优先用 `search-person`:
- 需要获取 userId 给其他产品使用(发消息/建待办/约日程)
- 已有 userId 需查完整详情(`contact user get --ids`)
1. `aisearch person --keyword "<姓名>" --dimension name` → `userId`;**多命中须列出候选请用户确认**。
2. **重名消歧**:多人同名时禁止默认选第一个,须追加 `contact user get --ids` 获取部门/职位后请用户确认,详见 [08-directory.md](./08-directory.md)「多命中」。
3. 需详情时:`contact user get --ids <userId>`(多人可 `--ids id1,id2,...`)
## #9 邮件
### mail-list-mailbox
查询当前用户自己的可用邮箱地址列表。**仅返回自己的邮箱**,不能查他人邮箱(查他人邮箱请走 [mail.md](../products/mail.md) 中「查找他人邮箱地址」三路并发查询流程)。
`mail mailbox list`
### mail-search
搜索邮件。**必须使用 KQL 语法通过 `--query` 传递查询条件**,禁止臆造 `--subject`、`--from` 等不存在的 flag。
1. 获取邮箱地址:`mail mailbox list` → 取用户邮箱地址。若用户已提供邮箱可跳过。
2. 构造 KQL 查询:根据用户意图将搜索条件转为 KQL 表达式(详见 [mail.md](../products/mail.md) 中 KQL 查询字段说明)。
- 按主题:`subject:周报`、`subject:"项目 进展"`(含空格须加双引号)
- 按发件人:`from:[email protected]` 或 `from:"张三"`
- 按日期:`date>2025-06-01T00:00:00Z`(ISO8601 格式,必须含时间部分)
- 按文件夹:`folderId:2`(2=收件箱, 1=已发送, 5=草稿, 6=已删除)
- 按是否有附件:`hasAttachments:true`
- 组合:`from:alice AND subject:周报 AND date>2025-06-01T00:00:00Z`
3. 执行搜索:`mail message search --email <邮箱> --query "<KQL表达式>" --size 20`
4. 查看详情(按需):`mail message get --email <邮箱> --id <messageId>`
### mail-send
发送邮件。
1. 获取邮箱地址:`mail mailbox list` → 取用户邮箱作为 `--from`。
2. 确定收件人:用户直接提供邮箱地址 → 直接使用;用户提供姓名 → 走「查找他人邮箱地址」三路并发流程(见 [mail.md](../products/mail.md))。
3. 发送:`mail message send --from <发件邮箱> --to <收件邮箱> --subject "<主题>" --body "<正文>"`(可选 `--cc`、`--attachment`、`--inline-attachment`)。
### mail-reply-forward
回复或转发邮件。
1. 获取邮箱地址:`mail mailbox list` → 取用户邮箱。
2. 定位原始邮件:若用户未提供 messageId → 先用 `mail-search` 搜索定位。
3. 执行:
- 回复:`mail message reply --from <邮箱> --id <messageId>`(可选 `--to`、`--subject`、`--body`)
- 回复全部:`mail message reply-all --from <邮箱> --id <messageId>`(可选 `--to`、`--subject`、`--body`)
- 转发:`mail message forward --from <邮箱> --to <收件邮箱> --id <messageId>`(可选 `--subject`、`--body`)
# 已知能力限制
遇到以下操作时,**不要重试或变通**,直接告知用户当前不支持并建议在钉钉客户端操作。
## chat
| 不支持的操作 | 说明 |
|------------|------|
| 撤回个人身份发送的消息 | 只有 `send-by-bot` 发送的消息才能通过 `recall-by-bot` 撤回。个人身份 (`chat message send`) 发送的消息无法通过 API 撤回 |
## doc
| 不支持的操作 | 说明 |
|------------|------|
| 文档权限管理 | ⚠️ 已通过 transitional helper 部分支持:`dws doc permission add/update/list` 可用(调 `add_permission`/`update_permission`/`list_permission` MCP tool);待 mse 注册对应 toolOverride 后此条删除 |
| 删除整篇文档/文件 | ⚠️ 已通过 transitional helper 支持:`dws doc delete --node <ID> --yes`(调 `delete_document`);待 mse 注册后此条删除 |
| 文档导出为 docx | ⚠️ 已通过 transitional helper 支持:`dws doc export --node <ID> --output ./x.docx`(内置渐进式退避轮询 + 自动下载,调 `submit_export_job` / `query_export_job` MCP tool);待 mse 注册对应 toolOverride 后此条删除 |
| 媒体附件下载/插入 | ⚠️ 已通过 transitional helper 支持:`dws doc media download/insert`(前者调 `download_doc_attachment`,后者 3 步流水线);待 mse 注册后 download 条可删,insert 因含本地 HTTP PUT 永久 helper |
## aitable
| 不支持的操作 | 说明 |
|------------|------|
| 创建公式/查找引用等高级字段类型 | 部分高级字段类型暂不支持 API 创建 |
| 自己 PUT 文件时 Content-Type 不为空 | OSS 签名机制要求 PUT 请求的 `Content-Type` 头**必须清空**,否则返回 `SignatureDoesNotMatch` / HTTP 403。这**不是 dws 限制**,是阿里云 OSS 行为。解决:`dws aitable import upload --file ./x.xlsx` 已内置正确处理;自己写 `curl` 时必须传 `-H "Content-Type:"`(注意冒号后是空值) |
## minutes
| 不支持的操作 | 说明 |
|------------|------|
| 跨听记的全局热词修正 | `replace-text` 仅修正**当前这一篇听记**的文字,不会影响后续新听记的语音识别结果。要让某个词长期不再被识别错,必须额外引导用户使用 `hot-word add` 添加个人热词 |
---
## 维护守则
> - 此文件随产品能力迭代更新。新增限制时按产品分类追加即可。
> - **当产品命令补齐后,必须同步删除对应的"不支持"条目**——否则 Agent 看到该条目会主动拒绝调用已存在的命令,造成可用能力被自我屏蔽。
> - 标注 ⚠️ 的条目表示"transitional helper 已临时支持",待 mse 端 toolOverride 落地后整条删除。
# 错误码说明
全产品错误参考 + 调试流程。Agent 遇到错误时查阅此文档。
## 错误返回格式
```json
{"success": false, "code": "InvalidParameter", "message": "baseId is required"}
{"success": false, "code": "AUTH_TOKEN_EXPIRED", "message": "Token验证失败"}
{"success": false, "code": "PermissionDenied", "message": "无权限访问该资源"}
```
## 错误分类与 Agent 行为
### 可自行修复
- 参数缺失 / 格式错误 / ID 无效 → 检查参数后修正重试
### 需用户介入
- 权限不足 / 资源不存在 / 配额超限 → 报告完整错误信息给用户,不要自行尝试替代方案
## 通用错误
- 请求超时 — 网络慢或服务端响应慢 → `--timeout 60` 重试
- 网络连接失败 — 无法连接 MCP Server → 用最简命令验证: `dws contact user get-self --format json`
- stderr 出现 `RECOVERY_EVENT_ID=<event_id>` — runtime 失败已被 CLI 捕获 → 优先执行 `dws recovery execute --event-id <event_id> --format json`
## Recovery 闭环
- `dws recovery plan --last|--event-id <event_id> --format json`:读取失败快照并生成恢复计划
- `dws recovery execute --last|--event-id <event_id> --format json`:生成带 `doc_search`、`probe_results`、`agent_task` 的分析包
- `dws recovery finalize --event-id <event_id> --outcome recovered|failed|handoff --execution-file execution.json --format json`:回写恢复结果
执行 `finalize` 时:
- `execution-file` 至少应包含 `actions`、`attempts`、`result`、`error_summary`
- 兼容旧格式:`action` + 数值型 `attempts`
- 若 recovery bundle 已进入 unknown/agent 路线,不要把 `human_actions` 视为最终结论;必须结合完整 bundle 判断
更多字段解释见 [recovery-guide.md](./recovery-guide.md)。
---
## aitable 高频错误
> 参数体系: `baseId / tableId / fieldId / recordId`。CLI flag 用 kebab-case(`--base-id`),JSON 内用 camelCase(`baseId`)。
- 参数缺失 / 无效请求 — 还在用旧参数 `dentryUuid` / `--doc` / `--sheet` → 改用 `--base-id` / `--table-id` / `--field-id` / `--record-ids`
- 参数传了但服务端没收到 — flag 用了 camelCase(如 `--baseId`)→ flag 用 kebab-case: `--base-id <ID>`
- `record query --filters` 无结果 — 单选/多选过滤用了 option name 而非 id → 先 `field get` 读取 options,用 option id 过滤
- record create/update 失败 — `cells` key 用了字段名(应为 fieldId);特殊字段格式错误 → 先 `table get` 拿字段目录;url 传 `{"text":"..","link":".."}`
- 更新选项后历史数据异常 — 更新 options 没传完整列表 / 没保留原 id → 先 `field get` 取完整配置,保留已有 option 的 id
- `cannot delete the last table` — 该表是 Base 最后一张表 → 先新建表再删旧表,或用 `base delete`
- `formula` 类型 `not supported yet` — 部分字段类型暂不支持 API 创建 → 复杂字段拆开单独创建,先建基础结构
**排查链路**: `base list` → `base get`(→tableId) → `table get`(→fieldId) → `record query`(→recordId)。别跳步,别猜 ID。
**批量上限**: record 100 条 / field 15 个 / table·field 详情 10 个。
---
## approval 高频错误
- approve/reject 缺少 taskId — 未先获取审批任务 → 先 `approval tasks --instance-id <ID>` 获取 taskId
- list-initiated 缺少 processCode — 未查询审批表单 → 先 `approval list-forms` 获取 processCode
- 撤销审批失败 — 非本人发起的审批 → `revoke` 只能撤销自己发起的审批
---
## chat 高频错误
- 参数互斥报错 — `--group` 与 `--users` 同时传入 → 群聊用 `--group`,单聊用 `--users`,二者互斥
- 群不存在 — openConversationId 不正确 → `chat search --query "群名"` 获取正确 ID
- 机器人无法添加到群 — 当前用户非群管理员 → 报告给用户,需群管理员操作
- Webhook Token 无效 — token 不正确或已失效 → 确认 Webhook Token 来源正确
- 添加/移除群成员失败 — userId 不正确或无权限 → 先 `contact user search` 确认 userId,需当前用户为群管理员
---
## calendar 高频错误
- 时间格式错误 — 未使用 ISO-8601 格式 → 标准格式: `2026-03-10T14:00:00+08:00`
- 会议室搜索报错 / 返空 — 企业会议室超 100 条未分组查询 → 先 `room list-groups` → 按 `--group-id` 逐组搜索
- 参与者 / 会议室添加失败 — eventId 不正确 → 先 `event list` 或 `event create` 获取正确 eventId
---
## contact 高频错误
- `dept list-children` 报错 — `--id` 传了非整数值 → deptId 必须为整数,从 `dept search` 获取
---
## 通用排查三步法
1. **确认 ID** — 从最顶层资源逐级获取,不猜 ID、不跳步
2. **确认参数** — flag 用 kebab-case,JSON 用 camelCase;特殊字段查产品参考文档确认格式
3. **确认限制** — 检查批量上限和已知约束(各产品注意事项见对应产品参考文档)
# 易混淆操作与字段规则
## 易混淆操作 (高风险场景必读)
| 用户说的 | 正确命令 | 不是这个 |
|---------|----------|---------|
| "创建一个新表格 (Base)" | `base create` | 不是 `table create` |
| "在表格里加一个数据表" | `table create` | 不是 `base create` |
| "看看表格里有哪些表" | `base get` | 不是 `field get` |
| "看看表里有哪些列" | `field get` / `table get` | 不是 `base get` |
| "搜索表格" (找 Base) | `base search` | 不是 `record query` |
| "搜索记录" (查表内数据) | `record query` | 不是 `base search` |
| "删掉这个数据表" | `table delete` | 不是 `record delete` |
| "删掉这条数据" | `record delete` | 不是 `table delete` |
| "删掉这个列" | `field delete` | 不是 `record delete` |
| "改字段类型" | 先 `field delete` 再 `field create` | `field update` **不能改类型** |
| "移动字段/调整字段顺序" | `view update --config '{"visibleFieldIds":[...]}'`(视图层重排,首列主字段必须保留在第一位) | 没有 `field reorder`/`field move` 命令;不能改字段在元数据里的"原始定义顺序" |
## field 子命令总览
> ⚠️ field 有且仅有以下 **4 个** 子命令,没有 `list`、`reorder`、`move`:
| 子命令 | 用途 |
|-------|------|
| `field get` | 获取字段详情(含完整 config/options)。**不是 `field list`** |
| `field create` | **创建字段(支持通过 config.options 设置选项)** |
| `field update` | 更新字段名称或配置(**不能改类型**,**不能改顺序**) |
| `field delete` | 删除字段(不可逆) |
> **想"调整字段顺序"?请使用 `view update`**(视图层操作,不属于 `field` 子命令):
> - 通过 `--config '{"visibleFieldIds":["fld1","fld2",...]}'` 传入 fieldId 数组,数组顺序即视图中的字段显示顺序
> - 仅影响**该视图**的列排列,同一 table 的其他视图与字段元数据原始顺序不变
> - **首列字段(主字段)必须保留在数组第一位**,不能移动到非首位
> - **漏传的字段不会被隐藏**,而是会被 API 自动追加到列表末尾
> - **读写命名不一致**:写入键名 `visibleFieldIds`,但读出(`view get`)时该字段在 view 里叫 `columns`——校验顺序时请看 `views[].columns`
## 字段创建时设置 config(重要)
创建 singleSelect/multipleSelect 字段时,**必须设置选项 (options)**:
```bash
# 创建带选项的单选字段 (推荐新语法: --name/--type/--config)
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--name "优先级" --type "singleSelect" \
--config '{"options":[{"name":"高"},{"name":"中"},{"name":"低"}]}' \
--format json
# 建表时也可以直接通过 --fields 批量带选项字段
dws aitable table create --base-id <BASE_ID> --name "任务表" \
--fields '[{"fieldName":"任务","type":"text"},{"fieldName":"状态","type":"singleSelect","config":{"options":[{"name":"待办"},{"name":"进行中"},{"name":"已完成"}]}}]' \
--format json
```
> ⚠️ **不要混淆**:
> - **字段创建**(`field create` 的 `--config` 或 `table create` 的 `--fields`):创建时就指定 `options`
> - **记录写入**(`record create` / `record update` 的 `--records`):只能写入已存在的选项名称
## 主字段约束(table create 必读)
> ⚠️ `table create` 的 `--fields` 中,**第一个字段自动成为主字段**。
> 主字段只能是 **text** 类型,不能是 attachment、checkbox、formula 等。
**实际影响**:当用户要求创建的字段不适合做主字段时(如附件、复选框),必须:
1. 先放一个 text 字段作为第一个字段(主字段)
2. 再放用户要求的字段
3. **告知用户**为何多了一个字段
```bash
# 例: 用户要求只创建附件字段 → 附件不能做主字段,必须先加 text 主字段
dws aitable table create --base-id <BASE_ID> --name "产品图片" \
--fields '[{"fieldName":"名称","type":"text"},{"fieldName":"产品图片","type":"attachment"}]' \
--format json
```
## 只读字段 (不可写入)
以下类型的字段不可写入, 执行 `field get` / `table get` 后识别并跳过:
- 创建时间 / 修改时间 (系统自动)
- 创建人 / 修改人 (系统自动)
- 自动编号
- 公式字段
- 引用字段
## 记录写入格式(record create / record update)
> 各字段类型的完整写入/读取格式规范请参考:[aitable-cell-value.md](./products/aitable/aitable-cell-value.md)
>
> 该文件是 cellValue 格式的 **source of truth**,包含所有字段类型的详细示例和注意事项。
## ⚠️ 附件上传完整流程(必读!)
> **不要**使用钉盘 (drive) 上传来替代此流程!钉盘 fileId **无法**写入 attachment 字段。
附件字段写入使用 `upload_attachment.py` 脚本,**2 步**完成:
```bash
# 步骤 1: 一键上传文件(脚本内部自动完成 prepare + PUT to OSS)
python3 scripts/upload_attachment.py <BASE_ID> /path/to/photo.png
# 输出: { "fileToken": "ft_xxx", "fileName": "photo.png", "size": 1024 }
# 步骤 2: 在 record create/update 中使用 fileToken
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json
```
# 全局参考
## 认证
```bash
# 首次: OAuth 设备流登录 (钉钉扫码授权)
dws auth login
# 查看状态
dws auth status
# 沙箱间迁移登录态(Linux,含 refresh token)
dws auth export -o dws-auth.tar.gz
dws auth import -i dws-auth.tar.gz
# 退出
dws auth logout
# 重置本地凭证 (Token 解密失败时使用)
dws auth reset
```
登录后自动管理 token 刷新,日常使用无需重复登录。
| Token | 有效期 | 说明 |
|-------|--------|------|
| Access Token | 2 小时 | 调用 API 的凭证,过期自动刷新 |
| Refresh Token | 30 天 | 换新 Access Token,使用后轮转 |
30 天内使用一次即自动续期。
### 认证失败处理
- 命令返回 `AUTH_TOKEN_EXPIRED` / `USER_TOKEN_ILLEGAL` / "Token验证失败" → 执行 `dws auth login` 重新登录
### Headless 环境 (CI/CD)
```bash
# 通过环境变量配置认证(无需交互式登录)
export DWS_CLIENT_ID=<your-app-key>
export DWS_CLIENT_SECRET=<your-app-secret>
dws auth login
# 或使用 --device 设备流登录(远程服务器/Docker)
dws auth login --device
```
refresh_token 单设备独占,远程刷新后源设备凭证失效。
## Recovery
当 runtime/MCP 命令失败且 stderr 额外输出 `RECOVERY_EVENT_ID=<event_id>` 时,说明 CLI 已经持久化了失败快照,可进入 recovery 闭环:
```bash
dws recovery plan --event-id <event_id> --format json
dws recovery execute --event-id <event_id> --format json
dws recovery finalize --event-id <event_id> --outcome recovered|failed|handoff --execution-file execution.json --format json
```
- `plan` / `execute` 也支持 `--last`,但 `--last` 与 `--event-id` 互斥
- recovery 文件保存在 `DWS_CONFIG_DIR/recovery/`
- CLI 会自动清理 30 天前的 recovery 文件和事件记录
- recovery 自己发起的文档检索与只读 probe 不会再创建新的 recovery 事件
更多闭环要求见 [recovery-guide.md](./recovery-guide.md)。
## 全局标志
| 标志 | 短名 | 说明 | 默认 |
|------|:---:|------|------|
| `--format` | `-f` | 输出格式: json / table / raw | json |
| `--jq` | | jq 表达式过滤输出 (如: `.items[] \| .name`) | 无 |
| `--fields` | | 筛选输出字段 (逗号分隔, 如: name,id,status) | 无 |
| `--verbose` | `-v` | 详细日志 | false |
| `--debug` | | 调试日志 | false |
| `--yes` | `-y` | 跳过确认提示 | false |
| `--dry-run` | | 预览操作不执行 | false |
| `--timeout` | | HTTP 超时 (秒) | 30 |
| `--mock` | | Mock 数据 (开发用) | false |
| `--client-id` | | 覆盖 OAuth Client ID | 无 |
| `--client-secret` | | 覆盖 OAuth Client Secret | 无 |
## 输出格式
### --format json (机器可读, 默认)
```json
{"success": true, "body": {...}}
```
### --format table (人类可读)
```
已创建 AI 表格 "项目管理" (UUID: abc123)
下一步:
dws aitable base get --base-id abc123
```
## 环境变量
| 变量 | 说明 |
|------|------|
| `DWS_CONFIG_DIR` | 覆盖默认配置目录 |
| `DWS_SERVERS_URL` | 自定义服务发现端点 |
| `DWS_CLIENT_ID` | 覆盖 OAuth Client ID (DingTalk AppKey) |
| `DWS_CLIENT_SECRET` | 覆盖 OAuth Client Secret (DingTalk AppSecret) |
凭证优先级: `--token` > `DWS_CLIENT_ID`/`DWS_CLIENT_SECRET` > OAuth 加密存储 (.data)
# 意图路由指南
当用户请求难以判断归属哪个产品时,参考本指南。
## 易混淆场景快速对照表
| 用户说... | 真实意图 | 应该用 | 不要用 | 理由 |
|-----------|----------|--------|--------|------|
| "搜一下 OAuth2 接入文档" | 搜索开发文档 | `devdoc` | — | 搜索开放平台技术文档,不是钉钉内部内容 |
| "查询开放平台 API 错误码/字段说明" | 搜索开放平台接口文档 | `devdoc` | — | 查 API 用法和错误码 |
| "查工作台应用 appXYZ 的详情" | 查询钉钉工作台应用 | `workbench app` | — | `appXYZ/app001` 是工作台 app id |
| "帮我创建一个应用" | 应用类型不明确 | 先追问 | — | 缺少工作台/AI/MCP 等上下文,不能默认 |
| "做一个 AI 应用" | AI 应用/智能体需求不明确 | 先追问或转对应能力 | — | 需求不明确时不默认走任何创建命令 |
| "创建 MCP 服务并配置 HSF tool" | MCP 平台工具配置 | OpenDev MCP 平台流程 | — | 这是 connector/tool 配置 |
| "帮我建一个项目跟踪表" | 创建数据表格 | `aitable` | `todo` | 涉及结构化数据/行列操作,不是个人待办 |
| "帮我记一下明天要做的事" | 创建个人待办 | `todo` | `aitable` | 个人待办提醒,非数据表 |
| "帮我建一个明天下午的日程" | 日历日程 | `calendar` | — | 日历日程管理(可含参与者/会议室)|
| "帮我看看收到的日报" | 日志收件箱 | `report` | `todo` | 钉钉日志系统(日报/周报),不是待办 |
| "帮我创建一个待办提醒" | 个人待办 | `todo` | `report` | 个人任务提醒,不是日志汇报 |
| "帮我提交请假审批" | 发起审批 | `oa` | — | 审批流程,不是待办或日志 |
| "帮我建一个项目群" | 创建群聊 | `chat group create` | — | 群聊管理,不是日历日程 |
| "把张三拉进群" | 添加群成员 | `chat group members add` | — | 先查 userId,再添加 |
| "让机器人在群里发个通知" | 机器人群发 | `chat message send-by-bot` | `chat message send-by-webhook` | 企业内部机器人发消息,需 robotCode |
| "通过 Webhook 发告警到群里" | Webhook 告警 | `chat message send-by-webhook` | `chat message send-by-bot` | 自定义机器人 Webhook,需 token |
| "给张三发一条机器人单聊消息" | 机器人单聊 | `chat message send-by-bot --users` | — | 机器人批量单聊,先查 userId |
---
## 典型场景详解
### 1. aitable vs todo — 表格数据 vs 待办任务
**用 `aitable` 的场景**:
- "创建一个表格记录团队成员信息" — 结构化数据,有行列
- "在表格里加一列'状态'字段" — 字段/列操作
- "查一下表格里所有优先级为高的记录" — 数据筛选和查询
- "用项目管理模板建一个表" — 模板创建
- 用户提到"多维表"、"Base"、"数据表"、"记录"
**用 `todo` 的场景**:
- "帮我记一下这周要做的事" — 个人任务管理
- "创建一个待办提醒" — 任务提醒
**判断关键**:有没有行列/字段/记录概念?有→ `aitable`;个人任务清单 → `todo`
---
### 2. devdoc vs workbench — 应用泛词消歧
`应用` 是泛词,不能直接等价为某个产品。
**用 `devdoc` 的场景**:
- "开放平台 API 怎么调用" — 接口文档
- "错误码/字段说明/接口参数" — 文档检索
**用 `workbench app` 的场景**:
- "工作台应用/钉钉工作台上的应用"
- "应用 app001/appXYZ 的详情"
**先追问的场景**:
- "创建应用/做个应用/查应用" 但没有工作台、文档、AI、MCP 等上下文
- "AI 应用/智能体" 但没有说明是哪种产品能力
- "MCP 服务/connector/HSF tool" 属于平台配置流程,不要转成任何创建命令
**判断关键**:是否在查 API 文档?是 → `devdoc`。是否在查钉钉工作台入口应用?是 → `workbench app`。仍不明确 → 追问,不调用写命令。
---
### 3. devdoc — 开发文档搜索
**用 `devdoc` 的场景**:
- "API 调用报错 403 怎么解决" — 走 `devdoc error diagnose`
- "requestId 15r6h45w0muec 为什么失败" — 走 `devdoc error diagnose --request-id ...`
- "搜一下 OAuth2 接入文档" — 开放平台技术文档
- "CLI 命令出错了怎么办" — CLI 使用错误
- 用户提到"开发"、"API"、"接口文档" → `devdoc article search`
- 用户提到"调用错误"、"错误码"、"requestId"、"traceId" → `devdoc error diagnose`
---
### 4. report vs todo — 日志 vs 待办
**用 `report` 的场景**:
- "帮我看看收到的日报" — 日志收件箱
- "帮我写/提交今天的日报(钉钉日志模版)" — 先 `report template list` / `template get`,再 `report entry submit --contents-file <tmp>.json`
- "有什么日志模版" — 查看模版
- "看看这个日志的已读统计" — 阅读状态
- "我发过的日志有哪些" — 已发送列表 (`report outbox list`)
- 用户提到"日报"、"周报"、"日志"
**用 `todo` 的场景**:
- "记一下这周要做的事" — 个人任务管理
**判断关键**:钉钉日志系统(日报/周报模版,含按模版创建汇报)→ `report`;任务清单→ `todo`
---
### 5. chat 内部 — 两种消息发送方式
**用 `chat message send-by-bot` 的场景**:
- "让机器人在群里发一条通知" — **机器人身份**发群消息
- "给张三发一条机器人单聊消息" — 机器人批量单聊
**用 `chat message send-by-webhook` 的场景**:
- "通过 Webhook 发告警到群里" — 自定义机器人 Webhook
- 用户有 Webhook Token
**判断关键**:企业内部机器人→ `send-by-bot`(需 robotCode);有 Webhook Token→ `send-by-webhook`
---
## 跨产品工作流路由
以下场景需要多个产品配合完成,注意上下文传递顺序。
### 创建日程并邀请同事(contact → calendar)
用户说"约张三明天下午开会":
```bash
# 1. 搜索同事 userId
dws contact user search --query "张三" --format json
# 2. 创建日程
dws calendar event create --title "会议" \
--start "2026-03-15T14:00:00+08:00" --end "2026-03-15T15:00:00+08:00" --format json
# 3. 添加参与者
dws calendar participant add --event <EVENT_ID> --users <USER_ID> --format json
```
### 创建待办并指派(contact → todo)
用户说"给张三建个待办":
```bash
# 1. 搜索同事 userId
dws contact user search --query "张三" --format json
# 2. 创建待办
dws todo task create --title "任务内容" --executors <USER_ID> --format json
```
# aisearch - AI 搜问
> `aisearch` 模块当前有三个规范子命令:`person`(搜人)、`enterprise`(搜企业内部知识内容)和 `behavior`(搜企业内部行为记录)。
>
> **搜人容错说明**(无需主动使用):CLI 兼容下列 alias 兜底,模型偶尔写 `search` / `find` / `query` / `contact` / `people` 等也能跑通——但**搜人输出和文档以 `person` 为准**。
>
> 搜人的 keyword flag 同样兼容 `--name` / `--q` / `--query` / `--text` 兜底,规范用法是 `--keyword`。
## 企业人员搜索
通过关键词搜索企业内人员信息,支持按维度筛选。
```
Usage:
dws aisearch person [flags]
Example:
dws aisearch person --keyword "张三" --dimension name --format json
dws aisearch person --keyword "产品部" --dimension department --format json
dws aisearch person --keyword "五道" --dimension supervisor --format json
dws aisearch person --keyword "AI搜问" --dimension duty --format json
dws aisearch person --keyword "李四" --dimension name,department --format json
dws aisearch person --keyword "13800138000" --dimension phone --format json
dws aisearch person --keyword "W12345" --dimension jobNumber --format json
Flags:
--keyword string 搜索关键词 (必填,如人名、技能关键词等)
--dimension string 查询维度,多个用逗号分隔 (默认 "all")
```
### dimension 可选值
| 值 | 含义 | 触发词 |
|----|------|--------|
| `all` | 全部维度(默认) | — |
| `name` | 姓名 | "叫什么"、"是谁" |
| `department` | 部门 | "部门"、"团队"、"哪个部门" |
| `position` | 职位 | "职位"、"岗位"、"职级" |
| `duty` | 职责/技能 | "负责什么"、"职责"、"技能"、"负责人" |
| `supervisor` | 上级 | "上级"、"领导"、"主管" |
| `subordinate` | 下级 | "下级"、"下属"、"团队成员" |
| `phone` | 手机号 | "手机号是多少"、"电话"、"联系方式" |
| `jobNumber` | 工号 | "工号"、"工号是多少"、"员工编号" |
### keyword 提取规则
仅填入实际的搜索目标(人名、技能关键词等),不包含查询维度词。维度词必须映射到 `--dimension`:
| 用户说 | keyword | dimension |
|--------|---------|-----------|
| "五道的上级是谁" | 五道 | supervisor |
| "张三负责什么" | 张三 | duty |
| "AI搜问的负责人是谁" | AI搜问 | duty |
| "产品部有谁" | 产品部 | department |
| "李四是哪个部门的" | 李四 | department |
| "13800138000是谁" | 13800138000 | phone |
| "工号W12345是谁" | W12345 | jobNumber |
---
## 意图判断
- 用户说"搜人/找人/谁负责/上级是谁/哪个部门的人" → `aisearch person`
- 用户说"搜资料/找方案/查文档/搜企业知识/项目相关内容/工作总结/周报总结" → `aisearch enterprise`
- 用户说"最近/本周/今天 + XX相关消息/文档/邮件有哪些" → `aisearch enterprise`,时间词进 `--time-range`,类型词进 `--types`
- 用户说"我发过/谁发给我/创建过/分享过/收到过/今天我干了什么" → `aisearch behavior`
- 用户说"搜同事/查部门/查通讯录" → `contact`(通讯录)
**关键区分**:`aisearch person`(AI 语义搜人,支持职责/手机号/上级/下级等维度)vs `aisearch enterprise`(按内容找企业内部知识)vs `aisearch behavior`(按动作找发送/创建/分享/编辑/接收记录)vs `contact`(通讯录精确查询:userId/部门成员列表)
### 高优先级抽取规则
- `enterprise` 抽槽顺序固定为:先抽时间词到 `--time-range`,再抽类型词到 `--types`,最后把剩余主题词放进 `--queries`。
- 所有类型词都必须从 `queries` 中剥离,不能写进 `--query/--queries`。例如“最近 OKR 相关邮件”中,`queries=OKR`、`types=mail`、`time-range=最近`。
- 错误示例:不要生成 `--query "搜索问题"`、`--query "OKR 邮件"`、`--query "AI 搜问 日程"`、`--query "项目 待办"` 这类丢失或混入类型词的命令。
- 也不要把完整自然语言原句塞进 `--query`;`enterprise` 不做自然语言解析,必须显式拆出 `--queries`、`--types`、`--time-range`。
- “相关消息/相关文档/相关邮件有哪些”默认是按内容找企业知识,走 `aisearch enterprise`;只有出现“我发过/某人发给我/我收到/我创建/我分享/我编辑”等行为动作时,才走 `aisearch behavior`。
- `queries` 只放主题词,不放“最近/本周/消息/文档/邮件/日程/待办/纪要/图片/链接/有哪些/相关”等时间、类型、语气词。
- 只要用户显式说“最近/本周/今天/昨天/本月/过去一周/Q3”等时间词,就必须填写 `--time-range`。
- 只要用户显式说出任一类型词,就必须填写对应 `--types`;多个类型同时出现时用逗号分隔,如 `--types im,mail`。
### enterprise 类型词映射
| 用户类型词 | types |
|------------|-------|
| 全部、所有、工作总结、日报总结、周报总结、月报总结 | `all` |
| 文档、资料、方案、模板 | `document` |
| 消息、聊天记录、群消息、群里说了什么 | `im` |
| 邮件、邮箱、mail、email | `mail` |
| 日程、会议邀请、会议安排 | `calendar` |
| 待办、任务、TODO | `todo` |
| 会议纪要、纪要、听记、闪记、录音摘要 | `minute` |
| 日志 | `report` |
| 图片、截图 | `image` |
| 链接、URL、网址 | `link` |
| AI 表格、多维表、notable | `notable` |
| 企业百科、百科 | `baike` |
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `aisearch person` | `userId`(用户ID)、`title`(姓名) | 展示搜索结果、后续操作(发消息/建待办等) |
## 重名消歧
> **CAUTION:** 多人同名时禁止默认选第一个 — 须追加 `contact user get --ids userId1,userId2,...` 获取部门/职位后请用户确认。详见 [08-directory.md](../best_practices/08-directory.md)「多命中」。
---
## 企业内部知识搜索
用于检索企业内部知识内容,例如文档、消息、日程、待办、听记、日志、图片、链接、AI 表格、企业百科、邮件等。它关注内容本身,适合查找某个主题的资料、搜索包含特定关键词的内容、了解项目/产品相关信息、准备汇报材料等场景。
```
Usage:
dws aisearch enterprise [flags]
Example:
dws aisearch enterprise --queries "智能化方案" --types document --format json
dws aisearch enterprise --queries "搜索问题" --types im --time-range "最近" --format json
dws aisearch enterprise --queries "搜问" --types im --time-range "最近" --format json
dws aisearch enterprise --queries "OKR" --types mail --time-range "最近" --format json
dws aisearch enterprise --queries "AI搜问" --types calendar --time-range "本周" --format json
dws aisearch enterprise --queries "项目" --types todo,minute --time-range "最近" --format json
dws aisearch enterprise --queries "发版" --types im --time-range "本周" --format json
dws aisearch enterprise --types all --time-range "本周" --format json
dws aisearch enterprise --queries "OKR" --types document,im,mail --format json
Flags:
--queries string 内容关键词,多个用逗号分隔;汇总类场景可留空
--types string 搜索类型,多个用逗号分隔 (默认 "all")
--time-range string 时间范围,仅当用户显式给出时间词时填写
```
### enterprise types 可选值
| 值 | 含义 | 触发词 |
|----|------|--------|
| `all` | 全部类型(默认) | 全部、所有、工作总结、日报总结、周报总结、月报总结 |
| `document` | 文档 | 文档、资料、方案、模板 |
| `im` | 消息 | 消息、聊天记录、群消息、群里发了什么 |
| `calendar` | 日程 | 日程、会议邀请、会议安排 |
| `todo` | 待办 | 待办、任务、TODO |
| `minute` | 会议纪要/闪记/听记 | 会议纪要、纪要、听记、闪记、录音摘要 |
| `report` | 日志 | 仅显式出现“日志”时使用 |
| `image` | 图片 | 图片、截图 |
| `link` | 链接 | 链接、URL、网址 |
| `notable` | 多维表 / AI 表格 | AI表格、多维表、notable |
| `baike` | 企业百科 | 企业百科、百科 |
| `mail` | 邮件 | 邮件、邮箱、mail、email |
### enterprise 参数提取规则
- `queries` 只放内容关键词,不放时间、类型词。比如“本周的 OKR 文档”中,`queries=OKR`;“最近 OKR 相关邮件”中,`queries=OKR`,不要写成 `queries=OKR 邮件`。
- 时间信息放到 `--time-range`,仅当用户显式给出“今天/本周/最近/9月/Q3/过去一周”等时间词时填写。
- 类型词放到 `--types`,所有类型词都不能留在 `--query/--queries`;多类型用逗号分隔。文档/资料/方案类型使用底层枚举 `document`(注意不是 `doc`)。`report` 仅在用户显式说“日志”时触发;“周报/日报/月报/工作汇报”不要自动映射为 `report`。
- `mail` 仅在用户显式说“邮件/邮箱/mail/email”时触发;一旦触发,必须进入 `--types mail`,不能留在 `--query/--queries`。
- “工作总结/日报总结/周报总结/月报总结”这类汇总场景,用 `--types all`,`--queries` 可留空。
| 用户说 | queries | types | time-range |
|--------|---------|-------|------------|
| “智能化方案相关文档” | 智能化方案 | document | 空 |
| “最近搜索问题相关的消息都有哪些” | 搜索问题 | im | 最近 |
| “最近搜问相关的消息都有哪些” | 搜问 | im | 最近 |
| “最近 OKR 相关邮件” | OKR | mail | 最近 |
| “本周 AI 搜问相关日程” | AI 搜问 | calendar | 本周 |
| “最近项目相关待办和纪要” | 项目 | todo,minute | 最近 |
| “本周的 OKR 文档” | OKR | document | 本周 |
| “最近发版相关消息” | 发版 | im | 最近 |
| “AI 搜问相关图片和链接” | AI 搜问 | image,link | 空 |
| “OKR 相关 AI 表格和百科” | OKR | notable,baike | 空 |
| “2025-12-06 到 2025-12-19 工作总结” | 空 | all | 2025-12-06 到 2025-12-19 |
| “本周的日志” | 空 | report | 本周 |
| “我收到的邮件” | 空 | mail | 空 |
---
## 企业内部行为记录搜索
用于检索“谁对什么做了什么”的企业内部行为记录,例如发过、创建过、分享过、编辑过、收到过的文档、消息、日程、待办、听记、日志、图片、链接、AI 表格、企业百科、邮件等。它关注行为流向和动作,不是按内容本身找知识。
```
Usage:
dws aisearch behavior [flags]
Example:
dws aisearch behavior --queries "智能化方案" --types document --format json
dws aisearch behavior --types mail --behavior-type send --direction "我->汐峰" --format json
dws aisearch behavior --types all --behavior-type create --time-range "本周" --format json
dws aisearch behavior --types im --chat-scope "scrum群" --behavior-type send --time-range "今天" --format json
Flags:
--queries string 内容关键词,多个用逗号分隔;汇总类场景可留空
--types string 搜索类型,多个用逗号分隔 (默认 "all")
--chat-scope string 消息所在会话/群范围,仅 IM 类型且用户明确指定群名时填写
--behavior-type string 行为类型 (默认 "all")
--time-range string 时间范围,仅当用户显式给出时间词时填写
--direction string 交互方向,如 "我->汐峰"、"汐峰->我"、"我<->汐峰"
```
### behavior types 可选值
| 值 | 含义 | 触发词 |
|----|------|--------|
| `all` | 全部类型(默认) | 今天我干了什么、我最近做过什么 |
| `document` | 文档 | 文档、资料、方案、模板 |
| `im` | 消息 | 消息、聊天记录、群消息、群里发了什么 |
| `calendar` | 日程 | 日程、会议邀请、会议安排 |
| `todo` | 待办 | 待办、任务、TODO |
| `minute` | 会议纪要/闪记/听记 | 会议纪要、纪要、听记、闪记、录音摘要 |
| `report` | 日志 | 仅显式出现“日志”时使用 |
| `image` | 图片 | 图片、截图 |
| `link` | 链接 | 链接、URL、网址 |
| `notable` | 多维表 / AI 表格 | AI表格、多维表、notable |
| `baike` | 企业百科 | 企业百科、百科 |
| `mail` | 邮件 | 邮件、邮箱、mail、email |
### behavior-type 可选值
| 值 | 含义 | 示例 |
|----|------|------|
| `all` | 全部行为(默认) | “智能化方案相关内容” |
| `send` | 发送 | “我发给汐峰的消息/邮件” |
| `create` | 创建 | “我创建过哪些文档” |
| `share` | 分享 | “我分享过的资料” |
| `edit` | 编辑 | “我编辑过的文档” |
| `receive` | 接收 | “汐峰发给我的文档”、“我收到的邮件” |
### 参数提取规则
- `queries` 只放内容关键词,不放时间、类型词、行为词。比如“本周我创建的智能化方案文档”中,`queries=智能化方案`。
- `types` 放内容类型,映射规则与 enterprise 相同;所有类型词都不能留在 `--query/--queries`,多类型用逗号分隔。
- 文档/资料/方案类型使用底层枚举 `document`(注意不是 `doc`)。`report` 仅在用户显式说“日志”时触发;“周报/日报/月报/工作汇报”不要自动映射为 `report`。
- `mail` 仅在用户显式说“邮件/邮箱/mail/email”时触发;一旦触发,必须进入 `--types mail`。
- `time-range` 仅当用户显式给出时间词时填写,不要根据语义猜时间。
- `direction` 仅当用户明确指定交互对象时填写,格式为 `发起者->接收者` 或 `我<->某人`;无具体对象时留空。
- “今天我干了什么/我最近做过什么”这类行为汇总场景,用 `--types all`,`--queries` 可留空。
| 用户说 | queries | types | behavior-type | time-range | direction | chat-scope |
|--------|---------|-------|---------------|------------|-----------|------------|
| “我发给汐峰的邮件” | 空 | mail | send | 空 | 我->汐峰 | 空 |
| “我发给汐峰的消息和邮件” | 空 | im,mail | send | 空 | 我->汐峰 | 空 |
| “汐峰发给我的文档” | 空 | document | receive | 空 | 汐峰->我 | 空 |
| “我创建过哪些文档” | 空 | document | create | 空 | 空 | 空 |
| “本周我创建的智能化方案文档” | 智能化方案 | document | create | 本周 | 空 | 空 |
| “我分享过的项目链接和图片” | 项目 | link,image | share | 空 | 空 | 空 |
| “我在 scrum 群里发了什么” | 空 | im | send | 空 | 空 | scrum群 |
| “帮我总结今天干了什么” | 空 | all | all | 今天 | 空 | 空 |
## 行为搜索 vs 知识搜索
- `aisearch behavior`:用户问“我/某人做过什么动作”,有发送、创建、分享、编辑、接收、收到、发给等行为词。
- `aisearch enterprise`:用户问“是什么/怎么做/在哪里/模板/方案/总结”,目标是内容本身或跨类型知识内容汇总。
# 记录操作详细指南
## 查询记录
```bash
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --format json
```
返回:
```json
{
"data": {
"records": [
{"recordId": "rec001", "cells": {"fldABC": "完成设计", "fldDEF": {"id":"opt1","name":"进行中"}}},
{"recordId": "rec002", "cells": {"fldABC": "编写文档", "fldDEF": {"id":"opt2","name":"待开始"}}}
]
}
}
```
按条件查询 (`--filters` 结构极易出错,请**强制**套用以下模板)
```bash
# 最外层必须是 "and" 或 "or",单选字段传文本名称。
# 示例:基础条件查询模板(可改内部 operator 为 contain 等)
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> \
--filters '{"operator":"and","operands":[{"operator":"eq","operands":["fld_state","进行中"]}]}' \
--format json
# 关键词搜索
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --keyword "设计" --format json
# 按 ID 查询
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --record-ids rec001,rec002 --format json
# 游标分页
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --limit 50 --cursor <CURSOR> --format json
```
## 添加记录
**必须先执行 `table get` 获取 fieldId,再写入。cells 的 key 必须是 fieldId(如 fldXXX),不是字段名。**
```bash
# 单条
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldABC":"完成设计","fldDEF":"进行中"}}]' \
--format json
# 多条
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[
{"cells":{"fldABC":"任务A","fldDEF":"待开始"}},
{"cells":{"fldABC":"任务B","fldDEF":"进行中"}}
]' --format json
```
返回:
```json
{"data": {"newRecordIds": ["rec-new-001", "rec-new-002"]}}
```
### --records 格式常见错误
```bash
# 正确: 参数名是 --records,cells key 是 fieldId
--records '[{"cells":{"fldABC":"值"}}]'
# 错误: 参数名写成 --data
--data '[{"cells":{"fldABC":"值"}}]'
# 错误: cells key 用了字段名而非 fieldId
--records '[{"cells":{"任务名称":"值"}}]'
# 错误: 用 fields 而非 cells
--records '[{"fields":{"fldABC":"值"}}]'
```
## 更新记录
`--records` 中每条记录必须包含 `recordId`(从 `record query` 获取):
```bash
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"recordId":"rec001","cells":{"fldDEF":"已完成"}}]' \
--format json
```
只需传入需修改的字段,未传入的保持原值。
## 删除记录
```bash
dws aitable record delete --base-id <BASE_ID> --table-id <TABLE_ID> \
--record-ids rec001,rec002 --yes --format json
```
不可逆操作。调用前建议先 `record query` 确认目标记录。
## 附件上传
> **不要使用钉盘 (drive) 上传!** 钉盘 fileId 无法写入 attachment 字段。
使用 `upload_attachment.py` 脚本(内部自动完成 prepare + PUT to OSS),**2 步**完成:
```bash
# 步骤 1: 一键上传文件
python3 scripts/upload_attachment.py <BASE_ID> /path/to/report.pdf
# 输出: { "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 }
# 步骤 2: 在 record create/update 中使用 fileToken 写入附件字段
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json
```
> attachment 字段值必须是数组 `[{"fileToken":"ft_xxx"}]`,支持多个附件。
## 字段类型写入规则
| 类型 | 写入格式 | 读取返回格式 |
|------|----------|-------------|
| text | `"fldXXX":"文本值"` | `"fldXXX":"文本值"` |
| number | `"fldXXX":123` | `"fldXXX":"123"` |
| singleSelect | `"fldXXX":"选项名"` | `"fldXXX":{"id":"xxx","name":"选项名"}` |
| multipleSelect | `"fldXXX":["选项1","选项2"]` | `"fldXXX":[{"id":"xxx","name":"选项1"}]` |
| date | `"fldXXX":"2026-03-04"` | ISO 日期字符串 |
| user | `"fldXXX":[{"userId":"123"}]` | `"fldXXX":[{"corpId":"x","userId":"123"}]` |
| attachment | `"fldXXX":[{"fileToken":"ft_xxx"}]`需先用脚本上传 | `"fldXXX":[{"url":"...","filename":"..."}]` |
### 只读字段(不要写入)
- 创建时间、修改时间、创建人、修改人
- 公式字段、引用字段
- 自动编号字段
执行 `table get` 后识别字段类型,跳过只读字段。
# AI表格 (aitable) 命令参考
> **渐进式文档**:本文件为路由层(索引 + 意图判断),各命令的详细参数、示例和踩坑说明在 [aitable/](./aitable/) 目录下按需加载。
## 文档地址 (URI)
| 资源 | URI 格式 |
|------|----------|
| Base 文档 | `https://alidocs.dingtalk.com/i/nodes/{baseId}` |
| 指定数据表 | `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}` |
| 指定数据表+视图 | `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}` |
| 模板预览 | `https://docs.dingtalk.com/table/template/{templateId}` |
> **操作后请返回文档 URI**:返回链接时必须带上当前操作的数据表 tableId,让用户点击后直接看到目标数据表,而不是落在空白的默认表。
> - 已知 tableId + viewId 时(view create 返回、view get 中提取):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}%26viewId%3D{viewId}`
> - 已知 tableId 时(table create 返回、base get 中提取、record 操作所用的 tableId):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}?iframeQuery=sheetId%3D{tableId}`
> - 仅有 baseId、无明确 tableId 时(如 base list/search):拼接 `https://alidocs.dingtalk.com/i/nodes/{baseId}`
>
> 补充:如果 URL 不是来自 `aitable` 命令返回,而是用户直接贴的原始 `alidocs` URL,先按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) probe,确认是 `able` 后再按 AI 表格处理。
## 命令索引表
### base (Base 管理)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `base list` | 列出最近访问的 Base | — | 仅返回最近访问过的,优先用 `base search` |
| `base search` | 按名称搜索 Base | `--query` | 关键词 ≥2 字符 |
| `base get` | 获取 Base 信息(含 tables 列表) | `--base-id` | 用户给 URL 时提取末尾 ID |
| `base create` | 创建 Base | `--name` | 创建后直接用返回的 baseId;**默认新建的 base 自带一个空白「数据表」(含 3 行空记录)和一个空白仪表盘**,如需干净的空 base,传 `--template-id 1743` |
| `base update` | 更新 Base 名称 | `--base-id` `--name` | — |
| `base delete` | 删除 Base | `--base-id` | 不可逆 |
### table (数据表管理)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `table get` | 获取表结构(字段+视图目录) | `--base-id` | 不传 `--table-ids` 返回全部表 |
| `table create` | 创建数据表 | `--base-id` `--name` `--fields` | fields 为 JSON 数组,至少 1 个 |
| `table update` | 修改表名 / 备注 / 行命名规则 | `--base-id` `--table-id` + 三选一(`--name` / `--description` / `--record-name-key`) | `--record-name-key` 是固定枚举(如 task/project/event/customer/ji_lu 等),非字段 ID |
| `table delete` | 删除表 | `--base-id` `--table-id` | 不可逆 |
### field (字段管理) → 详见 [aitable-field.md](./aitable/aitable-field.md)、[field-properties](./aitable/aitable-field-properties.md)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `field get` | 获取字段完整配置 | `--base-id` `--table-id` | 按需展开少量字段 |
| `field create` | 创建字段 | `--base-id` `--table-id` + (`--name --type` 或 `--fields`) | 支持单字段/批量模式 |
| `field update` | 更新字段名/配置 | `--base-id` `--table-id` `--field-id` | 不可变更字段类型 |
| `field delete` | 删除字段 | `--base-id` `--table-id` `--field-id` | 不可逆 |
#### 搜索字段选项
```
Usage:
dws aitable field search-options [flags]
Example:
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID>
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --keyword 已完成
dws aitable field search-options --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --limit 100
Flags:
--base-id string Base ID (必填)
--field-id string 目标字段 ID,必须是 singleSelect / multipleSelect 类型 (必填)
--keyword string 模糊搜索关键词,大小写不敏感、contains 匹配 option name;不传返回全部
--limit int 返回的最大 option 数量,默认 3000(全量),最大 3000
--table-id string Table ID (必填)
```
仅适用于 **singleSelect / multipleSelect** 字段。其他类型(text/number/date/...)调用会返回错误。
适用场景:
- options 较多,只想要含某关键词的子集(避免 `field get` 拉取整个字段配置带回所有 options)。
- 写入 record 前预览选项 id ↔ name 的映射,确认要使用的选项确实存在。
> **写 record 时**:`record create / update` 对 singleSelect/multipleSelect 可直接传 option **name**,不需要用本命令。本命令主要用于 **filter** 写法(filters 优先用 option **id**)或选项较多需要精确定位时。
### record (记录管理)
| 命令 | 用途 | 必读 reference | 路由提醒 |
|------|------|----------------|----------|
| `record query` | 查询/搜索记录 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 先 `table get` 拿 fieldId;`--all` 自动翻页;filters 结构见 reference |
| `record get` | 按 ID 取记录(`record query --record-ids` 的窄别名) | [aitable-record-query.md](./aitable/aitable-record-query.md) | 已知 recordId 时首选;必填 `--record-ids`(单次最多 100 条);未暴露 filters/sort/query/cursor/limit |
| `record create` | 新增记录 | [aitable-record-create.md](./aitable/aitable-record-create.md) | cells key 必须是 fieldId 不是字段名;单次最多 100 条 |
| `record update` | 更新记录(每条独立 cells) | [aitable-record-update.md](./aitable/aitable-record-update.md) | 需先 query 拿 recordId;只传需改字段;`--records` 是 `[{recordId,cells},...]` 数组 |
| `record batch-update` | 批量更新(同一 cells 应用到多条 recordId) | [aitable-record-update.md](./aitable/aitable-record-update.md)、[aitable-cell-value.md](./aitable/aitable-cell-value.md) | 适合"统一标记完成/统一改负责人"等共享 patch 场景;`--cells` 是 JSON object(key=fieldId,value 按字段类型见 cell-value.md),与 record update 的单条 cells 结构完全一致;必填 `--record-ids` `--cells`;单次最多 100 条 |
| `record delete` | 删除记录 | [aitable-record-delete.md](./aitable/aitable-record-delete.md) | 不可逆,需先 query 确认 |
| `record history-list` | 查询单条记录的变更历史 | [aitable-record-history.md](./aitable/aitable-record-history.md) | 必填 `--record-id`;分页 `--offset --limit`,limit 范围 [1,50] 默认 20 |
| `record query-empty` | 查询完全没填用户字段的空行 | [aitable-record-query.md](./aitable/aitable-record-query.md) | 一页扫描 `--limit` [1,100] 默认 100;扫完前需用 `--cursor` 翻页(nextCursor 为空才表扫完) |
| `record share-url` | 批量获取记录分享链接 | [aitable-record-share.md](./aitable/aitable-record-share.md) | 必填 `--record-ids`(CSV,单次最多 20 条);可选 `--view-id` 带视图上下文 |
| `record upsert` | 批量创建或更新(按 recordId 是否存在自动拆分) | [aitable-record-upsert.md](./aitable/aitable-record-upsert.md) | --records 同 record update 格式;带 recordId 走 update,不带走 create;单次最多 100 |
| `record primary-doc-get` | 查询记录的主键文档 nodeId | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | 返回的 nodeId 可直接用于 `dws doc read/update --node` |
| `record primary-doc-create` | 为记录创建主键文档(幂等) | [aitable-primary-doc.md](./aitable/aitable-primary-doc.md) | fieldId 必须是 primaryDoc 类型;已存在则返回已有 nodeId |
### view (视图管理)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `view get` | 获取视图配置(不传子命令) | `--base-id` `--table-id` | 不传 `--view-ids` 返回全部视图 |
| `view get <attr>` | 获取视图某个属性 | `--view-id` | 12 个:card/timebar/aggregate/filter/sort/group/visible-fields/field-widths(详见 [aitable-view-config.md](./aitable/aitable-view-config.md))+ lock/frozen-cols/row-height/fill-color-rule(详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)) |
| `view list` | 列出全部视图(`view get` 的别名) | `--base-id` `--table-id` | 与 `view get` 完全等价 |
| `view create` | 创建视图 | `--base-id` `--table-id` `--view-type` | 类型: Grid/Kanban/Gantt/Calendar/Gallery/FormDesigner;**Gantt 创建后必须 `view update timebar` 绑定日期字段** |
| `view update` | 整体更新视图 / 多属性合并更新 | `--base-id` `--table-id` `--view-id` | 可传 `--name --desc --config '{...}'`,**`--config` 路径继续保留** |
| `view update <attr>` | 按属性局部更新(推荐)| `--view-id` + typed flag / `--json` | 12 个:card/timebar/aggregate/field-widths/visible-fields/filter/sort/group/name + frozen-cols/row-height/fill-color-rule |
| `view lock [--off]` | 锁定/解锁视图 | `--base-id` `--table-id` `--view-id` | 默认锁定;`--off` 解锁。详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md) |
| `view duplicate` | 复制视图 | `--base-id` `--table-id` `--view-id` | 可选 `--new-name`;保留源视图全部配置。详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md) |
| `view delete` | 删除视图 | `--base-id` `--table-id` `--view-id` | 不可删最后一个/锁定视图 |
> **优先用 `view get <attr>` / `view update <attr>` 子命令**:每个属性独立命令,typed flag 友好,agent 不必拼 JSON。**`view update --config '{...}'` 仍可用**,适合一次性多属性更新或脚本场景。
> **属性按 attr 分类,决定该读哪份子文档**:
> - card / timebar / aggregate / filter / sort / group / visible-fields / field-widths → [aitable-view-config.md](./aitable/aitable-view-config.md)
> - lock / frozen-cols / row-height / fill-color-rule / duplicate → [aitable-view-extras.md](./aitable/aitable-view-extras.md)
> 后一类**不能**塞进 `view update --config '{...}'`,必须用各自专属子命令;如果错传 `flags` / `frozenColCount` / `cellHeight` / `conditionalFormats` 等 key 进 `--config`,CLI 会在 stderr 提示应改用的命令。
> **`view update --config` 支持的 9 个 key**:
> `visibleFieldIds` / `filter` / `sort` / `group` / `fieldWidths`(Grid) / `aggregate`(Grid) / `kanbanCard`(Kanban) / `ganttTimebar`(Gantt) / `galleryCard`(Gallery)。
> filter/sort/group 必须传**数组**格式(与 `record query --filters` 的对象格式不同;CLI 会自动容错)。其他 key 会被服务端忽略并打 warning。
### form (表单管理) → 详见 [aitable-form.md](./aitable/aitable-form.md)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 详情见 [aitable-form.md](./aitable/aitable-form.md) |
| `form get` | 按 viewId 取单个表单详情 | `--base-id` `--table-id` `--view-id` | — |
| `form create` | 创建表单视图 | `--base-id` `--table-id` `--name` | — |
| `form update` | 更新表单配置 | `--base-id` `--table-id` `--view-id` | title/name/description 至少一项 |
| `form delete` | 删除表单 | `--base-id` `--table-id` `--view-id` | 不可逆 |
| `form field list/update/hide` | 表单字段管理 | — | 详情见子文档 |
| `form questions create/delete` | 题目管理(=field create/delete) | — | 详情见子文档 |
| `form share get/update` | 表单分享配置 | — | 详情见子文档 |
> **创建表单**有两种等价方式:`form create --name "..."`(推荐)或 `view create --view-type FormDesigner --name "..."`。
### workflow (自动化工作流) → 详见 [aitable-workflow.md](./aitable/aitable-workflow.md)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `workflow list` | 列出 Base 下所有工作流 | `--base-id` | 支持 `--limit [1,100]` / `--offset >=0`;list 出参字段叫 `flowId` |
| `workflow get` | 获取单个工作流详情(含 flowSchema) | `--base-id` `--workflow-id` | `--workflow-id` 接受 list 里的 `flowId`(同值) |
| `workflow enable` | 启用工作流 | `--base-id` `--workflow-id` | 返回 `{enabled: true}` 是动作确认;要确认真启用看 list 的 `status` |
| `workflow disable` | 禁用工作流(高危) | `--base-id` `--workflow-id` `--yes` | 影响业务自动化,建议二次确认;status 变 STOP |
> **当前不支持通过 CLI 新建/修改/删除工作流**,请去 AI 表格 Web 端(数据表页面 → 自动化)配置。
### dashboard & chart → 详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
| 命令 | 用途 |
|------|------|
| `dashboard get/create/update/delete` | 仪表盘管理 |
| `dashboard config-example` | 查看仪表盘配置模板 |
| `dashboard arrange` | 自动重排仪表盘图表布局(智能填满网格,避免空缺) |
| `chart get/create/update/delete` | 图表管理 |
| `chart widgets-example` | 查看图表 widgets 配置模板 |
### export & import → 详见 [aitable-export-import.md](./aitable/aitable-export-import.md)
| 命令 | 用途 |
|------|------|
| `export data` | 导出数据(异步两阶段轮询) |
| `import upload` | 申请文件导入上传凭证 |
| `import data` | 触发导入 |
### attachment → 详见 [aitable-attachment.md](./aitable/aitable-attachment.md)
| 命令 | 用途 | 路由提醒 |
|------|------|----------|
| `attachment upload` | 准备附件上传凭证 | 不要用钉盘 drive 上传! |
### template (模板搜索)
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `template search` | 搜索模板 | `--query` |
### advperm (高级权限/自定义角色) → 详见 [aitable-advperm.md](./aitable/aitable-advperm.md)
| 命令 | 用途 | 必填参数 | 路由提醒 |
|------|------|----------|----------|
| `advperm enable` | 开启 Base 高级权限总开关 | `--base-id` | 不开启时角色规则不生效 |
| `advperm disable` | 关闭 Base 高级权限总开关(高危) | `--base-id` `--yes` | 关闭后全员回退默认权限 |
| `advperm role-list` | 列出 Base 下所有角色 | `--base-id` | 同时返回自定义角色和系统角色;`roleType == "custom"` 是自定义,前缀 `system_` 是系统角色 |
| `advperm role-get` | 获取单角色完整配置 | `--base-id` `--role-id` | 含 subRoles 与字段/行级规则 |
| `advperm role-create` | 创建自定义角色 | `--base-id` `--name` | 可选 `--sub-roles` 同时指定子角色权限规则 |
| `advperm role-update` | 增量更新自定义角色(PATCH) | `--base-id` `--role-id` | 未传字段不变;`--sub-roles` 按 (targetId,targetType) 合并 |
| `advperm role-delete` | 删除自定义角色 | `--base-id` `--role-id` `--yes` | 不可逆;系统角色禁删;**调用者必须是该 AI 表格的管理员/Owner**,非管理员会得到 401 AUTH_ERROR |
> **角色 CRUD 已全支持**:create/get/list/update/delete 都可走 CLI。
> 所有写命令(enable/disable/role-create/role-update/role-delete)需要 Base 管理员权限;非管理员只能调 `role-list` / `role-get`(只读)。
> "角色 ↔ 成员"绑定当前 CLI 不支持,仍需在 AI 表格 Web 端 → Base 设置 → 高级权限面板手动完成。
### section (文件夹与节点管理)
> 用于在 Base 的导航树中组织 table / dashboard / 表单视图 / 文档等节点(类似文件夹)。
> 操作前建议先用 `section list-nodes` 拿到 nodeId / sectionId 与父级关系。
#### 创建文件夹
```
Usage:
dws aitable section create [flags]
Example:
dws aitable section create --base-id <BASE_ID> --name 我的文件夹
dws aitable section create --base-id <BASE_ID> --name 子文件夹 --parent-section-id <SECTION_ID> --index 0
Flags:
--base-id string Base ID (必填)
--name string 文件夹名称 (必填)
--parent-section-id string 父文件夹 ID;不传或空字符串表示创建在 Base 根目录下
--index int 在父文件夹下的目标位置(0-based);不传则追加到末尾
```
返回 `data.sectionId` 与 `data.name`。
#### 重命名文件夹
```
Usage:
dws aitable section rename [flags]
Example:
dws aitable section rename --base-id <BASE_ID> --section-id <SECTION_ID> --new-name 新名称
Flags:
--base-id string Base ID (必填)
--section-id string 目标文件夹 ID (必填)
--new-name string 新的文件夹名称 (必填)
```
#### 删除文件夹
```
Usage:
dws aitable section delete [flags]
Example:
dws aitable section delete --base-id <BASE_ID> --section-id <SECTION_ID>
Flags:
--base-id string Base ID (必填)
--section-id string 目标文件夹 ID (必填)
```
> **注意**:删除不可逆;删除前可先用 `section list-empty` 确认是否为空文件夹。
#### 调整文件夹顺序
```
Usage:
dws aitable section reorder [flags]
Example:
dws aitable section reorder --base-id <BASE_ID> --section-id <SECTION_ID> --target-index 0
Flags:
--base-id string Base ID (必填)
--section-id string 目标文件夹 ID (必填)
--target-index int 目标位置(0-based)(必填)
```
> 在**当前父文件夹下**调整展示顺序。跨父级移动请用 `section move-node`。
#### 列出空文件夹
```
Usage:
dws aitable section list-empty [flags]
Example:
dws aitable section list-empty --base-id <BASE_ID>
Flags:
--base-id string Base ID (必填)
```
返回 `data.items: [{sectionId, name, parentSectionId}]` 与 `data.total`,用于清理或诊断导航树(parentSectionId 为空串表示在根目录下)。
#### 列出全部节点
```
Usage:
dws aitable section list-nodes [flags]
Example:
dws aitable section list-nodes --base-id <BASE_ID>
Flags:
--base-id string Base ID (必填)
```
返回 `data.items: [{nodeId, nodeType, parentSectionId, name?}]` 与 `data.total`,涵盖文件夹 / AI 表格 / 表单视图 / 仪表盘 / 文档 / 查询视图。
> **与其他命令的关联**:是 `section move-node` / `section reorder` 的前置定位命令——先用它拿到 nodeId 与 parentSectionId。
#### 移动节点
```
Usage:
dws aitable section move-node [flags]
Example:
dws aitable section move-node --base-id <BASE_ID> --node-id <NODE_ID> --new-parent-section-id <SECTION_ID>
dws aitable section move-node --base-id <BASE_ID> --node-id <NODE_ID> --new-parent-section-id "" --target-index 0
Flags:
--base-id string Base ID (必填)
--node-id string 要移动的节点 ID(文件夹/AI表格/表单视图/仪表盘/文档/查询视图)(必填)
--new-parent-section-id string 目标父文件夹 ID;空字符串表示移到 Base 根目录 (必填)
--target-index int Base 内节点的全局位置(0-based);不传则不调整
```
> 服务端自动识别节点类型,无需区分文件夹与非文件夹。返回 `data.nodeId / newParentSectionId / nodeType`。
> 对文件夹节点带 `--target-index` 时会先 move 再 reorder,中间失败会返回 `MOVE_OK_REORDER_FAILED`,可用 `section reorder` 重试。
## 复杂操作
### 仪表盘 / 图表(建议顺序)
```bash
# 1) 先看配置模板(JSONC)
dws aitable dashboard config-example --format json
dws aitable chart widgets-example --format json
# 2) 先拿 dashboard,再拿 chart 详情
dws aitable dashboard get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --format json
dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --format json
```
要点:
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用。
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错。
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断。
### 导出数据(两阶段轮询)
`export data` 常见为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
```bash
# 第一步:创建任务(按 scope 传必要参数)
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --export-format excel --timeout-ms 1000
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
```
参数约束
- `scope=all`:只需 `base-id`
- `scope=table`:必须 `table-id`
- `scope=view`:必须同时 `table-id + view-id`
## 意图判断
用户说"表格/多维表/AI表格":
- 查看/查找/列表 → `base search`(优先)或 `base list`(仅浏览最近访问)
- 详情 → `base get`
- 创建 → `base create`
- 修改 → `base update`
- 删除 → `base delete`
用户说"数据表/子表/table":
- 查看 → `table get`
- 创建 → `table create`
- 重命名 / 改备注 / 改行命名规则 → `table update`(三选一:`--name` / `--description` / `--record-name-key`)
- 用户说"行命名规则/记录别名/卡片显示成 task/project/event 这种" → `table update --record-name-key <枚举键>`,**中文 → 枚举键**对照见 [aitable-record-name-key.md](./aitable/aitable-record-name-key.md)
- 删除 → `table delete`
用户说"字段/列/column":
- 查看 → `field get`
- 添加 → `field create`(读 [aitable-field.md](./aitable/aitable-field.md))
- 修改 → `field update`
- 删除 → `field delete`
用户说"记录/行/数据/row":
- 查看/搜索 → `record query`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
- 找空行 / 没填东西的行 → `record query-empty`(读 [aitable-record-query.md](./aitable/aitable-record-query.md))
- 已知 recordId 反查字段值 → `record get`(按 ID 取专用,等价 `record query --record-ids`)
- 添加/写入 → `record create`(读 [aitable-record-create.md](./aitable/aitable-record-create.md))
- 修改/更新(每条独立 cells) → `record update`(读 [aitable-record-update.md](./aitable/aitable-record-update.md))
- **批量更新同一字段值**(统一标记/统一改值) → `record batch-update --record-ids ... --cells '{...}'`
- 删除 → `record delete`
- **查记录的字段变更历史 / 操作审计** → `record history-list`(读 [aitable-record-history.md](./aitable/aitable-record-history.md))
- **取记录分享链接 / 把这行发给同事** → `record share-url`(读 [aitable-record-share.md](./aitable/aitable-record-share.md))
- **不知道有没有 → 有就改、没有就建** → `record upsert`(读 [aitable-record-upsert.md](./aitable/aitable-record-upsert.md))
用户说"视图/view":
- 列出/查看全部视图 → `view list`(或 `view get` 不传 --view-ids,二者等价)
- 看某个视图详情 → `view get --view-ids <ID>`
- 创建 → `view create`
- 修改(含"调整字段顺序/隐藏字段") → `view update --config '{"visibleFieldIds":[...]}'`
- 修改某一项配置(filter/sort/group/card/timebar/aggregate 等)→ `view update <attr>`(读 [aitable-view-config.md](./aitable/aitable-view-config.md))
- 锁定 / 冻结列 / 行高 / 数据高亮规则 / 复制视图 → 读 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
- 删除 → `view delete`
用户说"锁定视图/解锁视图/lock view" → `view lock` / `view lock --off`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"冻结列/冻结首列/frozen columns" → `view update frozen-cols --count N`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"行高/单元格高度/紧凑模式/cell height" → `view update row-height --cell-height N`(合法档位 32/56/88/128),详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"数据高亮/条件格式/单元格上色/fill color rule" → `view update fill-color-rule --json '[...]'`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"复制视图/duplicate view" → `view duplicate --view-id ... [--new-name ...]`,详见 [aitable-view-extras.md](./aitable/aitable-view-extras.md)
用户说"筛选/过滤/filter" → 读 [aitable-filter-sort.md](./aitable/aitable-filter-sort.md)
用户说"统计/分析/聚合/TOP N/全量" → 读 [aitable-data-analysis-sop.md](./aitable/aitable-data-analysis-sop.md)
用户说"公式/formula/计算字段/派生指标" → 读 [aitable-formula-guide.md](./aitable/aitable-formula-guide.md)
用户说"查找引用/lookup/filterUp/跨表" → 读 [aitable-formula-guide.md](./aitable/aitable-formula-guide.md)(§5.4 跨表引用)
用户说"表单/form/收集表/问卷/催办填写" → 读 [aitable-form.md](./aitable/aitable-form.md)
用户说"自动化/工作流/流程/触发/automation/workflow" → 读 [aitable-workflow.md](./aitable/aitable-workflow.md)
- 看 Base 里有哪些流程 / 哪些在跑 → `workflow list`(看 `recordCount` / `runningCount`)
- 看某个流程具体配置(触发条件、动作步骤) → `workflow get`
- 启用流程 → `workflow enable`
- 临时停掉流程(调试 / 数据迁移)→ `workflow disable --yes`
- **新建 / 修改 / 删除流程**:当前不支持,引导用户到 AI 表格 Web 端 → 数据表 → 自动化 面板手动完成
用户说"仪表盘/图表/chart" → 读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
用户说"仪表盘排版乱了/图表对不齐/重新排布/自动布局/美化仪表盘" → `dashboard arrange`(读 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md))
用户说"附件/上传文件" → 读 [aitable-attachment.md](./aitable/aitable-attachment.md)
用户说"导入/导出/import/export" → 读 [aitable-export-import.md](./aitable/aitable-export-import.md)
用户说"模板" → `template search`
用户说"高级权限/角色/权限控制/谁能看/谁能改" → 读 [aitable-advperm.md](./aitable/aitable-advperm.md)
- 开/关高级权限 → `advperm enable` / `advperm disable --yes`
- 看角色配置 → `advperm role-list` 或 `advperm role-get`
- 建角色(可同时指定子角色权限) → `advperm role-create --name ... --sub-roles '[...]'`
- 改角色名 / 改子角色权限(PATCH 语义,未传字段不变) → `advperm role-update --role-id ... [--name ...] [--sub-roles '[...]']`
- 删角色 → `advperm role-delete --yes`
- **角色 ↔ 成员绑定**:当前 CLI 不支持,仍需在 AI 表格 Web 端面板手动完成
命令报错/操作失败 → 读 [aitable-error-recovery.md](./aitable/aitable-error-recovery.md)
**关键区分**: base=表格文件, table=数据表, field=列, record=行
## 核心工作流
```bash
# 1. 搜索/列出 Base — 提取 baseId
dws aitable base search --query "项目" --format json
# 2. 获取 Base 信息 — 提取 tableId
dws aitable base get --base-id <BASE_ID> --format json
# 3. 获取表结构 — 提取 fieldId
dws aitable table get --base-id <BASE_ID> --table-id <TABLE_ID> --format json
# 4. 查询记录
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --format json
# 5. 新增记录 (cells 用 fieldId 作 key)
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldXXX":"值"}}]' --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `base list/search` | `baseId` | 所有后续命令的 --base-id,拼接文档 URI |
| `base create` | `baseId` | 后续命令 + 文档 URI |
| `base get` | `tables[].tableId` | --table-id,拼接指定数据表 URI |
| `table create` | `tableId` | 后续命令 + 拼接指定数据表 URI |
| `table get` | `fields[].fieldId` | record 操作的 cells key, field get/update/delete |
| `record query` | `recordId` | record update/delete;按 ID 反查字段值用 `record get` |
| `template search` | `templateId` | base create --template-id,拼接模板预览 URI |
## URL → baseId 提取
用户提供 `https://alidocs.dingtalk.com/i/nodes/{baseId}` 链接时:
1. 提取 `/nodes/` 后的路径段作为 `baseId`
2. 去掉尾部的查询参数(`?` 及其后内容)
3. 传入 `--base-id` 参数
> 如果该 URL 来自 `dws aitable` 返回或已在当前链路 probe 过,可直接复用;
> 如果是用户直接提供的原始 `alidocs` URL,则先按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) probe,确认 `extension=able` 后再继续。
## 注意事项
- 所有操作使用 ID(baseId/tableId/fieldId/recordId),不使用名称
- records 的 cells key 是 fieldId,不是字段名称
- cells 写入/读取格式见 [aitable-cell-value.md](./aitable/aitable-cell-value.md)
- 最佳实践见 [aitable-best-practices.md](./aitable/aitable-best-practices.md)
## 自动化脚本
| 脚本 | 场景 |
|------|------|
| [bulk_add_fields.py](../../scripts/bulk_add_fields.py) | 批量添加字段 |
| [import_records.py](../../scripts/import_records.py) | 从 JSON/CSV 批量导入记录 |
| [aitable_export_via_task.py](../../scripts/aitable_export_via_task.py) | 文件导出(export_data 轮询 + 下载) |
| [upload_attachment.py](../../scripts/upload_attachment.py) | 上传附件到 AI 表格记录 |
## 相关产品
- [doc](./doc.md) — 富文本文档编辑,不是结构化数据表格
# advperm — 高级权限管理
控制 Base 的高级权限总开关,并管理自定义角色(增删改查 + 子角色权限规则)。
适用场景:"如何控制谁能看/改 Base 数据"、"开启/关闭高级权限"、"新建/修改/删除角色"、"按字段或行配置权限"。
## 命令一览
| 命令 | 用途 |
|------|------|
| `advperm enable` | 开启 Base 高级权限总开关 |
| `advperm disable` | 关闭 Base 高级权限总开关(高危) |
| `advperm role-list` | 列出 Base 下全部角色 |
| `advperm role-get` | 获取单角色完整配置 |
| `advperm role-create` | 创建自定义角色 |
| `advperm role-update` | 增量更新自定义角色(PATCH 语义) |
| `advperm role-delete` | 删除自定义角色(不可逆) |
> 所有子命令的 `--base-id` 必填,可用隐藏别名 `--base`。
## 命令详情
### advperm enable — 开启高级权限
```bash
dws aitable advperm enable --base-id BASE_ID --format json
```
返回 `{baseId, enabled: true}`。
只有开启后角色配置才会真正限制成员的可访问范围;关闭状态下角色配置仍可读但不生效。
### advperm disable — 关闭高级权限(高危)
```bash
dws aitable advperm disable --base-id BASE_ID --yes --format json
```
返回 `{baseId, enabled: false}`。关闭后所有角色配置即刻失效,全员回退到默认权限。涉及多人协作或敏感数据务必和用户二次确认,建议先 `role-list` 留底。
### advperm role-list — 列出全部角色
```bash
dws aitable advperm role-list --base-id BASE_ID --format json
```
返回结构:
```json
{
"data": {
"enabled": true,
"defaultRole": { "mode": 0 },
"roles": [
{
"roleId": "10685308981",
"name": "可查看角色",
"roleType": "custom",
"system": false,
"subRoles": [
{
"authLevel": "read",
"targetId": "HMEaRQ4",
"targetType": "sheet",
"config": { "actions": 268435455 },
"display": {
"authLevelLabel": "仅查看",
"targetTypeLabel": "数据表",
"permissionScopeNote": "...",
"actionsLabels": ["新增视图", "删除视图", "修改视图"],
"actionsNote": "..."
}
}
]
}
]
}
}
```
关键字段:
- `roleType`:`custom`(自定义) / `system_editor` / `system_reader` / `5000`(owner) / `4000`(manager)。
- `system`:boolean,true 表示系统角色(不可删)。
- `subRoles[].display.*`:服务端返回的人类可读标签,可直接拼接给用户阅读,无需自行映射枚举。
- 不返回角色成员列表;如需"成员-角色"映射请去 AI 表格 Web 端。
- 新建 Base 默认 `enabled=false`,开启后只有 `owner` / `manager` 两个 meta 角色;`system_editor` / `system_reader` 需要在 Web UI 给成员授权"可编辑/可查看"后才会被服务端自动生成。
`role-list` / `role-get` 不需要管理员权限,普通成员也可读。
### advperm role-get — 获取单角色配置
```bash
dws aitable advperm role-get --base-id BASE_ID --role-id ROLE_ID --format json
```
返回结构同 `role-list` 中单个 role 对象(含完整 `subRoles[].config` 字段/行级规则与 `display.*` 标签)。
### advperm role-create — 创建自定义角色
```bash
# 仅指定 name,子角色由服务端按默认(none)填充
dws aitable advperm role-create --base-id BASE_ID --name "市场可读" --format json
# 创建时即指定 sub-roles(推荐——避免再走一次 role-update)
dws aitable advperm role-create --base-id BASE_ID --name "市场可读" \
--sub-roles '[{"targetId":"<sheetId>","targetType":"sheet","authLevel":"read"}]' --format json
```
| flag | 必填 | 说明 |
|------|:---:|------|
| `--name` | ✅ | 角色名称 |
| `--role-type` | | 角色类型字符串(留空由服务端决定默认值,如 `custom`) |
| `--flow-type` | | 流程类型字符串(按业务需要) |
| `--sub-roles` | | JSON 数组:`[{targetId, targetType, authLevel, appId?, config?}]`,详见下方"sub-roles 子字段"段 |
返回新建角色的完整配置(同 `role-get` 出参格式,含自动生成的 default subRoles)。
系统角色无法通过本命令创建。
### advperm role-update — 增量更新自定义角色(PATCH 语义)
```bash
# 只改名
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID --name "新名字"
# 只改 sheet 子角色 authLevel,name 不传保持不变
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID \
--sub-roles '[{"targetId":"<sheetId>","targetType":"sheet","authLevel":"edit-own"}]'
```
| flag | 必填 | 说明 |
|------|:---:|------|
| `--role-id` | ✅ | 目标自定义角色 ID(数字 long 字符串) |
| `--name` | | 新角色名称;不传不修改 |
| `--role-type` / `--flow-type` | | 可选 |
| `--sub-roles` | | JSON 数组,**PATCH 合并语义**:按 `(targetId, targetType)` 合并到现有 subRoles,入参中的 sub 整体替换该 sub,**入参未提及的 sub 保留不变**(无需先调 `role-get` 自行 merge) |
**系统角色禁止更新**(包括 owner / manager / system_editor / system_reader)。
### sub-roles 子字段
每个 sub-role 描述「角色对某个权限目标的访问粒度」:
| 字段 | 类型 | 说明 |
|------|------|------|
| `targetId` | string | 目标资源 ID(数据表 → `tableId`;仪表盘 → `dashboardId`;应用 → `appId`) |
| `targetType` | string | `sheet` / `dashboard` / `app` |
| `authLevel` | string | `manage` / `edit-own` / `edit-custom-field` / `edit-field-range` / `read` / `none` |
| `appId` | string(可选) | 仅 `targetType=app` 时使用 |
| `config` | object(可选) | 字段/行级细化规则;含 `actions`(位图)/ `rows` / `cells`。结构与 `role-get` 出参 `subRoles[].config` 对齐 |
### advperm role-delete — 删除自定义角色(不可逆)
```bash
dws aitable advperm role-delete --base-id BASE_ID --role-id ROLE_ID --yes --format json
```
要求同时满足:
1. 该 Base 已开启高级权限(`role-list` 返回 `enabled=true`)。
2. 当前 dws 登录用户是该 Base 的管理员/Owner。
3. `--role-id` 是 `role-list` 返回的数字 long 字符串(如 `"10685308981"`),且对应角色 `system=false`。
不可逆,删前先 `role-get` 留底。
## 能力边界
| 能力 | 状态 |
|------|------|
| 开/关高级权限 | ✅ 需管理员 |
| 列出 / 读取角色 | ✅ 普通成员也可读 |
| 创建自定义角色 | ✅ 需管理员 |
| 增量修改角色(PATCH 语义,不清空未传字段) | ✅ 需管理员 |
| 删除自定义角色 | ✅ 需管理员 |
| 修改/删除系统角色 | ❌ 服务端禁止;只能在 AI 表格 Web 端操作 |
| 角色 ↔ 成员绑定 | ❌ CLI 暂不支持,需在 AI 表格 Web 端 → Base 设置 → 高级权限 → 角色管理面板手动完成 |
## 错误码速查
| 场景 | code | type | message |
|------|------|------|---------|
| advperm 关闭时调用写接口(如 `role-delete` / `role-create` / `role-update`) | `ADVANCED_PERMISSION_DISABLED` | `USER_ERROR` | `Advanced permission is disabled for base <BASE>, please enable it via setAdvancedPermission before managing roles` |
| 非管理员调用 `enable` / `disable` / `role-create` / `role-update` / `role-delete` | `401` | `AUTH_ERROR` | `the current user must be a manager (administrator) of this base to manage roles or advanced permission` |
| 删除/更新系统角色(`system=true`) | `600` | `USER_ERROR` | `Illegal argument` |
| 操作不存在的数字 roleId(get/update/delete) | `600` | `USER_ERROR` | `Illegal argument` |
| 传非数字 roleId(如 `owner` / `manager`) | `INVALID_PARAMS` | `INPUT_ERROR` | `roleId is required` |
| `role-create` 缺 `--name` | `INVALID_PARAMS` | `INPUT_ERROR` | `name is required` |
| `--sub-roles` JSON 不是数组 / 解析失败 | (CLI 层拦截) | — | `--sub-roles 解析失败 ...` / `--sub-roles 必须是 JSON 数组` |
| `--base-id` 无法解析 | `INVALID_BASE_ID` | `INPUT_ERROR` | `baseId cannot be resolved to docId` |
> `600 / Illegal argument` 同时覆盖"操作系统角色"和"操作不存在 roleId"两种情况。拿到 `600` 时先 `role-list` 自查目标 roleId 是否存在、是否 `system=true`,再据此引导用户。
## 典型工作流
### 排查"成员看不到某些字段/记录"
```bash
dws aitable advperm role-list --base-id BASE_ID --format json
# 若 enabled=false:高级权限未开,所有规则不生效,与用户确认是否需要 enable
dws aitable advperm enable --base-id BASE_ID --format json
dws aitable advperm role-list --base-id BASE_ID --format json
# 看 roles[] 里有哪些自定义角色
dws aitable advperm role-get --base-id BASE_ID --role-id ROLE_ID --format json
# 检查 subRoles[].config 中的字段/行级权限规则
```
### 新建一个"市场可读"角色
```bash
# 1. 确保高级权限已开
dws aitable advperm enable --base-id BASE_ID --format json
# 2. 拿目标 sheet 的 tableId
dws aitable table get --base-id BASE_ID --format json
# 3. 创建角色 + 指定 sheet 子角色 authLevel=read
dws aitable advperm role-create --base-id BASE_ID --name "市场可读" \
--sub-roles '[{"targetId":"<tableId>","targetType":"sheet","authLevel":"read"}]' \
--format json
# → 返回新角色完整配置,含 roleId,记下后续 patch / delete 使用
```
### 升级角色权限(read → edit-own),保留其他配置
```bash
# 只传 sub-roles,name 等其他字段保持不变(PATCH 语义)
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID \
--sub-roles '[{"targetId":"<tableId>","targetType":"sheet","authLevel":"edit-own"}]' \
--format json
```
### 改角色名(不影响权限规则)
```bash
dws aitable advperm role-update --base-id BASE_ID --role-id ROLE_ID --name "新名字"
```
### 清理废弃角色
```bash
dws aitable advperm role-list --base-id BASE_ID --format json
dws aitable advperm role-delete --base-id BASE_ID --role-id ROLE_ID --yes --format json
```
### 关闭高级权限(恢复全员可见)
```bash
dws aitable advperm role-list --base-id BASE_ID --format json > /tmp/roles-backup.json
dws aitable advperm disable --base-id BASE_ID --yes --format json
```
# attachment — 附件上传
> **STOP — 不要使用钉盘 (drive) 上传!** 钉盘 fileId 无法写入 attachment 字段。必须使用以下流程。
>
> **STOP — 严禁在 record create/update 的 cells 里直接传图片 URL!** 直传 `{"url":"https://..."}` 会导致服务端同步下载图片,批量写入时触发 TIMEOUT_ERROR。正确做法:先 `attachment upload` 获取 `fileToken`,再用 `{"fileToken":"ft_xxx"}` 写入。
## 准备附件上传
```
Usage:
dws aitable attachment upload [flags]
Example:
dws aitable attachment upload --base-id <BASE_ID> --file-name report.xlsx --size 204800
dws aitable attachment upload --base-id <BASE_ID> --file-name photo.png --size 1024 --mime-type image/png
Flags:
--base-id string Base ID (必填)
--file-name string 文件名,必须含扩展名 (必填)
--size int 文件大小(字节),>0 (必填)
--mime-type string MIME type(不传时根据扩展名推断)
```
## 附件上传完整流程(推荐:使用脚本,2 步完成)
```bash
# 步骤 1: 使用脚本一键上传(内部自动完成 prepare + PUT)
python3 scripts/upload_attachment.py <BASE_ID> /path/to/report.pdf
# 输出: { "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 }
# 步骤 2: 在 record create/update 中使用 fileToken 写入
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json
```
> `uploadUrl` 有时效性(`expiresAt`),脚本会自动在获取后立即上传。
## 手动流程(不使用脚本)
```bash
# 1. 获取上传凭证
dws aitable attachment upload --base-id <BASE_ID> --file-name report.pdf --size 204800 --format json
# → 返回 uploadUrl、fileToken
# 2. PUT 上传(Content-Type 必须是文件的具体 MIME type)
curl -X PUT "<uploadUrl>" -H "Content-Type: application/pdf" --data-binary @report.pdf
# 3. 写入记录
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"recordId":"recXXX","cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json
```
# AI 表格最佳实践
## 1. 字段可写性分类
| 字段类型 | 可写 | 正确方式 |
|----------|------|----------|
| 文本/数字/日期/单选/多选/复选框/URL | ✅ | record create/update |
| 附件 | ⚠️ | 必须先走 [attachment upload 流程](./aitable-attachment.md) |
| 创建人/修改人/创建时间/修改时间 | ❌ | 系统字段,只读 |
| 公式/查找引用 | ❌ | 只读,由系统计算 |
| AI 字段 | ❌ | 只读,由 AI 自动计算 |
## 2. 查询执行契约
1. **不要拉全量后在 context 里手动统计** — 优先用 `--filters` 在服务端过滤
2. **has_more=true 时不能做全局结论** — 数据可能不完整
3. **优先用 `--filters` 在服务端过滤** — 不要拉全量后在本地 jq/grep
4. **字段名必须来自 `table get` 真实返回** — 不要猜测 fieldId
5. **减少响应体积** — 用 `--field-ids` 仅返回需要的字段
## 3. 任务选路
| 用户诉求 | 优先方案 | 不要误走 |
|---------|----------|----------|
| 查看几条数据 | `record query` | 不要用 `--all` |
| 全量拉取/统计 | `record query --all` | 不要手动循环 cursor |
| 全量导出为文件 | `export data` | 不要 `--all` 拉全量再写文件 |
| 批量写入 | `record create`(分批 100 条) | 不要一次传超过 100 条 |
| 附件/图片上传 | `attachment upload` 获取 fileToken → `record create/update` 用 fileToken 写入 | **严禁直接传图片 URL 到附件字段**(服务端同步下载会超时) |
| 文件级导入 | `import upload` + `import data` | 不要手动解析 xlsx 再逐条写入 |
## 4. 创建/修改后回读确认
执行写操作后,建议立即回读确认结果:
| 写操作 | 建议回读命令 | 确认内容 |
|--------|-------------|----------|
| `table create` | `table get --table-ids <新tableId>` | 表名、字段列表是否符合预期 |
| `field create` | `table get --table-ids <tableId>` | 新字段是否出现在字段列表中 |
| `record create/update` | `record query --record-ids <新recordId>` | 写入值是否正确 |
## 5. AI 字段注意事项
- AI 字段的 prompt **必须至少包含一个 `fieldRef` 引用**,纯文本 prompt 会被后端拒绝
- 先创建/确认被引用字段的 fieldId,再在 prompt 中引用
- `outputType` 必须与字段类型一致(如 `outputType=text` 配 `--type text`)
# cells 写入/读取格式规范(cellValue 数据结构)
> 适用命令:`dws aitable record create --records`、`dws aitable record update --records`、`dws aitable record query` 返回
>
> 本文件是 DWS AI 表格 cellValue 的 **source of truth**。写入记录时,必须严格按此格式构造 cells 对象。
## 顶层规则
- cells 的 key **必须是 fieldId**(如 `fldXXX`),不是字段名称
- fieldId 必须从 `table get` 返回中获取
- 不同字段类型的 value 格式不同,混用会报错
- 系统只读字段(creator/lastModifier/createdTime/lastModifiedTime/formula)不可写入
## 各字段类型详解
### text(文本)
**写入**:字符串
```json
{"fldTextId": "这是一段文本"}
```
**读取**:字符串
```json
{"fldTextId": "这是一段文本"}
```
---
### number(数字)
**写入**:数字或数字字符串
```json
{"fldNumId": 123.45}
{"fldNumId": "123.45"}
```
**读取**:字符串形式的数字
```json
{"fldNumId": "123.45"}
```
---
### singleSelect(单选)
**写入**:选项名称字符串(推荐),或对象形式 `{id, name}`
```json
{"fldSelectId": "进行中"}
{"fldSelectId": {"id": "opt_xxx", "name": "进行中"}}
```
> 写入不存在的选项名称时,系统会自动创建该选项。
> 对象写入时 id 为准,服务端会校验 id 是否存在。
**读取**:对象 `{id, name}`
```json
{"fldSelectId": {"id": "opt_abc123", "name": "进行中"}}
```
---
### multipleSelect(多选)
**写入**:选项名称数组(推荐),或对象数组
```json
{"fldMultiId": ["标签A", "标签B"]}
{"fldMultiId": [{"id": "opt_a", "name": "标签A"}, {"id": "opt_b", "name": "标签B"}]}
```
> 写入时每项需带 id(对象模式)或直接传 name 字符串。不存在的 name 会自动补入选项配置。
**读取**:对象数组
```json
{"fldMultiId": [{"id": "opt_a", "name": "标签A"}, {"id": "opt_b", "name": "标签B"}]}
```
---
### date(日期)
**写入**:日期字符串、RFC3339 字符串、或毫秒时间戳
```json
{"fldDateId": "2026-03-15"}
{"fldDateId": "2026-03-15 09:00"}
{"fldDateId": "2026-03-15T09:00+08:00"}
```
**读取**:RFC3339 字符串(带时区)
```json
{"fldDateId": "2026-03-15T09:00:00+08:00"}
```
**过滤**(`record query --filters`):日期字段**只能用日期专用操作符** `date_eq` / `before` / `after` / `not_before` / `not_after` / `exist` / `un_exist`,比较值用日期字符串(如 `"2026-03-15"`)。
- ❌ 通用 `eq` / `ne` / `gt` / `gte` / `lt` / `lte` / `contain` 对日期字段无效,会静默返回 0 条;
- ❌ 不支持区间 `date_between` 与相对 `from_now`(CLI 会直接拒绝),范围查询用 `not_before` + `not_after` 组合。
- 详见 [aitable-filter-sort.md](./aitable-filter-sort.md) §日期字段过滤。
---
### currency(货币)
**写入**:数字(与 number 相同)
```json
{"fldCurrencyId": 99.5}
```
**读取**:字符串形式的数字(小数位数取决于 formatter 配置)
```json
{"fldCurrencyId": "99.5"}
```
---
### progress(进度)
**写入**:0~1 之间的浮点数(0 表示 0%,1 表示 100%)
```json
{"fldProgressId": 0.75}
```
> ⚠️ **常见错误**:写入 75 不会报错,但会被存储为 7500%(因为系统将其理解为 75 倍)。
> 正确做法:75% 应写入 0.75。API 不会拒绝超出 [0,1] 的值,但显示会异常。
> 如果字段配置了 `customizeRange`,则按自定义范围传值。
**读取**:字符串形式的数字
```json
{"fldProgressId": "0.75"}
```
---
### rating(评分)
**写入**:整数,必须在字段配置的 min~max 范围内
```json
{"fldRatingId": 4}
```
> ⚠️ 超出 max 范围的值(如 max=5 时写入 6)会被服务端拒绝并返回错误。
**读取**:数字(字符串形式)
```json
{"fldRatingId": "4"}
```
---
### checkbox(勾选)
**写入**:布尔值
```json
{"fldCheckId": true}
{"fldCheckId": false}
```
**读取**:布尔值
```json
{"fldCheckId": true}
```
---
### user(人员)
**写入**:对象数组,每项必须含 `userId` 和 `corpId`
```json
{"fldUserId": [{"userId": "staff_001", "corpId": "dingxxxxxxxx"}]}
```
> 单选字段(`multiple=false`)也必须传数组,只是数组长度为 1。
> 如果目标用户不在当前请求组织内,回退为 `[{"userRef": "ur_0AaZ19"}]`。
**读取**:对象数组
```json
{"fldUserId": [{"userId": "staff_001", "corpId": "dingxxxxxxxx"}]}
```
---
### department(部门)
**写入**:对象数组,每项含 `deptId`
```json
{"fldDeptId": [{"deptId": "52528700"}]}
```
**读取**:对象数组
```json
{"fldDeptId": [{"deptId": "52528700"}]}
```
---
### group(群组)
**写入**:对象数组,每项含 `cid`
```json
{"fldGroupId": [{"cid": "74577067501"}]}
```
> ⚠️ key 是 **`cid`**,不是 `openConversationId`
**读取**:对象数组
```json
{"fldGroupId": [{"cid": "74577067501"}]}
```
---
### url(链接)
**写入**:对象 `{text, link}` 或纯 URL 字符串
```json
{"fldUrlId": {"text": "钉钉官网", "link": "https://dingtalk.com"}}
{"fldUrlId": "https://dingtalk.com"}
```
> 纯字符串写入时,服务端自动补齐为 `{"text":"原字符串","link":"原字符串"}`
**读取**:对象 `{text, link}`
```json
{"fldUrlId": {"text": "钉钉官网", "link": "https://dingtalk.com"}}
```
---
### richText(富文本)
**写入**:对象 `{markdown: "..."}`
```json
{"fldRichId": {"markdown": "**加粗**\n普通文字\n"}}
```
**读取**:对象 `{markdown: "..."}`(有损,颜色/@人等信息可能丢失)
```json
{"fldRichId": {"markdown": "**加粗**\n普通文字\n"}}
```
---
### attachment(附件)
**写入**:对象数组,**必须使用 `fileToken`**
```json
{"fldAttachId": [{"fileToken": "ft_xxx"}]}
```
> ⚠️ **必须先通过 [attachment upload 流程](./aitable-attachment.md) 上传文件获取 `fileToken`,再将 `fileToken` 写入 cells。**
> ❌ **严禁直接传 `{"url": "https://..."}` 形式写入附件/图片字段** — 服务端会同步下载图片,10 条记录即触发 TIMEOUT_ERROR 超时。
> 写入会**整体覆盖**原附件列表,不是追加。
**读取**:对象数组(含下载链接、文件名、大小)
```json
{"fldAttachId": [{"url": "https://...", "filename": "report.pdf", "size": 204800}]}
```
---
### telephone / email / barcode / idCard(电话/邮箱/条码/身份证)
**写入**:字符串
```json
{"fldPhoneId": "13800138000"}
{"fldEmailId": "[email protected]"}
{"fldBarcodeId": "978-3-16-148410-0"}
{"fldIdCardId": "520402196001067498"}
```
> idCard 必须是后端认可的合法身份证号格式
**读取**:字符串
```json
{"fldPhoneId": "13800138000"}
```
---
### geolocation(地理位置)
**写入**:对象,包含 `address`、`name`、`location`
```json
{
"fldGeoId": {
"address": "浙江省杭州市思凯路与爱橙街交叉口东南200米",
"name": "阿里中心·未科D1幢",
"location": ["120.007852", "30.271194"]
}
}
```
> `location` 按 **[经度, 纬度]** 传**字符串数组**
**读取**:对象(含额外的 `fullAddress` 字段,由服务端自动拼接)
```json
{
"fldGeoId": {
"address": "浙江省杭州市",
"fullAddress": "阿里中心-浙江省杭州市",
"name": "阿里中心",
"location": ["120.007852", "30.271194"]
}
}
```
---
### unidirectionalLink / bidirectionalLink(关联字段)
**写入**:对象 `{linkedRecordIds: [...]}`
```json
{"fldLinkId": {"linkedRecordIds": ["recXXX", "recYYY"]}}
```
**读取**:对象 `{linkedRecordIds: [...]}`
```json
{"fldLinkId": {"linkedRecordIds": ["recXXX", "recYYY"]}}
```
---
### 只读字段(禁止写入)
以下字段类型由系统自动填充,`record create/update` 时**禁止传入**:
| 类型 | 说明 |
|------|------|
| `creator` | 创建人 |
| `lastModifier` | 最后编辑人 |
| `createdTime` | 创建时间 |
| `lastModifiedTime` | 最后编辑时间 |
| `formula` | 公式字段(系统计算) |
| AI 字段 | 由 AI 自动计算 |
## 常见错误速查
| 错误 | 正确做法 |
|------|----------|
| cells key 用字段名称 `"课程名称"` | 用 fieldId `"fldXXX"` |
| progress 写入 `75` | 写入 `0.75`(范围 0~1) |
| attachment 直接传文件路径或图片 URL | 必须先 `attachment upload` 获取 fileToken,再用 fileToken 写入(直传 URL 会超时) |
| user 字段传用户名字符串 | 传对象数组 `[{"userId":"...", "corpId":"..."}]` |
| group 字段用 `openConversationId` | 用 `cid` |
| singleSelect 传 option id 字符串 | 传 name 字符串或 `{"id":"...", "name":"..."}` 对象 |
| 对只读字段写入值 | 不传该字段,由系统自动填充 |
# dashboard & chart — 仪表盘与图表
## 建议操作顺序
```bash
# 1) 先看配置模板(JSONC)
dws aitable dashboard config-example --format json
dws aitable chart widgets-example --format json
# 2) 先拿 dashboard,再拿 chart 详情
dws aitable dashboard get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --format json
dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-id <CHART_ID> --format json
```
## 要点
- `dashboard get` 返回的 `charts[].chartId` 可直接给 `chart get` 使用
- `dashboard share get` 可能返回 `404`(资源不存在或未开通),需按可重试错误处理,不要误判为参数拼错
- `chart share get` 可正常返回 `enabled/shareUrl`,用于分享状态判断
## dashboard 子命令
| 命令 | 用途 | 必填参数 | 说明 |
|------|------|----------|------|
| `dashboard get` | 获取仪表盘详情(含 charts 列表) | `--base-id` `--dashboard-id` | — |
| `dashboard create` | 创建仪表盘 | `--base-id` + (`--config` 或 `--name`) | `--name` 简化版创建空看板;`--config` 传完整 JSON |
| `dashboard update` | 更新仪表盘 | `--base-id` `--dashboard-id` + (`--config` 或 `--name`) | `--name` 仅改名;`--config` 更新完整配置 |
| `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` `--yes` | — |
| `dashboard config-example` | 查看仪表盘配置模板 | 无 | 创建前先调此命令了解 config 结构 |
| `dashboard arrange` | 自动重排图表布局 | `--base-id` `--dashboard-id` | 把图表按行铺满网格,避免某行只占半幅、留下大片空白;返回 `{totalColumns, layout, alignedChartCount}` |
## chart 子命令
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `chart get` | 获取图表详情 | `--base-id` `--dashboard-id` `--chart-id` |
| `chart create` | 创建图表 | `--base-id` `--dashboard-id` `--config` |
| `chart update` | 更新图表配置 | `--base-id` `--dashboard-id` `--chart-id` `--config` |
| `chart delete` | 删除图表 | `--base-id` `--dashboard-id` `--chart-id` `--yes` |
| `chart widgets-example` | 查看图表 widgets 配置模板 | 无 |
## 配置获取流程
创建图表前,必须先调用 `chart widgets-example` 查看配置模板,了解每种图表类型需要的字段结构,然后根据实际 tableId 和 fieldId 填充配置。
# AI 表格数据分析 SOP
> 当用户诉求涉及查询、筛选、排序、统计、Top/Bottom N、分组聚合、判断全局结论时,必须先读本文档再执行。
## 1. 查询决策树
```
用户要做什么?
│
├─ 查看/导出原始记录明细
│ → record query [--filters] [--sort] [--field-ids] [--limit]
│
├─ 按条件筛选记录(如"状态=进行中的记录")
│ → record query --filters '{"operator":"and","operands":[...]}'
│
├─ 取 Top N / Bottom N(如"销售额最高的5条")
│ → record query --sort '[{"fieldId":"xxx","direction":"desc"}]' --limit 5
│
├─ 全量统计(如"一共多少条"、"所有记录的总销售额")
│ → record query --all --field-ids <目标字段>
│ → 本地计算 count / sum / avg
│
├─ 分组统计(如"每个状态各有多少条")
│ → record query --all --field-ids <分组字段>,<度量字段>
│ → 本地按分组字段 groupby 再聚合
│
└─ 判断全局结论(如"是否所有记录都满足条件")
→ record query --all(或用 --filters 反向筛选不满足的)
→ 基于全量结果判断
```
## 2. 核心规则
### 2.1 禁止基于默认分页下全局结论
`record query` 默认返回 100 条。如果返回 JSON 中 `data.nextCursor` 非空,表示还有后续数据,当前结果**不是全量**。
```json
{"data": {"nextCursor": "3hf5MtLbLZ", "records": [...]}}
```
- ❌ 错误:只查了默认 100 条就说"共 100 条记录"
- ✅ 正确:使用 `--all` 自动翻页拿全量,再统计
### 2.2 能在服务端过滤的,不要拉到本地再过滤
| 需求 | 正确做法 | 错误做法 |
|------|---------|---------|
| 筛选"状态=已完成" | `--filters '{"operator":"and","operands":[{"operator":"eq","operands":["fldXXX","已完成"]}]}'` | `--all` 拉全量再本地 filter |
| 按日期降序取最新5条 | `--sort '[...]' --limit 5` | `--all` 拉全量再本地 sort + slice |
| 模糊搜索标题含"Q1" | `--filters` 用 `contain` 操作符 | 全量拉取再本地 grep |
### 2.3 服务端无法完成时,才用 --all + 本地计算
以下场景服务端 filters/sort 无法满足,需要 `--all` 后本地处理:
- SUM / AVG / COUNT / MAX / MIN 聚合
- 分组统计(GROUP BY)
- 多字段联合计算(如"销售额 = 单价 × 数量")
- 去重计数(COUNT DISTINCT)
- 百分比/占比计算
### 2.4 --all 使用注意
```bash
dws aitable record query \
--base-id <baseId> \
--table-id <tableId> \
--all \
--field-ids <只取需要的字段> \
--format json
```
- **必须配合 `--field-ids`** 限制返回字段,减少数据量
- 对于大表(>1000条),先告知用户可能耗时
- `--all` 会自动处理分页,无需手动翻页
## 3. filters 快速参考
详细语法见 [aitable-filter-sort.md](./aitable-filter-sort.md)。
### 常用操作符速查
| 操作符 | 适用类型 | 含义 | 示例 operands |
|--------|---------|------|-------------|
| `eq` | 通用 | 等于 | `["fldXXX", "值"]` |
| `ne` | 通用 | 不等于 | `["fldXXX", "值"]` |
| `gt` / `lt` | 数值/日期 | 大于/小于 | `["fldXXX", "25"]` |
| `gte` / `lte` | 数值/日期 | 大于等于/小于等于 | `["fldXXX", "100"]` |
| `contain` | 文本 | 包含 | `["fldXXX", "关键词"]` |
| `exist` / `un_exist` | 通用 | 有值/为空 | `["fldXXX"]`(无第二参数) |
| `any_of` | 多选 | 包含任一 | `["fldXXX", "选项A"]` |
### filters 结构模板
```json
{
"operator": "and",
"operands": [
{"operator": "eq", "operands": ["<fieldId>", "<值>"]},
{"operator": "gt", "operands": ["<fieldId>", "<数值>"]}
]
}
```
## 4. 分析结果呈现规范
### 4.1 必须包含的信息
- **数据范围**:基于哪个表、哪些筛选条件、查询了多少条记录
- **计算方法**:用了什么聚合方式(sum/count/avg 等)
- **结果值**:精确到合理小数位
### 4.2 示例
> 基于「销售数据」表,筛选条件:日期 ≥ 2026-01-01,共查询到 342 条记录。
> - 总销售额:¥1,234,567.89(SUM)
> - 平均单价:¥3,610.46(AVG)
> - 最大单笔:¥89,000.00(MAX)
## 5. 任务选路心智模型
| 用户诉求 | 优先方案 | 不要误走 |
|---------|---------|---------|
| 一次性统计/临时分析 | `record query --all` + 本地聚合 | 不要创建 formula 字段 |
| 长期展示派生指标 | 创建 formula 字段(见 [formula-guide](./aitable-formula-guide.md)) | 不要每次手算再手动写入 |
| 按条件筛选记录 | `record query --filters` | 不要 `--all` 拉全量再本地 filter |
| 取最新/最大/前N | `--sort + --limit` | 不要 `--all` 再本地排序取前N |
| 关键词检索 | `record query --filters` 用 `contain` | 不要把表格当搜索引擎全文检索 |
| 验证"是否全部满足" | 反向 filters(筛不满足的),看是否有结果 | 不要 `--all` 逐条遍历 |
# AI 表格错误恢复指南
> 当 CLI 命令返回错误时,按本文档的映射表判断恢复动作。
## 1. 错误响应结构
```json
{
"status": "error",
"summary": "Failed to create records",
"trace_id": "2104a64c17790723347215232e085e"
}
```
- `status: "error"` 表示操作失败
- `summary` 包含错误摘要信息
- `trace_id` 用于问题追踪
## 2. 常见错误与恢复动作
### 2.1 记录操作错误
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
| `Failed to create records` | cellValue 格式错误或字段类型不匹配 | 先 `field get` 确认字段类型,再按 [cell-value](./aitable-cell-value.md) 规范重构值 |
| `record not found` | record-id 不存在或已删除 | 用 `record query` 重新查询确认目标记录 |
| rating 字段写入超出 max | 值超出字段配置范围 | 检查字段 config 的 min/max,确保值在范围内 |
| singleSelect 写入对象格式但 id 不存在 | option id 无效 | 改用 name 字符串写入(推荐),或先 `field get` 获取有效 option id |
### 2.2 字段操作错误
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
| `Failed to create field` | config 格式错误或必填项缺失 | 检查 [field-properties](./aitable-field-properties.md) 中该类型的必填 config |
| `field not found` | field-id 不存在 | 用 `table get` 获取最新字段列表 |
| formula 创建失败 | 公式语法错误或引用字段名不匹配 | 先 `field get` 确认字段精确名称,再检查公式语法(见 [formula-guide](./aitable-formula-guide.md)) |
| 删除主字段失败 | 主字段(第一列)不可删除 | 改为更新字段名或类型,不能删除 |
### 2.3 Base/Table 操作错误
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
| `base not found` | base-id 错误或无权限 | 确认 base-id 正确;尝试 `base list` 或 `base search` 重新定位 |
| `table not found` | table-id 错误 | 用 `table get --base-id <baseId>` 不带 table-ids 查看所有表 |
| 表名重复 | 同 Base 下已存在同名表 | 系统会自动续号(如"原名 1"),无需额外处理 |
### 2.4 视图操作错误
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
| `view not found` | view-id 错误 | 用 `view get --base-id <baseId> --table-id <tableId>` 查看所有视图 |
| 删除最后一个视图 | 表至少保留一个视图 | 不可删除唯一视图 |
### 2.5 filters/sort 错误
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
| filters 无效被忽略 | 根节点不是 and/or,或 operands 格式错误 | 确保 filters 根节点是 `{"operator":"and"/"or", "operands":[...]}` 结构 |
| sort 无效 | fieldId 不存在 | 先 `table get` 确认字段 ID |
| 筛选结果为空 | 条件过严或字段值不匹配 | 放宽条件验证;注意 singleSelect 筛选值用 option name 或 id |
### 2.6 导入导出错误
| 错误现象 / summary | 原因 | 恢复动作 |
|-------------------|------|---------|
| 导出任务超时 | 数据量大,异步任务未完成 | 用 `export data --task-id <taskId>` 轮询直到完成 |
| 导入文件格式错误 | 不支持的文件格式或文件损坏 | 确认文件为 .xlsx 格式且未加密 |
## 3. 重试策略
### 3.1 可重试的错误
| 错误类型 | 重试方式 | 最大重试次数 |
|---------|---------|------------|
| 网络超时 / 5xx | 等待 2s 后原样重试 | 2 |
| 导出任务未完成 | 轮询 task-id | 5(间隔 3s) |
| 并发写入冲突 | 串行重试 | 1 |
### 3.2 不可重试的错误(立即停止)
| 错误类型 | 原因 | 处理方式 |
|---------|------|---------|
| 权限不足 / 403 | 用户对该 Base 无权限 | 停止操作,提示用户确认权限 |
| 参数格式错误 | 请求结构不合法 | 修正参数后重试,不要原样重试 |
| 资源不存在 / 404 | ID 错误或资源已删除 | 重新查询定位资源 |
| 配额超限 / 429 | API 调用频率过高 | 等待后重试,并降低并发 |
### 3.3 重试前检查清单
在重试前,先确认:
1. ❓ 错误是暂时性的还是永久性的?
2. ❓ 参数有没有明显错误需要修正?
3. ❓ 是否需要先查询最新状态再重试?
## 4. 调试技巧
### 4.1 使用 --verbose 获取详细信息
```bash
dws aitable record create \
--base-id <baseId> \
--table-id <tableId> \
--records '[...]' \
--verbose --format json
```
`--verbose` 会输出请求/响应的详细信息,帮助定位问题。
### 4.2 使用 --dry-run 预览
```bash
dws aitable record create \
--base-id <baseId> \
--table-id <tableId> \
--records '[...]' \
--dry-run --format json
```
`--dry-run` 只预览不执行,适合在不确定参数是否正确时先验证。
## 5. 错误预防最佳实践
1. **写记录前先读字段结构** — `field get` 或 `table get` 确认字段类型和 ID
2. **写字段前先读 field-properties** — 确认 config 的必填项和格式
3. **formula 字段先确认引用字段名** — `[字段名]` 必须精确匹配
4. **options 更新传完整列表** — 更新 singleSelect/multipleSelect 的 options 是全量覆盖
5. **大批量操作分批执行** — 单次最多 100 条记录
6. **使用 --format json** — 确保输出可解析,方便错误判断
# export & import — 导入导出
## 导出数据(两阶段轮询)
`export data` 为异步任务:首次调用可能只返回 `taskId`,需要继续轮询。
> ⚠️ **`--format` 冲突警告**:`export data` 的 `--format` 是**导出格式**(excel/attachment 等),不是全局输出格式。**此命令禁止追加全局 `--format json`**,否则会覆盖导出格式导致 `INVALID_EXPORT_FORMAT` 错误。输出默认就是 JSON,无需额外指定。
```bash
# 第一步:创建任务(按 scope 传必要参数)——注意:不要加 --format json!
dws aitable export data --base-id <BASE_ID> --scope table --table-id <TABLE_ID> --format excel --timeout-ms 1000
# 第二步:拿 taskId 继续轮询,直到返回 downloadUrl
dws aitable export data --base-id <BASE_ID> --task-id <TASK_ID> --timeout-ms 3000
```
### 参数约束
| scope | 必传参数 |
|-------|----------|
| `all` | 只需 `--base-id` |
| `table` | 必须 `--table-id` |
| `view` | 必须 `--table-id` + `--view-id` |
## 导入文件(三步流程)
当用户要求将 Excel(`.xlsx`)或 CSV 文件完整导入 AI 表格时,**不需要自己解析文件内容**,直接使用文件级导入。
> **无需手动解析 CSV/Excel 再逐条 record create**,效率极低且容易出错。
```bash
# 第 1 步:申请上传凭证
dws aitable import upload --base-id <BASE_ID> \
--file-name data.xlsx --file-size <字节数> --format json
# → 返回 uploadUrl 和 importId
# 第 2 步:上传文件到 OSS(注意:Content-Type 必须设为空)
curl -X PUT "<uploadUrl>" -H "Content-Type:" --data-binary @data.xlsx
# 第 3 步:触发导入(新建表模式)
dws aitable import data --import-id <importId> --format json
# → 返回 status: success 和新建的 tableIds
# 第 3 步(替代):追加到已有表
dws aitable import data --import-id <importId> --table-id <TABLE_ID> --format json
# → 数据作为新行追加到指定表中
```
### 步骤说明
| 步骤 | 命令 | 说明 |
|------|------|------|
| 申请上传凭证 | `import upload --base-id <ID> --file-name <名称> --file-size <字节>` | `--file-size` 必须与实际文件大小一致 |
| 上传文件 | HTTP PUT(curl 等) | **必须** 带 `-H "Content-Type:"` 将 Content-Type 设为空,否则 OSS 返回 403 |
| 触发导入 | `import data --import-id <ID> [--table-id <TABLE_ID>]` | 同步等待,大多一次调用即返回结果;超时可用相同 importId 重试 |
### import data 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--import-id` | ✅ | `import upload` 返回的 importId |
| `--table-id` | ❌ | 传入时数据追加到该已有表;不传则每个 Sheet 新建独立的数据表 |
| `--timeout` | ❌ | 最长等待秒数,默认且推荐 30 |
| `--header-row` | ❌ | 表头所在行号(从 1 开始),数据从下一行读取。不传则自动识别 |
| `--src-sheet-name` | ❌ | 源文件中的 Sheet 名称,多 Sheet 文件时指定。不传则用第一个 Sheet |
| `--field-mapping` | ❌ | 字段映射 JSON(`{"目标字段名":"源列名"}`)。不传则按列名自动匹配 |
### 两种导入模式
| 模式 | 触发条件 | 效果 |
|------|----------|------|
| **新建表导入** | 不传 `--table-id` | 每个 Sheet 自动新建为独立数据表 |
| **追加导入** | 传入 `--table-id` | 数据作为新行追加到指定已有表,按列名自动匹配字段 |
### 支持的文件格式:xlsx vs csv
| 特性 | xlsx | csv |
|------|------|-----|
| 新建表导入 | ✅ | ✅ |
| 追加导入(`--table-id`) | ✅ | ✅ |
| `--header-row` | ✅ | ❌ 不支持 |
| `--src-sheet-name` | ✅(多 Sheet 支持) | ❌ 无 Sheet 概念 |
| `--field-mapping` | ✅ | ✅ |
> **CSV 限制**:CSV 没有 Sheet 概念,且表头固定为第一行,因此 `--header-row` 和 `--src-sheet-name` 对 CSV 均不可用。
>
> **建议**:需要指定表头行或多 Sheet 选择时,**必须使用 xlsx 格式**。CSV 仅适用于表头在第一行的简单导入场景。
### 追加导入的字段匹配规则
追加导入时,系统按以下规则将 Excel 列映射到目标表字段:
1. **不传 `--field-mapping`(自动匹配)**:按字段名**精确匹配** Excel 列名和目标表字段名。如果没有任何一列匹配上,导入会失败。
2. **传 `--field-mapping`(显式映射)**:按映射关系指定对应关系,key 为目标表字段名,value 为 Excel 列名。
> **追加导入失败常见原因**:Excel 列名与目标表字段名不一致(如 Excel 是"销售姓名"但表字段是"姓名"),导致自动匹配 0 个字段,报错 `"Failed to build import sheet infos from preview data"`。
>
> **解决方案**:
> 1. **首选**:创建目标表时,字段名与 Excel 表头列名**保持完全一致**
> 2. **备选**:传 `--field-mapping '{"目标字段名":"Excel列名"}'` 手动指定映射
> 3. **兜底**:如果 import data 多次失败,改用 `record create` 逐条写入
### 适用场景
- **新建表导入**:首次导入 Excel/CSV,让系统自动建表建字段
- **追加到已有表**:已有数据表结构,需要把 Excel 数据批量写入 → 传 `--table-id`(推荐 xlsx)
- **需要指定表头行**:源文件前几行非数据(如注释行)→ `--header-row`(必须用 xlsx)
- **多 Sheet 文件**:只导入特定 Sheet → `--src-sheet-name`(必须用 xlsx)
- **不适用**:需要复杂字段级控制(如只导入部分列、数据转换)→ 解析后用 `record create`
> **导入数据无法整体撤销**:文件一旦导入成功,数据即写入表中,没有"撤销导入"操作。如需清理导入的测试数据,只能手动通过 `record delete` 逐条或批量删除记录;如果是新建表模式导入的,可以直接 `table delete` 删除整张表。因此:
> - 测试/验证场景建议导入到**独立的测试表或测试 Base**,用完后整体删除
> - 如果用户明确表示不想导入测试数据或要求先预览内容再决定,应先解析文件内容展示给用户确认,而非直接导入
# 字段类型 config 规范(field create / table create / field update)
> 适用命令:`dws aitable field create`、`dws aitable table create --fields`、`dws aitable field update --config`
>
> 本文件是 DWS AI 表格字段 config 的 **source of truth**。创建/更新字段时,必须严格按此规范构造 JSON。
## 1. 顶层规则
- `table create --fields` 和 `field create --fields` 中每个字段对象:`{"fieldName":"xxx", "type":"xxx", "config":{...}}`
- `field create --name --type --config` 中 config 单独传 JSON 字符串
- `field update --config` 只传 config 部分
- 不需要 config 的类型(如 text、checkbox、attachment)可省略 config 字段
## 2. 字段类型速查
| type | 需要 config | config 核心字段 | 说明 |
|------|-------------|----------------|------|
| `text` | ❌ | — | 纯文本 |
| `number` | 可选 | `formatter` | 数字格式 |
| `singleSelect` | ✅ | `options` | 单选 |
| `multipleSelect` | ✅ | `options` | 多选 |
| `date` | 可选 | `formatter` | 日期格式 |
| `currency` | 可选 | `currencyType`, `formatter` | 货币 |
| `progress` | 可选 | `formatter`, `min`, `max`, `customizeRange` | 进度条 |
| `rating` | 可选 | `min`, `max`, `icon` | 评分 |
| `checkbox` | ❌ | — | 勾选框 |
| `user` | 可选 | `multiple` | 人员 |
| `department` | 可选 | `multiple` | 部门 |
| `group` | 可选 | `multiple` | 群组 |
| `url` | ❌ | — | 链接 |
| `richText` | ❌ | — | 富文本 |
| `telephone` | ❌ | — | 电话 |
| `email` | ❌ | — | 邮箱 |
| `attachment` | ❌ | — | 附件 |
| `geolocation` | ❌ | — | 地理位置 |
| `formula` | ✅ | `formula` | 公式(只读字段) |
| `unidirectionalLink` | ✅ | `linkedTableId`, `multiple` | 单向关联 |
| `bidirectionalLink` | ✅ | `linkedTableId`, `multiple` | 双向关联 |
| `creator` | ❌ | — | 系统字段:创建人(只读) |
| `lastModifier` | ❌ | — | 系统字段:最后编辑人(只读) |
| `createdTime` | ❌ | — | 系统字段:创建时间(只读) |
| `lastModifiedTime` | ❌ | — | 系统字段:最后编辑时间(只读) |
## 3. 各类型 config 详解
### 3.1 number(数字)
config 字段:`formatter`
可选值:
- `INT` — 整数
- `FLOAT_1` — 1 位小数
- `FLOAT_2` — 2 位小数(默认)
- `FLOAT_3` — 3 位小数
- `FLOAT_4` — 4 位小数
- `THOUSAND` — 千分位整数
- `THOUSAND_FLOAT` — 千分位 + 小数
- `PERCENT` — 百分比(整数)
- `PERCENT_FLOAT` — 百分比(小数)
```json
{"fieldName": "工时", "type": "number", "config": {"formatter": "FLOAT_2"}}
```
```json
{"fieldName": "完成率", "type": "number", "config": {"formatter": "PERCENT"}}
```
### 3.2 singleSelect / multipleSelect(单选 / 多选)
config 字段:`options`(必填)
options 结构:
- `options` 是数组,每项至少包含 `name`
- 创建时只传 `name`,`id` 由系统生成
- **更新时**:已有选项必须回传原 `id`(从 `field get` 获取),新增选项不传 id
```json
{
"fieldName": "优先级",
"type": "singleSelect",
"config": {
"options": [
{"name": "紧急"},
{"name": "高"},
{"name": "中"},
{"name": "低"}
]
}
}
```
更新已有字段时(保留原选项 + 新增):
```json
{
"options": [
{"id": "opt_existing_1", "name": "紧急"},
{"id": "opt_existing_2", "name": "高"},
{"id": "opt_existing_3", "name": "中"},
{"name": "极低"}
]
}
```
> 更新 options 是**全量覆盖**,不是追加!不传的旧选项会被删除,关联的单元格数据丢失。
### 3.3 date(日期)
config 字段:`formatter`
可选值:
- `YYYY-MM-DD`(默认)
- `YYYY-MM-DD HH:mm`
- `YYYY-MM-DD HH:mm:ss`
- `YYYY/MM/DD`
- `YYYY/MM/DD HH:mm`
```json
{"fieldName": "截止日期", "type": "date", "config": {"formatter": "YYYY-MM-DD"}}
```
```json
{"fieldName": "创建时间", "type": "date", "config": {"formatter": "YYYY-MM-DD HH:mm"}}
```
### 3.4 currency(货币)
config 字段:`currencyType`(必填)、`formatter`(可选)
currencyType 可选值:
`CNY` | `HKD` | `USD` | `EUR` | `GBP` | `MOP` | `VND` | `JPY` | `KRW` | `AED` | `AUD` | `BRL` | `CAD` | `CHF` | `INR` | `IDR` | `MXN` | `MYR` | `PHP` | `PLN` | `RUB` | `SGD` | `THB` | `TRY` | `TWD`
formatter 可选值(控制小数位):`INT` | `FLOAT_1` | `FLOAT_2`(默认)| `FLOAT_3` | `FLOAT_4`
```json
{"fieldName": "预算", "type": "currency", "config": {"currencyType": "CNY", "formatter": "FLOAT_2"}}
```
### 3.5 progress(进度)
config 字段:`formatter`(固定为 `PERCENT`)、`customizeRange`、`min`、`max`
- 默认范围:0~1(即 0%~100%)
- 自定义范围时 `customizeRange` 必须为 `true`
```json
{"fieldName": "完成度", "type": "progress", "config": {"formatter": "PERCENT"}}
```
自定义范围:
```json
{"fieldName": "进度", "type": "progress", "config": {"formatter": "PERCENT", "customizeRange": true, "min": 0, "max": 1}}
```
### 3.6 rating(评分)
config 字段:`min`、`max`、`icon`
- `min`:固定为 `1`
- `max`:1~10,默认 `5`
- `icon`:默认 `star`
```json
{"fieldName": "满意度", "type": "rating", "config": {"min": 1, "max": 5, "icon": "star"}}
```
### 3.7 user / department / group(人员 / 部门 / 群组)
config 字段:`multiple`
- `multiple`:`true`(多选,默认)| `false`(单选)
```json
{"fieldName": "负责人", "type": "user", "config": {"multiple": false}}
```
```json
{"fieldName": "协作部门", "type": "department", "config": {"multiple": true}}
```
### 3.8 formula(公式)
config 字段:`formula`(必填)
- 公式中引用字段使用**方括号 + 字段名**:`[字段名]`
- 支持的函数:参考钉钉 AI 表格公式文档
```json
{"fieldName": "合计", "type": "formula", "config": {"formula": "[单价] * [数量]"}}
```
```json
{"fieldName": "是否逾期", "type": "formula", "config": {"formula": "IF([截止日期] < NOW(), \"是\", \"否\")"}}
```
> ⚠️ formula 字段创建后为**只读**,不能通过 record create/update 写入值。
### 3.9 unidirectionalLink(单向关联)
config 字段:`linkedTableId`(必填)、`multiple`
- `linkedTableId`:目标表的 tableId
- `multiple`:`true`(多选,默认)| `false`(单选)
```json
{"fieldName": "关联项目", "type": "unidirectionalLink", "config": {"linkedTableId": "tblXXXXXX", "multiple": true}}
```
### 3.10 bidirectionalLink(双向关联)
config 字段:`linkedTableId`(必填)、`multiple`
- 与单向关联参数相同
- 创建后系统会**自动**在被关联表创建反向字段
```json
{"fieldName": "关联任务", "type": "bidirectionalLink", "config": {"linkedTableId": "tblYYYYYY", "multiple": true}}
```
## 4. AI 字段(ai-config)
AI 字段不使用 config,而使用独立的 `--ai-config` 参数。详见 [aitable-field.md](./aitable-field.md) 中的 AI 字段创建示例。
核心规则:
- `outputType` 必须与 `--type` 对应:text→text, select→singleSelect, multiSelect→multipleSelect, number→number, currency→currency, image/video→attachment
- `prompt` 中必须至少包含一个 `fieldRef` 引用
- 纯文本 prompt 会被后端拒绝
## 5. 常见错误
| 错误 | 说明 |
|------|------|
| options 更新时不传已有选项的 id | 会被视为新选项,旧选项被删除,关联数据丢失 |
| options 更新时只传新增项 | 全量覆盖,旧选项全部丢失 |
| formula 字段尝试写入值 | 只读字段,record create/update 会报错 |
| linkedTableId 传表名而非 ID | 必须传 tableId(如 `tblXXX`),不接受表名 |
| progress 值写入 50 表示 50% | 实际应写入 0.5(range 0~1) |
| rating 值超出 max | 写入会报错 |
# field — 字段管理
## field get — 获取字段详情
```
Usage:
dws aitable field get [flags]
Example:
dws aitable field get --base-id <BASE_ID> --table-id <TABLE_ID>
dws aitable field get --base-id <BASE_ID> --table-id <TABLE_ID> --field-ids fld1,fld2
Flags:
--base-id string Base ID (必填)
--field-ids string 字段 ID 列表,逗号分隔,单次最多 10 个
--table-id string Table ID (必填)
```
返回字段的完整配置(含 options 等)。在 table get 拿到字段目录后,按需展开少量字段的完整配置。
## field create — 创建字段
```
Usage:
dws aitable field create [flags]
Example:
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--name "状态" --type "singleSelect" --config '{"options":[{"name":"待办"},{"name":"进行中"},{"name":"已完成"}]}'
# 或者使用批量创建模式:
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--fields '[{"fieldName":"状态","type":"singleSelect","config":{"options":[{"name":"待办"}]}}]'
Flags:
--base-id string Base ID (必填)
--name string 要创建的单字段名称(与 --type 配合使用,替代 --fields)
--type string 要创建的单字段类型(参考 table create 字段类型)
--config string 单字段配置,如 options(可选)
--ai-config string 单字段 AI 配置 JSON(可选,用于创建 AI 字段)
--fields string 批量新增字段 JSON 数组,单次最多 15 个 (与 --name/--type 二选一)
--table-id string Table ID (必填)
```
允许部分成功,返回结果逐项标明成功/失败状态。
### AI 字段创建示例
```bash
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--name "AI摘要" --type text \
--ai-config '{
"outputType":"text",
"prompt":[
{"type":"text","value":"请将下面内容总结成不超过80字的中文摘要:"},
{"type":"fieldRef","fieldId":"fld_content"}
],
"autoRecompute":true,
"enableWebSearch":false,
"enableThinking":true
}' --format json
```
说明:
- `outputType` 与字段类型需一致(如 `outputType=text` 配 `--type text`)
- `prompt` 里通过 `fieldRef` 引用已有字段
- `autoRecompute=true` 表示引用字段变化后自动重算
- **AI 字段的 prompt 必须至少包含一个 `fieldRef` 引用**,纯文本 prompt 会被后端拒绝
### 关联字段与跨表引用字段
创建 `lookup`(关联引用)和 `filterUp`(查找引用)字段时,config 格式有严格要求:
#### bidirectionalLink / unidirectionalLink(关联字段)
```bash
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--name "关联客户" --type bidirectionalLink \
--config '{"linkedTableId":"<目标表tableId>","multiple":true}' --format json
```
#### lookup(关联引用,通过已有关联字段取值)
**前置条件**:本表必须已有一个 bidirectionalLink 或 unidirectionalLink 类型的关联字段。
```bash
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--name "客户城市" --type lookup \
--config '{"associateField":"<本表关联字段的fieldId>","valuesField":"<关联目标表中要取值的字段fieldId>","aggregator":"CONCATENATE"}' --format json
```
config 必填字段:
- `associateField`:**本表中**已有的关联字段(bidirectionalLink/unidirectionalLink)的 fieldId
- `valuesField`:**关联目标表中**要取值的字段 fieldId
- `aggregator`:聚合方式,可选 `SUM`|`AVERAGE`|`COUNT`|`MAX`|`MIN`|`CONCATENATE`
> 常见错误:`associateField` 不是目标表的 tableId,也不是目标表的字段 ID,而是**本表中关联字段自身的 fieldId**。
#### filterUp(查找引用,无需关联字段,直接跨表取值)
```bash
# 基本用法:字段对常量匹配
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--name "客户总金额" --type filterUp \
--config '{"targetSheet":"<目标表tableId>","filters":[{"fieldId":"<目标表字段Id>","operator":"equal","value":"匹配值","link":"AND"}],"valuesField":"<目标表中要取值的字段Id>","aggregator":"SUM"}' --format json
# 进阶用法:字段对字段动态匹配(currentSheetFieldId)
dws aitable field create --base-id <BASE_ID> --table-id <TABLE_ID> \
--name "本城市订单金额" --type filterUp \
--config '{"targetSheet":"<目标表tableId>","filters":[{"fieldId":"<目标表字段Id>","operator":"equal","currentSheetFieldId":"<本表字段Id>","link":"AND"}],"valuesField":"<目标表中要取值的字段Id>","aggregator":"SUM"}' --format json
```
config 必填字段:
- `targetSheet`:目标表的 tableId
- `filters`:至少一条筛选规则
- `fieldId`:目标表中用于匹配的字段 fieldId
- `operator`:仅支持 `equal`、`contain`(不支持 not_equal/not_contain)
- `value`:常量匹配值(与 `currentSheetFieldId` 二选一)
- `currentSheetFieldId`:本表中用于动态匹配的字段 fieldId(与 `value` 二选一,实现每行按本表字段值去目标表筛选)
- `link`:多条件时的逻辑关系,`AND` 或 `OR`(单条件时可省略,多条件时建议显式指定;所有 filter 的 link 必须统一)
- `valuesField`:目标表中要取值的字段 fieldId
- `aggregator`:聚合方式,可选 `SUM`|`AVERAGE`|`COUNT`|`MAX`|`MIN`|`CONCATENATE`
## field update — 更新字段
```
Usage:
dws aitable field update [flags]
Example:
dws aitable field update --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --name "新字段名"
dws aitable field update --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --config '{"options":[{"name":"A"},{"name":"B"}]}'
Flags:
--base-id string Base ID (必填)
--config string 字段配置 JSON (不修改时省略)
--ai-config string AI 配置 JSON (不修改时省略)
--field-id string Field ID (必填)
--name string 新字段名称 (不修改时省略)
--table-id string Table ID (必填)
```
- 不可变更字段类型
- 更新 singleSelect/multipleSelect 的 options 时需传入完整列表,已有选项应回传原 id
- `--name` / `--config` / `--ai-config` 至少传一个
## field delete — 删除字段
```
Usage:
dws aitable field delete [flags]
Example:
dws aitable field delete --base-id <BASE_ID> --table-id <TABLE_ID> --field-id <FIELD_ID> --yes
Flags:
--base-id string Base ID (必填)
--field-id string 待删除字段 ID (必填)
--table-id string Table ID (必填)
```
不可逆。禁止删除主字段和最后一个字段。
# filters & sort — 筛选排序语法参考
> 视图(view)配置的 filter/sort/group **整体写入**请优先用 `view update filter` / `view update sort` / `view update group` 子命令,详见 [aitable-view-config.md](./aitable-view-config.md)。本文件聚焦于 `record query --filters` 与 view config filter 的语法和差异。
## filters 结构规范
### 强制规则
1. **根节点必须是逻辑操作符**:`"operator"` 必须是 `"and"` 或 `"or"`,不能是 `"eq"` 等比较操作符
2. 比较操作必须放在根节点的 `"operands"` 数组内的对象中
3. `singleSelect` 和 `multipleSelect` 字段,推荐使用 **选项的 exact String 名称 (name)** 作为比较值
4. fieldId 必须通过 `table get` 或 `field get` 获取,不能直接用字段名称
### 精简防呆模板
CLI 同时兼容两种子条件写法(推荐格式 A):
**格式 A(operands 数组,推荐):**
```json
{
"operator": "and",
"operands": [
{"operator": "eq", "operands": ["fld_state", "进行中"]}
]
}
```
**格式 B(fieldId/value 对象,CLI 自动转换):**
```json
{
"operator": "and",
"operands": [
{"fieldId": "fld_state", "operator": "eq", "value": "进行中"}
]
}
```
4 种衍生:
- **OR 查询**:根节点 `"operator"` 改为 `"or"`
- **多条件 AND**:在 `"operands"` 数组中增加对象
- **文本包含**:内层 `"operator"` 改为 `"contain"`
- **为空判断**:`"operator":"un_exist"`,operands 只需 `["fieldId"]`
### 支持的操作符(已验证完整列表)
| 操作符 | 含义 | operands 格式 |
|--------|------|--------------|
| `eq` / `ne` | 等于 / 不等于 | `["fieldId", "value"]` |
| `contain` / `exclusive` | 包含 / 不包含(文本模糊) | `["fieldId", "value"]` |
| `gt` / `gte` / `lt` / `lte` | 大于 / ≥ / 小于 / ≤ | `["fieldId", "numStr"]` |
| `exist` / `un_exist` | 有值 / 为空 | `["fieldId"]`(无需第二项) |
| `any_of` / `none_of` / `all_of` | 包含任一 / 不包含任一 / 全包含(多选字段) | `["fieldId", "optionName"]` |
| `date_eq` / `before` / `after` | 日期等于 / 早于 / 晚于 | `["fieldId", "dateStr"]` |
| `not_before` / `not_after` | 不早于(≥) / 不晚于(≤) | `["fieldId", "2026-05-22"]` |
> **操作符拼写必须严格匹配上表**,CLI 会在调用前校验,错误拼写会被拒绝。
>
> **没有 `date_between`(区间)操作符**,也**不支持 `from_now`**——date 字段不支持区间/相对过滤,传了会被 CLI 拒绝。范围查询用 `not_before` + `not_after` 组合,见下方专节。
### 日期字段过滤(date / 创建时间 / 修改时间)
日期类字段的过滤规则与其它字段**不同**,是线上反馈最高频的踩坑点。**经集成测试实测**确认的规则:
1. **只能用日期专用操作符**:`date_eq` / `before` / `after` / `not_before` / `not_after` / `exist` / `un_exist`(与前端筛选 UI 的「等于 / 早于 / 晚于 / 早于或等于 / 晚于或等于 / 不为空 / 为空」一一对应)。
2. **比较值用日期字符串**,如 `"2026-05-22"`(也接受 RFC3339 / 毫秒时间戳,内部统一转成毫秒比较)。读取返回的是带时区 RFC3339(如 `"2026-05-22T00:00:00+08:00"`)。
3. **通用操作符 `eq` / `ne` / `gt` / `gte` / `lt` / `lte` / `contain` 对 date 字段无效**——无论传 ISO 字符串还是毫秒时间戳,都会**静默返回 0 条**。这是后端 date 字段的比较规则,不是 bug,CLI 也无法在本地拦截(不知道字段类型),务必用对操作符。
4. **没有区间操作符 `date_between`**,也**不支持 `from_now`(相对天数)**——均会静默返回 0 条,CLI 已直接拒绝。范围查询用 `not_before`(≥起点)+ `not_after`(≤终点)两个条件 `and` 组合。
| 需求 | 操作符 | 示例 operands |
|------|--------|--------------|
| 等于某天 | `date_eq` | `["fldDate", "2026-05-22"]` |
| 早于 / 晚于(不含当天) | `before` / `after` | `["fldDate", "2026-05-22"]` |
| 不早于(≥) / 不晚于(≤) | `not_before` / `not_after` | `["fldDate", "2026-05-22"]` |
| 有值 / 为空 | `exist` / `un_exist` | `["fldDate"]` |
**日期区间查询(替代 between)**——查 `2026-05-01 ~ 2026-05-31`(含端点):
```bash
dws aitable record query --base-id X --table-id Y \
--filters '{"operator":"and","operands":[{"operator":"not_before","operands":["fldDate","2026-05-01"]},{"operator":"not_after","operands":["fldDate","2026-05-31"]}]}'
```
### 常见错误拼写(CLI 会自动提示纠正)
| 错误写法 | 正确写法 | 说明 |
|------------|-----------|------|
| `equal` / `equals` / `is` / `==` | `eq` | 等于 |
| `not_equal` / `not_equals` / `is_not` / `!=` | `ne` | 不等于 |
| `like` / `contains` / `include` | `contain` | 文本包含 |
| `greater_than` | `gt` | 大于 |
| `less_than` | `lt` | 小于 |
| `not_eq` / `not_contain` / `is_empty` | `ne` / `exclusive` / `un_exist` | 其他易混淆 |
### 错误示例
❌ **缺失根节点 and/or**(API 将忽略该 filter,返回全表):
```json
{"operator":"eq","operands":["fldXXX","本科"]}
```
❌ **传入选项 ID 而非名称**(可能导致匹配不到 0 记录):
```json
{"operator":"and","operands":[{"operator":"eq","operands":["fldXXX","CXzrOHK9JI"]}]}
```
### 完整示例
单条件:
```bash
dws aitable record query --base-id X --table-id Y \
--filters '{"operator":"and","operands":[{"operator":"eq","operands":["fldStatusId","进行中"]}]}'
```
多条件 AND:
```bash
dws aitable record query --base-id X --table-id Y \
--filters '{"operator":"and","operands":[{"operator":"eq","operands":["fldStatusId","进行中"]},{"operator":"gt","operands":["fldStockId","0"]}]}'
```
## sort 结构规范
`--sort` 传 JSON 数组,排序方向字段**必须是 `direction`**,不要使用 `order`。
```bash
--sort '[{"fieldId":"fldXXX","direction":"desc"}]'
```
多字段排序:
```bash
--sort '[{"fieldId":"fldPriority","direction":"desc"},{"fieldId":"fldCreatedAt","direction":"asc"}]'
```
---
## view update --config 中的 filter / sort 格式
> **重要区分**:`record query --filters` 和 `view update --config` 中的 filter **格式不同**!
| 场景 | filter 格式 | 说明 |
|------|-------------|------|
| `record query --filters` | **对象**:`{"operator":"and","operands":[...]}` | 直接传最外层逻辑对象 |
| `view update --config` 的 filter | **数组**:`[{"operator":"and","operands":[...]}]` | 外面多一层数组包裹 |
| `view update --config` 的 sort | **数组**:`[{"fieldId":"X","direction":"asc"}]` | 与 record query --sort 一致 |
### 正确示例
```bash
# view update 设置筛选(filter 是数组)
dws aitable view update --base-id X --table-id Y --view-id Z \
--config '{"filter":[{"operator":"and","operands":[{"operator":"eq","operands":["fldStatus","待处理"]}]}]}'
# view update 设置排序(sort 是数组)
dws aitable view update --base-id X --table-id Y --view-id Z \
--config '{"sort":[{"fieldId":"fldPriority","direction":"desc"}]}'
# 同时设置 filter + sort + visibleFieldIds
dws aitable view update --base-id X --table-id Y --view-id Z \
--config '{"filter":[{"operator":"and","operands":[{"operator":"eq","operands":["fldStatus","进行中"]}]}],"sort":[{"fieldId":"fldDate","direction":"asc"}],"visibleFieldIds":["fld1","fld2","fld3"]}'
```
### CLI 自动容错
CLI 会自动修正以下常见错误格式(不会报错,但建议直接使用正确格式):
| 错误写法 | CLI 自动修正为 |
|----------|---------------|
| `"filter":{"operator":"and",...}` (对象) | `"filter":[{"operator":"and",...}]` (数组) |
| `"sort":{"fieldId":"X","direction":"asc"}` (对象) | `"sort":[{"fieldId":"X","direction":"asc"}]` (数组) |
| 子条件用 MCP 简写 `{"fieldId":"X","operator":"eq","value":"Y"}` | 自动转为 `{"operator":"eq","operands":["X","Y"]}` |
# form — 表单管理
## 命令一览
| 命令 | 用途 |
|------|------|
| `form list` | 列出数据表下所有表单视图 |
| `form get` | 按 viewId 取单个表单详情(list_form_views + viewIds 过滤) |
| `form create` | 创建表单视图(等价于 `view create --view-type FormDesigner`) |
| `form update` | 更新表单标题或描述 |
| `form delete` | 删除表单视图(不可逆) |
| `form field list` | 列出表单可见字段 |
| `form field update` | 更新字段必填/描述 |
| `form field hide` | 在表单中隐藏/显示字段(不影响底层数据表字段) |
| `form share get` | 获取分享配置 |
| `form share update` | 开启/关闭分享 |
| `form questions create` | 添加题目(等价于 `field create`,命令位置上的别名) |
| `form questions delete` | 删除题目(等价于 `field delete`,命令位置上的别名) |
## 建议操作顺序
```bash
# 1) 列出数据表下的表单视图
dws aitable form list --base-id BASE_ID --table-id TABLE_ID --format json
# 2) 查看单个表单详情
dws aitable form get --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --format json
# 3) 查看表单字段配置
dws aitable form field list --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --format json
# 4) 查看分享配置
dws aitable form share get --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID --format json
```
## 要点
- **创建表单**有两种等价方式:
- `form create --name "表单名"`(推荐,语义清晰)
- `view create --view-type FormDesigner --name "表单名"`(底层一致)
- `form update` 支持 `--title` 与 `--name` 两个等价参数;至少需传一项
- `form field update` 必须传 `--required` 或 `--field-description` 至少一项
- `form field hide` 仅控制字段在表单中的可见性,不影响底层数据表字段
- **题目管理**与字段管理本质相同(题目 = 表格字段):
- `form questions create` 与 `field create` 入参完全一致(`--fields` JSON 或 `--name --type`)
- `form questions delete` 与 `field delete` 入参完全一致(必传 `--field-id`)
- 设置必填要在 create 后用 `form field update --required true` 单独调一次
## form 子命令
| 命令 | 用途 | 必填参数 | 说明 |
|------|------|----------|------|
| `form list` | 列出表单视图 | `--base-id` `--table-id` | 返回 viewId/name/title/createdAt |
| `form get` | 按 viewId 取单个表单 | `--base-id` `--table-id` `--view-id` | 内部基于 list_form_views 过滤 |
| `form create` | 创建表单视图 | `--base-id` `--table-id` `--name` | viewType=FormDesigner |
| `form update` | 更新表单 | `--base-id` `--table-id` `--view-id` | `--title`/`--name`(等价)和 `--description` 至少传一项;同时传 title/name 时 title 优先 |
| `form delete` | 删除表单 | `--base-id` `--table-id` `--view-id` `--yes` | 不可逆 |
## form field 子命令
| 命令 | 用途 | 必填参数 | 说明 |
|------|------|----------|------|
| `form field list` | 列出表单字段 | `--base-id` `--table-id` `--view-id` | 返回 fieldId/name/type/required/hidden/description(hidden=true 的字段不在此返回) |
| `form field update` | 更新表单字段 | `--base-id` `--table-id` `--view-id` `--field-id` | `--required` 或 `--field-description` 至少一项 |
| `form field hide` | 切换字段隐藏 | `--base-id` `--table-id` `--view-id` `--field-id` `--hidden` | `--hidden true` 隐藏 / `--hidden false` 显示 |
## form questions 子命令
`form questions create/delete` 与 `field create/delete` 入参、行为完全一致,只是命令位置归属于 `form` 命令组,方便从表单视角操作题目。
| 命令 | 用途 | 必填参数 | 说明 |
|------|------|----------|------|
| `form questions create` | 添加题目 | `--base-id` `--table-id` + (`--fields` 或 `--name --type`) | 入参与 `field create` 完全一致 |
| `form questions delete` | 删除题目 | `--base-id` `--table-id` `--field-id` `--yes` | 入参与 `field delete` 完全一致;不可逆;批量需多次调用 |
## form share 子命令
| 命令 | 用途 | 必填参数 | 说明 |
|------|------|----------|------|
| `form share get` | 获取分享配置 | `--base-id` `--table-id` `--view-id` | 返回 enabled/status/shareFormUuid |
| `form share update` | 开启/关闭分享 | `--base-id` `--table-id` `--view-id` `--enabled` | `--enabled true` 开启 / `--enabled false` 关闭。注意:UI 上"发布并分享"按钮是另一概念,本命令只切换内部 enabled 标志,开启后需在 UI 刷新页面才会看到分享面板 |
## 完整工作流示例
> **占位符约定**:
> - `BASE_ID` 来自 `dws aitable base list` / `base search` 返回的 `data.bases[].baseId`
> - `TABLE_ID` 来自 `dws aitable base get --base-id BASE_ID` 返回的 `data.tables[].tableId`
> - `VIEW_ID` 来自步骤 1 `form create` 返回的 `data.viewId`
> - `FIELD_ID` 来自步骤 2 `form questions create` 返回的 `data.results[].fieldId`
```bash
# 1) 创建表单 → 取返回的 data.viewId 作为 VIEW_ID
dws aitable form create --base-id BASE_ID --table-id TABLE_ID --name "员工信息收集" --format json
# 2) 添加题目 → 取返回的 data.results[].fieldId 作为 FIELD_ID
dws aitable form questions create --base-id BASE_ID --table-id TABLE_ID \
--fields '[{"fieldName":"姓名","type":"text"},{"fieldName":"邮箱","type":"text"}]' --format json
# 3) 配置表单标题与描述
dws aitable form update --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
--title "员工信息收集" --description "请填写您的基本信息" --format json
# 4) 设置题目必填(FIELD_ID 来自步骤 2)
dws aitable form field update --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
--field-id FIELD_ID --required true --format json
# 5) 隐藏不需要的题目
dws aitable form field hide --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
--field-id FIELD_ID --hidden true --format json
# 6) 开启分享(注意:开启后需 UI 刷新页面才会看到分享面板)
dws aitable form share update --base-id BASE_ID --table-id TABLE_ID --view-id VIEW_ID \
--enabled true --format json
```
## 返回结构补充
- `form list` 返回 `data.formViews[]`,**每条仅含** `viewId/name/title/createdAt`;`shareFormUuid` 不在此返回,请用 `form share get` 单独获取。
- `form get` 返回结构与 `form list` 完全一致(`data.formViews[]`),仅含一条记录(与请求 viewId 一致)。Agent 提取时仍走 `data.formViews[0]`。
- `form field list` 仅返回**未隐藏**的字段;`hidden=true` 的字段不在此返回,如需查看全部字段请用 `field get`。
# AI 表格公式字段指南
> 当用户要创建 formula 类型字段、编写表内计算公式、做派生指标时,必须先读本文档。
## 1. 何时使用 formula 字段
| 场景 | 用 formula | 不用 formula |
|------|-----------|-------------|
| 长期展示在表中的派生值(如"总价=单价×数量") | ✅ | |
| 条件标记(如"超期=IF(截止日期<TODAY(),'是','否')") | ✅ | |
| 文本拼接(如"全名=姓&名") | ✅ | |
| 一次性统计分析(如"本月总销售额") | | ✅ 用 record query + 本地聚合 |
| 跨表查找引用 | | ✅ 用 lookup 字段(见下方说明) |
## 2. 创建 formula 字段
```bash
dws aitable field create \
--base-id <baseId> \
--table-id <tableId> \
--name "总价" \
--type formula \
--config '{"formula": "[单价] * [数量]"}' \
--format json
```
### config 结构
```json
{
"formula": "<公式表达式>"
}
```
- `formula` 是唯一必填字段
- 表达式中引用字段使用 **方括号 + 字段名**:`[字段名]`
- 字段名必须精确匹配(含空格、大小写)
## 3. 公式语法
### 3.1 引用规则
| 引用方式 | 语法 | 说明 |
|---------|------|------|
| 引用本表字段 | `[字段名]` | 字段名必须精确匹配 |
| 引用关联表字段 | 不支持 | 需要用 lookup 字段 |
### 3.2 常用函数分类
#### 数值计算
| 函数 | 用途 | 示例 |
|------|------|------|
| `+` `-` `*` `/` | 四则运算 | `[单价] * [数量]` |
| `SUM(...)` | 求和 | `SUM([Q1], [Q2], [Q3], [Q4])` |
| `ROUND(value, digits)` | 四舍五入 | `ROUND([金额] * 0.1, 2)` |
| `ABS(value)` | 绝对值 | `ABS([差额])` |
| `MAX(a, b, ...)` | 最大值 | `MAX([成绩1], [成绩2])` |
| `MIN(a, b, ...)` | 最小值 | `MIN([报价1], [报价2])` |
#### 文本处理
| 函数 | 用途 | 示例 |
|------|------|------|
| `&` | 文本拼接 | `[姓] & [名]` |
| `CONCATENATE(...)` | 拼接多个值 | `CONCATENATE([城市], "-", [区])` |
| `LEFT(text, n)` | 取左侧 n 字符 | `LEFT([编号], 4)` |
| `RIGHT(text, n)` | 取右侧 n 字符 | `RIGHT([手机], 4)` |
| `LEN(text)` | 文本长度 | `LEN([备注])` |
| `UPPER(text)` / `LOWER(text)` | 大小写转换 | `UPPER([代码])` |
#### 逻辑判断
| 函数 | 用途 | 示例 |
|------|------|------|
| `IF(条件, 真值, 假值)` | 条件判断 | `IF([金额] > 1000, "大额", "普通")` |
| `AND(a, b, ...)` | 逻辑与 | `IF(AND([状态]="完成", [评分]>=4), "优秀", "")` |
| `OR(a, b, ...)` | 逻辑或 | `IF(OR([等级]="A", [等级]="B"), "通过", "未通过")` |
| `NOT(expr)` | 逻辑非 | `NOT([已归档])` |
| `SWITCH(expr, v1, r1, v2, r2, ..., default)` | 多条件匹配 | `SWITCH([状态], "待办","🔴", "进行中","🟡", "完成","🟢", "")` |
#### 日期函数
| 函数 | 用途 | 示例 |
|------|------|------|
| `TODAY()` | 当前日期 | `IF([截止日期] < TODAY(), "已逾期", "正常")` |
| `NOW()` | 当前时间 | `NOW()` |
| `YEAR(date)` / `MONTH(date)` / `DAY(date)` | 提取年/月/日 | `YEAR([创建时间])` |
| `DATEDIF(start, end, unit)` | 日期差 | `DATEDIF([开始], [结束], "d")` 返回天数 |
| `DATEADD(date, count, unit)` | 日期加减 | `DATEADD([创建时间], 7, "d")` |
> `DATEDIF` 的 unit 参数:`"y"`=年, `"m"`=月, `"d"`=天
#### 空值处理
| 函数 | 用途 | 示例 |
|------|------|------|
| `BLANK()` | 空值常量 | `IF([备注] = BLANK(), "无", [备注])` |
| `IF(field, ...)` | 字段为空时视为 false | `IF([评分], [评分], 0)` |
## 4. 常见公式模板
### 4.1 计算类
```
// 含税价格
[不含税价] * (1 + [税率])
// 完成率百分比
[已完成数] / [总数]
// 折扣后价格
[原价] * (1 - [折扣率])
```
### 4.2 状态标记类
```
// 逾期标记
IF([截止日期] < TODAY(), "⚠️ 已逾期", "正常")
// 优先级标签
SWITCH([优先级], "紧急","🔴P0", "高","🟠P1", "中","🟡P2", "低","🟢P3", "")
// 进度状态
IF([进度] >= 1, "✅ 已完成", IF([进度] > 0, "🔄 进行中", "⏳ 未开始"))
```
### 4.3 文本拼接类
```
// 编号生成
"PRJ-" & [项目编码] & "-" & [序号]
// 地址拼接
[省] & [市] & [区] & [详细地址]
```
## 5. 注意事项与限制
### 5.1 formula 字段是只读的
- formula 字段的值由系统自动计算,**不能通过 `record create/update` 写入**
- 如果用户要"设置某个计算结果",应引导其修改源字段
### 5.2 字段名必须精确
- 公式中的 `[字段名]` 必须与表中实际字段名完全一致
- 创建 formula 字段前,先通过 `field get` 确认字段名
### 5.3 循环引用
- formula 字段不能引用自身
- 不能形成 A→B→A 的循环引用
### 5.4 与跨表引用字段的区别
钉钉 AI 表格有两种跨表取值方式:`lookup`(关联引用)和 `filterUp`(查找引用)。
| 维度 | formula | lookup (关联引用) | filterUp (查找引用) |
|------|---------|-----------------|-------------------|
| 字段类型 | `formula` | `lookup` | `filterUp` |
| 数据来源 | 本表字段 | 通过已有关联字段(bidirectionalLink/unidirectionalLink)取关联表字段 | 直接指定目标表 + 筛选条件取值 |
| 前置条件 | 无 | 必须先有关联字段 | 无需关联字段 |
| 适用场景 | 本表内计算、条件判断 | "我关联了某条记录,取它的某个字段值" | "在另一张表里按条件查找记录并聚合取值" |
#### lookup config(已验证)
```json
{
"associateField": "<本表中的关联字段 fieldId(bidirectionalLink/unidirectionalLink 类型)>",
"valuesField": "<关联目标表中要取值的字段 fieldId>",
"aggregator": "SUM|AVERAGE|COUNT|MAX|MIN|CONCATENATE"
}
```
创建示例:
```bash
dws aitable field create --base-id <baseId> --table-id <tableId> \
--name "关联名称" --type lookup \
--config '{"associateField":"<linkFieldId>","valuesField":"<targetFieldId>","aggregator":"CONCATENATE"}'
```
#### filterUp config(已验证)
```json
{
"targetSheet": "<目标表 tableId>",
"filters": [
{
"fieldId": "<目标表字段Id>",
"operator": "equal|contain",
"value": "<匹配值>",
"link": "AND"
}
],
"valuesField": "<目标表中要取值的字段Id>",
"aggregator": "SUM|AVERAGE|COUNT|MAX|MIN|CONCATENATE"
}
```
> `filters` 必须非空(至少一条筛选规则)。
> `filters[].operator` 仅支持:`equal`、`contain`(`not_equal`/`not_contain`/`is_empty` 等均不支持)。
> `filters[].link` 统一为 `"AND"` 或 `"OR"`。
### 5.5 创建前检查清单
1. 已通过 `field get` 确认所有引用字段的精确名称
2. 引用字段不包含 formula/lookup 等只读字段(可能导致二次计算延迟)
3. 公式语法正确(括号匹配、函数名正确)
4. 字段类型兼容(数值运算的字段确实是 number 类型)
## 6. 更新 formula 字段
```bash
dws aitable field update \
--base-id <baseId> \
--table-id <tableId> \
--field-id <fieldId> \
--config '{"formula": "[新字段A] + [新字段B]"}' \
--format json
```
更新时只需传新的 `formula` 表达式,系统会自动重新计算所有记录。
# 主键文档管理
## 适用场景
当需要为 AI 表格中的记录创建或查询关联的主键文档时使用。主键文档是 primaryDoc 类型字段对应的钉钉在线文档,可通过 `dws doc` 进行内容读写。
## 命令
### 查询主键文档
```bash
dws aitable record primary-doc-get --base-id BASE_ID --table-id TABLE_ID --record-id RECORD_ID
```
**参数:**
- `--base-id`(必填):Base ID
- `--table-id`(必填):Table ID
- `--record-id`(必填):Record ID
**返回:** `data.nodeId` — 主键文档的 nodeId,可直接传给 `dws doc read/update` 的 `--node` 参数。若该记录尚未创建主键文档,`nodeId` 为 null。
### 创建主键文档
```bash
dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --record-id RECORD_ID
```
**参数:**
- `--base-id`(必填):Base ID
- `--table-id`(必填):Table ID
- `--field-id`(必填):主键字段 ID,必须是 primaryDoc 类型(通过 `dws aitable table get` 查看字段类型)
- `--record-id`(必填):Record ID
**返回:** `data.nodeId` — 创建或已存在的主键文档 nodeId。
**幂等性:** 若该记录已有主键文档,直接返回已有文档的 nodeId,不会重复创建。
## 注意事项
- `fieldId` 必须是 primaryDoc 类型,否则返回 `INVALID_FIELD_TYPE` 错误
- 传入不存在的 `recordId` 会返回 `RECORD_NOT_FOUND` 错误
- 创建后可通过 `dws doc update --node <nodeId>` 写入文档内容,或 `dws doc read --node <nodeId>` 读取
## 典型工作流
```bash
# 1. 查询表结构,拿到 primaryDoc 字段的 fieldId
dws aitable table get --base-id BASE_ID --table-ids TABLE_ID
# 2. 为某条记录创建主键文档
dws aitable record primary-doc-create --base-id BASE_ID --table-id TABLE_ID --field-id FIELD_ID --record-id RECORD_ID
# 3. 拿到返回的 nodeId,用 dws doc 写入内容
dws doc update --node <data.nodeId> --content "# 项目方案\n\n文档正文内容..."
```
# record create — 新增记录
## 命令格式
```
Usage:
dws aitable record create [flags]
Example:
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldTextId":"文本内容","fldNumId":123}}]'
Flags:
--base-id string Base ID (必填)
--records string 记录列表 JSON 数组,单次最多 100 条 (必填,与 --records-file 二选一)
--records-file string 从文件读取 records JSON(替代 --records,适合超长数据或 Windows 环境)
--table-id string Table ID (必填)
```
## Windows / 超长 JSON 推荐
将 records JSON 写入文件,用 `--records-file ./records.json` 传入,避免命令行截断和引号转义问题。
## 常见错误(严格避免)
| 错误 | 说明 |
|------|------|
| 参数名用 `--data` | ❌ 参数名是 `--records`,不是 `--data` |
| cells key 用字段名 | ❌ cells key 必须是 fieldId(如 `fldXXX`),不是字段名称(如 `"课程名称"`) |
| 不先获取 fieldId | ❌ 必须先 `table get` 获取 fieldId,再写入记录 |
| 单次超 100 条 | ❌ 单次最多 100 条,超过需分批 |
| 附件/图片字段直传 URL | ❌ 严禁 `{"url":"https://..."}` — 会触发 TIMEOUT_ERROR。必须先 `attachment upload` 获取 `fileToken`,再用 `{"fileToken":"ft_xxx"}` 写入。详见 [aitable-attachment.md](./aitable-attachment.md) |
## 正确流程
```bash
# 先获取 fieldId
dws aitable table get --base-id <BASE_ID> --table-id <TABLE_ID> --format json
# 从返回中提取 fieldId(如 fldABC123)
# 再用 fieldId 写入记录
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldABC123":"Python入门"}}]' --format json
```
## cells 写入格式
各字段类型的写入格式见 [aitable-cell-value.md](./aitable-cell-value.md)。
# record delete — 删除记录
## 命令格式
```
Usage:
dws aitable record delete [flags]
Example:
dws aitable record delete --base-id <BASE_ID> --table-id <TABLE_ID> --record-ids rec1,rec2 --yes
Flags:
--base-id string Base ID (必填)
--record-ids string 待删除记录 ID 列表,逗号分隔,最多 100 条 (必填)
--table-id string Table ID (必填)
```
## 注意事项
- **不可逆操作**,调用前建议先 `record query` 确认目标记录
- 需要先通过 `record query` 获取 recordId
- 单次最多删除 100 条记录
# 行记录变更历史(record history-list)
按 recordId 查询单条记录的全部变更历史,用于审计、回溯字段变更、定位操作人。
## 命令
```
dws aitable record history-list \
--base-id BASE_ID --table-id TABLE_ID --record-id REC_ID \
[--offset N] [--limit M]
```
| flag | 说明 |
|------|------|
| `--base-id` | 所属 Base ID(必填,可用 `--base` 别名) |
| `--table-id` | 所属 Table ID(必填) |
| `--record-id` | 目标记录 ID(必填,单条;不支持批量) |
| `--offset` | 分页偏移量,默认 0 |
| `--limit` | 每页返回数量,范围 [1, 50],默认 20 |
## 返回结构
```jsonc
{
"data": {
"histories": [
{
"type": "field_change", // 变更类型
"action": "update", // 操作动作: create / update / delete
"newValue": "{\"...\":\"...\"}", // 变更后的值(JSON 字符串)
"oldValue": "{\"...\":\"...\"}", // 变更前的值(JSON 字符串)
"operateTime": 1733123456789, // 操作时间(毫秒级时间戳)
"typeChangedFields": "{...}", // 类型变更的字段信息(JSON 字符串)
"version": 7 // 版本号(单调递增)
}
]
}
}
```
`newValue` / `oldValue` / `typeChangedFields` 是 JSON 字符串(不是 JSON 对象),需要二次 `JSON.parse` 才能拿到结构化值。
## 字段含义速查
| 字段 | 用途 |
|------|------|
| `type` | 高层分类:`record_create` / `field_change` / `record_delete` 等。先按 type 过滤大类。 |
| `action` | 三态:`create` / `update` / `delete`。比 type 粗,但便于按"动作"统计。 |
| `version` | 单调递增整数;同一 record 越新值越大。**用作"上一条 vs 这一条"的稳定排序键**。 |
| `operateTime` | 毫秒时间戳;可格式化成可读时间。多条同 version 的极端场景用 operateTime 兜底排序。 |
## 典型用法
### 1. 看一条记录被改过几次
```bash
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --format json \
| jq '.data.histories[] | {version, action, operateTime}'
```
### 2. 翻页拉全量历史
```bash
# 第 1 页(最新 20 条)
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --limit 50 --offset 0
# 第 2 页
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --limit 50 --offset 50
```
`limit` 上限 50,需要更多请增加 `offset` 翻页。
### 3. 回溯某字段最近一次值
```bash
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --limit 50 --format json \
| jq '[.data.histories[] | select(.action == "update")][0].oldValue'
```
### 4. 找出删除事件(如果存在 delete history)
```bash
dws aitable record history-list --base-id BASE --table-id TBL --record-id REC --format json \
| jq '.data.histories[] | select(.action == "delete") | {version, operateTime}'
```
## 注意事项
- 一次只能查一条 record;如需批量审计多条记录请循环调用。
- 仅返回**字段值变更**与**记录生命周期事件**;视图、字段定义、表结构变更不在此 history 里。
- 历史保留时长由 server 决定,过老的记录可能不再返回。
## 与其他 record 命令的关系
- 想看记录"现在长什么样" → `record query` / `record get`
- 想看记录"过去长什么样、什么时候改的" → `record history-list`(本命令)
- 想看"这张表整体改过什么" → 当前 CLI 不支持表级 history;只能逐 record 查
# 行命名规则枚举键(recordNameKey)映射
`dws aitable table update --record-name-key <枚举键>` 用于设置数据表的"行命名规则"——卡片/详情页里"行"的展示别名。**取值是固定枚举,不是字段 ID**;传非法值服务端返回 `INVALID_RECORD_NAME_KEY`。
## 中文 → 枚举键(按 UI 下拉顺序)
| 用户说 | --record-name-key | 用户说 | --record-name-key |
|---|---|---|---|
| 记录 | `ji_lu`(默认) | 项目 | `project` |
| 任务 | `task` | 事件 | `event` |
| 请求 | `request` | 活动 | `campaign` |
| 目标 | `objective` | 交付物 | `deliverable` |
| 资产 | `asset` | 客户 | `customer` |
| 订单 | `order` | 联系人 | `contact` |
| 物料/物品 | `item` | 问题 | `question` 或 `issue` |
| 工单 | `ticket` | 候选人 | `candidate` |
| 商机/机会 | `opportunity` | 会议 | `meeting` |
| 成员 | `member` | OKR | `okr` |
## 其他常用键(按场景分组)
- **业务流程**:`approval` / `application` / `case` / `decision` / `delivery` / `payment` / `purchase_order` / `quote` / `release`
- **HR / 财务**:`employee` / `expense` / `budget` / `invoice`
- **产品 / 研发**:`feature` / `feedback` / `idea` / `bug` / `requirement` / `risk` / `sprint` / `story` / `subtask` / `epic`
- **CRM**:`account` / `lead` / `prospect` / `deal`
- **运营 / 支持**:`note` / `report` / `topic` / `session` / `service`
- **资源 / 通用**:`file` / `document` / `product` / `team` / `user` / `vendor` / `key_result` / `metric`
完整集合较大(共 273 个),服务端校验;以上未列出的合法键也可直接传(如 `goal` / `okr` / `pillar` / `phase` / `milestone` 等)。
## 使用示例
```bash
# 用户说"把这张表的行叫'任务'吧" → 传 task
dws aitable table update --base-id BASE --table-id TBL --record-name-key task
# 用户说"换成项目" → 传 project
dws aitable table update --base-id BASE --table-id TBL --record-name-key project
# 用户说"恢复成默认(记录)" → 传 ji_lu
dws aitable table update --base-id BASE --table-id TBL --record-name-key ji_lu
```
## 注意
- recordNameKey **不会在 `table get` 响应里回显**(`get_tables` DTO 设计上不暴露该字段);写入是否成功以 `table update` 的 set response 是否回填 `recordNameKey` 字段为准。
- 中文别名是 server 内置 i18n,UI 显示用户对应的国际化文案,CLI 必须传英文枚举键。
# record query — 查询记录
## 命令格式
```
Usage:
dws aitable record query [flags]
Example:
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID>
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --record-ids rec1,rec2
dws aitable record query --base-id <BASE_ID> --table-id <TABLE_ID> --query "关键词" --limit 50
Flags:
--base-id string Base ID (必填)
--cursor string 分页游标,首次不传
--field-ids string 返回字段 ID 列表,逗号分隔,单次最多 100 个
--filters string 结构化过滤条件 JSON
--query string 全文关键词搜索
--limit int 单次最大记录数,默认 100,最大 100
--record-ids string 指定记录 ID 列表,逗号分隔,单次最多 100 个
--sort string 排序条件 JSON 数组
--table-id string Table ID (必填)
--all 启用自动翻页,循环获取并合并所有记录后统一输出
--page-limit int 自动翻页最大页数(仅 --all 时生效)。默认 50,设为 0 表示无限制
```
两种模式: 按 ID 取(传 record-ids,忽略 filters/sort)或条件查(filters+sort+cursor 分页)。
## 自动翻页(--all + --page-limit)
- 传入 `--all` 启用自动翻页,CLI 自动循环获取并合并所有记录后统一输出
- `--page-limit` 控制最大翻页次数,默认 50 页(5000 条),设为 0 表示无限制
- 页间间隔 200ms,中途网络错误会 graceful stop 并输出已获取的数据
- **被截断时**(达到 page-limit 但仍有数据):输出中包含 `"hasMore": true` 和 `"cursor": "..."` 字段,可通过 `--cursor` 从断点继续拉取
- 适用于需要一次性获取全量数据的场景(如导出、统计、批量处理)
```bash
# 默认(最多 50 页 = 5000 条)
dws aitable record query --base-id X --table-id Y --all
# 无限制(拉完为止)
dws aitable record query --base-id X --table-id Y --all --page-limit 0
# 从上次断点继续
dws aitable record query --base-id X --table-id Y --all --cursor "上次返回的cursor"
```
## 排序参数规范
`--sort` 需要传 JSON 数组,排序方向字段必须是 `direction`(`asc` 或 `desc`),不要使用 `order`。
正确示例:
```bash
--sort '[{"fieldId":"wm8ns9bw2vmucb45xj3ix","direction":"desc"}]'
```
## filters 结构
详细语法见 [aitable-filter-sort.md](./aitable-filter-sort.md)。
快速模板:
```json
{"operator":"and","operands":[{"operator":"eq","operands":["<fieldId>","<value>"]}]}
```
> **singleSelect/multipleSelect 过滤**:filters 中可传 option id 或 option name,但建议优先用 **option id**(通过 `field get` 获取),更可靠。
## 减少响应体积
字段较多时,用 `--field-ids` 仅返回需要的字段,可显著减少返回数据量。
## 常见错误
- `--filters` 根节点直接用 `"operator":"eq"` → API 静默忽略,返回全表
- `--sort` 用 `"order":"desc"` → 必须用 `"direction":"desc"`
- 不加 `--field-ids` 拉全字段 → 大表响应体积过大
- 全量拉取后在 context 里手动统计 → 应优先用 `--filters` 服务端过滤
## record query-empty — 找空行
`record query-empty` 是与 `record query` 平行的独立子命令,专门按表内顺序扫描出"完全没填用户字段"的空行。
```bash
dws aitable record query-empty --base-id BASE_ID --table-id TABLE_ID
```
| flag | 说明 |
|------|------|
| `--base-id` / `--base` | 必填 |
| `--table-id` | 必填 |
| `--limit` | 单次**扫描预算**(不是返回数);范围 [1, 100],默认 100 |
| `--cursor` | 分页游标。响应中 `nextCursor` 非空 → 用它翻页继续扫;nextCursor 为空(或不存在)→ 已扫完整表 |
返回结构:
```jsonc
{ "data": { "records": [...], "nextCursor": "..." } }
```
### 关键语义
1. **`--limit` 是扫描预算不是返回数**:可能扫了 100 条但全部非空,本页 `records: []`。
2. **本页空 records ≠ 全表无空行**:必须看 `nextCursor`,nextCursor 还在就要继续翻。
3. **空行定义**:除系统字段(recordId / 创建人 / 创建时间 / 修改人 / 修改时间)外,所有 cell 都是 null、空字符串、空集合或空 Map。一般是用户在 UI 上"插入空行"产生的。
### 典型用法
```bash
# 扫一页,看本页有没有空行
dws aitable record query-empty --base-id BASE --table-id TBL
# 翻页
dws aitable record query-empty --base-id BASE --table-id TBL --cursor <上次的nextCursor>
# 把整表扫完(手动循环 cursor)
NC=""
while : ; do
R=$(dws aitable record query-empty --base-id BASE --table-id TBL ${NC:+--cursor "$NC"} --format json)
echo "$R" | jq '.data.records[] | .recordId'
NC=$(echo "$R" | jq -r '.data.nextCursor // empty')
[ -z "$NC" ] && break
done
```
# 行记录分享链接(record share-url)
按 recordId 批量获取记录的分享链接,把某行单独发给同事查看。
## 命令
```
dws aitable record share-url \
--base-id BASE_ID --table-id TABLE_ID \
--record-ids rec1,rec2,rec3 \
[--view-id VIEW_ID]
```
| flag | 说明 |
|------|------|
| `--base-id` | 所属 Base ID(必填,可用 `--base` 别名) |
| `--table-id` | 所属 Table ID(必填) |
| `--record-ids` | 目标 Record ID 列表,CSV 逗号分隔,**单次最多 20 条**(必填) |
| `--view-id` | 视图 ID(可选)。带上后链接打开会落在该视图上下文里 |
## 返回结构
```jsonc
{
"data": {
"items": [
{ "recordId": "rec1", "shareUrl": "https://..." },
{ "recordId": "rec2", "shareUrl": "https://..." }
]
}
}
```
`shareUrl` 为 null 表示该条获取失败(不影响其他条目)。
## 典型用法
```bash
# 一次拿一条记录的链接
dws aitable record share-url --base-id BASE --table-id TBL --record-ids rec1
# 批量拿,配合 jq 过滤出 url
dws aitable record share-url --base-id BASE --table-id TBL --record-ids rec1,rec2,rec3 --format json \
| jq '.data.items[] | {recordId, shareUrl}'
# 带视图上下文(链接打开时落在指定视图)
dws aitable record share-url --base-id BASE --table-id TBL --record-ids rec1 --view-id viw_VIP
```
## 注意事项
- **单次最多 20 条**,超出请客户端拆批。
- 该链接是分享链接(不是源文档链接),打开后看到的是该 record 的只读详情页。
- 取消单条分享 / 关闭整表分享当前 CLI 不支持,需要在 AI 表格 Web 端操作。
# record update — 更新记录
## 命令格式
```
Usage:
dws aitable record update [flags]
Example:
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"recordId":"recXXX","cells":{"fldStatusId":"已完成"}}]'
Flags:
--base-id string Base ID (必填)
--records string 待更新记录 JSON 数组,单次最多 100 条 (必填,与 --records-file 二选一)
--records-file string 从文件读取 records JSON(替代 --records,适合超长数据或 Windows 环境)
--table-id string Table ID (必填)
```
只需传入需修改的字段,未传入的保持原值。每条记录必须含 recordId 和 cells。
## 高频错误 flag(LLM 极易踩坑,必读)
CLI **没有** `--record-id` 和 `--cells` 两个独立 flag,**只接受 `--records` 一个参数**,格式为 JSON 数组。
即使只改一条记录,也必须包在数组里。
| 错误(LLM 直觉) | 正确 |
|---|---|
| `--record-id recXXX --cells '{"fldX":"值"}'` | `--records '[{"recordId":"recXXX","cells":{"fldX":"值"}}]'` |
| `--id recXXX --data '{"fldX":"值"}'` | 同上 |
| `--record-id recXXX --field fldX --value "新值"` | 同上 |
## 单条更新模板(直接复制)
```bash
dws aitable record update --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"recordId":"<RECORD_ID>","cells":{"<FIELD_ID>":"新值"}}]' --format json
```
## 引号转义提示
- Linux/macOS:外层用单引号 `'[...]'`,内部 JSON 用双引号即可
- Windows PowerShell:外层用双引号 `"[...]"`,内部双引号需转义为 `\"`
- 或将 JSON 写入临时文件,用 `--records-file ./records.json` 规避转义
# 行记录 Upsert(record upsert)
按 `recordId` 是否存在,自动把入参拆分到 update 链路或 create 链路:批次混合"已存在改 + 新出现建"时用,省掉客户端按 ID 分批的逻辑。
## 命令
```
dws aitable record upsert \
--base-id BASE_ID --table-id TABLE_ID \
--records '[{"recordId":"<可选>","cells":{...}}, ...]'
```
| flag | 说明 |
|------|------|
| `--base-id` | 必填(可用 `--base` 别名) |
| `--table-id` | 必填 |
| `--records` | 待 upsert 的记录 JSON 数组,**单次最多 100 条**(必填)|
| `--records-file` | 从文件读入(命令行 JSON 太长时用),与 `--records` 互斥优先级更高 |
## --records 结构
每项 JSON:
```jsonc
{
"recordId": "rec1", // 可选;带 → update,缺省 → create
"cells": { // 必填;key 是 fieldId,value 按字段类型
"fldTitleId": "新标题",
"fldNumberId": 42
}
}
```
`cells` 写入格式与 `record create` / `record update` **完全一致**(key 必须是 fieldId 不是字段名;按字段类型见 [aitable-cell-value.md](./aitable-cell-value.md))。
## 返回结构
```jsonc
{
"data": {
"createdRecordIds": ["recX", "recY"], // 不带 recordId 的项产出
"updatedRecordIds": ["recA", "recB"] // 带 recordId 的项产出
}
}
```
`createdRecordIds` 顺序对应入参里**不带 recordId**的项(按出现顺序汇总),同理 `updatedRecordIds` 对应**带 recordId**的项。
## 典型用法
```bash
# 1) 全部新建:所有项都不带 recordId
dws aitable record upsert --base-id BASE --table-id TBL --records '[
{"cells":{"fldTitleId":"任务1","fldStatusId":"待办"}},
{"cells":{"fldTitleId":"任务2","fldStatusId":"待办"}}
]'
# 2) 全部更新:所有项都带 recordId
dws aitable record upsert --base-id BASE --table-id TBL --records '[
{"recordId":"rec1","cells":{"fldStatusId":"已完成"}},
{"recordId":"rec2","cells":{"fldStatusId":"已完成"}}
]'
# 3) 混合:第 1 条更新(带 recordId),第 2 条创建(不带)
dws aitable record upsert --base-id BASE --table-id TBL --records '[
{"recordId":"rec1","cells":{"fldStatusId":"已完成"}},
{"cells":{"fldTitleId":"新增任务","fldStatusId":"待办"}}
]'
# 4) 长 JSON 用文件
dws aitable record upsert --base-id BASE --table-id TBL --records-file ./batch.json
```
## 与 record create / record update 的关系
| 场景 | 命令 |
|------|------|
| 确定全是新增 | `record create` |
| 确定全是更新(每条独立 cells) | `record update` |
| 确定全是更新(共享同一 cells) | `record batch-update` |
| **不确定有没有,按 recordId 自动分流** | `record upsert`(本命令) |
`record upsert` 的 `--records` 入参格式与 `record update` 完全相同,唯一差别是 `recordId` 字段在 upsert 里是可选的。如果批次确定全是更新或全是新建,用专用命令更清晰;批次混合时(典型场景:定时同步外部数据,源里既有已存在的也有新出现的),用 upsert。
## 注意事项
- **单次最多 100 条**(创建 + 更新合计),超出请客户端拆批。
- `cells` 的 key 必须是 fieldId 不是字段名(先用 `record query` 或 `field get` 拿 fieldId)。
- 只读字段(formula / lookup / 系统字段)不能写入 — upsert 链路与 update 链路同样限制。
# 视图配置(view get/update <attr>)
按属性局部读/写视图配置。每个属性独立子命令,typed flag 友好,agent 不必拼 JSON。
向后兼容:`view update --config '{...}'` 一次多属性入口仍可用。
## viewType × 支持矩阵
| viewType | card | timebar | aggregate | filter / sort / group | visible-fields | field-widths | name |
|---|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
| Grid | | | ✅ | ✅ | ✅ | ✅ | ✅ |
| Kanban | ✅ | | | ✅ | ✅ | | ✅ |
| Gallery | ✅ | | | ✅ | ✅ | | ✅ |
| Gantt | | ✅ | | ✅ | ✅ | | ✅ |
| Calendar | | | | ✅ | ✅ | | ✅ |
| FormDesigner | (走 `form` 系列命令) | | | | | | |
> card 在 Kanban 走 `kanbanCard`,在 Gallery 走 `galleryCard`,CLI 自动按 viewType dispatch(preflight 1 次 `get_views`)。timebar 仅 Gantt 支持;Calendar 服务端未暴露任何 timebar 配置。
> **Gantt 视图必须两步创建**:`view create --view-type Gantt` 只创建空壳(`ganttTimebar: {}`),**必须**紧跟 `view update timebar --start-field <日期字段ID>` 绑定时间轴字段,否则视图打开是空白。`create_view` 的 `--config` 中传入 `ganttTimebar` 会被服务端忽略。
## 读取:view get <attr>
所有 `view get <attr>` 共用 `--base-id` / `--table-id` / `--view-id`,输出是该属性子块的 JSON(不存在时输出 `{}`)。viewType 不匹配会报错并指明应该选哪种视图。
```bash
dws aitable view get card --view-id VIEW_ID --format json # Kanban / Gallery
dws aitable view get timebar --view-id VIEW_ID --format json # Gantt
dws aitable view get aggregate --view-id VIEW_ID --format json # Grid
dws aitable view get filter --view-id VIEW_ID --format json # 所有
dws aitable view get sort --view-id VIEW_ID --format json
dws aitable view get group --view-id VIEW_ID --format json
dws aitable view get visible-fields --view-id VIEW_ID --format json
dws aitable view get field-widths --view-id VIEW_ID --format json # Grid
```
## 写入:view update <attr>
所有 `view update <attr>` 共用 `--base-id` / `--table-id` / `--view-id`。
**typed flag + `--json` 可混用**;冲突时 typed flag 优先并 stderr 提示。
card / timebar / aggregate 三类写入有 viewType 校验(preflight 1 次 get_views)。
### view update card(Kanban / Gallery)
服务端按 viewType 分发到 `kanbanCard` 或 `galleryCard`。typed flag 共享。
| flag | 类型 | 说明 |
|------|------|------|
| `--cover-field-id` | string | 封面字段 ID(Kanban / Gallery 通用),与 `--no-cover` 互斥 |
| `--no-cover` | bool | 清除封面(等价 `coverFieldId="NONE"`) |
| `--cover-resize-mode` | string | `cover` / `contain` / `stretch` |
| `--hidden-field-title` | bool | 隐藏字段名标题(仅 Kanban 生效) |
| `--cover-mode` | string | `none` / `auto` / `custom`(仅 Gallery 生效) |
| `--display-field-name` | bool | 是否显示字段名(仅 Gallery 生效) |
| `--json` | JSON | 完整 card 子块对象 |
```bash
dws aitable view update card --view-id KANBAN_ID --cover-field-id fldAttachment --cover-resize-mode contain
dws aitable view update card --view-id KANBAN_ID --no-cover
dws aitable view update card --view-id GALLERY_ID --cover-mode auto
dws aitable view update card --view-id GALLERY_ID --json '{"coverMode":"custom","coverFieldId":"fldX","displayFieldName":true}'
```
### view update timebar(仅 Gantt)
| flag | 类型 | 说明 |
|------|------|------|
| `--start-field` | string (date fieldId) | 开始日期字段 |
| `--end-field` | string (date fieldId) | 结束日期字段 |
| `--display-field-id` | string | 时间条上显示的标题字段 |
| `--timeline-scale` | string | `year` / `quarter` / `month` / `weeks` |
| `--color-configs` | JSON 数组 | 颜色配置数组(结构由下游协议定义;清空传 `[]`) |
| `--official-holiday` | bool | 是否标注法定节假日 |
| `--json` | JSON | 完整 ganttTimebar 子块 |
```bash
dws aitable view update timebar --view-id GANTT_ID --start-field fldStart --end-field fldEnd --timeline-scale month
dws aitable view update timebar --view-id GANTT_ID --official-holiday=true
```
### view update aggregate(仅 Grid)
值是 `map[fieldId]→AggregateAction string`;传 null 清除某个字段聚合。
| flag | 类型 | 说明 |
|------|------|------|
| `--field-id` | string | 配合 `--action` 设置**单字段**聚合 |
| `--action` | string | `SUM`/`AVG`/`MAX`/`MIN`/`MEDIAN`/`RANGE`/`TOTAL`/`DISTINCT`/`EXIST`/`UN_EXIST`/`CHECKED`/`EARLIEST_DATE` 等(按字段类型可用) |
| `--clear-field-id` | string (CSV) | 一/多个字段 ID,清除其聚合 |
| `--json` | JSON | 完整 aggregate map |
```bash
dws aitable view update aggregate --view-id GRID_ID --field-id fldX --action SUM
dws aitable view update aggregate --view-id GRID_ID --clear-field-id fldA,fldB
dws aitable view update aggregate --view-id GRID_ID --json '{"fldX":"AVG","fldY":null}'
```
### view update field-widths(仅 Grid)
| flag | 类型 |
|------|------|
| `--field-id` + `--width` | string + int(单字段) |
| `--json` | `{fldId: width, ...}` |
```bash
dws aitable view update field-widths --view-id GRID_ID --field-id fldX --width 200
dws aitable view update field-widths --view-id GRID_ID --json '{"fldA":120,"fldB":200}'
```
### view update visible-fields(通用)
整组替换可见字段列表与顺序。首列字段(primaryDoc)必须保留在数组第一位。
> ⚠️ 注意:服务端**只接受 reorder,不接受真"隐藏字段"**——如果传入的列表比当前 columns 短,缺失的字段不会被隐藏。需要真正隐藏字段请到 AI 表格 Web UI。
| flag | 类型 |
|------|------|
| `--field-ids` | string (CSV) |
| `--json` | string 数组 JSON(与 `--field-ids` 同传时 `--json` 优先) |
```bash
dws aitable view update visible-fields --view-id VIEW_ID --field-ids fldPrimary,fldA,fldB
dws aitable view update visible-fields --view-id VIEW_ID --json '["fldPrimary","fldA","fldB"]'
```
### view update filter / sort / group(通用,纯 --json)
```bash
dws aitable view update filter --view-id VIEW_ID --json '[{"operator":"and","operands":[{"operator":"eq","operands":["fldX","value"]}]}]'
dws aitable view update sort --view-id VIEW_ID --json '[{"fieldId":"fldX","direction":"asc"}]'
dws aitable view update group --view-id VIEW_ID --json '[{"fieldId":"fldX","direction":"asc"}]'
```
> filter/sort/group 入参格式与 `record query --filters`(对象格式)**不同**:view config 这边外层必须是数组。传对象 CLI 会自动 wrap,建议直接用数组。详见 [aitable-filter-sort.md](./aitable-filter-sort.md)。
### view update name(重命名)
```bash
dws aitable view update name --view-id VIEW_ID --name "新视图名"
```
等价于 `dws aitable view update --view-id VIEW_ID --name "新视图名"`,无 `config` 参数。
## 服务端字段速查(与 dws CLI 关系)
| dws 子命令 | 服务端 `update_view.config` 子键 | 服务端 Java 模型 |
|---|---|---|
| `view update card`(Kanban) | `kanbanCard` | `KanbanCardUpdateInput` |
| `view update card`(Gallery) | `galleryCard` | `GalleryCardUpdateInput` |
| `view update timebar` | `ganttTimebar` | `GanttTimebarUpdateInput` |
| `view update aggregate` | `aggregate` | `Map<fieldId, AggregateAction>` |
| `view update visible-fields` | `visibleFieldIds` | `List<String>` |
| `view update filter / sort / group` | `filter` / `sort` / `group` | `List<Object>` |
| `view update field-widths` | `fieldWidths` | `Map<String, Object>` |
| `view update name` | (不在 config 内)`newViewName` 顶层 | — |
## 典型工作流
### 排查"Kanban 卡片为啥不显示封面"
```bash
dws aitable view get card --view-id KANBAN_ID --format json
# → 看 coverFieldId 是不是 "NONE" 或缺失;不是再看 coverResizeMode 是不是 contain 导致裁掉
```
### 创建可用的 Gantt 视图(必须两步)
```bash
# 第 1 步:创建 Gantt 视图
dws aitable view create --base-id BASE_ID --table-id TABLE_ID \
--view-type Gantt --name "项目甘特图" -f json
# → 记录返回的 viewId
# 第 2 步(必须):绑定日期字段,否则视图为空
dws aitable view update timebar --base-id BASE_ID --table-id TABLE_ID \
--view-id VIEW_ID --start-field fldDateStart
# 可选:加结束日期、标题字段、时间尺度
# --end-field fldDateEnd --display-field-id fldName --timeline-scale month
```
### 把 Gantt 时间轴改成季度尺度并加节假日
```bash
dws aitable view update timebar --view-id GANTT_ID \
--timeline-scale quarter --official-holiday=true
```
### 用 dws 脚本批量替换 Kanban 封面字段
```bash
for v in viw1 viw2 viw3; do
dws aitable view update card --view-id $v --cover-field-id fldNewCover --cover-resize-mode cover --format json | jq .status
done
```
### 一次性多属性更新(仍走 legacy --config)
```bash
dws aitable view update --view-id VIEW_ID --config '{
"visibleFieldIds":["fldPrimary","fldA","fldB"],
"filter":[{"operator":"and","operands":[]}],
"kanbanCard":{"coverFieldId":"fldImg","coverResizeMode":"contain"}
}'
```
# 视图扩展操作(lock / frozen-cols / row-height / fill-color-rule / duplicate)
本文档讲 5 项视图操作命令:
- 锁定 / 解锁视图:`view lock` / `view get lock`
- 冻结列:`view update frozen-cols` / `view get frozen-cols`
- 行高:`view update row-height` / `view get row-height`
- 数据高亮规则(条件填色):`view update fill-color-rule` / `view get fill-color-rule`
- 复制视图:`view duplicate`
> **与 [aitable-view-config.md](./aitable-view-config.md) 的分工**:
> - `aitable-view-config.md` 讲 `view get/update <attr>` 中 8 个属性:filter / sort / group / visible-fields / field-widths / aggregate / card / timebar。
> - 本文档讲上面 5 项额外能力(包括 attr 形式的 frozen-cols / row-height / fill-color-rule,以及顶层独立的 lock / duplicate)。
> 这 5 项**不能**通过 `view update --config '{...}'` 写入,必须用各自专属子命令。
## 命令矩阵
| 子命令 | 用途 | 必填参数 | 适用 viewType |
|---|---|---|---|
| `view lock [--off]` | 锁定(默认)/ 解锁视图 | `--base-id --table-id --view-id` | 全部 |
| `view get lock` | 读取锁定状态 | `--base-id --table-id --view-id` | 全部 |
| `view update frozen-cols --count N` | 冻结左侧 N 列(0 取消) | `--base-id --table-id --view-id --count` | Grid |
| `view get frozen-cols` | 读取冻结列数 | `--base-id --table-id --view-id` | Grid |
| `view update row-height --cell-height N` | 设置单元格高度(像素) | `--base-id --table-id --view-id --cell-height` | Grid |
| `view get row-height` | 读取单元格高度 | `--base-id --table-id --view-id` | Grid |
| `view update fill-color-rule --json '[...]'` | 全量覆盖条件填色规则 | `--base-id --table-id --view-id --json` | Grid |
| `view get fill-color-rule` | 读取条件填色规则 | `--base-id --table-id --view-id` | 全部(其他视图返回 `[]`) |
| `view duplicate [--new-name X]` | 复制视图 | `--base-id --table-id --view-id` | 全部 |
## 视图锁定 / 解锁
```bash
# 锁定(默认)
dws aitable view lock --view-id VIEW_ID
# 解锁
dws aitable view lock --view-id VIEW_ID --off
# 查询当前是否锁定
dws aitable view get lock --view-id VIEW_ID --format json
# → {"data": {"baseId": ..., "tableId": ..., "viewId": ..., "locked": true|false}}
```
锁定的视图禁止他人修改其配置(filter/sort/group/字段顺序等),但记录读写不受影响。锁定状态可重复 set,幂等。
## 冻结列(仅 Grid)
```bash
# 冻结从首列起 1 列
dws aitable view update frozen-cols --view-id VIEW_ID --count 1
# 取消冻结
dws aitable view update frozen-cols --view-id VIEW_ID --count 0
# 查询当前冻结列数
dws aitable view get frozen-cols --view-id VIEW_ID --format json
# → {"data": {..., "count": 1}} count 为 null 表示视图未显式设置
```
`--count` 必须 ≥ 0;负数会被拒绝。
## 行高(仅 Grid)
⚠️ **`--cell-height` 只接受 4 档枚举:32 / 56 / 88 / 128**(与前端 CELL_HEIGHTS 约定一致),其他值会被拒绝。默认值为 32。
```bash
# 设置行高 — 推荐档位 32 / 56 / 88 / 128
dws aitable view update row-height --view-id VIEW_ID --cell-height 56
# 查询当前行高
dws aitable view get row-height --view-id VIEW_ID --format json
# → {"data": {..., "cellHeight": 56}} cellHeight 为 null 表示视图未显式设置(前端按 32 渲染)
```
## 数据高亮规则(条件填色,仅 Grid)
`view update fill-color-rule` **整组覆盖**,传 `--json '[]'` 清空所有规则。
### 规则结构
每条规则 JSON 结构:
```jsonc
{
"type": "cell" | "row" | "column" | "preRow",
"formatFieldId": "fldX", // 命中规则后被高亮的字段(cell/column 类型有意义)
"format": { "color": "firstLine5" }, // ⚠️ 必须用 FORMAT_COLORS 代号,不接受 hex
"filters": [ // 当前固定 1 条
{
"fieldId": "fldX", // ⚠️ 不是 operands[0]
"symbol": "GT", // ⚠️ 不是 operator;大写枚举
"value": 100 // 部分 symbol(EXIST/UN_EXIST)不需要 value
}
]
}
```
### color 合法值(FORMAT_COLORS)
`firstLine1` ~ `firstLine11`(共 11 档色码,对应前端调色盘)。**不接受 `#FF0000` 这种 hex**。
### filter.symbol 合法值
| 类别 | symbol |
|---|---|
| 数值/通用比较 | `GT` / `LT` / `GTE` / `LTE` / `EQ` / `NE` |
| 文本 | `CONTAIN` / `EXCLUSIVE` |
| 存在性(无 value) | `EXIST` / `UN_EXIST` |
| 多选 / 集合 | `ALL_OF` / `ANY_OF` / `NONE_OF` |
| 日期 | `BEFORE` / `AFTER` / `NOT_BEFORE` / `NOT_AFTER` / `DATE_EQ` / `FROM_NOW` / `DATE_BETWEEN` |
> **与 `record query --filters` / `view update filter` 的格式不同**:那两处用 `{operator, operands}` 结构;这里是 `{fieldId, symbol, value}`。不要混用。
### 典型用法
```bash
# 1) 给金额字段 > 100 的单元格上 firstLine5 色
dws aitable view update fill-color-rule --view-id GRID_ID --json '[
{
"type":"cell",
"formatFieldId":"fldAmount",
"format":{"color":"firstLine5"},
"filters":[{"fieldId":"fldAmount","symbol":"GT","value":100}]
}
]'
# 2) 清空所有规则
dws aitable view update fill-color-rule --view-id GRID_ID --json '[]'
# 3) 查询当前规则
dws aitable view get fill-color-rule --view-id GRID_ID --format json
# → {"data": [...]} 数组
```
> **写入后请用 `view get fill-color-rule` 二次确认实际生效**,以读到的 `data` 数组为准。
## 复制视图
```bash
# 显式命名
dws aitable view duplicate --view-id VIEW_ID --new-name "副本视图"
# 系统自动命名(一般是 "原视图名 (副本)")
dws aitable view duplicate --view-id VIEW_ID --format json
# → {"data": {..., "viewId": "<新视图ID>", "sourceViewId": "<原视图ID>", "viewName": "..."}}
```
复制会保留源视图的 filter / sort / group / visible-fields / card / timebar 等全部配置;新视图的 viewId 与源视图独立。
## 这些字段不能用 `view update --config '{...}'` 写
下列字段必须用对应的专属子命令;如果错塞进 `view update --config`,CLI 会在 stderr 提示对应子命令并拒绝把字段当 view config 处理:
| 错误用法 | 应改用 |
|---|---|
| `--config '{"flags":1}'` | `view lock` / `view lock --off` |
| `--config '{"frozenColCount":2}'` | `view update frozen-cols --count N` |
| `--config '{"cellHeight":56}'` | `view update row-height --cell-height N` |
| `--config '{"rowHeightLevel":"tall"}'` | `view update row-height --cell-height N`(合法档位 32/56/88/128) |
| `--config '{"conditionalFormats":[...]}'` | `view update fill-color-rule --json '[...]'` |
## 典型工作流
### 配置一个"金额超阈值红色高亮"的 Grid 视图
```bash
BASE=baseXXX; TABLE=tblYYY; VIEW=viwGridZZ; FLD=fldAmount
# 1) 关键字段冻结,避免横向滚动看不到
dws aitable view update frozen-cols --base-id $BASE --table-id $TABLE --view-id $VIEW --count 1
# 2) 加大行高让数据更易读
dws aitable view update row-height --base-id $BASE --table-id $TABLE --view-id $VIEW --cell-height 56
# 3) 金额 > 100 的单元格上色
dws aitable view update fill-color-rule --base-id $BASE --table-id $TABLE --view-id $VIEW --json "[
{\"type\":\"cell\",\"formatFieldId\":\"$FLD\",\"format\":{\"color\":\"firstLine5\"},
\"filters\":[{\"fieldId\":\"$FLD\",\"symbol\":\"GT\",\"value\":100}]}
]"
# 4) 锁定视图,防止他人改坏
dws aitable view lock --base-id $BASE --table-id $TABLE --view-id $VIEW
```
### 复制一个"金牌客户"视图给销售团队
```bash
dws aitable view duplicate --view-id viw_VIP_template --new-name "金牌客户-华东区"
# 取返回里 data.viewId 进一步定制
```
### 排查"我设置了高亮规则为啥没生效"
```bash
# 看实际生效的 conditionalFormats
dws aitable view get fill-color-rule --view-id VIEW_ID --format json
# → 如果是 [] 说明上次写入失败;常见原因:color 用了 hex(必须 firstLineN)/ filter 用了 operator(必须 symbol)
```
# workflow — 自动化工作流管理
启停 / 查看 / 列出 Base 下的自动化工作流(在 AI 表格 Web 端配置的 "当 X 时自动 Y" 流程)。
适用场景:用户问 "停掉这个流程"、"看下都有哪些自动化流程"、"流程 X 的配置是什么"。
## 命令一览
| 命令 | 用途 |
|------|------|
| `workflow list` | 列出 Base 下所有工作流(含状态/创建人/最后修改时间),支持分页 |
| `workflow get` | 获取单个工作流详情(含 flowSchema 完整节点定义) |
| `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) |
| `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) |
> 所有子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
> 当前**不支持通过 CLI 新建工作流**,请在 AI 表格 Web 端配置好后用 `workflow list` 拿到 ID 再启停。
## 命令详情
### workflow list — 列出工作流
```bash
dws aitable workflow list --base-id BASE_ID --format json
dws aitable workflow list --base-id BASE_ID --limit 50 --offset 100
```
| flag | 说明 |
|------|------|
| `--base-id` | 必填 |
| `--limit` | 可选,分页大小 `[1, 100]`,不传走服务端默认 20 |
| `--offset` | 可选,分页偏移量 `>= 0`,不传走服务端默认 0 |
返回结构:
```json
{
"data": {
"list": [
{
"flowId": "G-FLOW-XXXXXX", // ★ 注意字段名是 flowId
"name": "流程1",
"description": "当创建记录时,就更新记录",
"status": "RUNNING", // RUNNING / STOP
"creatorStaffId": "281493",
"lastModifier": { "name": "李普阳", "staffId": "281493" },
"gmtModified": 1780318540000,
"versionId": "G-FLOW-VER-XXXXXX",
"icons": ["..."], // 触发器+动作的图标
"isSubFlow": false,
"opPermissions": { "canEdit": true }
}
],
"recordCount": 1, // Base 下总数
"runningCount": 1 // RUNNING 状态的数量
}
}
```
**注意**:
- 标识字段服务端在 `list` 里叫 **`flowId`**,但在 `enable` / `disable` 出参里叫 **`workflowId`**。CLI `--workflow-id` 传任一即可(同值)。
- `status` 是字符串枚举:`RUNNING`(启用中)/ `STOP`(已禁用),**不是** boolean。
- `runningCount` 是当前 Base 下 status=RUNNING 的工作流数,方便快速判断「有几个流程在跑」。
### workflow get — 获取单个工作流详情
```bash
dws aitable workflow get --base-id BASE_ID --workflow-id WORKFLOW_ID --format json
```
| flag | 说明 |
|------|------|
| `--base-id` | 必填 |
| `--workflow-id` | 必填,对应 list 出参里的 `flowId` |
返回完整工作流配置:
```json
{
"data": {
"name": "流程1",
"namespace": "...",
"status": "RUNNING",
"versionId": "G-FLOW-VER-XXXXXX",
"versionNo": 14,
"versionStatus": "...",
"accessor": {...}, // 访问者信息
"corpId": "...",
"flowAttribute": {...}, // 流程顶层属性
"flowSchema": {...}, // ★ 流程节点定义(触发器/动作/分支等)
"gmtCreate": 1780317804000,
"gmtModified": 1780318540000
}
}
```
`flowSchema` 是完整的节点 DAG,结构因流程而异(条件触发器 vs 定时触发器、单分支 vs 多分支等)。agent 应按需读取关心字段,不要试图建静态 schema。
### workflow enable — 启用工作流
```bash
dws aitable workflow enable --base-id BASE_ID --workflow-id WORKFLOW_ID --format json
```
返回 `{workflowId, enabled: true}` —— **`enabled: true` 是动作确认,不是当前状态查询**。要确认真启用了,必须再 `workflow list` 看 `status` 是否变成 `"RUNNING"` 或 `runningCount` 是否加 1。
### workflow disable — 禁用工作流(高危)
```bash
dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes --format json
```
返回 `{workflowId, disabled: true}` —— 同样是动作确认。禁用后该工作流不再自动触发。
**风险**:直接影响业务自动化(如停掉「记录创建后自动发通知」会让通知断流)。建议:
- 操作前先 `workflow get` 留底当前配置
- 脚本场景显式传 `--yes`;交互场景让用户在 prompt 中再次确认
## 能力边界
| 能力 | 状态 |
|------|------|
| 列出工作流 | ✅ |
| 看工作流详情(含 flowSchema) | ✅ |
| 启用/禁用 | ✅ |
| 新建工作流 | ❌ 当前不支持,请去 AI 表格 Web 端 → 数据表 → 自动化 创建 |
| 修改工作流配置 | ❌ 同上,需 Web UI 编辑 |
| 删除工作流 | ❌ 同上 |
| 查看运行历史/执行日志 | ❌ 暂未开放 |
| 手动触发/单次运行 | ❌ 暂未开放 |
## 错误码速查
| 场景 | code | type | 备注 |
|------|------|------|------|
| `workflow-id` 不存在调 get | `GET_WORKFLOW_ERROR` | `SYSTEM_ERROR` | message 可能为 null,先 `workflow list` 核对 ID |
| `workflow-id` 不存在调 enable | `ENABLE_WORKFLOW_ERROR` | `SYSTEM_ERROR` | message 含 "场域中不存在该 namespace" |
| `workflow-id` 不存在调 disable | `DISABLE_WORKFLOW_ERROR` | `SYSTEM_ERROR` | 同上 |
| `--limit` < 1 或 > 100 | (CLI 层拦截) | — | `--limit 必须在 [1, 100] 范围内,got N` |
| `--offset` < 0 | (CLI 层拦截) | — | `--offset 必须 >= 0,got N` |
> 拿到 `*_WORKFLOW_ERROR / SYSTEM_ERROR` 时,先 `workflow list` 自查目标 ID 是否还存在、是否在当前 Base 下。
## 典型工作流
### 看看 Base 里有哪些自动化在跑
```bash
dws aitable workflow list --base-id BASE_ID --format json | jq '.data | {total: .recordCount, running: .runningCount, items: .list | map({name, status, flowId})}'
```
### 临时停掉某个流程做调试
```bash
# 1. 留底当前状态
dws aitable workflow get --base-id BASE_ID --workflow-id WORKFLOW_ID --format json > /tmp/wf-backup.json
# 2. 禁用
dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes --format json
# 3. 调试做完后重启
dws aitable workflow enable --base-id BASE_ID --workflow-id WORKFLOW_ID --format json
# 4. 确认 status=RUNNING
dws aitable workflow list --base-id BASE_ID --format json | jq '.data.list[] | select(.flowId == "WORKFLOW_ID") | .status'
```
### 批量关掉某个 Base 下所有 workflow(调试 / 迁移前清场)
```bash
for WF in $(dws aitable workflow list --base-id BASE_ID --limit 100 --format json | jq -r '.data.list[] | select(.status == "RUNNING") | .flowId'); do
dws aitable workflow disable --base-id BASE_ID --workflow-id "$WF" --yes --format json | jq .status
done
```
## 注意事项
- `--workflow-id` 接受的就是 `list` 返回里的 `flowId`(同值,CLI 屏蔽了服务端字段名差异)。
- enable / disable 出参里的 `enabled` / `disabled` 是 **动作确认 flag**,不是当前状态字段。要确认真生效请走 `workflow list` 查 `status`。
- `workflow get` 的 `flowSchema` 结构随触发器/动作类型变化,不要假设固定字段。
- 新建/修改/删除工作流目前必须在 AI 表格 Web 端(数据表页面 → 自动化)完成。
# 考勤 (attendance) 命令参考
> **【命令合法性 — 必读】** 当前 dws 只提供以下 4 个考勤命令组,其余命令(check / checkin / class / group settings / adjustment / overtime / schedule / approve / selfsetting / globalsetting / report / vacation / boss-check 等)均**不存在**,调用会返回 `unknown command`:
>
> | 命令 | 用途 |
> |------|------|
> | `attendance rules` | 查询考勤组与考勤规则(我属于哪个考勤组、打卡范围、弹性工时) |
> | `attendance record get` | 查询某个人某天的考勤打卡详情 |
> | `attendance shift list` | 批量查询多名员工在某日期范围内的班次 |
> | `attendance summary` | 查询某个人的考勤统计摘要(周/月) |
> **【日期计算规则】** 所有含日期参数的命令均适用,禁止随意猜测日期范围,必须按下表精确计算:
>
> | 用户表达 | 起始(含)| 结束(含)| 说明 |
> |---------|------------|------------|------|
> | 本周 / 这周 | 本周**周一** | 本周**周日** | 周一为一周第一天 |
> | 上周 | 上周**周一** | 上周**周日** | 往前推一周 |
> | 本月 / 这个月 | 本月 **1 日** | 本月**最后一天** | 必须计算当月实际天数 |
> | 上月 | 上月 **1 日** | 上月**最后一天** | 往前推一个月 |
> | 今天 / 昨天 | 当天 | 当天 | start == end |
>
> 计算日期必须参考当前系统时间,不得硬编码或模糊估算;用户给定具体日期范围时直接采用。
## 命令总览
### 查询考勤组与考勤规则
```
Usage:
dws attendance rules [flags]
Example:
dws attendance rules --date 2026-03-14
dws attendance rules --date "2026-03-14 09:00:00"
Flags:
--date string 考勤日期,格式 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss (必填)
Notes:
- 用于回答:我属于哪个考勤组、打卡范围是什么、弹性工时怎么算
- 认证信息(corpId、optUserId)由系统自动注入,无需手动传入
```
### 查询个人考勤详情
```
Usage:
dws attendance record get [flags]
Example:
dws attendance record get --user USER_ID --date 2026-03-08
Flags:
--date string 查询日期,格式 YYYY-MM-DD (必填)
--user string 钉钉用户 ID (必填)
```
### 批量查询员工班次信息
```
Usage:
dws attendance shift list [flags]
Example:
dws attendance shift list --users userId1,userId2 --start 2026-03-03 --end 2026-03-07
Flags:
--users string 员工 ID 列表,逗号分隔,最多 50 人 (必填)
--start string 起始日期,格式 YYYY-MM-DD (必填)
--end string 结束日期,格式 YYYY-MM-DD (必填)
Notes:
- 单次查询最多 7 天、最多 50 人
```
### 查询某个人的考勤统计摘要
```
Usage:
dws attendance summary [flags]
Example:
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week
Flags:
--user string 钉钉用户 ID (必填)
--date string 工作日期,格式 yyyy-MM-dd HH:mm:ss (必填)
--stats-type string 统计类型:week(周统计)或 month(月统计)(必填)
Notes:
- --stats-type 必填,不填会返回 C0002 统计类型错误(钉钉服务端业务层强制要求)
```
## 意图判断
用户说"我属于哪个考勤组/打卡范围/弹性工时/考勤规则" → `attendance rules --date <日期>`
用户说"某人某天的打卡记录/打卡详情/几点上下班" → `attendance record get --user <userId> --date <日期>`
用户说"某些人的班次/排了什么班/某段时间的班次" → `attendance shift list --users <ids> --start <开始> --end <结束>`
用户说"某人的考勤统计/本周/本月出勤/迟到早退汇总" → `attendance summary --user <userId> --date <日期> --stats-type week|month`
关键区分:
- `record get` = 某天的逐次打卡明细(单人单日)
- `summary` = 一段时间的统计汇总(单人,周/月)
- `shift list` = 排班(谁哪天上什么班,可批量多人)
- `rules` = 考勤组与规则配置(不查打卡数据)
## 核心工作流
```bash
# 查看考勤组和规则
dws attendance rules --date 2026-03-14 --format json
# 查询某人某天的打卡详情(先用 contact 拿 userId)
dws contact user search --query "张三" --format json
dws attendance record get --user <userId> --date 2026-03-08 --format json
# 批量查询多人班次(最多 7 天、50 人)
dws attendance shift list --users userId1,userId2 --start 2026-03-03 --end 2026-03-07 --format json
# 查看某人本月考勤统计摘要
dws attendance summary --user <userId> --date "2026-03-12 15:00:00" --stats-type month --format json
```
## 上下文传递表
| 操作 | 提取 | 用于 |
|------|------|------|
| `contact user search/get-self` | `userId` | record get / summary 的 --user、shift list 的 --users |
## 注意事项
- `--user` / `--users` 需要 userId,可先用 `contact user search`、`contact user get-self` 或 `aisearch person` 获取
- `shift list` 单次最多 7 天、50 人,跨度更大需分批
- `summary` 的 `--stats-type` 必填(week / month),缺省会被钉钉服务端拒绝
- 认证信息(corpId、optUserId)由系统自动注入,无需手动传入
# 日历 (calendar) 命令参考
## CLI 命令树与黄金路径
- **二级子命令(必选其一)**:`event`(日程)、`attendee`(参会人)、`room`(会议室)、`busy`(闲忙)、`attachment`(日程附件)、`book`(用户日历列表)。`dws calendar` 后**必须**紧跟上述之一;**禁止**只执行 `dws calendar`(无子命令)。
- **个人日程 / 给自己留时间块 / 专注时段**:统一走 **`dws calendar event create`**。当前**没有**单独的 `personal schedule create` / `calendar create` 命令。
- **查日程列表**:`dws calendar event list --start "<ISO-8601>" --end "<ISO-8601>" --format json`,或优先使用脚本 `python scripts/calendar_today_agenda.py [today|tomorrow|week]`(见文末「自动化脚本」)。
- **查用户日历本列表**:`dws calendar book list`(返回主日历 `id == "primary"` 等)。**重要**可以查询他人共享给自己的日历本,根据日历本id可以进一步查询对方的日程信息。
- **CLI 不存在**独立的 `dws calendar list`;若误跑无子命令的 `dws calendar`,会打印整段 Usage,**切勿**将该段 help 当作工具结果再次塞进对话(会急剧增加 token 与首字延迟)。
- **必须**遵循指令说明进行调用。**绝对禁止**使用虚构指令,使用虚构参数。
## 反模式(禁止)
1. **禁止**执行 `dws calendar` 且不带二级子命令(会刷出大量帮助文本)。合法二级子命令:`event` / `attendee` / `room` / `busy` / `attachment` / `book`。
2. **禁止**使用不存在的子命令试探(如臆造 `dws calendar list`);需要日程列表时一律使用 **`dws calendar event list`**(带 `--start` / `--end`,见下文「查询日程列表」示例);需要日历本列表时使用 **`dws calendar book list`**。
3. **禁止**将完整 `--help`/Usage 输出作为「观察」重复提交给模型;若误触,应直接改用本节黄金路径中的合法命令并重试。
4. **禁止**继续使用已弃用的 `--max-results` 参数 —— 它们仍可被解析但**会被丢弃**,不再透传到 MCP,模型生成命令时也不要再带。
5. **禁止**为已有日程重新创建日程来预订会议室。若日程已存在(同一会话中刚创建、或用户明确指向某日程),必须使用 `room add --event <已有EVENT_ID> --rooms <ROOM_ID>` 追加会议室,**绝不能**再调一次 `event create --rooms`(会创建重复日程)。
6. **禁止**用 `--location` 替代会议室预订。`--location` 是纯文本地点备注字段,填入会议室名称**不会**完成任何预订或占用。预订会议室必须通过 `room add --rooms <roomId>` 或 `event create --rooms <roomId>`,roomId 来自 `room search` 返回;`--location` 与 `--rooms` 是两个独立字段,用途完全不同。
7. **禁止**只传 `--recurrence-*` 部分 flag **并不是彼此独立的参数**:只传其中一项(比如只改 `--recurrence-count`、只设 `--recurrence-type`)会让服务端收到不完整的 recurrence 结构,CLI 现已前置校验并直接拒绝这类调用。**修改已有周期日程的任何一个循环字段时,都必须重新提供完整的 pattern+range 字段集合**——必要时先 `event get` 读取现有 `recurrence`,再在命令中整体重传。
8. **禁止**用一条 指令 实现串行调用。比如当用户要求一次性安排多场不同的日程(例如「上午 10 点开项目评审、下午 2 点开复盘会、晚上 7 点聚餐」)时,必须**拆解成 N 条独立的 `event create`,依次串行执行**;每条命令自己写完整的 `--title` / `--start` / `--end`,绝不能把多个标题或多段时间塞进同一行。
## 核心概念
日历(calendar):日程的容器。每个用户有一个主日历(我的日历,id: primary),还可以订阅公共/团队日历,以及他人共享的日历。
日程(event):日历中的单个日程,包含起止时间、地点、标题、参会人等属性。支持单次日程和重复日程(有recurrence rule的日程,又称SeriesMaster),遵循RFC5545 iCalendar国际标准。
日程实例(event instance):日程的具体时间实例,可以通过event list指令查询时间段内的所有实例。1个普通日程和对应1个Instance,而1个重复性日程(SeriesMaster)对应N个Instance(同属一个日程序列)。
- 同一个日程序列具有相同的iCalUid,并且重复性日程,其eventId和iCalUid的值相同。因此可以通过重复性日程实例的iCalUid得到重复性日程(SeriesMaster)的eventId
重复规则(recurrence rule):定义重复性日程的重复规则。
参会人(attendee):日程的参与者。常用通讯录工具查询userId,dws contact user search --keyword "姓名"。
响应状态(response):参会人对日程的回应,包括:未响应、接受、待定、拒绝。
忙闲时间(busy):查询用户在指定时间段的忙闲状态,查询会议室在指定时间段的预定状态,用于会议时间协调。
会议室(room):room是 会议室 ,room可视为日程的资源类参会人,需要加入日程完成预订。注意和location区分,location只是地点,和room不同。
## 命令概览
### event 相关三级子命令
```
# 针对单个日程: 创建 | 修改 | 单查询 | 删除 | 响应日程(接受、暂定、拒绝)
dws calendar event [create|update|get|delete|respond] [flags]
# 按时间范围批量查询
dws calendar event list [flags]
# 对于非明确时间或一段时间范围的约会场景,可基于所有参会人的忙闲状态,推荐多个可用的时间块方案
dws calendar event suggest [flags]
```
### attendee 相关三级子命令
```
# 日程中参会人操作:添加 | 删除 | 查询
dws calendar attendee [add|delete|list] [flags]
```
### room 相关三级子命令
```
# 查询分组
dws calendar room list-groups [flags]
# 会议室搜索
dws calendar room search [flags]
# 预定会议室
dws calendar room add [flags]
# 释放会议室
dws calendar room delete [flags]
```
> room是会议室,用于线下开会场景。
### busy 相关三级子命令
```
# 按用户 / 会议室 + 时间窗查闲忙状态(--users 与 --rooms 至少其一),会议室的忙闲等同于预定记录
dws calendar busy search [flags]
```
### attachment 相关三级子命令
```
# 把已上传到钉盘的文件挂到日程上(不负责上传,只负责挂载)
dws calendar attachment add [flags]
```
### book 相关三级子命令
```
# 查询当前用户的所有日历,结果范围:用户自己的日历、已订阅的公共/团队日历、他人共享的日历。
dws calendar book list [flags]
```
> **说明**: 可以通过 --help 进一步查看指令明细,也可以继续查看下一节 命令总览
## 命令总览
### 查询日程列表
```
Usage:
dws calendar event list [flags]
Example:
dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00"
dws calendar event list --calendar-id primary
Flags:
--calendar-id string 日历 ID (默认 primary 主日历,仅在查询其他日历本时填写;通过 `book list` 获取)
--end string 结束时间 ISO-8601 (例如 2026-03-10T18:00:00+08:00)
--start string 开始时间 ISO-8601 (例如 2026-03-10T14:00:00+08:00)
```
> `--max-results` 已弃用:MCP 不再支持,CLI 仍接受但参数会被丢弃,结果固定最多返回 100 条。
**默认行为**:不传 `--start` / `--end` 时,默认返回今天的日程(00:00:00 ~ 23:59:59)。
**权限**:查询共享日历下的日程时,至少要有reader权限。
### 获取日程详情
```
Usage:
dws calendar event get [flags]
Example:
dws calendar event get --id <EVENT_ID>
dws calendar event get --id <EVENT_ID> --calendar-id primary
Flags:
--id string 日程 ID (必填)
--calendar-id string 日历 ID (默认 primary 主日历)
```
### 创建日程
```
Usage:
dws calendar event create [flags]
Example:
dws calendar event create --title "Q1 复盘会" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00"
dws calendar event create --title "周会" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \
--attendees userId1,userId2
dws calendar event create --title "项目评审" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \
--rooms roomId1,roomId2 # 创建时直接预定会议室
dws calendar event create --title "每日站会" \
--start "2026-03-10T09:00:00+08:00" --end "2026-03-10T09:30:00+08:00" \
--recurrence-type daily --recurrence-interval 1 --recurrence-range-type numbered --recurrence-count 10
Flags:
--title string 日程标题 (必填,最大2048字符)
--start string 开始时间 ISO-8601 (必填,例如 2026-03-10T14:00:00+08:00)
--end string 结束时间 ISO-8601 (必填,例如 2026-03-10T15:00:00+08:00)
--timezone string 时区 IANA 格式 (例如 Asia/Shanghai,默认 Asia/Shanghai)
--desc string 日程描述 (最大5000字符)
--attendees string 参会人 userId 列表,逗号分隔 (最多500人) 日程组织人自动放入参会人列表,无需传入userId
--open-dingtalk-ids string openDingTalkId 列表,逗号分隔 (与 --attendees 至少传一个)
--rooms string 会议室 roomId 列表,逗号分隔 (创建时直接预定,roomId 必须来自 `room search` 返回,若是循环会议,必须设置recurrence-end-date,避免长期预订)
# 以下 --recurrence-* 一旦使用任一 flag,必须同时提供完整的 pattern+range 字段(至少 --recurrence-type、--recurrence-interval(>0) 与 --recurrence-range-type)
# 否则 CLI 会报 "recurrence 结构不完整" 并拒绝执行
--recurrence-type string 循环类型: daily|weekly|absoluteMonthly|relativeMonthly|absoluteYearly
--recurrence-interval int 循环间隔 (如 daily 时表示每N天)
--recurrence-days-of-week string 周几: sunday,monday,...,saturday (weekly/relativeMonthly 时必填)
--recurrence-day-of-month int 每月第几天 (absoluteMonthly/absoluteYearly 时必填)
--recurrence-index string 每月第几周: first|second|third|fourth|last (relativeMonthly 时必填)
--recurrence-first-day-of-week string 一周起始日,默认 sunday
--recurrence-range-type string 循环范围: noEnd|endDate|numbered (与 --recurrence-type 必须成对出现)
--recurrence-end-date string 循环结束时间 ISO-8601 (range-type=endDate 时必填)
--recurrence-count int 循环次数 (range-type=numbered 时必填)
--rich-text-desc string html格式的富文本类型日程描述,用于复杂内容的展示
--location string 地点信息(纯文本备注,如‘3号楼A区’;**不等于**预订会议室)
--free-busy string 此日程的忙碌状态,默认值为busy。busy - 在忙闲视图中,此日程时间段为忙碌; free - 此日程不占用忙闲
```
> **说明**:个人日程也走 `event create`。如果只是给自己安排时间,不传 `--attendees` / `--open-dingtalk-ids` 即可。
### 修改日程
```
Usage:
dws calendar event update [flags]
Example:
dws calendar event update --id <EVENT_ID> --title "新标题"
dws calendar event update --id <EVENT_ID> --desc "新描述" --timezone Asia/Tokyo
dws calendar event update --id <EVENT_ID> --recurrence-type daily --recurrence-interval 1 \
--recurrence-range-type numbered --recurrence-count 5
Flags:
--id string 日程 ID (必填)
--title string 新标题
--start string 新开始时间 ISO-8601
--end string 新结束时间 ISO-8601
--desc string 新描述 (最大5000字符)
--timezone string 时区 IANA 格式 (例如 Asia/Shanghai)
# 以下 --recurrence-* 在 修改周期日程的循环规则时必须**整体**传入:MCP 不合并部分字段,只改其中一项(例如只传 --recurrence-count)会把规则覆盖成不完整状态
# 若只想微调已有规则,请先 `event get --id <ID>` 读取现有 recurrence,再在本命令重传完整的 pattern+range
--recurrence-type string 循环类型: daily|weekly|absoluteMonthly|relativeMonthly|absoluteYearly
--recurrence-interval int 循环间隔 (如 daily 时表示每N天)
--recurrence-days-of-week string 周几: sunday,monday,...,saturday (weekly/relativeMonthly 时必填)
--recurrence-day-of-month int 每月第几天 (absoluteMonthly/absoluteYearly 时必填)
--recurrence-index string 每月第几周: first|second|third|fourth|last (relativeMonthly 时必填)
--recurrence-first-day-of-week string 一周起始日,默认 sunday
--recurrence-range-type string 循环范围: noEnd|endDate|numbered (与 --recurrence-type 必须成对出现)
--recurrence-end-date string 循环结束时间 ISO-8601 (range-type=endDate 时必填)
--recurrence-count int 循环次数 (range-type=numbered 时必填)
--rich-text-desc string html格式的富文本类型日程描述,用于复杂内容的展示
--location string 地点信息(纯文本备注,如‘3号楼A区’;**不等于**预订会议室)
--free-busy string 修改此日程的忙碌状态,无需修改则不传。busy - 在忙闲视图中,此日程时间段为忙碌; free - 此日程不占用忙闲
```
> 支持修改标题、描述、时间、地点、忙碌状态等。如需修改会议室,请使用 dws calendar room [add|delete];如需修改参会人,请使用 dws calendar attendee [add|delete]
### 删除日程
> **CAUTION:** 不可逆操作 — 所有参会人同步取消,必须先向用户确认。
```
Usage:
dws calendar event delete [flags]
Example:
dws calendar event delete --id <EVENT_ID> --yes
Flags:
--id string 日程 ID (必填)
```
### 查看参会人
```
Usage:
dws calendar attendee list [flags]
Example:
dws calendar attendee list --event <EVENT_ID>
Flags:
--event string 日程 ID (必填)
```
### 添加参会人
```
Usage:
dws calendar attendee add [flags]
Example:
dws calendar attendee add --event <EVENT_ID> --users <USER_ID_1>,<USER_ID_2>
dws calendar attendee add --event <EVENT_ID> --users <USER_ID> --optional true
Flags:
--event string 日程 eventId (必填)
--users string 参会人 userId 列表,逗号分隔 (必填)
--optional string 是否可选参会人 (可选)
--calendar-id string 日历 ID (可选)
```
### 移除参会人
> **CAUTION:** 写操作 — 执行前须用户确认。
```
Usage:
dws calendar attendee delete [flags]
Example:
dws calendar attendee delete --event <EVENT_ID> --users <USER_ID> --yes
Flags:
--event string 日程 eventId (必填)
--users string 参会人 userId 列表,逗号分隔 (必填)
--calendar-id string 日历 ID (可选)
```
### 搜索会议室
> 此指令可用于搜索当前用户可用的会议室。**注意**,大部分会议室**仅在工作时间**可用,如果检索时间不在工作时间可能查不到任何结果。
> 此指令搜索到的会议室结果中,有两个值需要注意:
- customApprovalProcess: true - 表示该会议室设置了自定义审批流程,只能通过客户端完成预订。
- supportRecurring: true - 表示该会议室支持循环预定;false - 表示不支持循环预定,直接加入到循环日程会失败。
```
Usage:
dws calendar room search [flags]
Example:
dws calendar room search --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00"
dws calendar room search --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --group-id <GROUP_ID>
dws calendar room search --room-name 永澄亭 # 注意:用户即使说「永澄亭会议室」,也应仅传「永澄亭」
dws calendar room search # 不传 --start/--end 时默认当前时间起 1 小时
Flags:
--start string 开始时间 ISO-8601 (可选,不传则默认当前时间+1分钟缓冲)
--end string 结束时间 ISO-8601 (可选,不传则默认当前时间+1 小时)
--group-id string 会议室分组ID(可选,留空查根目录;超100条时需按分组查询)
--room-name string 按会议室名称过滤(可选,服务端模糊匹配;传入前必须由调用方精简,只保留核心专名)
```
> **时间约束(API 限制)**:`start` 必须是未来的时间(服务端校验:start can not less current time)。
> - 若传入的 `--start` 早于当前时间,CLI 会自动修正为 `now + 1min`,调用方无需额外处理。
> - 若传入的 `--end` 早于当前时间,CLI 直接报错——无法检索已过去的时间段。
> - **最佳实践**:调用方在组装时间参数时应确保 start/end 都是未来时间;若不确定,可省略 `--start`/`--end` 让 CLI 使用默认值(当前时间起 1 小时)。
**名称过滤使用规范**:`--room-name` 适用于用户说「预定永澄亭」「约西湖厅」这类按名找会议室的场景。
- **服务端是模糊匹配,但匹配词越精简命中率越高**,关键疗法:**调用方必须在调用 CLI 前自行精简名称,CLI 不会再做任何删减**。
- 常见需要剔除的用户口语后缀(仅示例,实际场景由模型自行判断):「会议室」「大会议室」「小会议室」「厅」「房」等。
- 示例对映:
- 用户:「帮我订永澄亭会议室」 → `--room-name 永澄亭`
- 用户:「西湖厅有空吗」 → `--room-name 西湖厅`(本身就是专名,不删即可)
- 用户:「预定贡嘎山大会议室」 → `--room-name 贡嘎山`
**优先路径**:当用户给了会议室中文名时,**优先用 `room search --room-name <核心专名> --start <开始时间> --end <结束时间>`**代替「先走 `list-groups`再遍历」的旧流程。`--room-name` 可与 `--group-id` 同时使用,表示「在指定分组内按名称过滤」。若返回空列表,再降级使用 `list-groups` 定位分组再查。
**`roomId` 与用户说的话不是一回事**:用户说的「C6-4-06-N 贡嘎山」等是**展示名/编号文案**,**绝不能**直接填进 `room add --rooms`。`--rooms` 只接受上一步 `room search`(或同类接口)返回 JSON 里的 **`rooms[].roomId`**。形态上多为**小写十六进制串**(长度以接口为准,例如 `e6b7b65b8b30fb707afcf6c3b699f028003e6834fdd7fee7`)。含**中文、空格、连字符拼接的楼层编号**、或凭空调 UUID/纯数字「试格式」——一律视为非法,必须先搜房再取返回字段。
> 如果知道roomId,想查该会议室的预订记录,直接用dws calendar busy search 指令
---
### 预定会议室
```
Usage:
dws calendar room add [flags]
Example:
dws calendar room add --event <EVENT_ID> --rooms <ROOM_ID>
Flags:
--event string 日程 ID (必填)
--rooms string 会议室 ID 列表 (必填)
```
> room是会议室,用于线下开会场景。将room加入到日程完成预订
> 重复性日程,预订会议室时,必须设置 循环结束时间(recurrence-end-date),noEnd 或者 指定循环次数 都无法完成预定。
### 移除会议室
> **CAUTION:** 写操作 — 执行前须用户确认。
```
Usage:
dws calendar room delete [flags]
Example:
dws calendar room delete --event <EVENT_ID> --rooms <ROOM_ID> --yes
Flags:
--event string 日程 ID (必填)
--rooms string 会议室 ID 列表 (必填)
```
### 会议室分组列表
```
Usage:
dws calendar room list-groups [flags]
Example:
dws calendar room list-groups
dws calendar room list-groups --page-size 20 --page-index 0
Flags:
--page-size string 页大小 (可选,默认 100,上限 100)
--page-index string 分页起始位置 (可选,默认 0)
```
### 添加日程附件
```
Usage:
dws calendar attachment add [flags]
Example:
dws calendar attachment add --event <EVENT_ID> --files <FILE_ID>:report.pdf,<FILE_ID2>:slides.pptx
Flags:
--event string 日程 ID (必填)
--files string 附件列表,格式 <fileId>:<name>,多项逗号分隔 (必填)
```
> 上传文件得到 `fileId` 需配合钉盘相关流程;本命令只负责把已上传的文件挂载到日程上。
### 查询用户日历列表
```
Usage:
dws calendar book list [flags]
Example:
dws calendar book list
```
> 通过此接口可查询当前用户的日历列表,包含 主日历本、他人共享的日历、订阅的公共/团队日历。
> 共享日历本中有来自 xxx 的,且权限大于reader,那么通过 `event list --calendar-id <xxx的日历本id> `可查到xxx完整的日程安排
> 主日历 `id` 固定为 `primary`,绝大多数日程操作都默认走主日历,只有当用户明确要求查/写其他日历本时才需要带 `--calendar-id`。
### 查询用户 / 会议室闲忙状态
```
Usage:
dws calendar busy search [flags]
Example:
# 查用户闲忙
dws calendar busy search --users <USER_ID_1>,<USER_ID_2> \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00"
# 查会议室闲忙
dws calendar busy search --rooms <ROOM_ID_1>,<ROOM_ID_2> \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00"
# 同时查用户 + 会议室
dws calendar busy search --users <USER_ID> --rooms <ROOM_ID> \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00"
Flags:
--end string 结束时间 ISO-8601 (必填)
--start string 开始时间 ISO-8601 (必填)
--users string 用户 ID 列表,逗号分隔 (与 --rooms 至少其一)
--rooms string 会议室 ID 列表,逗号分隔 (与 --users 至少其一)
```
> **说明**:
> - `--users` 与 `--rooms` 必须至少指定其一,可以同时指定;CLI 会做前置校验,两者都为空会直接报错。
> - 查询会议室闲忙前,可先用 `dws calendar room search` 或 `dws calendar room list-groups` 拿到 roomId。
> - 返回结果中的忙碌时段仅包含粗粒度的时间信息,不包含日程内容细节(如标题、参会人、地点),以保护隐私。
### 建议日程时间
```
Usage:
dws calendar event suggest [flags]
Example:
dws calendar event suggest --users userId1,userId2 --duration 60
dws calendar event suggest --start "2026-03-10T09:00:00+08:00" --end "2026-03-10T18:00:00+08:00" --users userId1
dws calendar event suggest --users userId1 --duration 30 --timezone Asia/Tokyo
Flags:
--start string 推荐时间范围开始 ISO-8601 (默认当前时间)
--end string 推荐时间范围结束 ISO-8601 (默认次日18点)
--timezone string 时区 IANA 格式 (默认 Asia/Shanghai)
--users string 参会人 userId 列表,逗号分隔
--duration string 日程持续时间,单位分钟 (默认30)
> 对于非明确时间或一段时间范围的约会场景,可基于所有参会人的忙闲状态,推荐多个可用的时间块方案,用于解决会议时间协调问题。
```
### 响应日程
```
Usage:
dws calendar event respond [flags]
Example:
dws calendar event respond --id <EVENT_ID> --status accepted
dws calendar event respond --id <EVENT_ID> --status declined
dws calendar event respond --id <EVENT_ID> --status tentative
Flags:
--id string 日程 ID (必填)
--status string 响应状态: needsAction(未操作)|accepted(接受)|declined(拒绝)|tentative(暂定) (必填)
```
> **说明**:作为日程参会人,设置自己的响应状态(接受、拒绝、暂定)。`--status` 可选值:`needsAction`(未操作,默认值)、`accepted`(接受)、`declined`(拒绝)、`tentative`(暂定)。
## 意图判断
用户说"日程/会议/约会/日历":
- 查看 → `event list`
- 详情 → `event get`
- 创建/约/给自己留时间块/个人日程 → `event create`(带参会人时加 `--attendees`,循环日程加 `--recurrence-*`)
- 修改/改时间/改描述 → `event update`(支持修改标题、时间、描述、时区、循环规则)
- 取消/删除 → `event delete`
- 推荐时间/什么时候有空/协调时间 → `event suggest`
- 接受/拒绝/暂定日程 → `event respond`
用户说"参会人/与会者":
- 查看 → `attendee list`
- 邀请/添加 → `attendee add --users <USER_ID>`(可选参会人加 `--optional true`)
- 移除 → `attendee delete --users <USER_ID>`
用户说"会议室/订会议室":
- 哪个空闲 → `room search`
- 按名找会议室(如「永澄亭」「永澄亭会议室」「约西湖厅」)→ 先在模型层精简名称(剔除「会议室」等通用后缀),再用 `room search --room-name <核心专名>`
- 预订
- 给已有日程订会议室 → `room add --event <已有EVENT_ID> --rooms <ROOM_ID>`
- 创建新日程并订会议室 → `event create --rooms`(仅当日程尚不存在时)
- 取消预定 → `room delete`
- 分组 → `room list-groups`,取 groupId 后 `room search --group-id`(可再叠加 `--room-name` 在分组内过滤)
用户说"有空吗/忙不忙/闲忙":
- 查询用户闲忙 → `busy search --users <USER_ID>`
- 查询会议室闲忙 → `busy search --rooms <ROOM_ID>`
- 用户 + 会议室一起查 → `busy search --users <USER_ID> --rooms <ROOM_ID>`
用户说"日程附件/给会议加文件/上传日程材料":
- 添加 → `attachment add`(先用钉盘上传得 fileId,再 `attachment add --files <fileId>:<name>`)
用户说"我有几个日历/查所有日历/共享日历本":
- 列表 → `book list`(主日历 id 固定为 `primary`)
用户说"查下xxx的日程安排":
- 查询是否有共享关系 -> `book list`
- 场景1: 共享日历本中有来自 xxx 的,且权限大于reader,那么通过 `event list --calendar-id <xxx的日历本id> `可查到xxx完整的日程安排
- 场景2: 共享日历本中没有来自 xxx 的。那么通过 `busy search -- <USER_ID>`,查询xxx的忙闲安排
## 核心工作流
### 创建会议 + 邀请参会人 + 预订会议室
`event create` 支持 `--attendees` 在创建时直接指定参会人,**自 calendar MCP v2 起**也支持 `--rooms` 在创建时一并预定会议室;旧流程的「先创建日程再 `room add`」依然有效。
**关键区分**:`event create --rooms` 仅在**日程尚不存在**时使用;若日程已存在(同一会话刚创建、或用户指向已有日程),必须走「给已有日程订会议室」流程(见下方),**禁止**重复 `event create`。
**方式一:创建时一步完成(仅当日程尚不存在时推荐)**
```bash
# Step 1: 搜索空闲会议室,记下 roomId
dws calendar room search --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --format json
# 若返回错误(会议室超100条),先查分组再按分组搜索:
# dws calendar room list-groups --format json
# dws calendar room search --start ... --end ... --group-id <GROUP_ID> --format json
# Step 2: 创建日程时直接指定参会人 + 会议室
dws calendar event create --title "Q1 复盘会" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" \
--attendees userId1,userId2 \
--rooms <ROOM_ID_FROM_STEP1> --format json
```
**方式二:先创建日程,再单独添加参会人 / 会议室**
```bash
# Step 1: 创建日程 — 提取 eventId
dws calendar event create --title "Q1 复盘会" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --format json
# Step 2: 添加参会人(必须用 Step 1 返回的 eventId)
dws calendar attendee add --event <EVENT_ID> --users userId1,userId2 --format json
# Step 3: 搜索空闲会议室
dws calendar room search --start ... --end ... --format json
# Step 4: 预定会议室
dws calendar room add --event <EVENT_ID> --rooms <ROOM_ID> --format json
```
### 给已存在的日程加附件
```bash
# Step 1: 用钉盘上传文件,得到 fileId(参见 dws drive 系列命令)
# Step 2: 把附件挂到指定日程
dws calendar attachment add --event <EVENT_ID> --files <FILE_ID>:report.pdf,<FILE_ID2>:slides.pptx --format json
```
### 查看日程列表
```bash
dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `event create` | `eventId` | attendee/room/attachment 操作的 --event |
| `event list` | `events[].eventId` | event get/update/delete/respond 的 --id |
| `event suggest` | 推荐的时间段 | event create 的 --start/--end |
| `event respond` | 响应结果 | — |
| `room search` | `rooms[].roomId` | room add 的 --rooms 或 event create 的 --rooms |
| `room list-groups` | `groups[].groupId` | room search 的 --group-id |
| `book list` | `id`(如 `primary`) | event list/get 的 --calendar-id |
| 钉盘上传 | 文件 `fileId` | attachment add 的 --files `<fileId>:<name>` |
## 注意事项
- 时间格式: `event create/update`、`event list`、`busy search` 和 `event suggest` 用 ISO-8601
- 时区: `event create/update` 和 `event suggest` 支持 `--timezone` 指定 IANA 时区(如 `Asia/Shanghai`、`America/New_York`),不传默认 `Asia/Shanghai`
- 创建日程时可通过 `--attendees` 直接指定参会人(最多500人),也可创建后用 `attendee add --users ...` 单独添加
- `event create` 的 `--attendees` 和 `--open-dingtalk-ids` 至少传一个(如果需要指定参会人)
- `attendee add` 可通过 `--optional true` 设为可选参会人(默认必选)
- `event suggest` 根据参会人闲忙自动推荐合适时间,适合会议时间未确定时使用
- 创建日程**支持**通过 `--rooms` 一步预定会议室(`event create --rooms roomId1,roomId2`);若创建后再加,仍可用 `room add`
- `room search` 不带 `--group-id` 时查根目录;企业会议室超过 100 条会报错,此时需先 `room list-groups` 获取分组,再按分组逐一查询
- `room list-groups` 支持 `--page-size` / `--page-index` 分页(schema 类型为字符串)
- **`event create --rooms` / `room add --rooms` 的唯一合法来源**:最近一次(同一会话、同一时段窗口)`room search` 返回体中的 `roomId`;禁止把用户自然语言会议室名当 `roomId` 传入(否则会 `roomId invalid` 等错误)
- **搜房无结果**:在符合早停/用户限定范围内,`room search`(含按分组逐组查)全部返回空或无空闲 → 应**直接向用户报错/说明失败**并结束订房;**禁止**假设 roomId、禁止无合法 `roomId` 时调用 `room add` / `event create --rooms` 试探、禁止用 `event get` 等绕路推断 roomId
- **评测 / 自动化断言**:凡涉及 `room add` / `event create --rooms` 的流程,`--rooms` 只能填上游 `room search`(或等价接口)返回 JSON 中的 **`rooms[].roomId`**;不得以会议室展示名、楼层文案或用户口语当作 `roomId`
- **附件**:`attachment add` 仅负责挂载,**不上传**文件;fileId 必须先通过钉盘流程取得;`--files` 多附件用 `<fileId>:<name>` 元素逗号分隔
- **日历本**:`book list` 返回的 `id` 才是合法 `calendarId`;如无明确说明,`event list` / `event get` 都不要带 `--calendar-id`,让接口默认走 primary 主日历
- **已弃用入参**:`--max-results`(event list)在新 MCP schema 中被移除;CLI 仍接受但**不会**透传到 MCP;模型生成命令时**禁止**继续使用
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [calendar_today_agenda.py](../../scripts/calendar_today_agenda.py) | 查看今天/明天/本周日程安排 | `python calendar_today_agenda.py today` |
| [calendar_schedule_meeting.py](../../scripts/calendar_schedule_meeting.py) | 一键创建日程+添加参会人+预定会议室;搜房失败时输出明确原因并返回非零退出码 | `python calendar_schedule_meeting.py --title "复盘会" --start "2026-03-15T14:00" --end "2026-03-15T15:00" --users userId1 --book-room` |
| [calendar_free_slot_finder.py](../../scripts/calendar_free_slot_finder.py) | 查询多人共同空闲时段 | `python calendar_free_slot_finder.py --users userId1,userId2 --date 2026-03-15` |
## 相关产品
- [conference](./simple.md) — 仅视频会议预约(返回入会链接),不含参会人/会议室管理
- [contact](./contact.md) — 搜索同事 userId,用于 attendee add --users
# 钉钉默认表情列表(emoji 回应可用)
> 本文件列出钉钉支持的所有用户可见默认表情(showType=1),共 199 个。
>
> 使用规则:
> - 用户描述的表情命中下表中的 `name` → 使用 `chat message add-emoji --emoji <name>` 贴 emoji 回应
> - 用户描述的表情未命中下表 → 先 `chat message create-text-emotion` 创建文字表情获取 emotionId,再 `chat message add-text-emotion` 贴文字表情回应
| # | emotionId | name | en_US |
|---|-----------|------|-------|
| 1 | emotion_001 | 微笑 | Smile |
| 2 | emotion_099 | 可爱 | Lovely |
| 3 | emotion_002 | 憨笑 | Wow |
| 4 | emotion_003 | 色 | Yum |
| 5 | emotion_004 | 发呆 | Dazed |
| 6 | emotion_005 | 老板 | Boss |
| 7 | emotion_036 | 傻笑 | Oops |
| 8 | emotion_006 | 流泪 | Sob |
| 9 | emotion_007 | 害羞 | Shy |
| 10 | emotion_008 | 闭嘴 | Silence |
| 11 | emotion_009 | 睡 | Sleepy |
| 12 | emotion_010 | 大哭 | Cry |
| 13 | emotion_011 | 尴尬 | Awkward |
| 14 | emotion_080 | 感谢 | Thanks |
| 15 | emotion_204 | 拒绝 | SayNo |
| 16 | emotion_078 | 赞 | Like |
| 17 | emotion_024 | 鼓掌 | Clap |
| 18 | emotion_105 | 打招呼 | Hi |
| 19 | emotion_159 | 666 | 666 |
| 20 | emotion_079 | 抱拳 | Salute |
| 21 | emotion_025 | 握手 | Shake |
| 22 | emotion_023 | OK | OK |
| 23 | emotion_033 | 胜利 | Peace |
| 24 | emotion_142 | 向左 | Left |
| 25 | emotion_143 | 向右 | Right |
| 26 | emotion_144 | 向上 | Up |
| 27 | emotion_145 | 向下 | Down |
| 28 | emotion_185 | 来呀 | Come |
| 29 | emotion_155 | 一点点 | ALittle |
| 30 | emotion_179 | 捏住 | Pinch |
| 31 | emotion_140 | 比心 | FingerHeart |
| 32 | emotion_106 | 送花花 | Flower |
| 33 | emotion_178 | 加油干 | MakeEffort |
| 34 | emotion_013 | 调皮 | Tongueout |
| 35 | emotion_014 | 大笑 | Laugh |
| 36 | emotion_015 | 惊讶 | Scowl |
| 37 | emotion_016 | 流汗 | Sweat |
| 38 | emotion_017 | 奋斗 | Fight |
| 39 | emotion_018 | 口罩 | Mask |
| 40 | emotion_019 | 生病 | Sick |
| 41 | emotion_020 | 吐 | Barf |
| 42 | emotion_021 | 难过 | Bummed |
| 43 | emotion_022 | 抓狂 | Crazy |
| 44 | emotion_026 | 右哼哼 | Humph |
| 45 | emotion_027 | 太阳 | Sunny |
| 46 | emotion_028 | 月亮 | Moon |
| 47 | emotion_029 | 强 | Thumbsup |
| 48 | emotion_030 | 弱 | Thumbsdown |
| 49 | emotion_031 | 彩带 | Tada |
| 50 | emotion_032 | 蛋糕 | Cake |
| 51 | emotion_034 | 骷髅 | Skull |
| 52 | emotion_035 | 撇嘴 | Pout |
| 53 | emotion_037 | 鄙视 | Dislike |
| 54 | emotion_038 | 嘘 | Shhh |
| 55 | emotion_040 | 思考 | Hmm… |
| 56 | emotion_041 | 亲亲 | Kiss |
| 57 | emotion_042 | 无奈 | Disappointed |
| 58 | emotion_043 | 感冒 | Pollution |
| 59 | emotion_044 | 对不起 | Sorry |
| 60 | emotion_045 | 再见 | Wave |
| 61 | emotion_046 | 投降 | GiveUp |
| 62 | emotion_047 | 哼 | Grumpy |
| 63 | emotion_048 | 欠扁 | FaceSlap |
| 64 | emotion_049 | 拜托 | Please |
| 65 | emotion_050 | 可怜 | Aww… |
| 66 | emotion_051 | 舒服 | Relax |
| 67 | emotion_052 | 爱意 | Romantic |
| 68 | emotion_054 | 财迷 | MoneyMoney |
| 69 | emotion_055 | 迷惑 | Puzzled |
| 70 | emotion_056 | 委屈 | Worried |
| 71 | emotion_057 | 灵感 | Idea |
| 72 | emotion_058 | 天使 | Angel |
| 73 | emotion_059 | 鬼脸 | SillyFace |
| 74 | emotion_060 | 凄凉 | Phew |
| 75 | emotion_061 | 郁闷 | Tired |
| 76 | emotion_063 | 坏笑 | Trick |
| 77 | emotion_064 | 算账 | SoMuch |
| 78 | emotion_206 | PK | PK |
| 79 | emotion_066 | 忍者 | Sneaky |
| 80 | emotion_039 | 衰 | Grr |
| 81 | emotion_067 | 炸弹 | Uh-Oh |
| 82 | emotion_081 | 笑哭 | LaughAndCry |
| 83 | emotion_082 | 嘿嘿 | Smirk |
| 84 | emotion_083 | 捂脸哭 | Facepalm |
| 85 | emotion_084 | 抠鼻 | NosePick |
| 86 | emotion_085 | 流鼻血 | BloodyNose |
| 87 | emotion_090 | 呲牙 | Grin |
| 88 | emotion_091 | 吃瓜 | EatingMelon |
| 89 | emotion_092 | 彩虹 | Rainbow |
| 90 | emotion_098 | 耶 | Yeah |
| 91 | emotion_012 | 发怒 | Steamed |
| 92 | emotion_100 | 捂眼睛 | CannotLook |
| 93 | emotion_101 | 推眼镜 | PushGlasses |
| 94 | emotion_102 | 暗中观察 | Peep |
| 95 | emotion_103 | 脑暴 | Brainstorming |
| 96 | emotion_112 | 冷笑 | Distressed |
| 97 | emotion_208 | 热 | HotFace |
| 98 | emotion_113 | 开心 | Happy |
| 99 | emotion_114 | 惊喜 | Surprised |
| 100 | emotion_115 | 回头 | LookBack |
| 101 | emotion_116 | 白眼 | RollEyes |
| 102 | emotion_117 | 一团乱麻 | Overwhelmed |
| 103 | emotion_149 | 黑眼圈 | DarkCircle |
| 104 | emotion_225 | 裂开 | Broken |
| 105 | emotion_156 | 恭喜 | Congrats |
| 106 | emotion_160 | 费解 | Confuse |
| 107 | emotion_167 | 收到 | RogerThat |
| 108 | emotion_186 | 快来 | ComeOn |
| 109 | emotion_086 | 敲打 | Hammer |
| 110 | emotion_176 | 捧脸 | HoldFace |
| 111 | emotion_194 | Get | Get |
| 112 | emotion_207 | 客服 | CustomerService |
| 113 | emotion_210 | AR | AR |
| 114 | emotion_221 | 小蜜蜂 | Bee |
| 115 | emotion_211 | 虎虎生威 | MajesticTiger |
| 116 | emotion_222 | 兔飞猛进 | Rabbit |
| 117 | emotion_226 | 龙头老大 | Dragon Face |
| 118 | emotion_236 | 蛇来运转 | Good luck |
| 119 | emotion_238 | 马上来财 | Wealth is coming |
| 120 | emotion_093 | 专注 | Concentrate |
| 121 | emotion_166 | 忙疯了 | CrazyBusy |
| 122 | emotion_187 | 等一等 | Wait |
| 123 | emotion_227 | 一脸苦笑 | Wry Smile |
| 124 | emotion_228 | 王之蔑视 | Unamused |
| 125 | emotion_229 | 洪荒之力 | Amazing |
| 126 | emotion_234 | 向左看 | Thinking |
| 127 | emotion_235 | 向右看 | Pondering |
| 128 | emotion_230 | YYDS | YYDS |
| 129 | emotion_231 | 这边请 | ThisWayPlease |
| 130 | emotion_232 | 弹射下班 | OffDuty |
| 131 | emotion_233 | 退退退 | Back |
| 132 | emotion_188 | 在吗 | Hello? |
| 133 | emotion_189 | 让人头大 | Hard |
| 134 | emotion_089 | 摊手 | Smugshrug |
| 135 | emotion_088 | 抱抱 | Hug |
| 136 | emotion_158 | 举手 | RaiseHand |
| 137 | emotion_177 | 开车 | Driving |
| 138 | emotion_191 | 抱大腿 | Follow |
| 139 | emotion_087 | 跪了 | YouWin |
| 140 | emotion_162 | 鞠躬 | Bow |
| 141 | emotion_180 | 选我 | PickMe |
| 142 | emotion_209 | 元气满满 | FullOfVitality |
| 143 | emotion_168 | 会议 | Meeting |
| 144 | emotion_095 | 猫咪 | Kitty |
| 145 | emotion_094 | 二哈 | Doggy |
| 146 | emotion_097 | 狗子 | Puppy |
| 147 | emotion_111 | 三多 | SanDuo |
| 148 | emotion_153 | 承让 | LetMeWin |
| 149 | emotion_154 | 撒花 | Celebration |
| 150 | emotion_070 | 礼物 | Present |
| 151 | emotion_104 | 生日快乐 | Birthday |
| 152 | emotion_071 | 爱心 | Love |
| 153 | emotion_072 | 心碎 | BrokenHeart |
| 154 | emotion_073 | 嘴唇 | Lips |
| 155 | emotion_074 | 鲜花 | Rose |
| 156 | emotion_075 | 残花 | Wilted |
| 157 | emotion_077 | 干杯 | Cheers |
| 158 | emotion_151 | 咖啡 | Coffee |
| 159 | emotion_152 | 奶茶 | MilkTea |
| 160 | emotion_202 | 茶 | Tea |
| 161 | emotion_218 | OKR | OKR |
| 162 | emotion_109 | KPI | KPI |
| 163 | emotion_108 | 100分 | 100 |
| 164 | emotion_110 | 对勾 | Check |
| 165 | emotion_192 | 打叉 | Wrong |
| 166 | emotion_174 | 气泡 | Bubble |
| 167 | emotion_157 | 加一 | PlusOne |
| 168 | emotion_193 | Done | Done |
| 169 | emotion_146 | 钉子 | Staple |
| 170 | emotion_076 | 出差 | BusinessTrip |
| 171 | emotion_181 | 高铁 | HighSpeedTrain |
| 172 | emotion_184 | 火箭 | Rocket |
| 173 | emotion_068 | 邮件 | Mail |
| 174 | emotion_163 | 文档 | Document |
| 175 | emotion_164 | 演示 | Presentation |
| 176 | emotion_165 | 表格 | Sheet |
| 177 | emotion_213 | 废纸篓 | Wastebasket |
| 178 | emotion_237 | 手机 | MobilePhone |
| 179 | emotion_203 | 时间 | Time |
| 180 | emotion_217 | 静音 | mute |
| 181 | emotion_201 | 公文包 | Briefcase |
| 182 | emotion_214 | 地球 | Earth |
| 183 | emotion_215 | 碳减排 | CarbonReduction |
| 184 | emotion_216 | 回收标志 | RecyclingSymbol |
| 185 | emotion_205 | 幼苗 | Seedling |
| 186 | emotion_096 | 红包 | RedPacket |
| 187 | emotion_150 | 锦鲤 | LuckyDog |
| 188 | emotion_148 | 福 | Luck |
| 189 | emotion_198 | 灯笼 | Lantern |
| 190 | emotion_199 | 爆竹 | Firecrackers |
| 191 | emotion_197 | 烟花 | Fireworks |
| 192 | emotion_195 | 恭喜发财 | Prosperity |
| 193 | emotion_161 | 月饼 | MoonCake |
| 194 | emotion_173 | 鸡腿 | ChickenLeg |
| 195 | emotion_169 | 休假 | Vacation |
| 196 | emotion_175 | 火 | Hot |
| 197 | emotion_223 | 点赞 | LikeHeartAndTripleSix |
| 198 | emotion_196 | 平安健康 | Peace&Health |
| 199 | emotion_200 | 定胜 | Victory |
# 会话与群聊 (chat) 命令参考
## 命令总览
### group (群组管理)
#### 创建群 — 当前登录用户自动成为群主
```
Usage:
dws chat group create [flags]
Example:
dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3
Flags:
--users string 成员 userId 列表,用户本身会自动加入,无需包含,逗号分隔,不超过20个 (必填)
--name string 群名称 (必填)
```
#### 查看群成员列表 — 分页查询指定群聊的成员
```
Usage:
dws chat group members list [flags]
Example:
dws chat group members list --id <openconversation_id>
Flags:
--cursor string 分页游标,首次从 0 开始
--id string 群 ID / openconversation_id (必填)
```
#### 添加群成员 — 向指定群聊添加成员,需传入群 ID 与用户 ID 列表
```
Usage:
dws chat group members add [flags]
Example:
dws chat group members add --id <openconversation_id> --users userId1,userId2
Flags:
--id string 群 ID / openconversation_id (必填)
--users string 要添加的用户 userId 列表,逗号分隔 (必填)
```
#### 移除群成员 — 从指定群聊中移除成员,需传入群 ID 与待移除的用户 ID 列表
```
Usage:
dws chat group members remove [flags]
Example:
dws chat group members remove --id <openconversation_id> --users userId1,userId2
Flags:
--id string 群 ID / openconversation_id (必填)
--users string 要移除的用户 userId 列表,逗号分隔 (必填)
```
#### 将机器人添加到群中 — 将自定义机器人添加到当前用户有管理权限的群聊中,如果没有权限则会报错
```
Usage:
dws chat group members add-bot [flags]
Example:
dws chat group members add-bot --robot-code <robot-code> --id <openconversation_id>
Flags:
--id string 群聊 openConversationId (必填)
--robot-code string 机器人 Code (必填)
```
#### 从群内移除机器人 — 将指定机器人从群聊中移除,需要群管理员或群主权限
```
Usage:
dws chat group members remove-bot [flags]
Example:
dws chat group members remove-bot --id <openConversationId> --bot-id <openBotId>
# 查询群 ID: dws chat search --query "群名"
# 查询群内机器人: dws chat group bots --group <openConversationId>
Flags:
--id string 群聊 openConversationId (必填)
--bot-id string 机器人 openBotId (必填)
```
#### 更新群名称
```
Usage:
dws chat group rename [flags]
Example:
dws chat group rename --id <openconversation_id> --name "新群名"
Flags:
--id string 群 ID / openconversation_id (必填)
--name string 修改后的群名称 (必填)
```
#### 根据群号获取群聊信息 — 当用户只提供了数字群号而非 openConversationId 时,用此命令转换
```
Usage:
dws chat group get-by-group-id [flags]
Example:
dws chat group get-by-group-id --group-id 12345678
# 群号为数字类型的群ID
Flags:
--group-id int 群号 (必填,数字类型)
```
#### 转让群主 — 将群主身份转让给群内其他成员
```
Usage:
dws chat group transfer-owner [flags]
Example:
dws chat group transfer-owner --group <openConversationId> --new-owner <openDingTalkId>
# 查询群 ID: dws chat search --query "群名"
# 查询人员: dws aisearch person --keyword "姓名" --dimension name
Flags:
--group string 群聊 openConversationId (必填)
--new-owner string 新群主 openDingTalkId (必填)
```
#### 获取群邀请链接 — 获取指定群聊的邀请加入链接
可选 --expires-seconds 指定链接有效期(秒),0 表示永久有效,不传则使用服务端默认值。
```
Usage:
dws chat group invite-url [flags]
Example:
dws chat group invite-url --group <openConversationId>
dws chat group invite-url --group <openConversationId> --expires-seconds 86400
dws chat group invite-url --group <openConversationId> --expires-seconds 0
# 查询群 ID: dws chat search --query "群名"
Flags:
--group string 群聊 openConversationId (必填)
--expires-seconds int64 链接有效期(秒),0 表示永久有效,不传使用服务端默认值
```
#### 退出群聊 — 当前用户退出指定群聊
```
Usage:
dws chat group quit [flags]
Example:
dws chat group quit --group <openConversationId>
# 查询群 ID: dws chat search --query "群名"
Flags:
--group string 群聊 openConversationId (必填)
```
#### 更新群头像 — 更新指定群聊的群头像
```
Usage:
dws chat group update-icon [flags]
Example:
dws chat group update-icon --group <openConversationId> --icon-media-id <mediaId>
# 查询群 ID: dws chat search --query "群名"
Flags:
--group string 群聊 openConversationId (必填)
--icon-media-id string 群头像 mediaId (必填)
```
#### 更新群设置 — 更新指定群聊的设置项
--setting-key 指定设置项,--status 指定值(0=关闭,1=开启)。
支持的 settingKey:
authority、joinValidation、onlyAdminCanAtAll、searchable、addFriendForbidden、
toolbarStatus、pluginCustomizeVerify、onlyAdminCanDING、allMembersCanCreateMcsConf、
onlyAdminCanSetMsgTop、onlyAdminCanPinMsg、onlyAdminCanSendFile、
allMembersCanCreateCalendar、groupEmailDisabled、groupRedEnvelopeSwitch、
groupLiveAuthority、groupBillAuthority
```
Usage:
dws chat group update-settings [flags]
Example:
dws chat group update-settings --group <openConversationId> --setting-key searchable --status 1
dws chat group update-settings --group <openConversationId> --setting-key onlyAdminCanAtAll --status 0
# 查询群 ID: dws chat search --query "群名"
Flags:
--group string 群聊 openConversationId (必填)
--setting-key string 群设置项 key (必填)
--status int 设置值: 0=关闭, 1=开启 (必填)
```
#### 查看群内所有机器人 — 获取指定群聊中的所有机器人列表
```
Usage:
dws chat group bots [flags]
Example:
dws chat group bots --group <openConversationId>
# 查询群 ID: dws chat search --query "群名"
Flags:
--group string 群聊 openConversationId (必填)
```
#### 解散群聊 — 解散指定群聊,操作不可逆,需要群主权限
```
Usage:
dws chat group dismiss [flags]
Example:
dws chat group dismiss --group <openConversationId>
# 查询群 ID: dws chat search --query "群名"
Flags:
--group string 群聊 openConversationId (必填)
```
#### 设置新成员入群可查看历史消息选项 — 控制新加入成员可见的历史消息范围
```
Usage:
dws chat group set-history [flags]
Example:
dws chat group set-history --group <openConversationId> --option RECENT_100
dws chat group set-history --group <openConversationId> --option FORBIDDEN
# 查询群 ID: dws chat search --query "群名"
Flags:
--group string 群聊 openConversationId (必填)
--option string 可见范围: FORBIDDEN | RECENT_100 | ALL (必填)
注意:
- FORBIDDEN:禁止查看历史消息(默认安全策略)
- RECENT_100:可查看最近 100 条消息(最常用)
- ALL:可查看全部历史消息(开放性最高)
```
#### 拉取我创建/管理的群 — 查询当前用户作为群主或管理员的群列表
可通过 --role 过滤角色:OWNER 仅群主、ADMIN 仅管理员,不传则返回全部。可通过 --limit 限制返回数量,不传则返回所有符合条件的群。
```
Usage:
dws chat group list-my-groups [flags]
Example:
dws chat group list-my-groups
dws chat group list-my-groups --role OWNER
dws chat group list-my-groups --role ADMIN --limit 10
Flags:
--role string 角色过滤: OWNER(仅群主) / ADMIN(仅管理员),不传返回全部
--limit int 最多返回群数量,不传返回全部
注意:
- 底层先拉取最近 1000 条会话,剔除单聊和话题圈后筛选出群主/管理员的群
- 内部群会校验 orgId 归属
- 不传 --role 时返回群主 + 管理员的所有群
```
### group-role (群身份管理)
#### 查看群身份列表 — 拉取指定群聊的自定义群身份列表
```
Usage:
dws chat group-role list [flags]
Example:
dws chat group-role list --group <openConversationId>
Flags:
--group string 群聊 openConversationId (必填)
```
#### 添加群身份 — 在指定群中创建一个新的自定义群身份
```
Usage:
dws chat group-role add [flags]
Example:
dws chat group-role add --group <openConversationId> --name "管理员"
Flags:
--group string 群聊 openConversationId (必填)
--name string 群身份名称 (必填)
```
#### 更新群身份名称 — 修改指定群身份的名称
```
Usage:
dws chat group-role update [flags]
Example:
dws chat group-role update --group <openConversationId> --role-id <openRoleId> --name "新名称"
Flags:
--group string 群聊 openConversationId (必填)
--role-id string 群身份 openRoleId,由 group-role list 返回 (必填)
--name string 群身份新名称 (必填)
```
#### 删除群身份 — 删除指定群聊中的某个自定义群身份
```
Usage:
dws chat group-role remove [flags]
Example:
dws chat group-role remove --group <openConversationId> --role-id <openRoleId>
Flags:
--group string 群聊 openConversationId (必填)
--role-id string 群身份 openRoleId,由 group-role list 返回 (必填)
```
#### 设置用户群身份 — 覆盖指定用户在群中的全部群身份(传空则清除所有身份)
```
Usage:
dws chat group-role set-user [flags]
Example:
dws chat group-role set-user --group <openConversationId> --user <userId> --role-ids roleId1,roleId2
# 查询人员: dws aisearch person --keyword "姓名" --dimension name
# 查询 role-id: dws chat group-role list --group <openConversationId>
Flags:
--group string 群聊 openConversationId (必填)
--user string 用户 userId(必填)
--role-ids string 群身份 openRoleId 列表,逗号分隔 (必填),传空字符串则清除该用户所有群身份
```
#### 移除用户的指定群身份 — 从用户身上移除指定的群身份(不影响其他群身份)
```
Usage:
dws chat group-role remove-user [flags]
Example:
dws chat group-role remove-user --group <openConversationId> --user <userId> --role-ids roleId1,roleId2
Flags:
--group string 群聊 openConversationId (必填)
--user string 用户 userId(必填)
--role-ids string 要移除的群身份 openRoleId 列表,逗号分隔 (必填)
```
#### 查询群成员的群身份 — 查询指定群成员当前持有的所有群身份
```
Usage:
dws chat group-role query-user [flags]
Example:
dws chat group-role query-user --group <openConversationId> --user <userId>
Flags:
--group string 群聊 openConversationId (必填)
--user string 用户 userId(必填)
```
### search (搜索群聊)
#### 根据关键词搜索群聊 — 分页返回匹配群聊列表
hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
**注意:**
1. query 不要拆分得太细,应使用群名称中连续的核心词作为关键词(如群名"项目冲刺群"应搜"项目冲刺"而非拆成"项目"+"冲刺"分别搜索)。
2. 当搜索结果返回多个群聊时,应列出候选群让用户确认目标群聊,不要自行假定并直接进行后续操作。
```
Usage:
dws chat search [flags]
Example:
dws chat search --query "项目冲刺"
dws chat search --query "项目冲刺" --limit 20 --cursor 0
Flags:
--query string 搜索关键词 (必填)
--limit int 每页返回数量(默认 20)
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
```
### message (会话消息管理)
#### 拉取群聊会话消息内容 — 拉取指定群聊的会话消息内容(仅群聊)
--group 指定群聊 openConversationId(**本命令仅支持群聊**;拉取单聊/私聊消息请改用 `chat message list-direct`)。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
```
Usage:
dws chat message list [flags]
Example:
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --limit 50
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --forward=false
# 拉单聊改用 list-direct: dws chat message list-direct --user <userId> --time "2025-03-01 00:00:00"
Flags:
--forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true)
--group string 群聊 openconversation_id(必填,**仅支持群聊**;查单聊用 chat message list-direct --user <userId>)
--limit int 返回数量,不传则不限制
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
注意:
- 本命令**仅支持群聊**,必须指定 --group;拉取单聊(私聊)消息请改用 `chat message list-direct`(旧版的 `list --user` / `list --open-dingtalk-id` 已不再支持)
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
- 话题圈消息拉取流程:如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息。要获取完整的话题内容,需要两步操作:(1) 先通过 dws chat message list 拉取话题主消息(即话题帖子本身);(2) 再调用 dws chat message list-topic-replies --group <openConversationId> --topic-id <openConvThreadId> 分页拉取该话题下的所有回复消息。只有话题主消息 + 回复列表合在一起,才是一条话题的完整内容。
```
#### 拉取单聊消息内容 — 按对方 userId 拉取与某同事的单聊(私聊)历史消息
按对方 userId(或 openDingTalkId)拉取与该同事的单聊会话消息,**专用于私聊**;查群聊请用 `chat message list --group`。同组织内同事用 --user,非同组织好友用 --open-dingtalk-id,二者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
```
Usage:
dws chat message list-direct [flags]
Example:
dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --limit 50
dws chat message list-direct --user <对方userId> --time "2025-03-01 00:00:00" --forward=false
dws chat message list-direct --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 20
# 查询对方 userId: dws contact user search --keyword "姓名" 或 dws aisearch person --keyword "姓名" --dimension name
Flags:
--forward true=从老往新(给定时间之后),false=从新往老(给定时间之前) (default true)
--user string 对方 userId(同组织内同事,与 --open-dingtalk-id 二选一)
--open-dingtalk-id string 对方 openDingTalkId(非同组织普通好友场景,与 --user 二选一)
--limit int 每页返回数量(默认 50)
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
注意:
- --user 与 --open-dingtalk-id 二选一,必须且只能指定其一;同组织同事优先用 --user
- --time 必填;翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
- 本命令是 `chat message list` 拆分出的单聊专用命令;查群聊消息请用 `chat message list --group`
```
#### 以当前用户身份发送消息 — --group 群聊 / --user 或 --open-dingtalk-id 单聊
**重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。**
**`--ai-tag` 默认开(默认 true)**:dws 发送的消息默认带「通过AI发送」角标,正常发无需特意加;仅当用户要求按本人发、不带角标时传 `--ai-tag=false`。仅 send / reply 支持。
--group 指定群聊 openConversationId 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。纯文本/Markdown 单聊传 --user 时直接走 userId 发送能力,不需要先手动查询 openDingTalkId。推荐使用 --text flag 传递消息内容(也支持位置参数)。可选 --title 作为消息标题。
若用户只提供了数字群号而非 openConversationId,需先调用 `chat group get-by-group-id` 将群号转为 openConversationId,再传入 --group。
--群聊时可选 --at-all @所有人,或 --at-open-dingtalk-ids 指定成员(仅群聊时生效)。
--富媒体消息:通过 --msg-type 指定类型(image/file),必须根据文件扩展名判断 msgType 后再发送。
```
Usage:
dws chat message send [flags] [<text>]
富媒体消息 msgType 决策(必须按此规则判断,不可跳过):
文件扩展名 → msgType → 发送参数
.jpg/.jpeg/.png/.gif/.bmp/.webp → image → dt_media_upload 上传 → `python scripts/extract_media_id.py <URL>` 提取 mediaId → --msg-type image --media-id
其他所有(.mp3/.wav/.mp4/.avi/.pdf/.doc/.xls/.zip 等) → file → conversation-info 获取 spaceId → drive upload --space-id 上传 → drive info 获取 dentryId → --msg-type file --dentry-id --space-id --file-name --file-type --file-path --file-size
Example:
dws chat message send --group <openconversation_id> --text "hello"
dws chat message send --user <userId> --text "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> --text "请查收"
dws chat message send --group <openconversation_id> "hello"
dws chat message send --group <openconversation_id> --title "周报提醒" --text "请大家本周五前提交周报"
# 幂等发送(24h 内相同 uuid 不重复投递)
dws chat message send --group <openconversation_id> --text "hello" --uuid "unique-id-123"
dws chat message send --group <openconversation_id> --at-all "@all 请大家注意"
dws chat message send --group <openconversation_id> --at-open-dingtalk-ids openDingTalkId1,openDingTalkId2 "<@openDingTalkId1> <@openDingTalkId2> 请查收"
# 默认即带「通过AI发送」角标,无需特意加 --ai-tag
dws chat message send --user <userId> --text "已处理好了"
# 仅当用户要求按本人发送、不带角标时
dws chat message send --user <userId> --text "已处理好了" --ai-tag=false
# 发送图片
dws chat message send --group <openconversation_id> --msg-type image --media-id <mediaId>
# 发送文件(音频/视频/文档等非图片文件统一走钉盘上传)
# 先 dws chat conversation-info --group <id> 获取 spaceId(取 newCSpaceIdIM)
# 再 dws drive upload --file <文件> --space-id <spaceId> 上传
# 再 dws drive info --file-id <fileId> --space-id <spaceId> 获取 dentryId
dws chat message send --group <openconversation_id> --msg-type file --dentry-id <dentryId> --space-id 24557356340 --file-name "report.pdf" --file-type "pdf" --file-path "/report.pdf" --file-size 234724
Flags:
--text string 消息内容(推荐使用,也可用位置参数)
--group string 群聊 openconversation_id(群聊时必填)
--user string 单聊接收人 userId(单聊时与 --open-dingtalk-id 二选一)
--open-dingtalk-id string 单聊接收人 openDingTalkId(单聊时与 --user 二选一)
--title string 消息标题(可选,默认「消息」)
--at-all @所有人(仅群聊时生效,可选,默认 false)
--at-open-dingtalk-ids string @指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效,可选)
--ai-tag 标记为「通过AI发送」角标,默认 true(默认带上);传 --ai-tag=false 关闭(按本人发送)
--media-id string 图片 mediaId(dt_media_upload 上传后用 `python scripts/extract_media_id.py <URL>` 提取,仅 msgType=image)
--msg-type string 消息类型: image/file(image 用 mediaId,file 用钉盘上传)
--dentry-id int64 钉盘文件 dentryId(msgType=file 时必填,通过 drive info 获取)
--space-id int64 钉盘空间 ID(msgType=file 时必填)
--file-name string 文件名(msgType=file 时必填)
--file-type string 文件类型/扩展名(msgType=file 时必填)
--file-path string 文件路径(msgType=file 时必填)
--file-size int64 文件大小,单位字节(msgType=file 时必填)
--uuid string 幂等 UUID,相同 uuid 在 24h 内不会重复发送(可选)
注意:
- --text 和位置参数二选一,--text 优先
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
- 纯文本/Markdown 单聊发送时 `--user` 和 `--open-dingtalk-id` 都可用;传 `--user` 时直接走 userId 发送能力
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- --at-all 和 --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;当设置--at-all时,消息内容中一定要包含对应的占位符@all;当设置--at-open-dingtalk-ids openDingTalkId1,openDingTalkId2时,消息内容中一定要包含对应格式的占位符<@openDingTalkId1> <@openDingTalkId2>
- **@ 群内机器人**:务必使用 `dws chat group bots --group <openConversationId>` 返回的 openDingtalkId(群级别 ID,与全局搜索结果不同);全局 `chat bot find` 返回的 ID 无法正确 @ 机器人
- **换行符**:消息内容按 Markdown 渲染,换行有两层要求,缺一不可:
1. 必须使用**真实换行符**(Unicode `U+000A`),而非字面量字符串 `\n`(反斜杠 + 字母 n)。程序或大模型构造参数时,须确保已正确反转义;否则全部内容会渲染在同一行
2. Markdown 规范下**单个换行不产生换行效果**。需要换行时请使用:段落分隔(连续两个真实换行符 `\n\n`)、行尾两个空格 + 真实换行符(硬换行 `<br>`),或直接写 HTML 的 `<br>` 标签
- 富媒体消息类型与参数对应关系:
- image(图片):--msg-type image --media-id
- file(音频/视频/文档等所有非图片文件):先 conversation-info 获取 spaceId → drive upload --space-id 上传 → drive info 获取 dentryId → --msg-type file --dentry-id --space-id --file-name --file-type --file-path --file-size
- mediaId 通过 dt_media_upload 上传获得,必须用脚本提取:`python scripts/extract_media_id.py "<URL>"`(输出如 @lQLPxxx,直接用于 --media-id)。禁止手动从 URL 中截取或拼接 mediaId,手动解析会因 URL 格式不稳定导致尺寸后缀残留
- --uuid 用于幂等发送,传入相同 uuid 在 24h 内不会重复投递消息(可选,群聊和单聊均支持)
- 富媒体消息的单聊优先使用 `--open-dingtalk-id`;传 `--user` 时 CLI 会尝试解析成 openDingTalkId 后发送
- 发送文件/媒体消息时,必须先根据文件扩展名判断 msgType:图片(.jpg/.png/.gif/.bmp/.webp)→image,其他所有→file;不可跳过此判断步骤
- 20MB 降级:图片超过 20MB 时 dt_media_upload 会失败,必须降级走钉盘上传 + Markdown 嵌入方式发送(参见「发送图片+文字消息」章节)。音频/视频/文件走钉盘上传无 20MB 限制
- 发送文字 + 文件混合消息时的完整流程:除了将文件以 Markdown 链接内嵌到文字消息中发送一条 md 消息外,还必须额外逐个发送独立的文件消息(--msg-type file),确保接收方可以直接下载原始文件。即:先发一条包含文字和文件链接的 md 消息,再对每个涉及的文件各发一条 --msg-type file 的文件消息
```
#### 查询消息发送状态 — 查询以当前用户身份发送的消息的发送状态
查询以当前用户身份发送的消息的发送状态。需要传入发送消息时返回的 openTaskId。
```
Usage:
dws chat message query-send-status [flags]
Example:
dws chat message query-send-status --open-task-id <openTaskId>
# openTaskId 由 dws chat message send 返回
Flags:
--open-task-id string 消息发送任务 ID (必填)
注意:
- openTaskId 由 `dws chat message send` 发送消息成功后返回
- 用于确认消息是否已成功发送或获取发送失败的原因
```
#### 撤回消息 — 撤回当前用户自己发出的消息
撤回当前用户以个人身份发送的消息。需要指定会话 ID(openConversationId)和消息 ID(openMessageId)。与 `recall-by-bot` 的区别:本命令通过 IM 接口撤回用户自己发出的消息,`recall-by-bot` 通过机器人接口撤回机器人发出的消息(需要 robot-code + processQueryKey)。
```
Usage:
dws chat message recall [flags]
Example:
dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>
# 查询会话 ID: dws chat search --query "群名"
# 消息 ID 可通过 dws chat message list 获取
Flags:
--conversation-id string 会话 openConversationId (必填,支持单聊/群聊,别名: --group / --id / --chat)
--msg-id string 消息 openMessageId (必填)
注意:
- --conversation-id 的别名: --group, --id, --chat (均可替代 --conversation-id)
- 消息 ID 可通过 `dws chat message list` 命令获取
- 仅支持撤回当前用户以个人身份发出的消息,不能撤回他人发送的消息,也不能撤回机器人发出的消息
- 与 `recall-by-bot` 的区别:本命令通过 IM 接口撤回用户自己发出的消息(需要 openConversationId + openMessageId),`recall-by-bot` 通过机器人接口撤回机器人发出的消息(需要 robot-code + processQueryKey)
```
#### 机器人发送消息(--group 群聊 / --users 单聊)
**重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。**
群聊:传 --group 指定群;单聊:传 --users 指定用户列表,二者只能选其一,不能同时指定。--text 支持 Markdown。群聊时可选 --at-user-ids @指定成员。
如果用户明确要求"用机器人/机器人身份/robot"发送,必须使用本命令,严禁改用 `chat message send` 以当前用户身份发送。
**重要**:机器人发群消息前,必须确认该机器人已在目标群中。若机器人不在群内会报错"机器人不存在",需先执行 `dws chat group members add-bot --id <openConversationId> --robot-code <robot-code>` 将机器人加入群聊后再发送。
```
Usage:
dws chat message send-by-bot [flags]
Example:
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --title "日报" --text "## 今日完成..."
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
Flags:
--group string 群聊 openConversationId(群聊时必填)
--robot-code string 机器人 Code (必填)
--text string 消息内容 Markdown (必填)
--title string 消息标题 (必填)
--users string 接收者 userId 列表,逗号分隔,最多20个(单聊时必填)
注意:
- 用户明确要求机器人发送时,必须使用 `chat message send-by-bot`;严禁使用 `chat message send` 以用户身份代发
- --group(群聊)与 --users(单聊)互斥,必须且只能指定其一
- send-by-bot 不支持 @成员/@所有人 参数(无 --at-user-ids/--at-open-dingtalk-ids/--at-all);如需在群里 @人,用 `chat message send --group` 走用户身份发送
- userId 获取方式:`dws contact user search --query "姓名"` 搜人获取 userId
- **换行符**:--text 按 Markdown 渲染,换行规则同 `chat message send`:
1. 必须使用**真实换行符**(`U+000A`),而非字面量 `\n`,否则全部内容会渲染在同一行
2. 单个换行不产生换行效果,需用空行(`\n\n`)做段落分隔,或行尾两空格 + 换行/`<br>` 做硬换行
```
#### 机器人撤回消息(--group 群聊 / 不传为单聊)
群聊:传 --group 与 --keys;单聊:仅传 --keys。--keys 为发送时返回的 processQueryKey 列表,逗号分隔。
```
Usage:
dws chat message recall-by-bot [flags]
Example:
dws chat message recall-by-bot --robot-code <robot-code> --group <openconversation_id> --keys <process-query-key>
dws chat message recall-by-bot --robot-code <robot-code> --keys key1,key2
Flags:
--group string 群聊 openConversationId(群聊撤回时必填)
--keys string 消息 processQueryKey 列表,逗号分隔 (必填)
--robot-code string 机器人 Code (必填)
```
#### 自定义机器人 Webhook 发送群消息
@ 人时需在 --text 中包含 @userId 或 @手机号,否则 @ 不生效;@所有人时需在 --text 中包含 @10 并带上 --at-all。
```
Usage:
dws chat message send-by-webhook [flags]
Example:
dws chat message send-by-webhook --token <webhook-token> --title "告警" --text "CPU 超 90% @10" --at-all
dws chat message send-by-webhook --token <webhook-token> --title "test" --text "hi @118785" --at-users 118785
Flags:
--at-all @ 所有人(需在 --text 中包含 @10)
--at-mobiles string @ 指定手机号,逗号分隔
--at-users string @ 指定用户,逗号分隔(需在 text 中包含 @userId)
--text string 消息内容 (必填)
--title string 消息标题 (必填)
--token string Webhook Token (必填)
注意:
- **换行符**:--text 按 Markdown 渲染,换行规则同 `chat message send`:
1. 必须使用**真实换行符**(`U+000A`),而非字面量 `\n`,否则全部内容会渲染在同一行
2. 单个换行不产生换行效果,需用空行(`\n\n`)做段落分隔,或行尾两空格 + 换行/`<br>` 做硬换行
```
#### 拉取群话题回复消息列表
查询指定群聊中某条话题消息的全部回复。--group 指定群会话 ID,--topic-id 指定话题 ID(由 dws chat message list 返回)。
```
Usage:
dws chat message list-topic-replies [flags]
Example:
dws chat message list-topic-replies --group <openconversation_id> --topic-id <topicId>
dws chat message list-topic-replies --group <openconversation_id> --topic-id <topicId> --time "2025-03-01 00:00:00" --limit 20
Flags:
--group string 群会话 openconversationId (必填)
--topic-id string 话题 ID,由 dws chat message list 返回 (必填)
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss(可选)
--limit int 返回数量(默认 50)
--forward true=从老往新,false=从新往老(默认 false)
```
#### 拉取指定时间范围内当前用户的所有会话消息 — 分页拉取当前登录用户在指定时间范围内的所有会话消息
--start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 "0",后续从响应中的 nextCursor 获取)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值作为下次 --cursor 继续翻页。
```
Usage:
dws chat message list-all [flags]
Example:
dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50
dws chat message list-all --start "2025-03-01 00:00:00" --end "2025-03-31 23:59:59" --limit 50 --cursor "abc123token"
Flags:
--start string 起始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
--end string 结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)
--limit int 每页返回数量(默认 50)
--cursor string 分页游标(首页传 "0",后续从响应中的 nextCursor 获取)
注意:
- 四个参数每次请求都会传递给服务端,cursor 首页传 "0"
- 与 chat message list 的区别:list 拉取指定群聊会话的消息(单聊用 list-direct),list-all 拉取当前用户所有会话的消息
- 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页
- 时间格式统一为 yyyy-MM-dd HH:mm:ss
```
#### 拉取指定发送者的消息 — 搜索特定人发送给我的消息(包含单聊和群聊)
> 推荐优先使用 `chat message search-advanced --user/--users`(userId)或 `--sender-ids`(openDingTalkId),它还能叠加关键词/群/at 等过滤条件。本命令保留给需要旧 list-by-sender 返回结构的场景。
搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
```
Usage:
dws chat message list-by-sender [flags]
Example:
dws chat message list-by-sender --sender-user-id <userId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
dws chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
dws chat message list-by-sender --sender-user-id <userId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --limit 20 --cursor 0
dws chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor <nextCursor>
Flags:
--sender-user-id string 发送者 userId(与 --sender-open-dingtalk-id 二选一)
--sender-open-dingtalk-id string 发送者 openDingTalkId(与 --sender-user-id 二选一,适用于无法获取 userId 的场景)
--start string 开始时间,ISO-8601 格式 (必填)
--end string 结束时间,ISO-8601 格式 (必填)
--limit int 每页返回数量(默认 50)
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
注意:
- --sender-user-id 和 --sender-open-dingtalk-id 二者互斥,必须且只能指定其一:
- --sender-user-id 传 userId(企业内部应用常用)
- --sender-open-dingtalk-id 传 openDingTalkId(三方应用或跨组织场景常用,无法获取 userId 时使用)
- openDingTalkId 获取方式见下方「openDingTalkId 获取方式」小节
- 不需要指定单聊/群聊,返回结果自带会话类型标识
- 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00"、"2026-03-10" 等
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
```
#### 拉取 @我 的消息 — 搜索时间范围内 @我 的消息
> 推荐使用 `chat message search-advanced --at-me`,它还能叠加关键词/群/发送者等过滤条件。本命令适用于仅需拉取 @我 消息的简单场景。
搜索时间范围内 @我 的消息,可选指定群聊。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
```
Usage:
dws chat message list-mentions [flags]
Example:
dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
dws chat message list-mentions --start "2026-04-01T00:00:00+08:00" --end "2026-04-14T00:00:00+08:00" --limit 20 --cursor 0
dws chat message list-mentions --group <openconversation_id> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor 0
dws chat message list-mentions --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor <nextCursor>
Flags:
--group string 群聊 openconversation_id(可选,不传则查全部)
--start string 开始时间,ISO-8601 格式 (必填)
--end string 结束时间,ISO-8601 格式 (必填)
--limit int 每页返回数量(默认 50)
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
注意:
- --group 可选,不传则查询所有会话中 @我 的消息;传入则只查指定群聊
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00"、"2026-03-10" 等
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
```
#### 拉取特别关注人的消息
拉取当前用户特别关注人的消息。分页参数 --limit 指定每页数量,--cursor 传分页游标(首次不传或传 0)。返回结果中 hasMore=true 时用 nextCursor 作为下次 --cursor 继续翻页。
```
Usage:
dws chat message list-focused [flags]
Example:
dws chat message list-focused --limit 50
dws chat message list-focused --limit 20 --cursor <nextCursor>
Flags:
--limit int 每页返回数量(默认 50)
--cursor int64 分页游标(首次不传或传 0,翻页传 nextCursor)
注意:
- 首次调用不传 --cursor 或传 0,后续翻页传 nextCursor
```
#### 获取未读会话列表
获取当前用户有未读消息的会话信息。可选通过 `--count` 限制返回条数,不传则使用服务端默认值。
```
Usage:
dws chat message list-unread-conversations [flags]
Example:
dws chat message list-unread-conversations
dws chat message list-unread-conversations --count 20
Flags:
--count int 返回未读会话条数(可选)
```
#### 查询消息的已读/未读状态
查询指定会话中消息的已读/未读状态(仅消息发送者可查询自己发出的消息)。--conversation-id 指定会话 openConversationId(群聊或单聊均可),--message-id 指定消息 ID(由 dws chat message list 返回的 openMessageId,必须是当前用户发送的消息)。目标用户 userId 使用 --user/--users;目标用户 openDingTalkId 使用 --target-open-dingtalk-ids;不传目标用户则返回所有接收者的状态。
```
Usage:
dws chat message read-status [flags]
Example:
dws chat message read-status --conversation-id <openConversationId> --message-id <openMessageId>
dws chat message read-status --conversation-id <openConversationId> --message-id <openMessageId> --user userId1,userId2
dws chat message read-status --conversation-id <openConversationId> --message-id <openMessageId> --users userId1,userId2
dws chat message read-status --conversation-id <openConversationId> --message-id <openMessageId> --target-open-dingtalk-ids openDingTalkId1,openDingTalkId2
Flags:
--conversation-id string 会话 openConversationId (必填,群聊或单聊均可)
--message-id string 消息 openMessageId,由 chat message list 返回 (必填,必须是当前用户发送的消息)
--user string 目标用户 userId,支持逗号分隔(可选,不传则查所有接收者)
--users string 目标用户 userId 列表,逗号分隔(可选,不传则查所有接收者)
--target-open-dingtalk-ids string 目标用户 openDingTalkId 列表,逗号分隔(可选,不传则查所有接收者)
注意:
- 仅消息发送者可查询自己发出的消息的已读/未读状态,查询他人发的消息会报错
- --conversation-id 的别名: --group, --id, --chat (均可替代 --conversation-id)
- --message-id 从 dws chat message list 返回的消息列表中获取(字段名 openMessageId)
- --user / --users 传目标用户 userId
- --target-open-dingtalk-ids 不传时返回该消息所有接收者的已读状态;传入则只返回指定 openDingTalkId 用户的状态
```
#### 按关键词搜索消息 — 在当前用户的会话中按关键词搜索消息
> 推荐优先使用 `chat message search-advanced`,它是本命令的严格超集:query 可选(非必填)、支持多个会话(非单个)、还能叠加发送者/at 等维度过滤。
按关键词搜索消息内容。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
```
Usage:
dws chat message search [flags]
Example:
dws chat message search --query "changefree" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0
dws chat message search --query "codereview" --group <openconversation_id> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 100 --cursor 0
dws chat message search --query "链接" --start "2026-04-15T00:00:00+08:00" --end "2026-04-16T00:00:00+08:00" --limit 100 --cursor <nextCursor>
Flags:
--query string 搜索关键词 (必填)
--group string 群聊 openconversation_id(可选,不传则搜索所有会话)
--start string 开始时间,ISO-8601 格式 (必填)
--end string 结束时间,ISO-8601 格式 (必填)
--limit int 每页返回数量(默认 100)
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
注意:
- --group 可选,不传则搜索所有会话中的消息;传入则只搜索指定会话
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00"、"2026-03-10" 等
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
```
#### 多维度搜索消息(推荐首选) — 支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度搜索
> 推荐:这是消息搜索的首选接口。它可以完全替代 `chat message search`(query 可选 vs 必填,支持多个会话 vs 单个),大部分替代 `chat message list-by-sender`(通过 --user/--users 按 userId 搜索发送者,或通过 --sender-ids 按 openDingTalkId 搜索)和 `chat message list-mentions`(通过 --at-me 搜索@我的消息)。仅在拉取「特别关注人」消息时需要退回 `list-focused`。
支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度搜索消息。发送者 userId 使用 --user/--users;发送者或 @ 人的 openDingTalkId 使用 --sender-ids/--at-ids。所有参数均为可选,至少指定一个搜索条件。
```
Usage:
dws chat message search-advanced [flags]
Example:
dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
dws chat message search-advanced --user <userId> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
dws chat message search-advanced --users <userId1>,<userId2> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
dws chat message search-advanced --sender-ids <openDingTalkId1>,<openDingTalkId2> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
dws chat message search-advanced --at-me --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
dws chat message search-advanced --at-ids <openDingTalkId1>,<openDingTalkId2> --conversation-ids <openConversationId1>,<openConversationId2> --limit 50 --cursor 0
dws chat message search-advanced --conversation-ids <单聊openConversationId> --query "合同" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
# 查询群 ID: dws chat search --query "群名"
# 查询单聊会话 ID: dws chat conversation-info --user <userId>
# 查询人员: dws aisearch person --keyword "姓名" --dimension name
Flags:
--query string 搜索关键词(可选)
--user string 发送者 userId,支持逗号分隔(可选)
--users string 发送者 userId 列表,逗号分隔(可选)
--sender-ids string 发送者 openDingTalkId 列表,逗号分隔(可选)
--at-me 只搜索 @我 的消息(可选,默认 false)
--at-ids string @指定人的 openDingTalkId 列表,逗号分隔(可选)
--conversation-ids string 会话 openConversationId 列表,逗号分隔(可选,群聊或单聊均可,不传则搜索所有会话)
--start string 开始时间,ISO-8601 格式(可选)
--end string 结束时间,ISO-8601 格式(可选)
--cursor string 分页游标(默认 "0")
--limit int 每页返回数量(默认 100)
--conversation-ids 的别名: --groups
注意:
- 所有参数均为可选,但至少需要指定一个搜索条件
- --user / --users 传发送者 userId
- --sender-ids 和 --at-ids 传 openDingTalkId
- --conversation-ids 可指定多个会话 ID(群聊或单聊均可),逗号分隔,不传则搜索所有会话
- 群聊 openConversationId 通过 `dws chat search --query "群名"` 获取
- 单聊 openConversationId 通过 `dws chat conversation-info --user <userId>` 或 `--open-dingtalk-id <openDingTalkId>` 获取
- 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00"、"2026-03-10" 等
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
- 替代关系:完全替代 search(严格超集);大部分替代 list-by-sender(--user 覆盖按 userId 搜索发送者,--sender-ids 覆盖按 openDingTalkId 搜索)和 list-mentions(--at-me 覆盖核心功能);不能替代 list-focused(「特别关注」是独立维度)
```
#### 根据消息 ID 批量查询消息
```
Usage:
dws chat message list-by-ids [flags]
Example:
dws chat message list-by-ids --msg-ids msgId1,msgId2,msgId3
# 最多传 50 条消息 ID
Flags:
--msg-ids string 消息 ID 列表,逗号分隔,最多 50 条 (必填)
```
#### 表情回应选择策略
> 贴表情时,优先查 [chat-emoji-list.md](chat-emoji-list.md) 中的默认表情名称(共 199 个,如「赞」「鼓掌」「感谢」等):
> - 命中 → 使用 `add-emoji --emoji <name>`(直接贴 emoji)
> - 未命中 → 先 `create-text-emotion` 创建文字表情获取 emotionId,再 `add-text-emotion` 贴文字表情
#### 对消息添加 emoji 表情回应
```
Usage:
dws chat message add-emoji [flags]
Example:
dws chat message add-emoji --conversation-id <openConversationId> --msg-id <openMsgId> --emoji "赞"
dws chat message add-emoji --conversation-id <openConversationId> --msg-id <openMsgId> --emoji "鼓掌"
# --emoji 的值必须是 chat-emoji-list.md 中的 name(中文名),如:赞、鼓掌、感谢、微笑 等
# 查询会话 ID: dws chat search --query "群名"
Flags:
--conversation-id string 会话 openConversationId (必填,支持单聊/群聊,别名: --group / --id / --chat)
--msg-id string 消息 openMsgId (必填)
--emoji string emoji 表情名称,必须是默认表情列表中的 name 值 (必填,参见 chat-emoji-list.md)
```
#### 移除消息的 emoji 表情回应
```
Usage:
dws chat message remove-emoji [flags]
Example:
dws chat message remove-emoji --conversation-id <openConversationId> --msg-id <openMsgId> --emoji "赞"
# 查询会话 ID: dws chat search --query "群名"
Flags:
--conversation-id string 会话 openConversationId (必填,支持单聊/群聊,别名: --group / --id / --chat)
--msg-id string 消息 openMsgId (必填)
--emoji string emoji 表情名称,必须是默认表情列表中的 name 值 (必填,参见 chat-emoji-list.md)
```
#### 对消息添加文字表情回应(当默认表情列表中没有所需表情时使用)
```
Usage:
dws chat message add-text-emotion [flags]
Example:
dws chat message add-text-emotion --conversation-id <openConversationId> --msg-id <openMsgId> --emotion-id <emotionId> --emotion-name "赞" --text "nice" --background-id im_bg_5
Flags:
--conversation-id string 会话 openConversationId (必填,支持单聊/群聊,别名: --group / --id / --chat)
--msg-id string 消息 openMsgId (必填)
--emotion-id string 表情 ID (必填,通过 create-text-emotion 或已知表情获取)
--emotion-name string 表情名称 (必填)
--text string 文字内容 (必填)
--background-id string 背景 ID (必填)
```
#### 移除消息的文字表情回应
```
Usage:
dws chat message remove-text-emotion [flags]
Example:
dws chat message remove-text-emotion --conversation-id <openConversationId> --msg-id <openMsgId> --emotion-id <emotionId> --emotion-name "赞" --text "nice" --background-id <backgroundId>
Flags:
--conversation-id string 会话 openConversationId (必填,支持单聊/群聊,别名: --group / --id / --chat)
--msg-id string 消息 openMsgId (必填)
--emotion-id string 表情 ID (必填)
--emotion-name string 表情名称 (必填)
--text string 文字内容 (必填)
--background-id string 背景 ID (必填)
```
#### 创建文字表情(获取 emotionId)— 当 chat-emoji-list.md 中没有所需表情时,先创建再贴
```
Usage:
dws chat message create-text-emotion [flags]
Example:
dws chat message create-text-emotion --emotion-name "赞" --text "nice"
dws chat message create-text-emotion --emotion-name "感谢" --text "感谢" --background-id im_bg_5
Flags:
--emotion-name string 表情名称 (必填)
--text string 文字内容 (必填)
--background-id string 背景 ID(可选,不传则由服务端默认分配)
注意:
- 创建后返回 emotionId,可用于 add-text-emotion 命令
- 如果已有合适的表情,无需创建新的
```
### list-top-conversations (置顶会话)
#### 拉取置顶会话列表
拉取当前用户的置顶会话列表。分页参数 --limit 指定每页数量,--cursor 传分页游标(首次不传或传 0)。返回结果中 hasMore=true 时用 nextCursor 作为下次 --cursor 继续翻页。
```
Usage:
dws chat list-top-conversations [flags]
Example:
dws chat list-top-conversations --limit 1000
dws chat list-top-conversations --limit 1000 --cursor <nextCursor>
Flags:
--limit int 每页返回数量(默认 1000)
--cursor int64 分页游标(首次不传或传 0,翻页传 nextCursor)
注意:
- 用户询问"置顶会话"时,直接调用此命令返回置顶会话列表即可
- 用户询问"置顶消息"时,需两步:先调用此命令拉取置顶会话列表获取各会话的 openConversationId,再用 `chat message list --group <openConversationId>` 分别拉取每个会话内的消息
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
```
### download-media (下载消息资源)
#### 下载消息中的资源(图片/视频/语音等)到本地
下载聊天消息中的图片、视频、语音等资源到本地文件。流程:先获取下载 URL,再 HTTP GET 下载。
```
Usage:
dws chat message download-media [flags]
Example:
dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/
dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./photo.jpg
Flags:
--type string 资源类型: mediaId (必填)
--resource-id string 资源 ID,mediaId 类型时为消息中的 mediaId 值 (必填)
--message-id string 消息 openMessageId (必填)
--open-conversation-id string 会话 openConversationId (必填)
--output string 本地保存路径,文件或目录 (必填)
注意:
- resource-id 从 `dws chat message list` 返回的消息内容中获取 mediaId
- message-id 从 `dws chat message list` 返回的 openMessageId
- open-conversation-id 从 `dws chat search` 获取 openConversationId
- --output 如果指定目录,文件名会从下载 URL 中自动推断
```
#### 资源链接形态分流 — 按 content 里的链接 host 选下载方式
`message list` / `message list-all` 拉到的消息,`content` 里的资源**不一定都是 mediaId**,下载方式由链接的 host 决定、不由文件扩展名决定;**首选对应 dws 命令**,裸 curl 仅对明确的公开直链有效。按下表分流、不要混用:
| `content` 里的形态 | 资源性质 | 下载方式 |
|---|---|---|
| `mediaId=...`(图片 / 视频 / 语音,扩展名任意) | 钉钉消息媒体;**host 不唯一**:`down.dingtalk.com/media`(公开直链)或 `*.trans.dingtalk.com/...?Expires=&Signature=`(签名 + 会过期) | **首选 `dws chat message download-media`**(内部取最新下载 URL,不受过期影响,需 `--message-id` + `--open-conversation-id`)。仅当链接是 `down.dingtalk.com/media` 公开直链时,可 `curl -sL -o` 快捷下载;**签名/过期链(`trans.dingtalk.com`、带 `Signature`/`Expires`)不要裸 curl,过期会 403** |
| `[文件] xxx fileId: <fileId>`(钉盘文件) | 钉盘临时签名链接 | `dws drive download --node <fileId> --output <路径>`(裸 curl 不通用,需签名头) |
| `https://alidocs.dingtalk.com/i/nodes/<nodeId>`(钉钉文档 / .adoc / 视频 .mov 等节点) | 文档 / 钉盘节点 | 读内容用 `dws doc`,下文件用 `dws drive download`(裸 curl 只得 HTML 预览页) |
> - 下载到本地后用 `file <文件>` 判断真实类型:部分链接扩展名是 `.unknown`、服务端按 `application/octet-stream` 返回,仍可正常下载。
> - 图片消息还会附带 AI 识别的内容描述(`<imageContent>...</imageContent>`),不下载也能理解图意。
> - 钉盘文件在 `content` 里给的是 `fileId`、不是 mediaId,必须走 `dws drive download`(下载链接是带签名头的临时链接,裸 curl 不通用)。
> - alidocs 节点裸 curl 只会拿到 HTML 预览页、不是文件本体,需走 `dws doc`(读内容)或 `dws drive download`(下文件)。
### search-common (搜索共同群)
#### 搜索共同群 — 查询指定人共同所在的群聊
根据昵称列表搜索共同群聊。--nicks 指定要搜索的人员昵称(逗号分隔,必填)。--match-mode 控制匹配模式:AND 表示所有人都在群里,OR 表示任一人在群里(默认 AND)。分页参数 --limit(默认 20)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
```
Usage:
dws chat search-common [flags]
Example:
dws chat search-common --nicks "风雷,山乔" --limit 20 --cursor 0
dws chat search-common --nicks "天鸡,乐函" --match-mode OR --limit 20 --cursor 0
dws chat search-common --nicks "风雷,山乔,天鸡" --limit 10 --cursor <nextCursor>
Flags:
--nicks string 要搜索的昵称列表,逗号分隔 (必填)
--match-mode string 匹配模式:AND=所有人都在群里,OR=任一人在群里(默认 AND)
--limit int 每页返回数量(默认 20)
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
注意:
- --nicks 传人员昵称(花名),逗号分隔,如 "风雷,山乔"
- --match-mode AND 表示群里必须包含所有指定的人;OR 表示包含任意一人即可
- 翻页:hasMore=true 时,用返回的 nextCursor 作为下次 --cursor
```
### conversation-info (获取会话基础信息)
#### 获取会话基础信息 — 含会话关联的钉盘共享空间 ID
获取指定会话的基础信息,包含会话关联的钉盘共享空间 ID (newCSpaceIdIM)。发送文件消息前需先调用此命令获取 spaceId,再用 drive upload --space-id 上传文件到共享空间。
```
Usage:
dws chat conversation-info [flags]
Example:
dws chat conversation-info --group <openConversationId> --format json
dws chat conversation-info --user <userId> --format json
dws chat conversation-info --open-dingtalk-id <openDingTalkId> --format json
Flags:
--group string 群聊 openConversationId(群聊时使用)
--user string 单聊对方 userId(单聊时使用)
--open-dingtalk-id string 单聊对方 openDingTalkId(单聊时使用)
注意:
- --group、--user、--open-dingtalk-id 互斥,必须且只能指定其一
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- 返回值中的 newCSpaceIdIM 为会话共享空间 ID,用于 drive upload --space-id 参数
- 上传到共享空间的文件对方才能打开,上传到个人空间的文件对方无法访问
```
#### 合并转发多条消息 — 将多条消息合并后转发到目标会话(源/目标会话均支持单聊/群聊)
```
Usage:
dws chat message combine-forward [flags]
Example:
dws chat message combine-forward --src-conversation-id <srcOpenCid> --msg-ids <id1>,<id2>,<id3> --dest-conversation-id <destOpenCid>
dws chat message combine-forward --src-conversation-id <srcOpenCid> --msg-ids <id1>,<id2> --dest-conversation-id <destOpenCid> --uuid <idempotencyKey>
Flags:
--src-conversation-id string 源会话 openConversationId (必填)
--msg-ids string 源消息 openMessageId 列表,逗号分隔 (必填)
--dest-conversation-id string 目标会话 openConversationId (必填)
--uuid string 幂等键(可选)
注意:
- 与 chat message forward 区别: forward 转单条,combine-forward 合并多条为一条转发
- --msg-ids 多个消息 ID 用逗号分隔,无顺序要求
```
#### 钉住某条消息(Pin) — 将指定消息设置为钉住状态
```
Usage:
dws chat message set-pin-msg [flags]
Example:
dws chat message set-pin-msg --open-conversation-id <openConversationId> --msg-id <openMessageId>
Flags:
--open-conversation-id string (必填)会话 openConversationId(支持群聊/单聊)
--msg-id string (必填)消息 openMessageId
注意:
- 钉住消息后,会话成员均可在会话中看到被钉住的消息
```
#### 取消钉住某条消息(Unpin) — 取消指定消息的钉住状态
```
Usage:
dws chat message unset-pin-msg [flags]
Example:
dws chat message unset-pin-msg --open-conversation-id <openConversationId> --msg-id <openMessageId>
Flags:
--open-conversation-id string (必填)会话 openConversationId(支持群聊/单聊)
--msg-id string (必填)消息 openMessageId
注意:
- 取消钉住后消息仍保留在会话中,只是不再被标记为钉住状态
```
#### 拉取某个会话中钉住的消息列表 — 拉取指定会话中被钉住的消息列表
```
Usage:
dws chat message list-pin-msg [flags]
Example:
dws chat message list-pin-msg --open-conversation-id <openConversationId>
dws chat message list-pin-msg --open-conversation-id <openConversationId> --size 50
dws chat message list-pin-msg --open-conversation-id <openConversationId> --cursor <nextCursor> --size 20
Flags:
--open-conversation-id string (必填)会话 openConversationId(支持群聊/单聊)
--cursor string (选填)分页游标,首次不传,翻页时传上次返回的 nextCursor
--size int (选填)一次拉取的消息数量(默认 20,最大 100)
注意:
- 与 `chat message list` 区别: list-pin-msg 只返回被钉住的消息;list 拉取全部消息
- 分页: hasMore=true 时,用返回的 nextCursor 作为下次 --cursor 继续翻页
```
### bot (机器人管理)
#### 搜索【我创建的】机器人 — 仅返回当前用户自己创建的机器人
范围: 仅限当前登录用户自己创建的机器人(不含他人创建、官方机器人)。
返回字段: 没有 openDingTalkId,如果需要给机器人发单聊消息请用 find。
典型触发词: "我创建的机器人""我的机器人""我自己的机器人""我做的机器人""查看我的机器人"。
```
Usage:
dws chat bot search [flags]
Example:
dws chat bot search --page 1
dws chat bot search --page 1 --size 10 --name "日报"
Flags:
--name string 按名称搜索
--page int 页码,从1开始 (默认 1)
--size int 每页条数 (默认 50),别名: --limit
```
#### 搜索【全部可用】机器人 — 含他人创建/官方机器人,额外返回 openDingTalkId
范围: 当前用户可用的全部机器人(含他人创建、官方机器人)。
返回字段: 额外返回 openDingTalkId(可用于给机器人发单聊消息),search 没有此字段。
典型触发词: "搜索机器人""找一个机器人""帮我找 XXX 机器人""所有可用机器人""查机器人"。
```
Usage:
dws chat bot find [flags]
Example:
dws chat bot find --query "日报"
dws chat bot find --query "日报" --limit 20
dws chat bot find --query "日报" --limit 20 --cursor <上次返回的 nextCursor>
Flags:
--query string 搜索关键词 (必填)
--limit int 每页返回数量(默认 20)
--cursor string 分页游标(首次调用不传,翻页时传上次返回的 nextCursor)
注意:
- cursor 必须用上次返回的 nextCursor 字符串原值,不要传 "0" 或其他数字字面量
(服务端 String 类型,但网关会把数字字符串 auto-coerce 回 Integer 导致 PARAM_ERROR)
```
search 与 find 选择指南:
| 维度 | `chat bot search` | `chat bot find` |
|------|-------------------|-----------------|
| 范围 | 仅我创建的机器人 | 全部可用机器人(含他人/官方) |
| 额外返回 openDingTalkId | 无 | 有(可用于给机器人发单聊消息) |
| 触发词 | "我创建的""我的""我自己的" | "搜索机器人""找机器人""查机器人" |
### category (会话分组管理)
#### 获取用户自定义会话分组
```
Usage:
dws chat category list
Example:
dws chat category list
# 返回当前用户的所有自定义会话分组
```
#### 拉取指定分组下的会话列表
```
Usage:
dws chat category list-conversations [flags]
Example:
dws chat category list-conversations --category-id <分组ID>
# 分组ID 可通过 dws chat category list 获取
Flags:
--category-id int 会话分组 ID (必填)
```
### mute (会话免打扰)
#### 会话消息免打扰 — 开启或关闭会话消息免打扰(支持单聊和群聊)
```
Usage:
dws chat mute [flags]
Example:
dws chat mute --conversation-id <openConversationId>
dws chat mute --conversation-id <openConversationId> --off
# 查询群 ID: dws chat search --query "群名"
# 查询单聊会话 ID: dws chat conversation-info --user <userId>
Flags:
--conversation-id string 会话 openConversationId (必填,支持单聊/群聊)
--id string --conversation-id 的别名
--chat string --conversation-id 的别名
--off 关闭免打扰(不传则开启免打扰)
注意:
- 默认行为是开启免打扰,传 --off 则关闭免打扰
- 支持单聊和群聊,openConversationId 可通过 chat search(群聊)或 chat conversation-info(单聊)获取
```
## 意图判断
用户说"我特别关注的人最近发了什么消息/关注的人最近聊了啥/星标联系人最近的动态" → `chat message list-focused`(零参数一行命令)
用户说"某人发给我的消息/指定发送者的消息/某人最近的消息" → `chat message list-by-sender --sender-user-id <userId>` 或 `--sender-open-dingtalk-id <openDingTalkId>`(跨单聊+群聊)
用户说"和某人的单聊聊天记录/拉某人单聊历史" → `chat message list-direct --user <userId>` 或 `--open-dingtalk-id <openDingTalkId>`
用户说"某个群的聊天记录" → `chat message list --group <openConversationId>`
用户说"我最近所有消息/我今天的消息" → `chat message list-all --start <ISO> --end <ISO>`
用户说"@我的消息/提及我的" → `chat message list-mentions --start <ISO> --end <ISO>`
用户说"搜索消息里的关键词/包含XX的消息" → `chat message search-advanced --query "<关键词>"`(首选,严格超集)
用户说"我和某人的共同群" → `chat search-common --nicks "<昵称1>,<昵称2>"`
用户说"未读会话列表" → `chat message list-unread-conversations`
用户说"群里某条话题的回复" → `chat message list-topic-replies --group <id> --topic-id <id>`
用户说"置顶会话/置顶消息" → `chat list-top-conversations` 列会话 → 再 `chat message list --group <id>` 拉消息(两步)
用户说"建群/创建群聊" → `chat group create`
用户说"搜索群/找群" → `chat search`
用户说"我创建的群/我管理的群/我是群主的群/我当管理员的群" → `chat group list-my-groups`
用户说"群成员/看群里有谁" → `chat group members list`
用户说"拉人进群/加群成员" → `chat group members add`
用户说"踢人/移除群成员" → `chat group members remove`
用户说"加机器人到群" → `chat group members add-bot`
用户说"改群名" → `chat group rename`
用户说"聊天记录/会话消息/拉取会话" → `chat message list`
用户说"某人发给我的消息/指定发送者/某人的消息" → `chat message list-by-sender`(用户未明确说"单聊"时优先使用,跨单聊/群聊)
用户说"拉取和某人的单聊记录/单聊消息" → `chat message list-direct --user`(单聊专用;用户明确说"单聊"时使用)
用户说"@我的消息/at我的/提及我的" → `chat message list-mentions`
用户说"未读消息会话/未读会话列表/我的未读会话" → `chat message list-unread-conversations`
用户说"发群消息(以个人身份)" → `chat message send --group`
用户说"发单聊消息(以个人身份)" → `chat message send --user`(有 userId 时)或 `chat message send --open-dingtalk-id`(有 openDingTalkId 时)
用户说"机器人发消息/机器人群发" → `chat message send-by-bot`
用户说"撤回我发的消息/撤回消息" → `chat message recall`(通过 IM 接口撤回当前用户自己发出的消息,需要 openConversationId + openMessageId)
用户说"撤回机器人发的消息/机器人撤回消息" → `chat message recall-by-bot`(通过机器人接口撤回机器人发出的消息,需要 robot-code + processQueryKey)
用户说"Webhook 发消息/告警消息" → `chat message send-by-webhook`
用户说"话题回复/群话题消息回复/拉取话题回复" → `chat message list-topic-replies`
用户说"所有消息/全部会话消息/拉取全部消息/时间范围内消息/我的消息/我今天的消息/查我的钉钉消息/最近的消息" → `chat message list-all`
用户说"特别关注人的消息/关注的人的消息/星标联系人的消息" → `chat message list-focused`
用户说"消息已读未读/谁看了消息/查读状态/消息读取状态" → `chat message read-status`
用户说"查看我的机器人" → `chat bot search`
用户说"搜索消息/查找关键词/搜一下消息里的XX" → 优先使用 `chat message search-advanced`(推荐首选,严格超集);仅在简单关键词搜索且无其他维度需求时可用 `chat message search`
用户说"多维度搜索/按发送者搜索/按人搜消息/指定多个群搜索/@我的消息搜索" → `chat message search-advanced`(推荐首选,支持多维度组合搜索)
用户说"查询消息发送状态/消息发没发成功/消息状态" → `chat message query-send-status`
用户说"我和XX的共同群/我们都在哪些群/查共同群" → `chat search-common`
用户说"置顶会话/置顶消息/我的置顶/查看置顶" → `chat list-top-conversations`
用户说"查看会话分组/自定义分组" → `chat category list`
用户说"某个分组下的会话/分组会话列表" → `chat category list-conversations`
用户说"根据群号查群信息/群号查群/群号转openConversationId" → `chat group get-by-group-id`(当用户发消息时只提供了群号,用此工具将群号转为 openConversationId,再调用发消息接口)
用户说"查看群身份/群的自定义身份列表" → `chat group-role list`
用户说"创建/添加群身份" → `chat group-role add`
用户说"修改/更新群身份名称" → `chat group-role update`
用户说"删除群身份" → `chat group-role remove`
用户说"给某人设置群身份/设定用户的群身份" → `chat group-role set-user`
用户说"移除某人的群身份/撤销群身份" → `chat group-role remove-user`
用户说"查询某人的群身份/某人在群里有什么身份" → `chat group-role query-user`
用户说"转让群主/换群主/群主转让" → `chat group transfer-owner`
用户说"群邀请链接/入群链接/加群链接" → `chat group invite-url`
用户说"批量查消息/按ID查消息/根据消息ID查" → `chat message list-by-ids`
用户说"emoji回应/表情回应/给消息加表情" → `chat message add-emoji`
用户说"取消emoji回应/移除表情回应" → `chat message remove-emoji`
用户说"文字表情回应/添加文字表情" → `chat message add-text-emotion`
用户说"取消文字表情回应/移除文字表情" → `chat message remove-text-emotion`
用户说"创建文字表情/新建文字表情" → `chat message create-text-emotion`
用户说"免打扰/消息免打扰/静音/开启免打扰/关闭免打扰" → `chat mute`
用户说"引用回复/回复消息/引用消息回复" → `chat message reply`
用户说"转发消息/转发一条消息/把消息转发到另一个群" → `chat message forward`
用户说"合并转发/批量转发/合并转发多条消息" → `chat message combine-forward`
用户说"群机器人列表/群里有哪些机器人/查看群机器人" → `chat group bots`
用户说"从群里移除机器人/踢出机器人" → `chat group members remove-bot`
用户说"搜索机器人/找机器人/查机器人/帮我找XXX机器人" → `chat bot find`(全部可用机器人,额外返回 openDingTalkId 可发单聊)
用户说"给机器人发单聊/给机器人发消息/跟机器人聊天" → 必须先 `chat bot find`(拿 openDingTalkId)→ 再 `chat message send --open-dingtalk-id`(search 没有 openDingTalkId,无法发单聊)
用户说"我创建的机器人/我的机器人/我自己的机器人/查看我的机器人" → `chat bot search`(仅我创建的机器人,无 openDingTalkId)
用户说"解散群/解散群聊" → `chat group dismiss`
用户说"设置历史消息/新成员看历史/新成员可见消息" → `chat group set-history`
用户说"置顶会话/取消置顶/会话置顶" → `chat set-top`(设置/取消置顶),`chat list-top-conversations`(查看置顶列表)
用户说"全员禁言/群禁言/解除禁言" → `chat group-mute`
用户说"禁言某人/指定成员禁言/解除某人禁言" → `chat group-mute-member`
用户说"设管理员/取消管理员/设置群管理员" → `chat group set-admin`
关键区分:
- `chat search` — 搜**群/会话名**返回 `openConversationId`,**不**搜消息内容;要搜消息内容请用 `chat message search-advanced`(首选)/ `chat message search` / `list-by-sender` / `list-all`,**勿混淆**
- `chat message list` — 拉取指定**群聊**的消息(需指定 --group,**仅群聊**),按时间点 + 方向翻页
- `chat message list-direct` — 单聊专用,拉取与指定用户的单聊(私聊)记录(--user / --open-dingtalk-id;用户明确说"单聊""私聊"时使用)
- `chat message list-by-sender` — 搜索指定发送者发给我的消息,跨所有会话(单聊+群聊均包含,用户只说"某人发的消息"时优先使用)
- `chat message list-mentions` — 拉取 @我 的消息(跨单聊/群聊,可选指定群)
- `chat message list-unread-conversations` — 拉取当前用户存在未读消息的会话列表(可选 `--count`)
- `chat message read-status` — 查询指定消息的已读/未读状态(仅消息发送者可查询自己发的消息,需指定 --group 和 --message-id,可选 --target-open-dingtalk-ids 查特定人)
- `chat message list-all` — 拉取当前用户所有会话的消息,按时间范围 + cursor 分页。只要用户没有指定某个具体的会话(如某个群名、某个人名),即使提到"单聊消息""群聊消息"等笼统范围,也应路由到此命令
- `chat message list-topic-replies` — 拉取群话题的回复消息列表
- `chat message list-focused` — 拉取特别关注人的消息,cursor 分页
- `chat list-top-conversations` — 拉取置顶会话列表(用户询问"置顶会话"或"置顶消息"时路由到此),cursor 分页
- `chat message send` — 以当前用户身份发消息(群聊或单聊),text 为位置参数;支持 --msg-type 发送富媒体消息:image(图片)、file(音频/视频/文档等所有非图片文件),图片的 mediaId 通过 dt_media_upload 上传获得,其他文件需先获取会话共享空间再上传钉盘
- `chat message search` — 按关键词搜索消息内容(跨所有会话,可选指定群)
- `chat search-common` — 搜索共同群,查询指定人共同所在的群聊(AND=所有人都在,OR=任一人在)
- `chat message send-by-bot` — 以**机器人**身份发消息(群聊或单聊),text 为 --text flag
- `chat message send-by-webhook` — 通过**自定义机器人 Webhook** 发群消息
- `chat message recall-by-bot` — 通过**机器人接口**撤回机器人发出的消息,需要 `--robot-code` + `--keys`(发送时返回的 processQueryKey);传 `--group` 为群聊撤回,不传为单聊撤回
- `chat message recall` — 通过 **IM 接口**撤回当前用户自己发出的消息,需要 `--conversation-id`(openConversationId)+ `--msg-id`(openMessageId,可通过 `chat message list` 获取);群聊单聊均通过 `--conversation-id` 区分
- `chat message query-send-status` — 查询个人发送的消息的发送状态(需 send 返回的 openTaskId)
- `chat message search-advanced` — 多维度搜索消息(支持关键词、发送者、@我、@指定人、多个会话等维度组合,与 `search` 的区别:`search` 仅支持关键词且必填,`search-advanced` 所有参数均可选)
- `chat message list-by-ids` — 根据消息 ID 批量查询消息(最多 50 条)
- `chat message add-emoji` / `remove-emoji` — 对消息添加/移除 emoji 表情回应
- `chat message add-text-emotion` / `remove-text-emotion` — 对消息添加/移除文字表情回应
- `chat message create-text-emotion` — 创建文字表情模板,返回 emotionId 供 add-text-emotion 使用
- `chat category list` — 获取用户自定义会话分组列表
- `chat category list-conversations` — 拉取指定分组下的会话列表
- `chat mute` — 开启/关闭会话消息免打扰(默认开启,--off 关闭)
- `chat group transfer-owner` — 转让群主
- `chat group invite-url` — 获取群邀请链接
- `chat message reply` — 引用回复消息(在群聊中引用某条消息并回复文字)
- `chat message forward` — 转发单条消息(将一条消息从源会话转发到目标会话)
- `chat set-top` — 设置/取消会话置顶(默认置顶,--off 取消)
- `chat group-mute` — 全员禁言/取消全员禁言(默认禁言,--off 取消)
- `chat group-mute-member` — 指定群成员禁言/取消禁言(需指定 --users 和 --mute-time)
- `chat group set-admin` — 设置/取消群管理员(默认设为管理员,--off 取消)
## openDingTalkId 获取方式
多个命令参数需要 openDingTalkId(如 --open-dingtalk-id、--at-open-dingtalk-ids、--sender-open-dingtalk-id),统一获取方式如下:
1. 若知道姓名:`dws contact user search --query "姓名"` → 直接从结果中获取 openDingTalkId
2. 若只有 userId:先 `dws contact user get --ids <userId>` 获取姓名 → 再 `dws contact user search --query "姓名"` 获取 openDingTalkId
openDingTalkId 为当前用户视角下的目标用户唯一标识,不可跨用户共享。
## 核心工作流
```bash
# 1. 搜索群 — 提取 openconversation_id
dws chat search --query "项目冲刺" --format json
# 2. 拉取群消息
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --format json
# 2b. 拉取未读会话列表
dws chat message list-unread-conversations --count 20 --format json
# 3. 以个人身份发送群消息
dws chat message send --group <openconversation_id> --title "周报提醒" "请大家本周五前提交周报" --format json
# 4. 以个人身份单聊(通过 userId)
dws chat message send --user <userId> "你好" --format json
# 4b. 以个人身份单聊(通过 openDingTalkId,三方应用等无法获取 userId 时使用)
dws chat message send --open-dingtalk-id <openDingTalkId> "你好" --format json
# 5. 机器人发群消息(Markdown)
dws chat message send-by-bot --robot-code <robot-code> \
--group <openconversation_id> --title "日报" --text "## 今日完成..." --format json
# 6. 机器人单聊发消息
dws chat message send-by-bot --robot-code <robot-code> \
--users userId1,userId2 --title "提醒" --text "请提交周报" --format json
# 7. Webhook 发告警
dws chat message send-by-webhook --token <webhook-token> \
--title "告警" --text "CPU 超 90% @10" --at-all --format json
```
## 复合工作流
### 机器人发消息后撤回(完整流程)
`recall-by-bot` 通过机器人接口撤回机器人发出的消息(需要 `--robot-code` + `--keys`)。`chat message recall` 通过 IM 接口撤回当前用户自己发出的消息(需要 `--conversation-id` + `--msg-id`)。
```bash
# Step 1: 查我的机器人 — 提取 robot-code
dws chat bot search --format json
# Step 2: 用机器人发消息 — 提取返回中的 processQueryKey
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
--title "通知" --text "内容" --format json
# Step 3: 用同一个 robot-code + processQueryKey 撤回
dws chat message recall-by-bot --robot-code <robot-code> --group <openconversation_id> \
--keys <processQueryKey> --format json
```
### 机器人发群消息(含机器人不在群内的处理)
机器人通过 `send-by-bot --group` 发群消息时,如果返回"机器人不存在"错误,说明该机器人尚未加入目标群,需先邀请进群再发送。
```bash
# Step 1: 查我的机器人 — 提取 robot-code
dws chat bot search --format json
# Step 2: 尝试发送,若报"机器人不存在"则执行 Step 3
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
--title "通知" --text "内容" --format json
# Step 3: 邀请机器人进群
dws chat group members add-bot --id <openconversation_id> --robot-code <robot-code>
# Step 4: 重新发送
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
--title "通知" --text "内容" --format json
```
### 给机器人发单聊消息(必须先用 find 拿 openDingTalkId)
给机器人发单聊消息时,必须先用 `chat bot find` 搜索机器人拿到 `openDingTalkId`,再用 `chat message send --open-dingtalk-id` 发送。不能用 `chat bot search`,因为 search 不返回 `openDingTalkId`。
```bash
# Step 1: 搜索机器人 — 提取 openDingTalkId(必须用 find,search 没有此字段)
dws chat bot find --query "玉澜" --format json
# Step 2: 用 openDingTalkId 发单聊消息
dws chat message send --open-dingtalk-id <openDingTalkId> --text "你好" --format json
```
### @指定人发群消息(用户身份)
`send-by-bot` 不支持 @ 参数。群里 @人/@所有人请用 `chat message send --group`(当前用户身份):通过 `--at-open-dingtalk-ids` 传入 openDingTalkId 列表 @指定成员(多个逗号分隔),`--text` 中需包含对应 `@openDingTalkId` 文本;`--at-all` @所有人。
```bash
# Step 1: 搜人获取 openDingTalkId
dws contact user search --query "张三" --format json
# Step 2: @指定成员发群消息(注意 text 中带 @openDingTalkId)
dws chat message send --group <openconversation_id> \
--at-open-dingtalk-ids openDingtalkId1,openDingtalkId2 \
--text "@openDingtalkId1 @openDingtalkId2 请查收本周报告" --format json
# @所有人
dws chat message send --group <openconversation_id> \
--at-all --text "请所有人注意" --format json
```
### 发送图片+文字 / 文件+文字消息(跨产品: drive → chat)
- **图片+文字**:图片**必须**通过 `dt_media_upload` 工具(非 dws 命令,是 agent 可调用的独立 tool)上传获取 mediaId,然后用 Markdown 嵌入方式发送。**禁止**使用钉盘上传图片。
- **文件+文字**:文件通过钉盘上传 + Markdown 嵌入方式发送。
纯发图片/文件(不带文字)的完整流程见 [intent-guide.md](../intent-guide.md) 对应章节。
```bash
# === 图片+文字 ===
# Step 1: 调用 dt_media_upload 工具上传图片(这是一个独立的 tool,不是 dws 命令)
# dt_media_upload 会返回 mediaId(如 @lQLPxxx)
# 提取 mediaId 可使用脚本: python extract_media_id.py "<返回的URL>"
# Step 2: 用 Markdown 语法发送(mediaId 作为图片引用)
dws chat message send --group <openconversation_id> \
--text " 这是本周的数据汇总" --format json
# === 文件+文字 ===
# Step 1: 上传文件到钉盘
dws drive upload --file "报告.pdf" --format json
# Step 2: 获取下载链接
dws drive download --file-id <dentryUuid> --format json
# Step 3: 用 Markdown 语法发送
dws chat message send --group <openconversation_id> \
--text "[报告.pdf](下载链接) 这是季度报告" --format json
```
#### 创建并推送流式卡片 — 向群聊或单聊发送流式卡片消息
群聊传 --group,单聊传 --receiver,二者互斥。
**注意:send-card 必须和 update-card 搭配使用。** 创建卡片时无需传入内容,后续通过 update-card 更新内容,最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。
flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。
```
Usage:
dws chat message send-card [flags]
Example:
dws chat message send-card --group <openConversationId>
dws chat message send-card --receiver <openDingTalkId>
# 查询群 ID: dws chat search --query "群名"
# 查询人员: dws aisearch person --keyword "姓名" --dimension name
Flags:
--group string 群聊 openConversationId(群聊时必填,与 --receiver 互斥)
--receiver string 单聊接收者 openDingTalkId(单聊时必填,与 --group 互斥)
```
#### 流式更新卡片内容 — 更新已发送的流式卡片内容
--biz-id 为 send-card 返回的业务 ID,--flow-status 控制流式状态。
flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。
**最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。**
```
Usage:
dws chat message update-card [flags]
Example:
dws chat message update-card --biz-id <bizId> --content "更新的卡片内容" --flow-status 2
dws chat message update-card --biz-id <bizId> --content "最终内容" --flow-status 3
Flags:
--biz-id string 卡片业务 ID (必填)
--content string 卡片消息内容 (必填)
--flow-status int 流式状态 (必填)
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `chat search` | `openConversationId` | message send/list、group members 等的 --group |
| `chat group create` | `openConversationId` | 同上 |
| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor |
| `aisearch person` | `userId` | message send 的 --user、list-direct 的 --user、send-by-bot 的 --users、list-by-sender 的 --sender-user-id |
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-direct 的 --open-dingtalk-id、list-by-sender 的 --sender-open-dingtalk-id |
| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code(仅我创建的机器人,无 openDingTalkId) |
| `chat bot find` | `openDingTalkId` | 给机器人发单聊消息(全部可用机器人,额外返回 openDingTalkId) |
| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
| `chat message send` | `openTaskId` | query-send-status 的 --open-task-id |
| `chat message list` | `openMessageId` | recall 的 --msg-id |
| `chat message search` | `nextCursor` | 下次 message search 的 --cursor |
| `chat message search-advanced` | `nextCursor` | 下次 message search-advanced 的 --cursor |
| `chat search-common` | `openConversationId` | message send/list 等的 --group |
| `chat conversation-info` | `newCSpaceIdIM` | drive upload 的 --space-id(发送文件消息前获取共享空间) |
| `chat message list` | `openMsgId` | message read-status 的 --message-id |
| `chat group-role list` | `openRoleId` | group-role update/remove/set-user/remove-user 的 --role-id |
| `chat message create-text-emotion` | `emotionId` | add-text-emotion 的 --emotion-id |
| `chat category list` | `categoryId` | category list-conversations 的 --category-id |
| `chat group get-by-group-id` | `openConversationId` | 同 chat search,将群号转为 openConversationId |
| `chat message send-card` | `bizId` | update-card 的 --biz-id |
| `drive download` | 下载链接 | message send 的 Markdown 图片/链接语法 |
| `chat message list` | `openMessageId` | message reply 的 --ref-msg-id、message forward 的 --msg-id |
| `chat search` | `openConversationId` | set-top 的 --conversation-id、group-mute / group-mute-member 的 --group |
## 注意事项
- **发消息前参数审查(必须执行)**:
- 发消息(`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 是位置参数(恰好 1 个),非 flag;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;纯文本/Markdown 单聊传 `--user` 时直接走 userId 发送能力;`--at-all`、`--at-open-dingtalk-ids` 仅在 `--group` 群聊时生效;富媒体消息通过 `--msg-type` 指定类型(image/file),必须显式指定;发送文件/媒体消息时,必须先根据文件扩展名判断 msgType:图片→image,其他所有→file,不可跳过此判断
- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor
- `chat message list` **仅支持群聊**,必须指定 `--group`;拉取单聊用 `chat message list-direct`(`--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` 查询指定消息的已读/未读状态,仅消息发送者可查询自己发出的消息;`--group`、`--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`;用户询问"置顶会话"或"置顶消息"时均路由到此命令
- `--user` 和 `--open-dingtalk-id` 本质上都是发起单聊操作,只是用户标识格式不同:userId 为企业内部应用常用标识,openDingTalkId 为三方应用或跨组织场景下的用户标识,服务端对两种 ID 的解析逻辑不同
- `--time` 格式: `yyyy-MM-dd HH:mm:ss`,为拉取消息的起始时间点;`--forward` 控制方向(默认 true,拉给定时间之后的消息),`--limit` 控制数量
- `chat search` 挂在 `chat` 下(非 `chat group` 下),路径为 `dws chat search`
- `send-by-bot` 群聊传 `--group`,单聊传 `--users`,二者互斥且必选其一;**不支持 @成员/@所有人**(无 `--at-user-ids`/`--at-open-dingtalk-ids`/`--at-all`),群里 @人请用 `chat message send --group` 走用户身份;群聊场景如果返回"机器人不存在"错误,需先通过 `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);新群主用 `--new-owner`(传 openDingTalkId)
- `chat group invite-url` 获取群邀请链接,需传 --group(openConversationId),可选 --expires-seconds 指定有效期(秒,0=永久)
- `chat group quit` 退出群聊,需传 --group(openConversationId)
- `chat group update-icon` 更新群头像,需传 --group(openConversationId)和 --icon-media-id(mediaId)
- `chat group update-settings` 更新群设置,需传 --group(openConversationId)、--setting-key(设置项 key)、--status(0=关闭 1=开启)
- `chat message send-card` 创建并推送流式卡片,群聊传 --group,单聊传 --receiver,二者互斥;不传 content,后续通过 update-card 更新内容
- `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` 的用户身份发送语义一致;**同样支持 `--ai-tag`(默认 true,默认带「通过AI发送」角标,传 `--ai-tag=false` 关闭)**(详见上文 send 段的「AI 代发标记」规则)
- **如何获取 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,逗号分隔)、--mute-time(毫秒,仅禁言时必填,支持 300000/3600000/86400000/604800000/2592000000),传 --off 解除禁言
- `chat group set-admin` 设置/取消群管理员,需传 --group(openConversationId)、--user/--users(userId,逗号分隔),默认设为管理员,传 --off 取消
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [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 | `python extract_media_id.py "<URL>"`(输出如 @lQLPxxx,直接用于 --media-id) |
## 相关产品
- [contact](./contact.md) — 搜索同事/好友,获取 userId 用于 --user、send-by-bot --users、list-by-sender --sender-user-id;获取 openDingTalkId 用于 message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-by-sender 的 --sender-open-dingtalk-id
- [drive](./drive.md) — 上传文件获取下载链接,用于 Markdown 图片/文件消息
# 通讯录 (contact) 命令参考
> **CRITICAL — 命令合法性**:contact 只有 `user` / `dept` / `relation` 三个可操作的二级子命令。
> 不存在 `contact search`、`contact find`、`contact list`、`contact get`、`contact user find/list`。
> 角色/标签(label)查询命令已下线,不再提供 `contact label list/get/list-members`。
> 构造命令前必须确认路径在下方「命令总览」中存在;不确定时,**根据意图对照下方「意图判断」选择正确命令**。
>
> **CRITICAL — 根部门**:钉钉根部门 `deptId=1`。`dept` 系列命令查根部门统一传 `--dept 1` 或 `--depts 1`,不要传 `self / me / root / 0`。
>
> **CRITICAL — 搜人首选 aisearch**:凡是"找人/搜人/谁负责 XX/某事项/某项目的人/上级/下级/团队成员"——**第一反应**是 `dws aisearch person`(详见 [aisearch.md](./aisearch.md)),不是 `contact user search`,**更不是反问用户要文档链接**。典型反例:用户说"查询集团推进事项"= 问"集团推进事项这个职责/项目下的人是谁",正确做法是 `dws aisearch person --keyword "集团推进事项" --dimension duty --format json`。
## 命令总览
### user (人员查询)
#### 获取当前用户信息
```
Usage:
dws contact user get-self [flags]
Aliases:
get-self, self, me, whoami, current
Example:
dws contact user get-self
dws contact user self # 别名
dws contact user me # 别名
dws contact user whoami # 别名
dws contact user current # 别名
Notes:
- 触发词:我是谁 / 我的信息 / 我的 userId / 当前用户 / 本人 / self / me / whoami
- 顶层亦已挂 `dws contact get-self / user-self / current-user` 提示,误写会引导到正确命令
- **禁止**用 `dws contact user get --ids me/self/current` 代替(会报错);正确用法是 `get-self` 或其别名
```
#### 按关键词搜索用户
```
Usage:
dws contact user search [flags]
Example:
dws contact user search --query "张三"
Flags:
--query string 搜索关键词 (必填)
Returns: (列表,每项包含以下字段)
name string 成员姓名
nick string 成员昵称
userId string 成员 ID(仅同事关系时返回)
title string 员工职位(仅同事关系时返回)
openDingTalkId string 当前用户视角下的目标用户唯一标识,不可跨用户共享;可用于发消息等好友关系场景的操作
```
> **CAUTION:** 多人同名时禁止默认选第一个 — `user search` 不返回部门信息,须追加 `contact user get --ids userId1,userId2,...` 获取部门/职位后请用户确认。详见 [08-directory.md](../best_practices/08-directory.md)「多命中」。
#### 按手机号搜索用户
```
Usage:
dws contact user search-mobile [flags]
Example:
dws contact user search-mobile --mobile 13800138000
Flags:
--mobile string 手机号 (必填)
```
#### 批量获取用户详情
```
Usage:
dws contact user get [flags]
Example:
dws contact user get --ids userId1,userId2
Flags:
--ids string 用户 ID 列表,逗号分隔 (必填)
Notes:
- **禁止**将 `self/me/current/whoami` 作为 userId 传入;查自己请用 `dws contact user get-self`
```
### profile (用户档案 / 花名册)
#### 查询花名册有权限的字段列表
```
Usage:
dws contact user profile fields
Example:
dws contact user profile fields
Flags:
无
```
查询花名册有权限的字段列表,根据当前用户查询花名册有权限的字段列表。认证信息(corpId、optUserId)由系统自动注入,无需手动传入。
#### 查询员工花名册字段信息(个人档案)
```
Usage:
dws contact user profile get [flags]
Example:
dws contact user profile get --staff-id STAFF_ID
dws contact user profile get --staff-id STAFF_ID --fields fieldCode1,fieldCode2
Flags:
--staff-id string 查询员工 ID(可选)
--fields string 指定字段集合, 逗号分隔, 可通过 profile fields 获取(可选)
```
查询员工花名册字段信息,根据当前用户指定员工和字段列表,查询相应管理范围内员工的字段值信息。
花名册字段包含:试用/转正信息、个人/家庭信息、学历信息、银行卡/合同信息、紧急联系人和其他企业自定义信息。
> **与 `contact user get` 的区别**:`user get` 返回组织管理信息(部门、主管、管理员权限),`user profile get` 返回个人档案信息(学历、家庭、银行卡等)。
### dismission (离职员工)
#### 分页获取离职员工列表
```
Usage:
dws contact user dismission search [flags]
Example:
dws contact user dismission search
dws contact user dismission search --name "张三"
dws contact user dismission search --start 2026-01-01 --end 2026-03-31
dws contact user dismission search --depts 123456,789012 --page 1 --limit 50
Flags:
--name string 员工姓名,模糊搜索(可选)
--start string 离职日期查询范围开始,格式 YYYY-MM-DD(可选)
--end string 离职日期查询范围结束,格式 YYYY-MM-DD(可选)
--depts string 部门 ID 列表,逗号分隔(可选)
--hide-retirement 是否隐藏退休,默认 true(可选)
--hide-partner 是否隐藏合作伙伴,默认 false(可选)
--page int 页码,从 1 开始(可选,默认 1)
--limit int 页大小,200 以内(可选,默认 20)
```
查询离职员工列表,支持按员工姓名、离职日期范围、部门进行过滤。认证信息(corpId、optUserId)由系统自动注入,无需手动传入。
`--start` 和 `--end` 必须同时设置或同时不设置,不允许只传其中一个。
### dept (部门查询)
#### 搜索部门
```
Usage:
dws contact dept search [flags]
Example:
dws contact dept search --query "技术部"
Flags:
--query string 搜索关键词 (必填)
```
#### 获取部门详情
```
Usage:
dws contact dept get-info [flags]
Example:
dws contact dept get-info --dept 12345
Flags:
--dept string 部门 ID (必填)
Notes:
- **钉钉根部门 `deptId=1`**;查根部门用 `--dept 1`
```
#### 查看子部门
```
Usage:
dws contact dept list-children [flags]
Example:
dws contact dept list-children --dept 1 # 枚举根部门下的一级部门
dws contact dept list-children --dept 12345 # 枚举指定部门的直属子部门
Flags:
--dept string 父部门 ID (必填)
Returns:
success bool 调用是否成功
result list 直属子部门列表,每项包含以下字段:
deptId int 子部门 ID
deptName string 子部门名称
Notes:
- **钉钉根部门 `deptId=1`**;查询一级部门请用 `--dept 1`
- 仅返回**直属**(直接下一级)子部门,不递归;需要逐层下钻请对子 deptId 继续调用本命令
- 受组织架构可见性控制:仅返回调用者**有权限查看**的子部门
- 父部门不可见或无子部门时返回 result=[] 空列表(非错误)
```
#### 查看部门成员
```
Usage:
dws contact dept list-members [flags]
Example:
dws contact dept list-members --depts 12345,67890
dws contact dept list-members --depts 1 # 根部门
Flags:
--depts string 部门 ID 列表,逗号分隔 (必填)
Notes:
- **钉钉根部门 `deptId=1`**;查根部门直属成员用 `--depts 1`
- 仅返回**本部门**直接成员,**不含下级部门**成员;需含下级请先 `dept list-children` 枚举子部门,再对子 deptId 分别/合并调用 `list-members`
- 受组织架构可见性控制;`--depts` 支持逗号分隔批量查询多个部门
- 跨层级成员展开见 [08-directory.md](../best_practices/08-directory.md) 的 `cross-level-dept-members` recipe
```
## 意图判断
> **搜人首选 `aisearch person`**:凡是“找人/搜人/找同事/谁负责/上级/下级”均优先用 [aisearch person](./aisearch.md),以下场景才用 contact。
用户说"我是谁/我的信息/我的 userId/当前用户/本人/self/me/whoami" → `user get-self`(无需参数;禁止用 `user get --ids me/self` 代替)
用户需要 userId 给其他产品使用(发消息/建待办/约日程)→ `user search`(按名字)或 `user search-mobile`(按手机号)
用户说"查用户详情/部门/主管/管理员" → `user get`(需 userId,返回组织管理信息)
用户说"花名册字段/有哪些字段/字段列表" → `user profile fields`
用户说"花名册/员工档案/学历/家庭/银行卡/紧急联系人/合同" → `user profile get`(需 staffId,返回个人档案信息)
用户说"离职员工/离职名单/离职人员/已离职" → `user dismission search`
用户说"找部门/哪个部门" → `dept search`
用户说"部门详情/部门信息/部门多少人" → `dept get-info`(返回部门ID、部门名称、部门人数;需 deptId,若只有部门名称需先 `dept search`)
用户说"子部门/下设部门/部门有哪些下级部门/枚举二级部门" → `dept list-children`(需父 deptId;只有部门名先 `dept search`)
用户说"部门有谁/部门成员/人员名单" → `dept list-members`(需 deptId;**仅本部门不含下级**,含下级先 `dept list-children` 再合并查)
用户查询涵盖"角色/职责"(主管/管理员/财务/HR/总经理/谁负责 XX 等)→ 优先用 [`aisearch person`](./aisearch.md) 按职责维度找人(`dws aisearch person --keyword "<角色或职责>" --dimension duty`)。
> [!IMPORTANT]
> **角色查人 vs 查某人的属性 — 判断口径**:先判断用户的终点是"人"还是"属性":
> - 终点是**人**("管理员有哪些人""谁负责财务""找 XX 角色的成员")→ 走 [`aisearch person`](./aisearch.md)(职责维度找人)
> - 终点是**属性**("张三是不是管理员""查某人的主管/管理员权限")→ 已知 userId 查个人详情,走 `user get`(返回 isAdmin/leader 等字段)
>
> 反例对照:
> - "管理员都有哪些人" → `aisearch person --keyword 管理员 --dimension duty`(终点=人员列表)
> - "张三是不是管理员" → `user get --ids <userId>`(终点=某个人的**属性**)
> - "查一下张三的管理员权限" → `user get --ids <userId>`(终点=某个人的**属性**)
>
> 注意:`contact label` 角色查询命令已下线,不要再构造 `contact label list/get/list-members`。OA 审批只管审批流程(待审批/同意/拒绝),**不支持**查询角色成员;群角色(chat group-role)只管群内身份,不涉及企业组织角色。
用户说"我关注了谁/我的特别关注列表/我的星标联系人/特别关注的人有哪些" → `relation list-my-followings`
> [!IMPORTANT]
> **易混淆硬规则**:`relation list-my-followings` **只**返回"我特别关注的人员列表"(一组 openDingTalkId),**不**返回任何消息内容。
>
> **禁止路由到本命令的场景**(query 中同时包含『关注/特别关注/星标』和以下任一消息域动词/名词时,必须路由到 [`chat message list-focused`](./chat.md)):
> - 动词类:**发**了什么、**说**了什么/啥、**聊**了什么、**讲**了什么
> - 名词类:**消息**、**聊天**、**动态**、**最新内容**
>
> **判断口径**:先扫描 query 是否含上述动词/名词;含则路由到 `chat message list-focused`,**不论** query 主语是否为"我特别关注的人"。
>
> 反例对照:
> - "我特别关注的人有哪些" → `relation list-my-followings`(终点=人员列表)
> - "我特别关注的人**最近发了什么消息**" → `chat message list-focused`(含"发""消息")
> - "我关注的人**最近都说了啥**" → `chat message list-focused`(含"说")
组合场景(多子部门、跨层级成员、强消歧)见 [08-directory.md](../best_practices/08-directory.md)。
## 核心工作流
```bash
# 1. 查看自己的信息 — 提取 userId
dws contact user get-self --format json
# 2. 按名字搜索同事或好友 — 可提取 同事的userId,或好友的openDingTalkId
dws contact user search --query "张三" --format json
# 3. 查看部门结构 — 提取 deptId
dws contact dept search --query "技术部" --format json
# 4. 查看部门详情(部门ID、名称、人数)
dws contact dept get-info --dept <deptId> --format json
# 5. 查看直属子部门 — 提取子 deptId 列表
dws contact dept list-children --dept <父deptId> --format json
# 6. 查看部门成员
dws contact dept list-members --depts <deptId> --format json
# 7. 查询花名册有权限的字段列表
dws contact user profile fields --format json
# 8. 根据字段 code 查询指定员工的花名册信息
dws contact user profile get --staff-id <STAFF_ID> --fields fieldCode1,fieldCode2 --format json
# 9. 查询所有可见字段的花名册信息
dws contact user profile get --staff-id <STAFF_ID> --format json
# 10. 查询全部离职员工
dws contact user dismission search --format json
# 11. 按姓名/时间范围/部门筛选离职员工
dws contact user dismission search --name "张三" --format json
dws contact user dismission search --start 2026-01-01 --end 2026-03-31 --format json
dws contact user dismission search --depts 123456,789012 --hide-retirement=false --format json
```
## 上下文传递表
| 操作 | 提取 | 用于 |
|------|------|------|
| `user get-self/search` | `userId` | 其他产品中的 --users/--executor 参数 |
| `user get-self/search` | `orgAuthEmail` | mail message send 的 --to/--cc (跨产品) |
| `user get-self/search` | `userId` | profile get 的 --staff-id |
| `user profile fields` | `fieldCode` | profile get 的 --fields |
| `dept search/list-children` | `deptId` | dept get-info/list-children/list-members 的 --dept/--depts |
| `dept search/list-children` | `deptId` | dismission search 的 --depts |
## 注意事项
- `user get-self` 是获取 userId 的最快方式,其他产品的 --users/--executor 都需要 userId
- `user get --ids` 和 `dept list-members --depts` 都支持批量查询,逗号分隔
- `user get` 返回组织管理信息(部门、主管、管理员权限),`user profile get` 返回个人档案信息(学历、家庭、银行卡等),注意区分
- `user profile get` 的 `--staff-id` 可通过 `user get-self`、`user search` 或 `aisearch person` 获取
- `user profile get` 的 `--fields` 可通过 `user profile fields` 获取可用字段 code 列表;不填则查询所有可见字段
- 建议先执行 `user profile fields` 获取可用字段列表,再根据需要的字段 code 执行 `user profile get`
- `user dismission search` 的 `--start`/`--end` 必须同时设置或同时不设置,不允许只传其中一个
- `user dismission search` 默认隐藏退休人员(`--hide-retirement` 默认 true),默认展示合作伙伴(`--hide-partner` 默认 false)
- 角色/职责类查询(主管、管理员、财务、HR 等任意角色,或"谁负责 XX")优先走 [`aisearch person`](./aisearch.md) 的职责维度(`--dimension duty`);`contact label` 角色查询命令已下线,不要再构造
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [contact_dept_members.py](../../scripts/contact_dept_members.py) | 按部门名称搜索并列出所有成员 | `python contact_dept_members.py --query "技术部"` |
# dev — 开放平台开发者命令
`dws dev` 是面向**开发者**的命令组,分三个子树:
| 子命令 | 职责 |
|--------|------|
| `dev app` | 应用生命周期(创建/查询/更新/删除/凭证/权限/成员/安全/网页/机器人/**建号**/版本/事件订阅) |
| `dev connect` | **建联**:把现成机器人接到当前本地 agent(起 Stream,不建号) |
| `dev doc` | 开放平台开发文档搜索(同 `dws devdoc`) |
> ⚠️ **关键区分**:`dws chat bot search/find` 只查询已有机器人(IM 视角);**创建/建号**机器人走 `dws dev app robot submit`;**建联**走 `dws dev connect`。"创建机器人"/"建联"一律走 `dev`,禁止走 `chat`。
---
## 典型工作流:创建应用 → 配置机器人 → 版本发布/审批 → 建联
```bash
# Step 1:创建开放平台应用,拿 unifiedAppId
dws dev app create --name "我的 AI 机器人" --desc "接 opencode" --dry-run --format json
dws dev app create --name "我的 AI 机器人" --desc "接 opencode" --yes --format json
# → 返回 unifiedAppId
# Step 2:在明确的 unifiedAppId 上配置机器人能力
dws dev app robot config --unified-app-id <unifiedAppId> --name "小助手" --desc "接 opencode" --dry-run --format json
dws dev app robot config --unified-app-id <unifiedAppId> --name "小助手" --desc "接 opencode" --yes --format json
# Step 3:版本发布/审批,线上搜索、加群、路由消息以 RELEASE 为准
dws dev app version create --unified-app-id <unifiedAppId> --desc "发布机器人能力" --dry-run --format json
dws dev app version create --unified-app-id <unifiedAppId> --desc "发布机器人能力" --yes --format json
dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId> --format json
dws dev app version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
dws dev app version publish --unified-app-id <unifiedAppId> --version-id <versionId> --yes --format json
dws dev app version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
# Step 4:本地建联 — 把机器人接到本地 agent,前台常驻(Ctrl-C 退出)
dws dev connect --unified-app-id <unifiedAppId> --channel opencode
# 若只有 robot result 返回的一次性凭证,clientSecret 只能用占位并按敏感信息处理:
dws dev connect --robot-client-id <clientId> --robot-client-secret <clientSecret-from-result>
```
`check-approval` 若返回 `approvalMode=SELECT_APPROVER`,让用户从候选里选择;不要默认取第一个审批人。用户选定后再给 `publish` 追加 `--approver-user-id <userId>`。
选择题优先原样展示 `approvalPromptText`(带 `A.`/`B.` 序号 + `姓名(userId: xxx)` 的成品文案);需结构化时用 `approvalOptions[].label`,不得退化成多个泛化的“候选审批人”。`completionState=WAITING_FOR_APPROVER_SELECTION` / `mustAskUser=true` 表示必须等待用户选择。
完成态规则:
- `robot result` 顶层 `completionState=BLOCKED_BY_VERSION_PUBLISH` / `mustContinue=true` / `terminal=false` 是硬门禁,必须继续执行 blocking `nextSteps`。
- `robot result` 顶层 `completionState=BLOCKED_BY_MISSING_UNIFIED_APP_ID` / `actionRequired=provide_unified_app_id` 时,必须停下要求明确的 `unifiedAppId`;不能用 `clientId/appKey` 自动反查后写版本。
- `dev connect` 成功只代表本地 Stream 调试可用,不能代表机器人线上可用。
- `dev connect` dry-run 或启动输出里的 `completionState=LOCAL_DEBUG_ONLY` / `doesNotPublish=true` 表示只完成本地调试,不得作为最终完成。
- `robot result` 返回 `lifecycle.overallComplete=false`,或版本未进入 `RELEASE` / `AUDIT` / `UNDER_REVIEW` 前,不要总结“全部完成”“机器人已创建并成功连接”“可以在钉钉中 @机器人使用”。
- “创建机器人并连接 qoder”类任务的闭环必须包含:建号完成、本地 qoder Stream 建联成功、版本已发布或已提交审批;若 `SELECT_APPROVER` 需要选审批人,则停在候选审批人选择。
`robot result` 异步状态:
| status | 含义 | 下一步 |
|--------|------|--------|
| `WAITING` | 创建中 | 按 `intervalSeconds` 继续轮询 |
| `SUCCESS` | 创建完成 | 保存 `robotCode/clientId/clientSecret`;若结果含明确 `unifiedAppId` 才能继续版本发布,否则停下要求用户提供 |
| `APPROVAL_REQUIRED` | 已建号但线上使用需审核 | 不要重复建号;若结果含明确 `unifiedAppId` 才能提交版本发布审核,否则停下要求用户提供 |
| `FAIL` | 失败 | 读 `errorCode/errorMsg`,可带原 `taskId` 重新 `submit` |
| `EXPIRED` | 任务过期 | 重新 `submit` |
---
## dev app — 应用生命周期
```bash
# 查询应用列表
dws dev app list --format json
# 查询单个应用详情
dws dev app get --unified-app-id <unifiedAppId> --format json
# 创建应用
dws dev app create --name <名称> --desc <描述> --format json
# 更新应用信息
dws dev app update --unified-app-id <unifiedAppId> --name <新名称> --format json
# 停用/启用应用
dws dev app disable --unified-app-id <unifiedAppId> --yes --format json
dws dev app enable --unified-app-id <unifiedAppId> --yes --format json
# 删除应用(不可逆,需 --confirm-name 二次确认)
dws dev app delete --unified-app-id <unifiedAppId> --confirm-name <应用名> --yes --format json
```
---
## dev app robot — 机器人(建号与配置)
```bash
# 建号:异步提交
dws dev app robot submit --name <智能体名> --robot-name <机器人名> --desc <描述> --dry-run --format json
dws dev app robot submit --name <智能体名> --robot-name <机器人名> --desc <描述> --yes --format json
# 建号:轮询结果
dws dev app robot result --task-id <taskId> --format json
# SUCCESS/APPROVAL_REQUIRED 后只有结果含明确 unifiedAppId 才能走版本发布
# 若顶层有 completionState=BLOCKED_BY_MISSING_UNIFIED_APP_ID,要求用户提供 unifiedAppId;不能用 appKey/clientId 自动反查后写版本
# 若顶层有 completionState=BLOCKED_BY_VERSION_PUBLISH 或 mustContinue=true,不得停在 dev connect
# 查询现有机器人配置
dws dev app robot get --unified-app-id <unifiedAppId> --format json
# robotStatus=UNCONFIGURED → 未配置,走 config;OFFLINE → 走 enable;ONLINE → 已就绪
# 创建/更新机器人配置(upsert)
dws dev app robot config --unified-app-id <unifiedAppId> --name <机器人名> --format json
# 启用/停用机器人
dws dev app robot enable --unified-app-id <unifiedAppId> --format json
dws dev app robot disable --unified-app-id <unifiedAppId> --format json
```
---
## dev app credentials — 凭证
```bash
# 查询 clientId/clientSecret
dws dev app credentials get --unified-app-id <unifiedAppId> --format json
# 重置凭证(旧 secret 立即失效)
dws dev app credentials reset --unified-app-id <unifiedAppId> --yes --format json
```
---
## dev connect — 建联
```bash
# 前台建联,自动探测渠道
dws dev connect \
--robot-client-id <clientId> --robot-client-secret <clientSecret>
# 明确指定渠道(opencode/claudecode/qoder/qoderwork/workbuddy/codex/gemini/hermes/openclaw/custom)
dws dev connect --channel opencode \
--robot-client-id <clientId> --robot-client-secret <clientSecret>
# 建联前预览方案(不实际起连接,检查 cli.installed 字段)
dws dev connect --channel opencode \
--robot-client-id <clientId> --robot-client-secret <clientSecret> \
--dry-run --format json
# 后台守护进程模式(崩溃自拉起)
dws dev connect --daemon \
--robot-client-id <clientId> --robot-client-secret <clientSecret>
# 查看/停止后台连接器
dws dev connect status --format json
dws dev connect stop
```
常用 flag:
| Flag | 说明 |
|------|------|
| `--channel` | 渠道,默认 `auto` 自动探测 |
| `--agent-model` | 覆盖 agent 模型(如 `claude-sonnet-4-6`) |
| `--agent-workdir` | agent 运行目录(放知识文件可给机器人项目上下文) |
| `--reply-card` | AI 卡片回复,默认开启(`--reply-card=false` 关闭) |
| `--card-template` | AI 卡片模板 ID(开发者后台→本应用→AI 卡片设置获取) |
| `--knowledge-dir` | 本地知识目录(.md/.txt),每条消息检索后拼入 prompt |
| `--daemon` | 后台守护进程 |
| `--owner-user-id` | 数字分身:执行类请求先发给主人审批 |
**预检 cli.installed**:`--dry-run` 出参的 `cli` 字段含 `installed/autoInstall/installHint`。`installed:false, autoInstall:false`(桌面 App 渠道)时先引导用户安装对应 App,不要直接起连接。
`dev connect` 是本地调试步骤。dry-run JSON 的 `invocation` 会包含 `scope=local_debug_only`、`doesNotPublish=true`、`completionState=LOCAL_DEBUG_ONLY`、`terminal=false`;真实前台/daemon 启动也会提示“本地调试,不代表线上发布完成”。这些信号不能抵消版本发布/审批闭环。
---
## dev app event — 事件订阅
事件码定位优先用 `event list --keyword` 搜索;只有用户明确要「全部事件」时才翻全量(逐页)。
```bash
dws dev app event list --unified-app-id <unifiedAppId> --keyword <关键词> --format json
dws dev app event subscribe --unified-app-id <unifiedAppId> --event-codes <code1>,<code2> --dry-run --format json
dws dev app event unsubscribe --unified-app-id <unifiedAppId> --event-codes <code1>,<code2> --yes --format json
```
---
## dev app permission — 权限
```bash
dws dev app permission list --unified-app-id <unifiedAppId> --format json
dws dev app permission add --unified-app-id <unifiedAppId> --scope-code <scopeValue> --format json
dws dev app permission remove --unified-app-id <unifiedAppId> --scope-code <scopeValue> --yes --format json
```
---
## dev app version — 版本发布
配置变更(权限/机器人/网页等)需通过版本通道才生效。
```bash
dws dev app version create --unified-app-id <unifiedAppId> --format json
dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId> --format json
dws dev app version publish --unified-app-id <unifiedAppId> --version-id <versionId> --format json
dws dev app version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
```
---
## 注意事项
- **`clientSecret` 只在 `robot result` 返回一次**,务必立即保存;遗失需走 `credentials reset` 重置
- 改配置后机器人不自动生效,需走 `version create → publish` 才上线
- `hermes`/`openclaw` 渠道走官方建联,`dws dev connect` 不代建机器人,会输出指引后退出
- 应用名在企业内唯一;`app list/get` 用 `--app-key` 过滤但不能定位单应用,定位单应用须用 `--unified-app-id`
# 开放平台文档 (devdoc) 命令参考
搜索钉钉**开放平台**开发文档,用于回答开发者关于 OpenAPI、字段、错误码、接入指南、配额等技术问题。
## 命令总览
### 搜索开发文档
```
Usage:
dws devdoc article search [flags]
Example:
dws devdoc article search "MCP"
dws devdoc article search --query "OAuth2 接入"
dws devdoc article search --keyword "机器人" --size 10
dws devdoc article search --query "消息卡片" --page 2 --size 5
dws devdoc article search --query "消息卡片" --cursor "<NEXT_CURSOR>" --size 5
Flags:
--query string 搜索关键词 (必填)
--keyword string 搜索关键词 (--query 的别名)
--page int 分页页码 (从 1 开始,默认 1)
--cursor string 分页游标,翻页传上次返回的 nextCursor;传入后不再使用 --page
--size int 分页大小 (默认 10)
```
### 错误排查
```
Usage:
dws devdoc error diagnose [flags]
dws devdoc error troubleshoot [flags]
Example:
dws devdoc error diagnose --request-id 15r6h45w0muec
dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程"
dws devdoc error diagnose --error-code 33012 --error-message "missing scope"
dws devdoc error diagnose --query "机器人回调失败" --context "HTTP 403"
Flags:
--query string 原始排查问题
--request-id string 开放平台 requestId
--trace-id string requestId 的兼容别名
--error-code string 错误码
--error-message string 错误描述,会合并进原始问题
--api string API 名称,会合并进原始问题作为补充检索词
--context string 额外排查上下文,会合并进原始问题
--page int 分页页码 (从 1 开始,默认 1)
--cursor string 分页游标,翻页传上次返回的 nextCursor;传入后不再使用 --page
--size int 分页大小 (默认 10)
```
## 意图判断
用户问开放平台 API / 字段 / 错误码 / SDK / 鉴权 / 回调 / 配额相关的技术细节:
- 走 `devdoc article search`,把用户问的关键短语作为位置参数或 `--query`
- 用户已经给出错误码、错误描述或 requestId:
- 先走 `devdoc error diagnose`;若后端返回 `PARAM_ERROR - 未找到指定工具` / `unknown tool`,降级 `devdoc article search`,同时把该结果标记为 `needs_gateway_tool_registration`
用户已经提供 requestId / traceId / 错误码 / 错误描述 / 失败上下文:
- 走 `devdoc error diagnose`,优先传 `--request-id`,没有 requestId 时传 `--error-code`、`--error-message`、`--query` 或 `--context`
关键区分:
- devdoc(钉钉**开放平台**开发者文档,面向研发) vs doc(钉钉在线文档,面向普通用户内容)
- devdoc 只做搜索/诊断,不做读取;命中条目返回标题、摘要、文档链接,由 Agent 引用链接或进一步浏览
- `devdoc error diagnose` 只返回诊断事实、参考资料和链接,不生成 AI 分析结论
- `--api`、`--error-message`、`--context` 是 CLI 侧易用参数,调用 MCP 时会合并到 `query`;MCP 入参只发送 `query`、`requestId`、`errorCode`、`page`/`cursor`、`size`
## 核心工作流
```bash
# 开发者问"OAuth2 怎么接"
dws devdoc article search --query "OAuth2 接入" --format json
# 简短关键词可直接作为位置参数
dws devdoc article search "MCP" --format json
# 命中结果多时翻页
dws devdoc article search --query "消息卡片" --page 2 --size 5 --format json
# 推荐动态翻页:优先使用上一页返回的 nextCursor
dws devdoc article search --query "消息卡片" --cursor "<NEXT_CURSOR>" --size 5 --format json
# 查错误码 / 字段含义
dws devdoc article search --query "errcode 40078" --format json
# 已经有 requestId 时排查
dws devdoc error diagnose --request-id 15r6h45w0muec --format json
# 只有 traceId 时按 requestId 兼容处理
dws devdoc error diagnose --trace-id 15r6h45w0muec --api "创建日程" --format json
# 只有错误码和错误描述时排查
dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
# 错误排查结果继续翻页
dws devdoc error diagnose --error-code "40014" --query "access_token" --cursor "<NEXT_CURSOR>" --format json
```
## 上线/注入验收
```bash
dws devdoc --help
dws devdoc article search --query "OAuth2 接入" --dry-run --format json
dws devdoc error diagnose --error-code "40014" --query "access_token" --dry-run --format json
dws schema devdoc.search_open_platform_docs_rag --format json || true
dws schema devdoc.search_open_error_code_rag --format json || true
```
- `--help` / `--dry-run` 只能证明 CLI 命令面和映射存在,不能证明后端工具已注册
- 真实调用若返回 `PARAM_ERROR - 未找到指定工具`,不是用户参数问题;记录为网关/工具注册待闭环,并降级到 article search 或本地已挂载 Markdown
## 注意事项
- 关键词必填;可用位置参数、`--query` 或兼容别名 `--keyword`。建议传用户原话里的关键名词(API 名、错误码、能力名),不要过度改写
- 错误排查至少提供 `--query`、`--request-id`、`--error-code`、`--error-message`、`--context` 之一;单独 `--api` 只作为补充上下文,不足以发起排查
- 返回按相关性排序,默认 `--size 10`;响应有 `hasMore=true` 时,用 `nextCursor` 传给 `--cursor` 继续翻页,不要手写猜测下一页
- 命中结果里的链接是钉钉开放平台公开文档,可直接给用户做参考
- 不要把 devdoc 用来查业务数据(那是 aitable / doc / report 的事);devdoc 只查**官方开发者文档**
# DING 消息 (ding) 命令参考
## 命令总览
### 发送 DING 消息
```
Usage:
dws ding message send [flags]
Example:
dws ding message send --robot-code <ROBOT_CODE> --users <USER_ID_1>,<USER_ID_2> --content "请查看"
Flags:
--content string 消息内容 (必填)
--robot-code string 机器人 ID (必填, 可从 应用管理→机器人 获取, 或设 DINGTALK_DING_ROBOT_CODE)
--users string 接收人 userId 列表 (必填)
--type string 提醒类型: app/sms/call (默认 app)
```
### 撤回 DING 消息
```
Usage:
dws ding message recall [flags]
Example:
dws ding message recall --robot-code <ROBOT_CODE> --id <OPEN_DING_ID>
Flags:
--id string DING 消息 ID (必填)
--robot-code string 机器人 ID (必填, 或设 DINGTALK_DING_ROBOT_CODE)
```
## 意图判断
用户说"DING 一下/紧急通知/电话提醒" → `message send`
用户说"撤回 DING" → `message recall`
关键区分:
- ding(紧急提醒, 支持电话/短信) vs bot(常规群/单聊消息)
- sms/call 类型有通信费用
## 核心工作流
```bash
# 应用内 DING (免费)
dws ding message send --robot-code <ROBOT_CODE> --type app --users userId1,userId2 --content "请查看" --format json
# 电话 DING (紧急, 有成本!)
dws ding message send --robot-code <ROBOT_CODE> --type call --users userId1 --content "紧急告警" --format json
# 撤回
dws ding message recall --robot-code <ROBOT_CODE> --id <OPEN_DING_ID> --format json
```
## 上下文传递表
| 操作 | 提取 | 用于 |
|------|------|------|
| `message send` | `openDingId` | message recall 的 --id |
## 注意事项
- `--robot-code` 从钉钉开放平台 **应用管理 → 机器人** 中获取,也可设环境变量 `DINGTALK_DING_ROBOT_CODE`
- sms/call 类型有通信费用,使用前需和用户确认
- 默认 `--type app`(应用内 DING,免费)
# 文档 (doc) 命令参考
> **渐进式文档**:本文件为路由层(命令索引 + 场景索引 + 意图判断 + 工作流),各命令的详细参数、示例和踩坑说明在 [doc/](./doc/) 目录下按需加载。
## 文档地址 (URI)
| 资源 | URI 格式 |
|------|----------|
| 文档节点 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` |
| edit / preview 链接 | `https://alidocs.dingtalk.com/document/{edit\|preview}?...&dentryKey={key}` |
> **操作后请返回文档 URI**:每次执行 create / read / update 等操作后,从返回数据中提取 `docUrl` 直接返回;缺失时用 `doc info --node <ID>` 补查。
## 前置条件 — 执行操作前必读
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下子文件:**
1. **解析 URL / 定位文档**(几乎所有命令都需要先拿 nodeId)
→ 必读 [`doc/doc-info.md`](./doc/doc-info.md)(URL/dentryKey 提取规则、ID 边界、contentType 路由、**获取 nodeId 三种方式 A/B/C**)
2. **创建或编辑文档内容**(`doc create` / `doc update` / `doc block insert|update`)
→ 必读 [`doc/style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md)(**形态优先级硬规则:JSONML > element JSON > markdown**;markdown overwrite 会丢富结构)
- 从零创建时加读 [`doc/style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md)
- **任何 `doc create` 都必须先读 [`doc/style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) §2.0 类型决策表 + §1 硬规则**(决定骨架 + 全局约束,不读就不知道用哪种骨架)
- 涉及 callout / 分栏 / 富 block 精修时再加读 style-guideline §4-§7 + [`doc/format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md)
**未读以上文件就改写已有文档会导致富结构丢失、参数错误或样式不达标。其他命令(阅读 / 评论 / 权限 / 附件 / 下载导出 / 文件操作)按需查下方 §命令索引表跳转对应子文件加载,不必提前加载。**
## 查询命令帮助
当你不确定某个命令的具体参数、格式或可选项时,**优先执行 `--help` 查询**,不要猜测参数名或凭记忆编造。
```bash
# 查看 doc 下所有子命令
dws doc --help
# 查看具体命令的完整参数说明
dws doc read --help
dws doc create --help
dws doc block insert --help
# 查看子命令组下的所有命令
dws doc block --help
dws doc media --help
```
规则:
- 参数名不确定时 → 先 `--help`,再调用
- 报错 "unknown flag" 时 → `--help` 确认正确的 flag 名称
- 不确定某个功能是否存在时 → `dws doc --help` 查看命令列表
## 命令索引表
> 命令名 → 单文件,按需加载子文档。复杂任务请优先看下方 §场景索引。
### 阅读 / 元信息
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc info` | 文档元信息(含 contentType / extension) | `--node` | [`doc/doc-info.md`](./doc/doc-info.md) |
| `doc read` | 读取正文(markdown 或 jsonml) | `--node` | [`doc/doc-read.md`](./doc/doc-read.md) |
### 创建 / 写入 / 块编辑
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc create` | 创建文字文档(adoc) | `--name` | [`doc/doc-create.md`](./doc/doc-create.md) |
| `doc update` | 整篇 / 段落级更新(markdown / jsonml) | `--node` `--mode` | [`doc/doc-update.md`](./doc/doc-update.md) |
| `doc block list/insert/update/delete` | 块级精细编辑(含 JSONML 节点操作) | `--node` (+ `--block-id`) | [`doc/doc-block.md`](./doc/doc-block.md) |
### 附件 / 评论 / 导出
| 命令 | 用途 | 必填参数 | 详见 |
|------|------|----------|------|
| `doc media insert/download` | 附件 / 图片插入与下载 | `--node` `--file` 或 `--resource-id` | [`doc/doc-media.md`](./doc/doc-media.md) |
| `doc comment list/create/reply/create-inline` | 文档评论与划词评论 | `--node` (+ ...) | [`doc/doc-comment.md`](./doc/doc-comment.md) |
| `doc export` / `doc export get` | 在线文档导出 docx | `--node` `--output` | [`doc/doc-export.md`](./doc/doc-export.md) |
### 文件操作(doc 与 drive 均提供,按场景选用)
> **说明**:以下命令在 `doc` 下均为可用命令;`drive` 下也有等价命令(聚合钉盘 + 文档空间)。需要纯文件/钉盘语义时用 `drive`,需要在文档空间内操作时用 `doc`,两者都不会报 deprecated。
| doc 命令 | drive 等价命令 | 详见 |
|----------|----------------|------|
| `doc upload` | `dws drive upload` | [`drive.md`](./drive.md) |
| `doc download` | `dws drive download` | [`drive.md`](./drive.md) |
| `doc copy` | `dws drive copy` | [`drive.md`](./drive.md) |
| `doc move` | `dws drive move` | [`drive.md`](./drive.md) |
| `doc rename` | `dws drive rename` | [`drive.md`](./drive.md) |
| `doc delete` | `dws drive delete` | [`drive.md`](./drive.md) |
| `doc folder create` | `dws drive mkdir` | [`drive.md`](./drive.md) |
| `doc file create` | `dws wiki node create --type <type>` | [`wiki.md`](./wiki.md) |
| `doc permission *` | `dws drive permission *` | [`drive.md`](./drive.md) |
| `doc list` | `dws drive list --workspace` / `dws wiki node list` | [`drive.md`](./drive.md) / [`wiki.md`](./wiki.md) |
| `doc search` | `dws drive search` / `dws wiki node search` | [`drive.md`](./drive.md) / [`wiki.md`](./wiki.md) |
### 排版规范 / JSONML 参考
| 资源 | 用途 | 详见 |
|------|------|------|
| 创建工作流 | 标题、位置、骨架、回读校验 | [`doc/style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) |
| 改写工作流 | 编辑形态优先级、分片 append、回读验收 | [`doc/style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md) |
| 排版规范 | 文档类型 / 骨架 / 元素边界 / 颜色语义 | [`doc/style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) |
| JSONML 范例 | 所有节点类型的可复制命令 | [`doc/format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) |
| JSONML 节点结构 | 字段定义 + JSON Schema | [`doc/format/doc-jsonml-schema.md`](./doc/format/doc-jsonml-schema.md) |
## 场景索引
> 任务驱动的入口:知道任务但不确定要读哪些命令文件时,按本表**一次性 Read 文件组**,不要逐个跳。
| 任务场景 | 一次性读取 | 主命令 |
|---------|-----------|--------|
| 定位 nodeId / URL 解析 | [`doc-info.md`](./doc/doc-info.md)(搜索请用 `dws drive search` / `dws wiki node search`;遍历请用 `dws drive list` / `dws wiki node list`) | `drive search` / `wiki node search` |
| 阅读已有文档 | [`doc-info.md`](./doc/doc-info.md) + [`doc-read.md`](./doc/doc-read.md) | read |
| 创建新文档 | [`doc-create.md`](./doc/doc-create.md) + [`doc-update.md`](./doc/doc-update.md)(写入管道)+ [`style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | create |
| 创建文档且包含图片/截图/图文并茂 | [`doc-create.md`](./doc/doc-create.md) + [`doc-media.md`](./doc/doc-media.md) + [`style/doc-create-workflow.md`](./doc/style/doc-create-workflow.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | create → media insert |
| 局部改写 / 段落替换(保真) | [`doc-read.md`](./doc/doc-read.md) + [`doc-update.md`](./doc/doc-update.md) + [`doc-block.md`](./doc/doc-block.md) + [`format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) + [`style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md) | block update |
| 整篇 overwrite(可选并发检查) | [`doc-read.md`](./doc/doc-read.md) + [`doc-update.md`](./doc/doc-update.md) + [`format/doc-jsonml-schema.md`](./doc/format/doc-jsonml-schema.md) + [`style/doc-update-workflow.md`](./doc/style/doc-update-workflow.md) | update |
| 插入富 block(callout / 分栏 / 表格) | [`doc-block.md`](./doc/doc-block.md) + [`format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | block insert |
| 上传图片 / 附件 | [`doc-media.md`](./doc/doc-media.md) | media insert |
| 评论 / 划词评论(含 @人) | [`doc-comment.md`](./doc/doc-comment.md)(+ `dws contact user search` 取 mention 用 userId) | comment create |
| 文档分享 / 节点级权限 | [`drive.md`](./drive.md)(推荐 `dws drive permission add/update/list/remove`;`doc permission *` 亦可用) | `drive permission` |
| 导出 PDF / DOCX | [`doc-info.md`](./doc/doc-info.md) + [`doc-export.md`](./doc/doc-export.md) | export |
| 文件下载 / 上传 / 移动 / 重命名 / 复制 | [`drive.md`](./drive.md)(推荐 `dws drive upload/download/copy/move/rename/delete`;同名 `doc` 命令亦可用) | `drive *` |
## 意图判断
用户说"找文档/搜文档/最近文档":
- 全局搜索 → `dws drive search --query "<关键词>"`(聚合钉盘+文档空间)
- 空间内搜索 → `dws wiki node search --workspace <WS_ID> --keyword "<关键词>"`
- 遍历文件夹 → `dws drive list --workspace <WS_ID>` 或 `dws wiki node list --workspace <WS_ID>`
用户说"看文档/读内容/文档内容":
- 读取 → `read`(需文档 ID 或 URL)
- 元信息 → `info`
用户说"写文档/创建文档":
- 新建文字文档(adoc)→ `doc create`
- 追加内容 → `update --mode append`
- 覆盖替换 → `update --mode overwrite`
- 指定父目录时,只有明确的文档文件夹 `nodeId` / `alidocs` 文件夹 URL 才能放入 `--folder`;上一步若只返回了纯数字 `dentryId`、`spaceId` 或 drive `parent-id`,不要把它传给 doc 的 `--folder`
> 严禁把「创建表格」路由到 `doc create`:
>
> - 用户说"创建表格/新建表格/建个电子表格/在线表格" → 走 [`dws sheet create`](./sheet.md#创建钉钉表格文档)(axls 在线电子表格)
> - 用户说"创建多维表格/新建 AI 表格/建 base/数据库表" → 走 [`dws aitable base create`](./aitable.md#创建-ai-表格)(able 多维表格)
用户说"建文件夹/新建目录":
- 创建 → `dws drive folder create`
用户说"上传文件/传文件/上传到文档/上传到知识库":
- 上传 → `dws drive upload`(需本地文件路径)
用户说"下载/导出/下载到本地/导出文档/导出为Word/导出为docx/把文档导出来":
- **必须先判断目标文件类型**,再决定走 `export` 还是 `download`:
- 在线文档 (alidocs/adoc) → **`doc export`**(导出是内容层操作,仅对 adoc 有意义)
- 已有文件(PDF、图片、附件、视频等非在线文档) → **`dws drive download`**
- 判断方法:
1. 如果用户明确说了"导出文档"、"导出为Word/docx" → 直接走 `doc export`
2. 如果用户明确说了"下载PDF/图片/附件" → 直接走 `drive download`
3. 不确定时,先用 `drive info --node <ID>` 查询节点信息,根据返回的 `contentType` 字段判断:
- `contentType` 为 `ALIDOC` → 走 `doc export`
- `contentType` 为 `DOCUMENT`/`IMAGE`/`VIDEO` 等 → 走 `drive download`
> **严禁将"导出文档"直接路由到 `download`**。`download` 只能下载已有文件(原样下载),`export` 是将在线文档格式转换后导出为 docx,两者完全不同。
用户说"复制文档/拷贝文件/复制到":
- 复制 → `dws drive copy`
用户说"移动文档/搬到/移到/转移文件":
- 移动 → `dws drive move`
用户说"重命名/rename/改名/改文档名/修改文档名称/修改文档标题/把这个文档叫做...":
- 重命名 → `dws drive rename --node <DOC_ID_OR_URL> --name "新名称"`
- 只有用户明确说"正文里的标题/章节标题/段落标题/H1 标题"时,才走 `block update`。
用户说"删除文档/删掉这个文件/移到回收站/丢掉这篇文档":
- 删除节点 → `dws drive delete`
用户说"插入附件/上传附件到文档/往文档里加文件/加附件":
- 插入附件 → `media insert`(需文档 ID 或 URL + 本地文件路径)
用户说"插入图片/加图片/放张图/嵌入图片/往文档里插图":
- 插入图片 → `media insert`(需文档 ID 或 URL + 本地图片文件路径)
- 注意:图片也是通过 `media insert` 作为附件块插入文档,不是通过 `block insert`
用户说"下载附件/获取附件/取出文档里的附件":
- 下载附件 → `media download`(需文档 ID 或 URL + 资源 ID)
用户说"编辑块/改段落/插入标题/删除块":
- 查看结构 → `block list`
- 插入 → `block insert`
- 修改 → `block update`
- 删除 → `block delete`
用户说"给某人开权限/分享给某人/授权某文档/把这篇文档给 xxx 看":
- 新增权限 → `dws drive permission add`
- 修改权限 → `dws drive permission update`
- 查看谁有权限 → `dws drive permission list`
- 移除权限 → `dws drive permission remove`
> **关键区分**:
>
> - "把**某篇文档**授权给某人" → `drive permission add`(节点级,包括「我的文档」下的文档都支持)
> - "把**某个知识库**整体授权给某人" → `wiki member add`(容器级,但**「我的文档」个人空间不支持**)
> 补充:如果用户直接粘贴的是原始 `alidocs` URL,先按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) probe;只有 probe 确认是 `adoc` / `file` / `folder` 后,才继续按下列意图执行。
**用户直接粘贴文档 URL(无其他指令)**:
- 默认 → `read`(读取文档内容)
- 如 URL 明显是文件夹 → `list`(列出文件夹内容)
**用户粘贴 URL + 附加指令**:
- "帮我看看这个文档" → `read`
- "这个文档的信息" → `info`
- "往这个文档追加内容" → `update --mode append`
- "把这个文档标题改成 X" / "这个文档改名为 X" → `dws drive rename`
- "把正文里的一级标题/章节标题改成 X" → `block update`
关键区分: doc(文档编辑/阅读) vs aitable(数据表格操作) vs drive(钉盘文件管理)
## 核心工作流
> 步骤性指引。"读哪些文件"组合参见上方 §场景索引;命令详细参数参见对应子文件。
```bash
# ── 工作流 1: 定位并阅读文档 ──
dws drive search --query "<关键词>" --format json # 1. 搜索定位 nodeId(或 wiki node search)
dws doc info --node <DOC_ID> --format json # 2. 元信息(含 contentType)
dws doc read --node <DOC_ID> --format json # 3. 读 markdown 正文
# ── 工作流 2: 创建文档(含分片自动写入)──
dws drive folder create --name "项目资料" --format json # 1. (可选) 创建文件夹
dws doc create --name "项目周报" --content-file /tmp/x.md \
--folder <DOC_FOLDER_NODE_ID> --format json # 2. 创建 + 写入
dws doc read --node <DOC_ID> --format json # 3. 回读校验(必须)
# ── 工作流 3: 局部改写(保真,首选 JSONML)──
dws doc block list --node <DOC_ID> --content-format jsonml # 1. 取 uuid
dws doc block list --node <DOC_ID> --content-format jsonml --block-id <UUID> # 2. 读子树
dws doc block update --node <DOC_ID> --block-id <UUID> \
--content-format jsonml --element '[...]' # 3. 写回(uuid 必须 == --block-id)
dws doc read --node <DOC_ID> --content-format jsonml # 4. 回读
# ── 工作流 4: 整篇 overwrite(仅在新骨架重写时)──
dws doc read --node <DOC_ID> --content-format jsonml --output /tmp/doc.json # 1. 读出当前 JSONML
# 修改 /tmp/doc.json 中的 jsonml 数组
dws doc update --node <DOC_ID> --content-file /tmp/doc.json \
--content-format jsonml --mode overwrite # 2. 写回(默认不做并发检查)
dws doc read --node <DOC_ID> --content-format jsonml # 3. 回读
# 担心被并发覆盖时,可加 --revision <N> 触发并发检查(详见 doc-update.md)
# ── 工作流 5: 上传独立文件 vs 插入附件到文档正文 ──
dws drive upload --file ./report.pdf --folder <ID> # 上传作为独立文件(存储层)
dws doc media insert --node <DOC_ID> --file ./report.pdf # 上传并作为附件块插入正文(内容层)
# ── 工作流 6: 下载 vs 导出(先 info 判 contentType)──
dws doc info --node <NODE_ID> --format json # 必须先查 contentType
dws doc export --node <NODE_ID> --output ~/downloads/ # contentType=ALIDOC 走 export(内容层)
dws drive download --node <NODE_ID> --output ~/downloads/ # contentType≠ALIDOC 走 drive download(存储层)
# ── 工作流 7: 评论 + 划词(@人 需 userId)──
dws contact user search --query "张三" --format json # 取 userId
dws doc comment create --node <DOC_ID> --content "请确认" --mention <uid> --format json
dws doc block list --node <DOC_ID> --format json # 划词需先取 blockId / paragraph.text
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> \
--start 0 --end 10 --content "建议调整" --selected-text "原文" --format json
# ── 工作流 8: 权限授予(节点级,推荐用 drive,doc permission 亦可用)──
dws contact user search --query "张三" --format json # 取 userId
dws drive permission add --node <DOC_ID> --user <uid1>,<uid2> --role EDITOR --format json
dws drive permission list --node <DOC_ID> --format json # 校验
# ── 工作流 9: 文件操作(推荐用 drive,同名 doc 命令亦可用)──
# 第一步:获取 nodeId
# 方式 A(优先):用户直接提供 URL / nodeId → 直接传 --node
# 方式 B:按关键字找:dws drive search --query "项目周报" --format json
# 方式 C:按文件夹遍历:dws drive list --workspace <WS_ID> --format json
# 第二步:执行
dws drive copy --node <DOC_ID_OR_URL> --folder <TARGET_FOLDER> --format json
dws drive move --node <DOC_ID_OR_URL> --folder <TARGET_FOLDER> --format json
dws drive rename --node <DOC_ID_OR_URL> --name "新名称" --format json
dws drive delete --node <DOC_ID_OR_URL> --yes --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `drive search` / `wiki node search` | 文档 `nodeId` / URL | doc read / info / update 等所有 `--node` 入参 |
| `drive list` / `wiki node list` | `nodes[].nodeId` | doc read / info / update / block 操作的 --node |
| `create` | `nodeId` | update / block 操作的 --node |
| `block list` | `blockId` | block insert 的 --ref-block, block update/delete 的 --block-id |
| `read --content-format jsonml` | `revision` | update --content-format jsonml 的 --revision(可选,并发检查时使用) |
| `media insert` | `resourceId` | 附件已插入文档,可通过 block list 查看附件块 |
| `media download` | 附件下载链接 `downloadUrl` | 下载文档中的附件资源 |
| `block list` | attachment 块的 `resourceId` | media download 的 --resource-id |
| `comment list` | `commentList[].commentKey` | comment reply 的 --comment-key |
| `comment create` | `commentKey` | comment reply 的 --comment-key |
| `comment create-inline` | `commentKey` | comment reply 的 --comment-key |
| `block list` | `blocks[].element.id` | comment create-inline 的 --block-id |
| `block list` | `blocks[].element.paragraph.text` | 计算 create-inline 的 --start / --end 偏移量 |
| `contact user search` | `userId` | comment create/reply/create-inline 的 --mention;drive permission 的 --user |
## 相关产品
- [wiki](./wiki.md) — 知识库空间级管理(创建/查询/列出/搜索知识库),doc 中的文档存储在 wiki 知识库中
- [aitable](./aitable.md) — 结构化数据表格(行列/字段/记录),不是富文本文档
- [drive](./drive.md) — 钉盘文件存储/上传/下载,不是文档内容编辑
- [report](./report.md) — 钉钉日志系统(日报/周报模版),不是在线文档
# doc block(块级精细编辑:list / insert / update / delete)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> 2. [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md) — 改写流程(编辑形态优先级、JSONML validator 行为)
> 3. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — JSONML 范例(含 callout / 分栏 / 表格 / 标题等节点的完整命令)
> 4. [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md) — JSONML 节点结构字段定义
>
> **同任务常配合**:[`doc-update.md`](./doc-update.md)(整篇 overwrite / 末尾追加纯文本)/ [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)(JSONML 复制范例)
> **改写已有文档优先 JSONML**:保真度最高、callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套都能 1:1 round-trip;写入端有 validator 兜底。详见 [`./style/doc-update-workflow.md` §1.3 编辑形态优先级](./style/doc-update-workflow.md)。
---
## doc block list(查询块元素)
```
Usage:
dws doc block list [flags]
Example:
dws doc block list --node <DOC_ID>
dws doc block list --node <DOC_ID> --start-index 0 --end-index 5
dws doc block list --node <DOC_ID> --block-type heading
dws doc block list --node <DOC_ID> --content-format jsonml
dws doc block list --node <DOC_ID> --content-format jsonml --block-id <UUID>
Flags:
--node string 文档 ID 或 URL (必填)
--start-index int 起始位置 (从 0 开始)
--end-index int 终止位置 (含)
--block-type string 按块类型过滤
--content-format string 输出格式: 默认为 element,可选 jsonml(返回 JSONML 节点数组)
--block-id string 指定块 UUID(content-format=jsonml 时读取完整子树)
```
### content-format=jsonml 返回示例
每个 block 包含 `jsonml` 字段(JSON string,解析后为 JSONML 数组):
```json
{
"blocks": [
{
"blockId": "mpeurp5mj5o9xz4hnj",
"blockType": "h2",
"index": 0,
"jsonml": "[\"h2\",{\"uuid\":\"mpeurp5mj5o9xz4hnj\"},...]"
}
],
"totalCount": 5
}
```
---
## doc block insert(插入块元素)
```
Usage:
dws doc block insert [flags]
Example:
dws doc block insert --node <DOC_ID> --text "这是一段文字"
dws doc block insert --node <DOC_ID> --heading "二级标题" --level 2
dws doc block insert --node <DOC_ID> --element '{"blockType":"paragraph","paragraph":{"text":"内容"}}'
dws doc block insert --node <DOC_ID> --text "在此处之前插入" --ref-block <BLOCK_ID> --where before
dws doc block insert --node <DOC_ID> --content-format jsonml --element '["p",{"uuid":"..."},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"新段落"]]]'
# 插入引用块(blockquote)
dws doc block insert --node <DOC_ID> --element '{"blockType":"blockquote","blockquote":{"text":"这是一段引用内容"}}'
# 插入分栏块(columns):2 栏,children 为每栏的内容
dws doc block insert --node <DOC_ID> --element '{"blockType":"columns","columns":{"size":2},"children":[{"blockType":"paragraph","paragraph":{"text":"左栏内容"}},{"blockType":"paragraph","paragraph":{"text":"右栏内容"}}]}'
# 插入表格(table):2 行 3 列
dws doc block insert --node <DOC_ID> --element '{"blockType":"table","table":{"rolSize":2,"colSize":3,"cells":[["姓名","部门","职位"],["张三","工程部","开发"]]}}'
# 插入行内图片(inline image):构造一个空 paragraph,在 children 中用 image elementType 指定图片 src
dws doc block insert --node <DOC_ID> --element '{"blockType":"paragraph","paragraph":{},"children":[{"elementType":"image","properties":{"src":"https://example.com/photo.png"}}]}'
# 插入附件块(attachment):resourceId 为资源ID,type 为 MIME 类型,viewType 可选 preview/summary
dws doc block insert --node <DOC_ID> --element '{"blockType":"attachment","attachment":{"resourceId":"12345-xxx-xxx-123-xxxxx","type":"application/pdf","name":"报告.pdf","viewType":"preview"}}'
# 插入分割线:使用 doc update --mode append 以 Markdown 格式追加
dws doc update --node <DOC_ID> --content "---" --mode append
Flags:
--node string 文档 ID 或 URL (必填)
--text string 快捷: 段落文本内容
--heading string 快捷: 标题文本
--level int 标题级别 1-6 (配合 --heading,默认 1)
--element string 块元素 JSON (高级);content-format=jsonml 时为 JSONML 数组字符串
--content-format string 输入格式: 默认为 element,可选 jsonml
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
--index int 参照位置索引 (从 0 开始)
--where string 插入方向: before / after (默认 after)
--ref-block string 参照块 ID (优先级高于 --index)
```
> **content-format=jsonml 完整范例**:参见 [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md),包含所有节点类型的正确 insert/update 命令示例。
---
## doc block update(更新块元素)
```
Usage:
dws doc block update [flags]
Example:
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text "新内容"
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --element '{"blockType":"heading","heading":{"text":"新标题","level":1}}'
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content-format jsonml --element '["h1",{"uuid":"<BLOCK_ID>"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"新标题"]]]'
Flags:
--node string 文档 ID 或 URL (必填)
--block-id string 目标块 ID (必填)
--text string 快捷: 段落文本内容
--heading string 快捷: 标题文本
--level int 标题级别 1-6 (配合 --heading,默认 1)
--element string 块元素 JSON (高级);content-format=jsonml 时为 JSONML 数组字符串
--content-format string 输入格式: 默认为 element,可选 jsonml
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
```
> 使用 `--content-format jsonml` 时,`element` 中的 `uuid` **必须**等于 `--block-id`,否则报错。
---
## doc block delete(删除块元素)
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws doc block delete [flags]
Example:
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes
Flags:
--node string 文档 ID 或 URL (必填)
--block-id string 目标块 ID (必填)
```
---
## JSONML 格式的块操作(首选路径)
使用 `--content-format jsonml` 可以直接以 JSONML 节点格式进行块操作,覆盖所有节点类型(不限于 block element 定义的有限类型)。
```bash
# 列出顶层 JSONML 节点
dws doc block list --node DOC_ID --content-format jsonml
# 读取指定 uuid 的完整子树
dws doc block list --node DOC_ID --content-format jsonml --block-id UUID
# 插入 JSONML 节点(同级定位)
dws doc block insert --node DOC_ID --content-format jsonml \
--element '["p", {}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "新段落"]]]' \
--ref-block UUID --where after
# 插入 JSONML 节点(容器内定位)
dws doc block insert --node DOC_ID --content-format jsonml \
--element '["p", {}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "新段落"]]]' \
--parent-block UUID --index 2
# 整体替换(update 时 uuid 必须等于 --block-id)
dws doc block update --node DOC_ID --block-id UUID --content-format jsonml \
--element '["p", {"uuid": "UUID"}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "修改后内容"]]]'
# 删除(无需 format 区分)
dws doc block delete --node DOC_ID --block-id UUID
```
> 关于校验:CLI 不做结构修复,裸字符串、缺 uuid 等错误会被 validator 直接抦下。如需同时启用 JSON 语法修复(修复 LLM 遗漏的括号/逗号),用 `--fix-jsonml`。文本结构定义见 [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)。
## 关键说明
- **块类型**:paragraph、heading、blockquote、callout、columns、orderedList、unorderedList、table、sheet、attachment、slot。
- **快捷 vs --element**:`block insert` 优先使用 `--text` 或 `--heading` 快捷方式;复杂块类型(table、callout、columns 等)使用 `--element` JSON 或 `--content-format jsonml`。
- **简单内容追加**:建议用 [`./doc-update.md`](./doc-update.md) `--mode append`,不必走 block insert。
- **JSONML validator**(写入端默认行为):
- 裸字符串、缺 uuid 等结构错误会被 validator 抦下并返回带 path 的错误(如 `$[2][2]: paragraph child must be span wrapper, got raw string.`)。
- `--fix-jsonml` 开启 JSON 语法修复,推荐 agent 调用。
- **图片插入**:插入图片走 [`./doc-media.md`](./doc-media.md) `media insert`(作为附件块),不走 block insert。
- **分割线**:用 [`./doc-update.md`](./doc-update.md) `--content "---" --mode append`,不走 block insert。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `blocks[].blockId` | `block insert` 的 `--ref-block`、`block update/delete` 的 `--block-id` |
| `blocks[].element.id` | [`./doc-comment.md`](./doc-comment.md) `comment create-inline` 的 `--block-id` |
| `blocks[].element.paragraph.text` | 计算 [`./doc-comment.md`](./doc-comment.md) `comment create-inline` 的 `--start` / `--end` 偏移量 |
| attachment 块的 `resourceId` | [`./doc-media.md`](./doc-media.md) `media download` 的 `--resource-id` |
## 常用模板
```bash
# ── element JSON 形态(次选,老接口)──
# 文本段落
dws doc block insert --node <DOC_ID> --text "这是段落文字" --content-format element
# 标题(level 1-6)
dws doc block insert --node <DOC_ID> --heading "二级标题" --level 2 --content-format element
# 引用块
dws doc block insert --node <DOC_ID> --content-format element \
--element '{"blockType":"blockquote","blockquote":{"text":"引用内容"}}'
# 分栏(2 栏)
dws doc block insert --node <DOC_ID> --content-format element \
--element '{"blockType":"columns","columns":{"size":2},"children":[{"blockType":"paragraph","paragraph":{"text":"左栏"}},{"blockType":"paragraph","paragraph":{"text":"右栏"}}]}'
# 表格(2x3)
dws doc block insert --node <DOC_ID> --content-format element \
--element '{"blockType":"table","table":{"rolSize":2,"colSize":3,"cells":[["姓名","部门","职位"],["张三","工程部","开发"]]}}'
# Callout(element JSON 次选;首选 JSONML container[subType=colorBlocks])
dws doc block insert --node <DOC_ID> --content-format element \
--element '{"blockType":"callout","callout":{"emoji":"⚠️","bgColor":"#FDE2E0","content":[{"text":"高风险操作,先备份"}]}}'
# 在指定 block 之前插入
dws doc block insert --node <DOC_ID> --text "在此之前" --ref-block <BLOCK_ID> --where before --content-format element
# 修改某块文本
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --text "修改后" --content-format element
# 修改标题
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content-format element \
--element '{"blockType":"heading","heading":{"text":"新标题","level":2}}'
# 删除(用户确认后)
dws doc block delete --node <DOC_ID> --block-id <BLOCK_ID> --yes
# ── JSONML 形态(首选)──
# 列出 + 取 uuid
dws doc block list --node <DOC_ID> --content-format jsonml
# 读单个 block 完整子树
dws doc block list --node <DOC_ID> --content-format jsonml --block-id <UUID>
# 插入段落(同级定位)
dws doc block insert --node <DOC_ID> --content-format jsonml --ref-block <UUID> --where after \
--element '["p",{},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"新段落"]]]'
# 插入 callout(colorBlocks)
dws doc block insert --node <DOC_ID> --content-format jsonml --ref-block <UUID> --where after \
--element '["container",{"uuid":"co1","subType":"colorBlocks","metadata":{"bgcolor":"#FDE2E0","border":"#F5C2C7"}},["p",{"uuid":"co1p1"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"高风险操作,先备份"]]]]'
# 插入分栏(columns / column)
dws doc block insert --node <DOC_ID> --content-format jsonml --ref-block <UUID> --where after \
--element '["container",{"uuid":"cols1","subType":"columns","metadata":{"size":"2"}},["container",{"uuid":"cols1c1","subType":"column"},["p",{"uuid":"cols1c1p"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"左栏"]]]],["container",{"uuid":"cols1c2","subType":"column"},["p",{"uuid":"cols1c2p"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"右栏"]]]]]'
# 整体替换(update 的 uuid 必须等于 --block-id)
dws doc block update --node <DOC_ID> --block-id <UUID> --content-format jsonml \
--element '["p",{"uuid":"<UUID>"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"修改后内容"]]]'
# 容器内定位插入(指定 parent + index)
dws doc block insert --node <DOC_ID> --content-format jsonml --parent-block <UUID> --index 2 \
--element '["p",{},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"在容器第 3 项位置插入"]]]'
```
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族)
- [`./doc-update.md`](./doc-update.md)(整篇改写 / 纯文本追加)
- [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md)(编辑形态优先级、validator 行为)
- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)(所有节点的可复制 JSONML 范例)
- [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 节点结构字段定义)
- [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md)(callout 颜色 / 元素边界规范)
# doc comment(文档评论:list / create / reply / create-inline)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
>
> **同任务常配合**:`dws contact user search`(查 `--mention` 用 userId)/ [`doc-block.md`](./doc-block.md)(划词评论必须先取 blockId 与 paragraph 文本)
---
## doc comment list(查询文档评论列表)
```
Usage:
dws doc comment list [flags]
Example:
dws doc comment list --node <DOC_ID>
dws doc comment list --node <DOC_ID> --type inline --resolve-status unresolved
dws doc comment list --node <DOC_ID> --page-size 20 --next-token <TOKEN>
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--page-size int 每页返回的评论数量
--next-token string 下一页 token,从上一次请求的返回结果中获取 (首次请求不传)
--type string 按评论类型过滤: global (全文评论) / inline (划词评论)
--resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决)
```
---
## doc comment create(创建全文评论)
```
Usage:
dws doc comment create [flags]
Example:
dws doc comment create --node <DOC_ID> --content "这里需要修改"
dws doc comment create --node <DOC_ID> --content "请review" --mention uid1,uid2
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--content string 评论的文字内容,纯文本 (必填)
--mention string 被 @ 的用户 uid 列表,逗号分隔
```
---
## doc comment reply(回复评论)
```
Usage:
dws doc comment reply [flags]
Example:
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "同意"
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "请确认" --mention uid1,uid2
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--content string 回复的文字内容,表情回复时填写表情名称 (必填)
--comment-key string 被回复评论的 commentKey,格式: {13位毫秒时间戳}{32位UUID},可从 list/create 结果获取 (必填)
--emoji 设为 true 时作为表情贴图回复 (默认 false)
--mention string 被 @ 的用户 uid 列表,逗号分隔
```
---
## doc comment create-inline(创建划词评论)
```
Usage:
dws doc comment create-inline [flags]
Example:
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content "这里需要修改"
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 5 --end 20 --content "建议调整" --selected-text "被选中的原文"
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content "请review" --mention uid1,uid2
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--block-id string 评论标记所在的块 ID,可通过 dws doc block list 获取 (必填)
--start int 评论标记在块内文本中的起始字符偏移量,从 0 开始 (必填)
--end int 评论标记在块内文本中的结束字符偏移量,必须大于 start (必填)
--content string 评论的文字内容,纯文本 (必填)
--selected-text string 选中文本的内容,填写后评论列表中会展示「引用原文:xxx」
--mention string 被 @ 的用户 uid 列表,逗号分隔
```
## 关键说明
- `--mention` 接受 `userId` 列表(逗号分隔),需要先用 `dws contact user search --query "<姓名>"` 拿到 userId。
- `--comment-key` 是 13 位毫秒时间戳 + 32 位 UUID 的拼接字符串,从 `list` / `create` / `create-inline` 返回中提取。
- 划词评论的 `--start` / `--end` 是块内文本字符偏移量,从 0 开始;通过 [`./doc-block.md`](./doc-block.md) `block list` 取 `paragraph.text` 后人工或脚本计算。
- `reply` 加 `--emoji` 时 `--content` 填表情名称(如 `比心`、`赞`),不是文字内容。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `commentList[].commentKey` | `comment reply` 的 `--comment-key` |
| `comment create` `commentKey` | `comment reply` 的 `--comment-key` |
| `comment create-inline` `commentKey` | `comment reply` 的 `--comment-key` |
| [`./doc-block.md`](./doc-block.md) `block list` 的 `blocks[].element.id` | `comment create-inline` 的 `--block-id` |
| [`./doc-block.md`](./doc-block.md) `block list` 的 `blocks[].element.paragraph.text` | 计算 `create-inline` 的 `--start` / `--end` 偏移量 |
| `dws contact user search` 的 `userId` | `comment create/reply/create-inline` 的 `--mention` |
## 常用模板
```bash
# 查看文档全部评论
dws doc comment list --node <DOC_ID> --format json
# 仅看未解决的划词评论
dws doc comment list --node <DOC_ID> --type inline --resolve-status unresolved --format json
# 创建全文评论
dws doc comment create --node <DOC_ID> --content "这里需要补充数据来源" --format json
# 创建评论 + @人(先 contact user search 拿 userId)
dws contact user search --query "张三" --format json
# 提取 userId 后:
dws doc comment create --node <DOC_ID> --content "请确认这部分" --mention <uid1>,<uid2> --format json
# 文字回复
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "已修改" --format json
# 表情回复(--content 填表情名称)
dws doc comment reply --node <DOC_ID> --comment-key <COMMENT_KEY> --content "比心" --emoji --format json
# 划词评论(先 block list 取 blockId + paragraph.text,计算 start/end)
dws doc block list --node <DOC_ID> --format json
# 计算偏移后:
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 0 --end 10 --content "这里需要修改" --format json
# 划词评论 + 引用原文 + @人
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> --start 5 --end 20 --content "请确认这部分" --selected-text "被选中的原文内容" --mention <uid1>,<uid2> --format json
```
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族)
- [`./doc-block.md`](./doc-block.md)(取 blockId 与块内文本以计算划词偏移)
- `dws contact user search`(取 mention 用的 userId,跨产品命令)
# doc create(创建文档)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> 2. [`./style/doc-create-workflow.md`](./style/doc-create-workflow.md) — 创建工作流(标题、位置、骨架、回读校验)
> 3. [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md) — 排版规范(草稿元素清单、骨架样板)
> 4. [`./doc-update.md` §内容写入管道](./doc-update.md#内容写入管道createupdate-共用) — 长内容自动分片、`--content-file` vs `--content` 选择
> 5. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — 仅当使用 `--content-format jsonml` 时必读
## 创建路由前置判断(必看)
> `dws doc create` 只能创建在线文字文档(adoc),**不要**用它承接所有「新建 xxx」请求。收到「创建/新建」类需求时,必须先按文件类型分流:
>
> - 用户说「创建表格 / 新建表格 / 建个电子表格 / 在线表格 / 销售数据表」等 → 走 [`dws sheet create`](../sheet.md#创建钉钉表格文档)(钉钉在线电子表格 `axls`),**不要**走 `doc create`
> - 用户说「创建多维表格 / 新建 AI 表格 / 建个 base / 数据库表」等 → 走 [`dws aitable base create`](../aitable.md#创建-ai-表格)(多维表格 `able`),**不要**走 `doc create`
> - 用户说「创建文档 / 新建文档 / 写篇文档 / 会议纪要 / 周报 / 方案」等文字型内容 → 才走 `dws doc create`
>
> 一句话口诀:表格 → sheet/aitable;文档 → doc。
## 命令格式
```
Usage:
dws doc create [flags]
Example:
dws doc create --name "项目周报"
dws doc create --name "Q1 总结" --content "# Q1 总结" --folder <DOC_FOLDER_NODE_ID>
dws doc create --name "知识库文档" --workspace <WS_ID>
dws doc create --name "周报" --content-file ./weekly.md --folder <DOC_FOLDER_NODE_ID>
cat report.md | dws doc create --name "月报" --content -
Flags:
--name string 文档名称 (必填)
--folder string 目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID
--workspace string 目标知识库 ID
--content string 文档初始内容(短文本字面量);传 - 表示从 stdin 读取
--content-file string 从文件读取文档内容(UTF-8)。推荐长/多行/表格内容使用
--content-format string 内容格式: 默认为 markdown,可选 jsonml
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
```
## 关键说明
- **`--name` 是 H1**:正文从 `##` 开始;正文内不要再写 `#` 一级标题(除非确需且已说明动机)。
- 不传 `--folder` 和 `--workspace` 时,默认创建在「我的文档」根目录。
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
- 输入方式选择见 [`./doc-update.md` §内容写入管道](./doc-update.md#内容写入管道createupdate-共用)(与 update 共用)。短文本字面量可 `--content`,多行/表格/特殊字符必须 `--content-file` 或 `--content -`。
- 长内容(>30000 字符)CLI 自动分片:先创建空文档拿 `nodeId`,再按 markdown 标题边界切分后逐片 append;调用方无需手动编排。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `nodeId` | [`./doc-update.md`](./doc-update.md) / [`./doc-block.md`](./doc-block.md) / [`./doc-media.md`](./doc-media.md) 的 `--node` |
| `docUrl` | 最终交付给用户的链接;缺失时用 [`./doc-info.md`](./doc-info.md) 补查 |
| `chunksWritten` | 判断是否触发自动分片;> 1 时重点检查章节顺序 |
## 回读验收(必读)
CLI **不会**自动回读校验。**每次创建后**都必须执行 `doc read --node <nodeId>` 校验关键标题、段落首句、表格表头是否完整。详见 [`./style/doc-create-workflow.md` «回读验收»](./style/doc-create-workflow.md)。
## 常用模板
```bash
# 默认创建到「我的文档」根目录(推荐文件路径)
dws doc create --name "<文档名>" --content-file /tmp/<name>.md --content-format markdown
# 创建到指定文件夹
dws doc create --name "<文档名>" --content-file /tmp/<name>.md --folder <DOC_FOLDER_NODE_ID> --content-format markdown
# 创建到知识库
dws doc create --name "<文档名>" --content-file /tmp/<name>.md --workspace <WS_ID> --content-format markdown
# 创建空文档(仅取 nodeId 后再分步写入,适合 >200KB 兜底)
dws doc create --name "<文档名>" [--folder <ID> | --workspace <ID>] --content-format markdown
# 短纯文本字面量(< 2KB 且无换行/表格才允许)
dws doc create --name "<文档名>" --content "短内容" --content-format markdown
# stdin(heredoc / pipe)
cat report.md | dws doc create --name "月报" --content - --content-format markdown
# JSONML 起稿(决策型 / 对展示效果有要求时直接用 JSONML 构造)
# 详见 doc-create-workflow.md §JSONML 起稿判定
dws doc create --name "<文档名>" --content-file /tmp/<name>.json --content-format jsonml
# JSONML 创建到指定文件夹
dws doc create --name "<文档名>" --content-file /tmp/<name>.json --content-format jsonml --folder <DOC_FOLDER_NODE_ID>
```
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- [`./doc-update.md`](./doc-update.md)(写入管道、长 markdown、追加段落、回读补救)
- [`./style/doc-create-workflow.md`](./style/doc-create-workflow.md)(创建流程 + 回读验收)
- [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md)(草稿排版规范)
- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) / [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 节点结构与范例)
# doc export(在线文档导出为 docx)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> **路由前置判断**:用户说「下载/导出」时**必须**先用 [`./doc-info.md`](./doc-info.md) `info --node <ID> --format json` 查 `contentType`:
> - `contentType` 为 `ALIDOC`(在线文档)→ **必须用 `export`**,禁止用 `download`
> - `contentType` 为 `DOCUMENT`/`IMAGE`/`VIDEO` 等(已有文件)→ 用 `dws drive download`(详见 [`../drive.md`](../drive.md))
>
> `drive download` 只能下载**已有文件**(原样下载),`export` 是将**在线文档格式转换**后导出为 docx,两者完全不同。
---
## doc export(一体化命令)
```
Usage:
dws doc export [flags]
Example:
dws doc export --node "https://alidocs.dingtalk.com/i/nodes/xxx" --output ./exported.docx
dws doc export --node <DOC_ID> --output ~/downloads/
Flags:
--node string 要导出的文档标识,支持文档 URL 或 dentryUuid (必填)
--output string 本地保存路径,文件路径或目录 (必填)
--export-format string 导出格式,当前仅支持 docx (默认)
```
CLI 内部自动完成:提交导出任务 → 渐进式退避轮询(最多约 5 分钟)→ 成功后自动下载文件。
**只需一条命令,无需手动轮询。**
---
## doc export get(手动兜底查询任务)
```
Usage:
dws doc export get [flags]
Example:
dws doc export get --job-id <JOB_ID>
Flags:
--job-id string 导出任务 ID (必填)
```
仅在 `dws doc export` 超时或中断后,用于手动查询任务状态。通常不需要调用。
## 关键说明
- `export` 是一体化命令,一条命令自动完成提交→轮询→下载,**无需手动编排轮询**。CLI 内部使用渐进式退避轮询(最多约 5 分钟)。
- `export` 超时或中断后,CLI 会输出 `jobId`,可用 `dws doc export get --job-id <jobId>` 手动查询任务状态。
- `export` 当前仅支持钉钉在线文档(alidocs,`contentType=ALIDOC`)导出为 `docx`,**在线表格导出请使用其他命令**。
- `--output` 既可以是文件完整路径,也可以是目录(CLI 自动按文档名生成 `.docx`)。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `localPath` | 用户可访问的本地文件路径 |
| 中断时返回的 `jobId` | `export get` 的 `--job-id` |
## 常用模板
```bash
# 一体化导出(最常用)
dws doc export --node <DOC_ID> --output ./exported.docx
# 输出到目录(自动按文档名命名)
dws doc export --node <DOC_ID> --output ~/downloads/
# alidocs URL 直传
dws doc export --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --output ./exported.docx
# 兜底:超时或中断后手动查任务
dws doc export get --job-id <JOB_ID> --format json
```
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- [`./doc-info.md`](./doc-info.md)(前置:判断 contentType=ALIDOC 才走 export)
- [`../drive.md`](../drive.md)(非 ALIDOC 文件用 `dws drive download`)
# doc 文件操作(upload / download / copy / move / rename / delete + folder create)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
---
## doc upload(上传文件到钉钉文档/知识库)
```
Usage:
dws doc upload [flags]
Example:
dws doc upload --file ./report.pdf
dws doc upload --file ./slides.pptx --name "Q1汇报.pptx" --folder <DOC_FOLDER_NODE_ID>
dws doc upload --file ./data.xlsx --workspace <WS_ID> --convert
Flags:
--file string 本地文件路径 (必填)
--name string 文件显示名称 (默认使用文件名)
--folder string 目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID
--workspace string 目标知识库 ID
--convert 是否转换为钉钉在线文档
```
### 关键说明
- `upload` 是三步自动完成的流程(获取凭证 → OSS 上传 → 提交入库),无需手动分步操作。
- 支持上传任意类型文件(PDF、Office、图片等)到钉钉文档空间或知识库。
- `--convert` 可将 Office 文件转换为钉钉在线文档。
- **`doc upload` vs `drive upload`**:用户提到「知识库 / 文档空间 / workspace」→ `doc upload`;提到「钉盘 / 网盘 / 我的文件」→ `drive upload`;未明确目标时默认 `drive upload`。
- 与 [`./doc-media.md`](./doc-media.md) `media insert` 的区别:`upload` 上传到文档空间作为**独立文件**;`media insert` 作为**附件块插入到文档正文中**。
---
## doc download(下载文件到本地)
> **路由前置判断**:用户说「下载 / 导出」时**必须**先用 [`./doc-info.md`](./doc-info.md) `info --node <ID> --format json` 查 `contentType`:
>
> - `contentType` 为 `ALIDOC`(在线文档)→ 走 [`./doc-export.md`](./doc-export.md) `export`
> - `contentType` 为 `DOCUMENT`/`IMAGE`/`VIDEO` 等(已有文件)→ 走本命令 `download`
```
Usage:
dws doc download [flags]
Example:
dws doc download --node <NODE_ID>
dws doc download --node <NODE_ID> --output ./report.pdf
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --output ~/downloads/
Flags:
--node string 文件节点 ID 或 URL (必填)
--output string 本地保存路径 (文件路径或目录,必填)
```
### 关键说明
- `download` 是两步自动完成的流程(获取下载链接 → HTTP GET 下载),支持自动推断文件名;`--output` 可指定文件路径或目录。
- **严禁将「导出文档」直接路由到 `download`**——`download` 只能下载已有文件(原样下载),`export` 是将在线文档格式转换后导出为 docx。
---
## doc copy(复制文档/文件)
```
Usage:
dws doc copy [flags]
Example:
dws doc copy --node <DOC_ID> --folder <TARGET_DOC_FOLDER_NODE_ID>
dws doc copy --node <DOC_ID> --workspace <TARGET_WS_ID>
dws doc copy --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --folder <DOC_FOLDER_NODE_ID>
Flags:
--node string 文档/文件 ID 或 URL (必填)
--folder string 目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID
--workspace string 目标知识库 ID 或 URL (不传 --folder 时复制到该知识库根目录)
```
### 关键说明
- 需要对源文档有「阅读」权限,且对目标文件夹有「编辑」权限。
- `copy` 是异步任务,若任务未完成则不会返回新文档 ID;如需获取可稍后通过 [`./doc-list.md`](./doc-list.md) 查询目标文件夹。
---
## doc move(移动文档/文件)
```
Usage:
dws doc move [flags]
Example:
dws doc move --node <DOC_ID> --folder <TARGET_DOC_FOLDER_NODE_ID>
dws doc move --node <DOC_ID> --workspace <TARGET_WS_ID>
dws doc move --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --folder <DOC_FOLDER_NODE_ID>
Flags:
--node string 文档/文件 ID 或 URL (必填)
--folder string 目标文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID
--workspace string 目标知识库 ID 或 URL (不传 --folder 时移动到该知识库根目录)
```
### 关键说明
- 需要对源文档有「管理」权限,且对目标文件夹有「编辑」权限;移动后原位置的文档将不再存在。
---
## doc rename(重命名文档/文件)
```
Usage:
dws doc rename [flags]
Example:
dws doc rename --node <DOC_ID> --name "新名称"
dws doc rename --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --name "项目周报 v2"
Flags:
--node string 文档/文件 ID 或 URL (必填)
--name string 新名称 (必填)
```
### 关键说明
- 需要对文档有「编辑」权限。
- 只要意图是修改文档在列表和链接中展示的名称,统一路由到本命令;**不要**走 `drive`、`doc update` 或重新 `doc create`。
- 只有用户明确说「正文里的标题/章节标题/段落标题/H1 标题」时,才走 [`./doc-block.md`](./doc-block.md) `block update`。
---
## doc delete(删除到回收站)
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws doc delete [flags]
Example:
dws doc delete --node <DOC_ID> --format json # 查询 nodeId: dws doc search --query "..." 或 dws doc list
Flags:
--node string 文档/文件 ID 或 URL (必填)
```
权限要求: 对文档有「管理」权限。
正确流程:1.向用户展示「即将删除「文档名」到回收站」 → 2.等用户确认 → 3.执行 `dws doc delete --node <ID> --yes`。
---
## doc folder create(创建文件夹)
```
Usage:
dws doc folder create [flags]
Example:
dws doc folder create --name "项目资料"
dws doc folder create --name "子文件夹" --folder <PARENT_DOC_FOLDER_NODE_ID>
Flags:
--name string 文件夹名称 (必填)
--folder string 父文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID
--workspace string 目标知识库 ID
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `upload` | `nodeId` / URL | 上传后文件的访问链接;后续 `--node` 入参 |
| `download` | 本地文件路径 | 下载后的文件保存位置 |
| `copy` | `nodeId` / URL(异步,不保证返回) | 异步任务,未完成时不返回新文档 ID;可用 [`./doc-list.md`](./doc-list.md) 在目标文件夹查询 |
| `folder create` | `nodeId` | [`./doc-create.md`](./doc-create.md) / `list` / `upload` / `copy` / `move` 的 `--folder` |
## 常用模板
```bash
# ── upload ──
# 上传到「我的文档」根目录
dws doc upload --file ./report.pdf
# 上传到指定文件夹
dws doc upload --file ./slides.pptx --name "Q1汇报.pptx" --folder <DOC_FOLDER_NODE_ID>
# 上传到知识库并转换为在线文档
dws doc upload --file ./data.xlsx --workspace <WS_ID> --convert
# ── download(仅限非 ALIDOC 文件)──
# 自动推断文件名,下到目录
dws doc download --node <NODE_ID> --output ~/downloads/
# 指定文件路径
dws doc download --node <NODE_ID> --output ./report.pdf
# ── copy ──
# 复制到指定文件夹(异步)
dws doc copy --node <DOC_ID_OR_URL> --folder <TARGET_DOC_FOLDER_NODE_ID> --format json
# 复制到目标知识库根目录
dws doc copy --node <DOC_ID_OR_URL> --workspace <TARGET_WS_ID> --format json
# ── move ──
# 移动到指定文件夹
dws doc move --node <DOC_ID_OR_URL> --folder <TARGET_DOC_FOLDER_NODE_ID> --format json
# 移动到知识库根目录
dws doc move --node <DOC_ID_OR_URL> --workspace <TARGET_WS_ID> --format json
# ── rename ──
dws doc rename --node <DOC_ID_OR_URL> --name "新名称" --format json
# ── delete(用户确认后才加 --yes)──
dws doc delete --node <DOC_ID_OR_URL> --yes --format json
# ── folder create ──
# 在「我的文档」根目录创建
dws doc folder create --name "项目资料" --format json
# 在指定父文件夹下创建
dws doc folder create --name "子文件夹" --folder <PARENT_DOC_FOLDER_NODE_ID> --format json
# 在知识库下创建
dws doc folder create --name "项目资料" --workspace <WS_ID> --format json
```
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族)
- [`./doc-info.md`](./doc-info.md)(前置:判断 contentType 决定 download / export)
- [`./doc-export.md`](./doc-export.md)(在线文档格式转换导出 docx)
- [`./doc-media.md`](./doc-media.md)(文件作为附件块插入文档正文,与 upload 区分)
- [`./doc-search.md`](./doc-search.md) / [`./doc-list.md`](./doc-list.md)(拿 nodeId 的入口)
# doc info(获取文档元信息 + URL 解析)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> 2. [`../../url-patterns.md`](../../url-patterns.md) — 仅当用户原始 `alidocs` URL 需要 probe 时
>
> **同任务常配合**:`dws drive search` / `dws wiki node search`(先定位 nodeId)/ [`doc-read.md`](./doc-read.md)(确认是 ALIDOC 后读正文)
## 命令格式
```
Usage:
dws doc info [flags]
Example:
dws doc info --node <DOC_ID>
dws doc info --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
dws doc info --node "https://alidocs.dingtalk.com/document/edit?dentryKey=<DENTRY_KEY>"
dws doc info --node "https://alidocs.dingtalk.com/document/preview?dentryKey=<DENTRY_KEY>"
Flags:
--node string 文档 ID 或 URL (必填)
```
## 文件内容获取路由规则
> 当用户请求"分析/查看/读取某个文件内容"时,**必须先调用 `dws doc info` 获取文件元数据**,再根据返回的 `contentType` 和 `extension` 字段选择对应链路:
| contentType | extension | 操作 | 命令 |
|-------------|-----------|------|------|
| ALIDOC | adoc | 在线获取 Markdown 内容 | `dws doc read --node <ID>` |
| ALIDOC | axls | 在线读取表格数据 | `dws sheet get-all-sheets` → `dws sheet get-range` |
| ALIDOC | able | 在线查询多维表格记录 | `dws aitable get-tables` → `dws aitable query-records` |
| 非 ALIDOC | — | **不支持在线分析** | 告知用户需下载到本地后查看 |
**关键规则**:非 ALIDOC 类型文件(PDF/Word/图片/视频等)不支持在线分析,用户可以选择下载后本地查看。
## URL 识别与 DOC_ID 提取
当用户输入包含钉钉文档 URL 时,**必须先识别并提取 DOC_ID**,再判断意图。
补充:如果这是用户直接提供的原始 `alidocs` URL,必须先按 [链接规范](../../url-patterns.md#alidocs-url-类型探测流程) probe 一次确认真实类型,再判断是否继续走 `doc`。
### 支持的 URL 格式
| 格式 | 示例 | DOC_ID 提取方式 |
|------|------|----------------|
| `alidocs.dingtalk.com/i/nodes/{id}` | `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` | 取 URL 路径最后一段:`9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` |
| `alidocs.dingtalk.com/i/nodes/{id}?queryParams` | `https://alidocs.dingtalk.com/i/nodes/abc123?doc_type=wiki_doc` | 忽略 query 参数,取路径最后一段:`abc123` |
### 提取规则
1. 匹配 URL 中 `alidocs.dingtalk.com` 域名
2. 取 URL path 的最后一段作为 DOC_ID(去掉 query string 和 fragment)
3. 提取出的 DOC_ID 可直接用于所有 `--node` 参数,也可将完整 URL 传给 `--node`(CLI 会自动解析)
4. 对用户直接提供的原始 `alidocs` URL,先按 [链接规范](../../url-patterns.md#alidocs-url-类型探测流程) 执行 probe;只有 probe 确认是 `adoc` / `file` / `folder` 时,才继续走 `doc`
## ID 边界与参数映射
- `nodeId` 是 `doc` 命令的统一节点标识;文档、文件夹、文件都通过 `nodeId` 或完整 `alidocs` URL 传给 `--node` / `--folder`。
- `dentryUuid` 是 `alidocs` URL `/i/nodes/{dentryUuid}` 的最后一段,在 `doc` 场景中等价于可传入 CLI 的 `nodeId`;不要把它改写成数字 ID。
- `dentryId` 通常是纯数字,**不是** `doc` 的 `nodeId`,也不是 `doc --folder` 的目标文件夹 ID;不要把数字 `dentryId` 当作 `--node`、`--folder` 或 `--parent-id` 使用。
- `parentId` / `--parent-id` 不是 `doc` 命令参数;`doc` 里目标父文件夹统一使用 `--folder <folderNodeId或folderUrl>`,目标知识库使用 `--workspace <workspaceId或workspaceUrl>`。
- 如果上下文只有数字 `dentryId`,但用户要读、改、移动、复制、重命名文档,先通过 `dws drive search` / `dws drive list` / 用户提供的 `alidocs` URL 获取 `nodeId` / `dentryUuid`,不要用数字 `dentryId` 重试为父目录参数。
## 处理流程
```
用户输入含 alidocs.dingtalk.com URL
→ 若是用户直接提供的原始 URL,先按链接规范做 probe
→ 提取 DOC_ID(URL 路径最后一段)
→ 结合用户意图选择命令(doc 默认 read,folder 默认 list,file 默认 download)
→ 将 DOC_ID 传给 --node 参数
```
## 获取 nodeId 的三种方式(按场景选择,**无需全部执行**)
执行 `read` / `info` / `update` / `copy` / `move` / `rename` / `delete` / `block` 等所有需要 `--node` 的命令前,按以下顺序确定 nodeId 来源,**命中即停**,不要走多余的 search/list 浪费一次调用:
| 方式 | 触发条件 | 操作 |
|------|----------|------|
| **A** | 用户**直接提供文档 URL 或 nodeId** | **直接传给 `--node`**,无需额外查询;优先使用此方式 |
| **B** | 用户给出关键字 / 文档名 | `dws drive search --query "<关键字>" --format json` 或 `dws wiki node search --workspace <WS_ID> --keyword "<关键字>"` 从返回中提取 nodeId |
| **C** | 用户指向某个文件夹下的文档 | `dws drive list --workspace <WS_ID> --format json` 或 `dws wiki node list --workspace <WS_ID>` 从返回中提取 |
> **关键节省**:方式 A 命中时,禁止再调 search/list "确认一下" —— 用户提供的 URL/nodeId 本身就是权威输入。同理,`--folder` 也支持 alidocs 文件夹 URL 直传,不要先 search 把 URL 解析成纯数字 ID 再传。
## nodeId 多格式说明
所有 `--node` 参数同时支持以下格式,系统自动识别:
- **文档 ID**: 字母数字字符串,如 `9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
- **文档 URL**: `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}`,如 `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
- **文档链接(edit/preview)**: `https://alidocs.dingtalk.com/document/{edit|preview}?...&dentryKey={key}`(必须传入完整 URL,不要提取其中的 query 参数单独使用)
以下命令效果相同:
```bash
dws doc read --node 9E05BDRVQePjzLkZt2p2vE7kV63zgkYA
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA"
dws doc read --node "https://alidocs.dingtalk.com/document/edit?dentryKey=wo1g3x54FzVEJ5yE"
dws doc read --node "https://alidocs.dingtalk.com/document/preview?cid=74993670680&type=d&docKey=Pd6l2Z7V8ZWydl7M&dentryKey=rBGBr2r1HmwanAGW"
```
> **注意**:`document/edit` 和 `document/preview` 格式 URL 中的 `dentryKey` 参数值不是合法的独立 nodeId,禁止提取后单独使用,必须传入完整 URL。URL 中可能包含 `utm_source`、`chInfo` 等追踪参数,无需手动去除,直接传入完整 URL 即可。
`--folder` 参数同样支持 alidocs 文件夹 URL 或文档文件夹 nodeId。
不要把纯数字 `dentryId` 当成这里的 ID。需要父文件夹时,使用文件夹的 `nodeId` / `dentryUuid` / URL 传给 `--folder`;不能改用 `--parent-id`。如果上一步只拿到了 drive/chat 链路里的纯数字 `dentryId`、`spaceId` 或 `parent-id`,说明还没有拿到 doc 文件夹,应该省略 `--folder` 使用默认文档根目录,或先通过 `dws drive search` / `dws drive list` 找到文档文件夹 nodeId。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `contentType` + `extension` | 选择 [`./doc-read.md`](./doc-read.md) / `dws sheet ...` / `dws aitable ...` / `dws drive download`(非 ALIDOC 走存储层下载) |
| `nodeId` / `docUrl` | 后续所有 `--node` 入参 |
## 常用模板
```bash
# 标准用法(nodeId 直传)
dws doc info --node <DOC_ID> --format json
# alidocs URL 直传(CLI 自动解析)
dws doc info --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
# document/edit URL 直传(必须完整 URL,禁止单独提取 dentryKey)
dws doc info --node "https://alidocs.dingtalk.com/document/edit?dentryKey=<DENTRY_KEY>" --format json
# document/preview URL 直传
dws doc info --node "https://alidocs.dingtalk.com/document/preview?dentryKey=<DENTRY_KEY>" --format json
```
**反例**(这些 ID **不能**直接用于 `--node`):
```bash
# ❌ 纯数字 dentryId(drive 链路)
dws doc info --node 1234567890123 # 错误
# ❌ 单独的 dentryKey(必须带完整 URL)
dws doc info --node "wo1g3x54FzVEJ5yE" # 错误
# ❌ workspaceId(应作为 --workspace 用)
dws doc info --node <WS_ID> # 错误
```
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- `dws drive search` / `dws wiki node search`(前置:定位 nodeId 的搜索入口,详见 [`../drive.md`](../drive.md) / [`../wiki.md`](../wiki.md))
- [`./doc-read.md`](./doc-read.md)(contentType=ALIDOC + extension=adoc 的后续命令)
- [`../../url-patterns.md`](../../url-patterns.md)(用户原始 alidocs URL 的 probe 流程)
# doc list(遍历文件列表)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
>
> **同任务常配合**:[`doc-search.md`](./doc-search.md)(关键字检索更精准)/ [`doc-info.md`](./doc-info.md)(拿到 nodeId 后查元信息)
## 命令格式
```
Usage:
dws doc list [flags]
Example:
dws doc list
dws doc list --folder <DOC_FOLDER_NODE_ID>
dws doc list --workspace <WS_ID> --page-size 20
Flags:
--folder string 文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID
--workspace string 知识库 ID
--page-size int 每页数量
--page-token string 分页 token (从上次结果的 nextPageToken 获取)
```
## 关键说明
- 不传任何 flag 时遍历"我的文档"根目录。
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
- 需翻页时使用 `nextPageToken` → `--page-token`。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `nodes[].nodeId` | [`doc-read.md`](./doc-read.md) / [`doc-info.md`](./doc-info.md) / [`doc-update.md`](./doc-update.md) / [`doc-file-ops.md`](./doc-file-ops.md) 等所有 `--node` 入参 |
| folder 类型的 `nodeId` | 当前 `list --folder` 递归遍历;[`doc-create.md`](./doc-create.md) / [`doc-file-ops.md`](./doc-file-ops.md) 的 `--folder` |
## 常用模板
```bash
# 浏览"我的文档"根目录
dws doc list --format json
# 浏览指定文档文件夹(folder nodeId 或 alidocs 文件夹 URL)
dws doc list --folder <DOC_FOLDER_NODE_ID> --format json
dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
# 浏览指定知识库根目录(取出 workspaceId 后)
dws doc list --workspace <WS_ID> --page-size 20 --format json
# 翻页
dws doc list --folder <DOC_FOLDER_NODE_ID> --page-token <nextPageToken> --format json
```
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- [`./doc-search.md`](./doc-search.md)(关键字检索路径)
- [`./doc-info.md`](./doc-info.md)(拿到 nodeId 后查元信息)
# doc media(附件 / 图片:download / insert)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> ⚠️ **图片插入硬规则**:
> - 图片来源如果是钉盘/文档空间中的文件,**必须先下载到本地**(`dws drive download --node <图片nodeId> --output /tmp/xxx.png`),再执行 `media insert`
> - **禁止**把钉盘/文档节点 URL(如 `alidocs.dingtalk.com/i/nodes/...`)写进 Markdown `` 图片语法——这些是页面链接,不是可渲染的图片资源
> - 创建文档时需要图文并茂:先用 `doc create` 写入纯文本骨架,再对每张图片执行 `media insert`,最后用 `doc block list` 验证附件块存在
---
## doc media insert(上传附件并插入文档)
`media insert` 是三步自动完成的流程(获取附件上传凭证 → OSS 上传 → 插入附件块到文档),无需手动分步操作。
> **图片插入也走 `media insert`**:用户说"插图 / 加图片 / 嵌入图片"时使用本命令,不要走 [`doc-block.md`](./doc-block.md) `block insert`。
```
Usage:
dws doc media insert [flags]
Example:
dws doc media insert --node <DOC_ID> --file ./report.pdf
dws doc media insert --node <DOC_ID> --file ./data.bin --name "数据文件.dat" --mime-type application/octet-stream
dws doc media insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where before
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--file string 本地文件路径 (必填)
--name string 附件显示名称 (默认使用文件名)
--mime-type string 文件 MIME 类型 (默认根据扩展名推断)
--index int 插入位置索引
--where string 相对位置: before / after (配合 --ref-block)
--ref-block string 参考块 ID (配合 --where)
```
### 关键说明
- `--mime-type` 可选,不指定时根据扩展名自动推断;支持常见文件类型(PDF、Office、图片、视频、压缩包等)。
- 与 `dws drive upload` 的区别:`drive upload` 将文件上传到文档空间/知识库作为**独立文件**;`media insert` 将文件作为**附件块插入到文档正文中**。
---
## doc media download(下载文档附件)
```
Usage:
dws doc media download [flags]
Example:
dws doc media download --node <DOC_ID> --resource-id <RESOURCE_ID>
dws doc media download --node "https://alidocs.dingtalk.com/i/nodes/xxx" --resource-id <RESOURCE_ID>
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--resource-id string 附件资源 ID,可通过 dws doc block list 获取 (必填)
```
### 关键说明
- `media download` 用于获取文档正文中附件的**临时下载链接**。
- `--resource-id` 可通过 [`./doc-block.md`](./doc-block.md) `block list` 返回的 attachment 块获取,或从 [`./doc-read.md`](./doc-read.md) 返回的 OSS 链接 `/att/<resourceId>.ext` 中提取。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `media insert` `resourceId` | 附件已插入文档;可通过 [`./doc-block.md`](./doc-block.md) `block list` 查看附件块 |
| `media download` `downloadUrl` | 下载文档中的附件资源(临时链接,会过期) |
## 常用模板
```bash
# 基本用法:插入本地文件到文档
dws doc media insert --node <DOC_ID> --file ./report.pdf
# 指定附件显示名称
dws doc media insert --node <DOC_ID> --file ./data.xlsx --name "Q1数据报表.xlsx"
# 指定 MIME 类型(扩展名无法推断时)
dws doc media insert --node <DOC_ID> --file ./data.bin --name "导出数据.dat" --mime-type application/octet-stream
# 在指定块之前插入附件
dws doc media insert --node <DOC_ID> --file ./image.png --ref-block <BLOCK_ID> --where before
# 完整流程:创建文档 → 写入内容 → 插入附件
dws doc create --name "项目报告" --content "# 项目报告\n\n以下为相关附件:" --content-format markdown
# 提取 nodeId 后:
dws doc media insert --node <DOC_ID> --file ./design.pdf
dws doc media insert --node <DOC_ID> --file ./timeline.xlsx --name "项目时间线.xlsx"
# 下载文档中的附件(resourceId 从 block list 获取)
dws doc media download --node <DOC_ID> --resource-id <RESOURCE_ID>
```
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族)
- [`./doc-block.md`](./doc-block.md)(block list 取 attachment 的 resourceId)
- [`../drive.md`](../drive.md)(独立文件上传:`dws drive upload`)
- [`./style/doc-style-guideline.md` §4.9 附件与图片](./style/doc-style-guideline.md)(图示与附件使用规范)
# doc permission(文档权限:add / update / list)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> **关键区分**:
> - "把**某篇文档**授权给某人" → `doc permission add`(节点级,包括「我的文档」下的文档都支持)
> - "把**某个知识库**整体授权给某人" → `wiki member add`(容器级,但**「我的文档」个人空间不支持**)
---
## doc permission add(节点级授权)
```
Usage:
dws doc permission add [flags]
Example:
dws doc permission add --node <DOC_ID> --user uid1 --role READER
dws doc permission add --node <DOC_ID> --user uid1,uid2 --role EDITOR
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --user uid1 --role MANAGER
Flags:
--node string 目标文档/文件夹的 ID 或 URL (必填)
--user strings 被授权的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
--role string 授予的角色 (必填,大小写敏感,必须全大写): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读)
--workspace string 所属知识库 ID (选填,仅用于辅助构造返回的 docUrl,业务实际依赖 nodeId)
```
> **重要约束**:
>
> - 仅支持 USER 类型授权。
> - 角色枚举严格大写:`MANAGER` / `EDITOR` / `DOWNLOADER` / `READER`(`OWNER` 不可通过此接口添加)。
> - 操作者需在该节点具备「可编辑(EDITOR)」及以上角色(OWNER / MANAGER / EDITOR)。
> - 授权对象是文档节点本身,不需要也不应该用 `wiki member add`(那个是知识库容器级授权)。
---
## doc permission update(修改权限)
```
Usage:
dws doc permission update [flags]
Example:
dws doc permission update --node <DOC_ID> --user uid1 --role EDITOR
dws doc permission update --node <DOC_ID> --user uid1,uid2 --role READER
Flags:
--node string 目标文档/文件夹的 ID 或 URL (必填)
--user strings 目标用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
--role string 新角色 (必填,大小写敏感,必须全大写): MANAGER / EDITOR / DOWNLOADER / READER
--workspace string 所属知识库 ID (选填)
```
---
## doc permission list(列出权限)
```
Usage:
dws doc permission list [flags]
Example:
dws doc permission list --node <DOC_ID>
dws doc permission list --node <DOC_ID> --max-results 50
dws doc permission list --node <DOC_ID> --filter-role EDITOR
Flags:
--node string 目标文档/文件夹的 ID 或 URL (必填)
--workspace string 所属知识库 ID (选填)
--max-results int 返回数量上限,最大 200 (默认 50)
--filter-role string 按角色过滤: MANAGER / EDITOR / DOWNLOADER / READER (选填)
```
> 接口不支持游标分页,使用 `--max-results` 一次性拉取。
## 关键说明
- 三个命令的 `--role` / `--filter-role` 都是**大小写敏感**且**必须全大写**:`MANAGER` / `EDITOR` / `DOWNLOADER` / `READER`。
- `--user` 单次最多 30 个 userId,逗号分隔。
- 需要 userId 时,使用 `dws contact user search --query "<姓名>"`(跨产品命令)取得。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `dws contact user search` 的 `userId` | `permission add/update` 的 `--user` |
| [`./doc-search.md`](./doc-search.md) / [`./doc-list.md`](./doc-list.md) 的 `nodeId` | 三命令的 `--node` |
## 常用模板
```bash
# 查看谁有权限
dws doc permission list --node <DOC_ID> --format json
# 按角色过滤查看
dws doc permission list --node <DOC_ID> --filter-role EDITOR --format json
# 拉取更多结果(默认 50,最大 200)
dws doc permission list --node <DOC_ID> --max-results 200 --format json
# 给单人开 READER 权限
dws doc permission add --node <DOC_ID> --user <uid1> --role READER --format json
# 给多人开 EDITOR 权限(最多 30 个 uid)
dws doc permission add --node <DOC_ID> --user <uid1>,<uid2>,<uid3> --role EDITOR --format json
# 设为管理者
dws doc permission add --node <DOC_ID> --user <uid1> --role MANAGER --format json
# 修改某人角色(READER → EDITOR)
dws doc permission update --node <DOC_ID> --user <uid1> --role EDITOR --format json
# 通过 alidocs URL 直接授权
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --user <uid1> --role READER --format json
```
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令族)
- `dws contact user search`(取 userId 的入口,跨产品命令)
- [`../wiki.md`](../wiki.md)(知识库容器级授权 `wiki member add`,与本节点级权限相区分)
# doc read(读取文档内容)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> 2. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — 仅当使用 `--content-format jsonml` 时必读
>
> **同任务常配合**:[`doc-info.md`](./doc-info.md)(先解析 URL,确认 contentType=ALIDOC、extension=adoc)/ [`doc-update.md`](./doc-update.md)(读后改写)/ [`doc-block.md`](./doc-block.md)(块级精修前先读结构)
## 命令格式
```
Usage:
dws doc read [flags]
Example:
dws doc read --node <DOC_ID>
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
dws doc read --node <DOC_ID> --content-format jsonml --output ./doc.json
Flags:
--node string 文档 ID 或 URL (必填)
--content-format string 输出格式: 默认为 markdown,可选 jsonml(返回完整 JSONML 结构)
--output string 输出到本地文件路径(仅 --content-format jsonml 时生效)
```
## 关键说明
- 默认返回 **Markdown** 格式的文档内容,仅限有"下载"权限的文档。
- 返回的 Markdown 中,附件以 OSS 临时下载链接形式给出(如 `https://alidocs2.oss-cn-zhangjiakou.aliyuncs.com/res/.../att/<resourceId>.ext?Expires=...`),**链接会过期**。链接过期后从 URL 路径中提取 `<resourceId>`(即 `/att/` 后、扩展名前的 UUID 部分),用 `media download --node <DOC_ID> --resource-id <resourceId>` 重新获取下载链接。
- `--content-format jsonml` 返回完整 JSONML 结构(含 `revision`),用于无损读改写;可直接配合 [`doc-update.md`](./doc-update.md) 的 `--content-format jsonml --content-file` 写回。`revision` 仅在并发敏感场景下需要透传给 update 触发并发检查(详见下方)。
## content-format=jsonml 输出
输出 JSON 对象,包含 `revision`(版本号)和 `jsonml`(JSONML body 数组):
```json
{
"revision": 42,
"jsonml": ["root", {"sectPr": {}}, ["p", {"uuid": "abc"}, "Hello"], ...]
}
```
可直接用于 `doc update --content-format jsonml --content-file` 写回。`revision` 字段在普通改写场景下**不需要**透传——`doc update` 默认直接覆盖。仅在担心多 agent 并发覆盖时,才把 `revision` 通过 `--revision` 透传给 update 触发并发检查(详见下方 §并发安全模式)。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| Markdown 正文 | 用户可读输出 / 二次处理 |
| JSONML `jsonml` 数组 | [`doc-update.md`](./doc-update.md) 的 `--content-file` + `--content-format jsonml` |
| JSONML `revision` | [`doc-update.md`](./doc-update.md) 的 `--revision`(可选;担心被并发覆盖时使用) |
| 附件链接中的 `resourceId` | [`doc-media.md`](./doc-media.md) 的 `--resource-id`(链接过期后续期) |
## 常用模板
```bash
# 默认 Markdown 输出(最常用)
dws doc read --node <DOC_ID> --format json
# alidocs URL 直传
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
# JSONML 完整结构 → 文件(无损改写前置)
dws doc read --node <DOC_ID> --content-format jsonml --output /tmp/doc.json
# 之后修改 /tmp/doc.json 中的 jsonml 数组,再用:
# dws doc update --node <DOC_ID> --content-file /tmp/doc.json --content-format jsonml --mode overwrite
# 担心被并发覆盖时,再加 --revision <从上面 read 拿到的 revision>
```
## 并发安全模式(担心被并发覆盖时使用)
如果你担心在编辑期间别人也在改这个文档,可以把 read 返回的 `revision` 透传给 update 触发服务端并发检查:
1. `dws doc read --node <DOC_ID> --content-format jsonml --output /tmp/doc.json` — 输出 JSON 中的 `revision` 字段(如 `42`)记下来。
2. 编辑 `/tmp/doc.json` 中的 `jsonml` 字段。
3. `dws doc update --node <DOC_ID> --content-file /tmp/doc.json --content-format jsonml --mode overwrite --revision 42` — 文档若在期间被改过,服务端返回 `VersionConflict`,重做第 1 步即可。
不带 `--revision` 时服务端不做并发检查,直接覆盖;普通单 agent 编辑场景下默认不传即可。
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- [`./doc-info.md`](./doc-info.md)(前置:判断 contentType / extension)
- [`./doc-update.md`](./doc-update.md)(读后改写)
- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) / [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 节点结构)
# doc search(搜索文档)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
>
> **同任务常配合**:[`doc-list.md`](./doc-list.md)(目录遍历,互补于关键字搜索)/ [`doc-info.md`](./doc-info.md)(拿到 nodeId 后查元信息)/ [`doc-read.md`](./doc-read.md)(拿到 nodeId 后读取正文)
## 命令格式
```
Usage:
dws doc search [flags]
Example:
dws doc search --query "会议纪要"
dws doc search
dws doc search --extensions pdf,docx
dws doc search --query "方案" --created-from 1700000000000 --created-to 1710000000000
dws doc search --creator-uids uid1,uid2
dws doc search --workspace-ids wsId1,wsId2
Flags:
--query string 搜索关键词 (不传则返回最近访问)
--extensions strings 按文件扩展名过滤,不含点号,逗号分隔 (如 pdf,docx,png)。
钉钉在线文档: adoc(文字) axls(表格) appt(演示文稿) awbd(白板) adraw(画板) amind(脑图) able(多维表格) aform(收集表)
常见附件: pdf docx doc xlsx xls pptx ppt csv txt md json xml zip rar png jpg jpeg gif mp4 mp3
以上仅为参考,extensions 为开放参数,服务端支持的扩展名不限于此。不确定文件后缀时,建议不传 --extensions 让搜索返回所有类型,再从结果中按文件名后缀筛选
--created-from int 创建时间起始 (毫秒时间戳,含)
--created-to int 创建时间截止 (毫秒时间戳,含)
--visited-from int 访问时间起始 (毫秒时间戳,含)
--visited-to int 访问时间截止 (毫秒时间戳,含)
--creator-uids strings 按创建者用户 ID 过滤,逗号分隔
--editor-uids strings 按编辑者用户 ID 过滤,逗号分隔
--mentioned-uids strings 按 @提及的用户 ID 过滤,逗号分隔
--workspace-ids strings 按知识库 ID 过滤,支持知识库 URL,逗号分隔
--page-size int 每页数量
--page-token string 分页 token (从上次结果的 nextPageToken 获取)
```
## 关键说明
- 不传 `--query` 时返回最近访问列表,适合"最近文档"类意图。
- `--extensions` 是开放参数,传入服务端不识别的扩展名时不会报错(可能也搜不到);优先通过文件名后缀人工筛选。
- 多个时间戳为毫秒时间戳,注意单位(不是秒)。
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| 文档 `nodeId` / URL | [`doc-read.md`](./doc-read.md) / [`doc-info.md`](./doc-info.md) / [`doc-update.md`](./doc-update.md) / [`doc-file-ops.md`](./doc-file-ops.md) 的 `--node` |
| `createTime` / `creatorUid` | 创建时间与创建者过滤的二次检索 |
## 常用模板
```bash
# 关键字搜索(最常用)
dws doc search --query "项目周报" --format json
# 仅最近访问(不传 --query)
dws doc search --format json
# 按扩展名过滤(在线文档族 + 常见办公附件)
dws doc search --extensions adoc,axls,able,docx,xlsx,pdf
# 按创建时间窗口(毫秒时间戳)
dws doc search --query "方案" --created-from 1700000000000 --created-to 1710000000000
# 按创建者过滤(多个 uid 逗号分隔)
dws doc search --creator-uids uid1,uid2
# 按知识库范围过滤(支持知识库 URL)
dws doc search --workspace-ids wsId1,wsId2
# 翻页
dws doc search --query "周报" --page-size 30 --page-token <nextPageToken>
```
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- [`./doc-list.md`](./doc-list.md)(目录遍历替代路径)
- [`./doc-info.md`](./doc-info.md)(URL → nodeId 提取)
# doc update(更新文档内容)
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
> 2. [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md) — 改写流程(编辑形态优先级、分片 append、回读验收)
> 3. [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md) — 排版规范
> 4. [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) — 仅当使用 `--content-format jsonml` 时必读
>
> **同任务常配合**:[`doc-read.md`](./doc-read.md)(改写前必读,jsonml 模式拿当前结构;担心被并发覆盖时再取 revision)/ [`doc-block.md`](./doc-block.md)(单 block 改写优先;本命令更适合追加 / 整篇 overwrite)
## 命令格式
```
Usage:
dws doc update [flags]
Example:
dws doc update --node <DOC_ID> --content "# 追加内容" --mode append
dws doc update --node <DOC_ID> --content "# 完整替换" --mode overwrite
dws doc update --node <DOC_ID> --content-file ./part1.md --mode append
dws doc update --node <DOC_ID> --content "# 插入到第3个block前" --mode append --index 2
dws doc update --node <DOC_ID> --content-file ./body.json --content-format jsonml --mode overwrite
cat part2.md | dws doc update --node <DOC_ID> --content - --mode append
Flags:
--node string 文档 ID 或 URL (必填)
--content string 文档内容(短文本字面量);传 - 表示从 stdin 读取
--content-file string 从文件读取文档内容(UTF-8)。推荐长/多行/表格内容使用
--mode string 更新模式: overwrite=覆盖, append=追加 (必填)
--content-format string 内容格式: 默认为 markdown,可选 jsonml
--revision int 文档版本号(仅 --content-format jsonml 时生效,可选);传入后服务端做并发检查,版本不一致时返回 VersionConflict。不传则直接覆盖,不做并发检查
--fix-jsonml 启用 JSON 语法修复(括号/逗号补全),推荐 agent 调用时使用
--index int 插入位置(从 0 开始),仅在 mode=append 时生效。指定将内容插入到文档第几个 block 之前。不传时追加到末尾。block 的 index 可通过 doc block list 获取。插入成功后,该位置及之后所有 block 的 index 会依次 +1
```
## 关键说明
- `--mode` 必填,无默认值。`overwrite` **清空原内容后重写**,谨慎使用;`append` 更安全。
- 整篇 overwrite 大文档前**必须**先向用户提示风险并等待确认(详见 [`./style/doc-update-workflow.md` §4.5](./style/doc-update-workflow.md))。
- `--content` 中的换行必须是**真实换行符**(Unicode `U+000A`),不是字面量 `\n`;多行/表格/长文本优先 `--content-file` 或 `--content -`。
- **写入后必须回读**——返回 `success=true` 不等于内容真的写入完整(详见 [`./style/doc-update-workflow.md` §6](./style/doc-update-workflow.md))。
## JSONML 格式写入
使用 `--content-format jsonml` 可以 JSONML 结构直接写入文档,实现无损读写。当前仅支持 `--mode overwrite`。
**输入格式**:JSON 对象,包含 `jsonml` 字段(文本必须用 `span/data-type=text + span/data-type=leaf` 包裹,详见 [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md)):
```json
{"jsonml": ["root", {"sectPr": {}},
["p", {"uuid": "p1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "hello"]]],
["p", {"uuid": "p2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "world"]]]
]}
```
> CLI 不做结构修复——裸字符串、缺 uuid 等错误会被 validator 直接抦下。body 必须以 `["root", ...]` 为根节点,缺少会报错。如果输入来自 LLM 生成且可能有 JSON 语法错误(缺括号/逗号),加 `--fix-jsonml` 启用 JSON 语法修复。
**典型流程**(无损读改写):
1. `dws doc read --node <DOC_ID> --content-format jsonml --output ./doc.json` — 获取文档 JSONML 结构
2. 修改 `doc.json` 中的 jsonml 数组内容
3. `dws doc update --node <DOC_ID> --content-file ./doc.json --content-format jsonml --mode overwrite` — 写回
> 默认不传 `--revision`,服务端直接覆盖,不做并发检查。担心多 agent 同时改时,按下方 §并发安全模式 加 `--revision`。
**节点结构参考**:[`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)
### 并发安全模式(担心被并发覆盖时使用)
如果你担心在编辑期间别人也在改这个文档,可以传 `--revision` 触发服务端并发检查:
1. `dws doc read --node <DOC_ID> --content-format jsonml --output /tmp/doc.json` — 返回的 JSON 里有一个 `revision` 字段(比如 `42`)。
2. 编辑 `/tmp/doc.json` 里的 `jsonml` 字段。
3. `dws doc update --node <DOC_ID> --content-file /tmp/doc.json --content-format jsonml --mode overwrite --revision 42` — 如果文档在期间被改过,服务端返回 `VersionConflict`,此时重新执行第 1 步即可。
不带 `--revision` 时,服务端不做并发检查,直接覆盖。普通单 agent 编辑场景下默认不传即可。
## 内容写入管道(create / update 共用)
> **关键原则**:CLI 内置自动分片。超长内容(>30000 字符)自动按 markdown 结构切分后逐片写入,对调用方透明。写入完成后由调用方自行决定是否回读确认。
### 输入方式选择
| 场景 | 推荐方式 | 说明 |
|------|---------|------|
| 短文本(<2KB,无换行/表格/特殊字符) | `--content "..."` | 字面量传入,最简单 |
| 长文本(≥2KB)、含换行、含表格 | `--content-file ./file.md` | **必须**用文件路径,避免 shell escape 和截断 |
| 含特殊字符(`"`、`\`、`$`、`` ` ``) | `--content-file ./file.md` | 字面量传入会被 shell 转义破坏 |
| 管道/heredoc 输入 | `--content -` 或 `cat file \| dws doc ...` | 从 stdin 读取 |
### 自动分片行为
当内容超过 30000 字符时,CLI 自动执行:
1. **create**: 先创建空文档拿 `nodeId`,再按 markdown 标题边界切分后逐片 append
2. **update (overwrite)**: 第一片用 overwrite,后续片用 append
3. **update (append)**: 所有片段用 append
分片策略按优先级:H1 标题 → H2 标题 → H3 标题 → 空行(段落边界)→ 硬切(保留表格/代码块完整性)
如果某片写入超时,自动将分片大小减半重试(最小 5000 字符,低于此值报错)。
### 输出格式
写入成功后输出 JSON(混合 `[INFO]` 进度行):
```json
{"success": true, "nodeId": "xxx", "chunksWritten": 3}
```
| 字段 | 说明 |
|------|------|
| `nodeId` | 文档节点 ID,可用于后续读取或追加 |
| `chunksWritten` | 实际写入的分片数(1 = 单次写入) |
### 内容完整性验证(必读)
CLI **不会**自动执行回读验证。**你必须在文档写入完成后主动回读确认**:
1. 使用 `dws doc read --node <nodeId>` 读取写入后的文档内容
2. 检查关键段落是否完整、顺序是否正确
3. 如发现内容缺失或异常,使用 `dws doc update --mode append` 补写缺失部分
> **何时回读**:每次 create/update 操作完成后都应回读。如果是连续多次编辑同一文档,可以在全部编辑完成后统一回读一次。
### 进度输出示例
```
[INFO] 内容较长 (45000 字符),自动分片写入...
[INFO] 已创建空文档 (nodeId=abc123),开始分片写入...
[INFO] 写入分片 (1/3),15000 字符...
[INFO] 写入分片 (2/3),15000 字符...
[INFO] 写入分片 (3/3),15000 字符...
[INFO] 全部 3 个分片写入完成
{"success": true, "nodeId": "abc123", "chunksWritten": 3}
```
### CONTENT_TRUNCATED 错误
当分片写入持续超时且减半到最小阈值仍失败时,返回 `CONTENT_TRUNCATED` 错误码。应对策略:
1. 检查网络和后端服务状态
2. 已写入的部分内容可通过 `dws doc read --node <NODE_ID>` 查看
3. 从断点处手动用 `dws doc update --mode append` 继续追加
## 长 Markdown 写入
**核心规则**:含多行、表格、`\n` 或长度 >2KB 的 Markdown **必须**通过 `--content-file` 或 `--content -`(stdin)传入,禁止直接作为 `--content` 命令行字符串——shell escape 会破坏换行和表格,且命令行长度受限。
`dws doc create` 和 `dws doc update` 支持两种内容来源(`--content-file` 优先于 `--content`):
| 形式 | 说明 |
|------|------|
| `--content "..."` | 字面量(仅推荐短文本 <2KB 且无换行/表格) |
| `--content -` | 从 stdin 读取(可配合 heredoc/pipe) |
| `--content-file path` | 从文件读取(UTF-8),推荐 |
### 短/中等长度(< 200KB)— 单步写入
```bash
# 1. 把内容写入 UTF-8 文本文件:
# Linux/Mac: /tmp/<name>.md;Windows: %TEMP%\<name>.md
# 2. 一步写入:
dws doc update --node <DOC_ID> --content-file <tmp> --mode overwrite --content-format markdown
```
### 超长(> 200KB 兜底)— 分片追加
```bash
# 1. 按 markdown 标题或段落边界切成 ≤200KB 的片段(不要切断表格)
# 2. 逐个追加:
dws doc update --node <nodeId> --content-file <part> --mode append --content-format markdown
```
> **注意**:分块 append 存在静默失败风险(部分片段返回 success 但实际未写入),执行前**必须**向用户发出截断风险提示并等待确认。完整规范见 [`../../best_practices/04-document.md` «分块 append 截断风险提示»](../../best_practices/04-document.md)。
### stdin 变体
```bash
# pipe
cat report.md | dws doc update --node <DOC_ID> --content - --mode append --content-format markdown
# heredoc(真实换行,含表格)
dws doc update --node <DOC_ID> --mode append --content - --content-format markdown <<'EOF'
## 追加段落
| 列1 | 列2 |
|---|---|
| a | b |
EOF
```
## 上下文传递
| 从返回中提取 | 用于 |
|-------------|------|
| `success` + `chunksWritten` | 判断是否需要回读补救(`chunksWritten > 1` 时重点查章节顺序) |
| 错误码 `CONTENT_TRUNCATED` | 触发 [`./doc-read.md`](./doc-read.md) 查断点 + 再次 `update --mode append` |
## 常用模板
```bash
# overwrite 整段(用户已确认)
dws doc update --node <DOC_ID> --content-file /tmp/<name>.md --mode overwrite --content-format markdown
# append 末尾追加
dws doc update --node <DOC_ID> --content-file /tmp/<name>-append.md --mode append --content-format markdown
# append 到指定 block 前(index 通过 doc block list 获取)
dws doc update --node <DOC_ID> --content-file /tmp/<name>.md --mode append --index 2 --content-format markdown
# JSONML 整篇无损 overwrite(默认不做并发检查;并发敏感时加 --revision <N>)
dws doc update --node <DOC_ID> --content-file /tmp/<name>.json --content-format jsonml --mode overwrite
# 短文本字面量(<2KB 无换行)
dws doc update --node <DOC_ID> --content "## 简短追加" --mode append --content-format markdown
# stdin(pipe)
cat report.md | dws doc update --node <DOC_ID> --content - --mode append --content-format markdown
# stdin(heredoc,含表格)
dws doc update --node <DOC_ID> --mode append --content - --content-format markdown <<'EOF'
## 追加段落
| 列1 | 列2 |
|---|---|
| a | b |
EOF
```
## 参考
- [`../doc.md` §意图判断](../doc.md#意图判断)(如何路由到本命令)
- [`./doc-read.md`](./doc-read.md)(改写前必读;jsonml 模式拿当前结构,担心并发时再取 revision)
- [`./doc-block.md`](./doc-block.md)(单 block 改写更精准)
- [`./style/doc-update-workflow.md`](./style/doc-update-workflow.md)(编辑形态优先级、分片 append 风险、回读验收)
- [`./style/doc-style-guideline.md`](./style/doc-style-guideline.md)(排版规范)
- [`./format/doc-jsonml-cookbook.md`](./format/doc-jsonml-cookbook.md) / [`./format/doc-jsonml-schema.md`](./format/doc-jsonml-schema.md)(JSONML 范例 / 节点结构)
# JSONML Cookbook
> 本文档提供 `dws doc create/update --content-format jsonml` 和 `dws doc block insert/update --content-format jsonml` 的**完整可用范例**。
> 所有示例均基于真实文档 serialize 输出验证。节点结构详细定义见 [doc-jsonml-schema.md](./doc-jsonml-schema.md)。
> 合法节点类型和属性的权威参考为 `wukong/products/jsonml-schema-v2.json`。
## 决策型文档骨架范例(doc create 用)
以下是一个"方案对比汇报"的完整 JSONML 文件内容,展示摘要 callout + 彩色表格 + 状态高亮。
**可直接复制到 `/tmp/<name>.json` 后用 `dws doc create --name "..." --content-file /tmp/<name>.json --content-format jsonml` 创建。**
```json
["root", {},
["container", {"subType": "colorBlocks", "metadata": {"bgcolor": "#E8F5E9", "border": "left"}},
["p", {}, ["span", {"data-type": "text"},
["span", {"data-type": "leaf", "bold": true, "sz": 14, "szUnit": "pt"}, "✅ 推荐方案 A:上线快、依赖已有流程"]
]],
["p", {}, ["span", {"data-type": "text"},
["span", {"data-type": "leaf"}, "主要风险:权限配置需补 | 决策时限:本周五前"]
]]
],
["h2", {}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "方案对比"]]],
["table", {"colsWidth": [120, 200, 200]},
["tr", {},
["tc", {"fill": "#F5F5F5"}, ["p", {}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf", "bold": true}, "维度"]]]],
["tc", {"fill": "#E8F5E9"}, ["p", {}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf", "bold": true}, "方案 A(推荐)"]]]],
["tc", {}, ["p", {}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf", "bold": true}, "方案 B"]]]]
],
["tr", {},
["tc", {}, ["p", {}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "上线周期"]]]],
["tc", {}, ["p", {}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf", "color": "#2E7D32"}, "1 周"]]]],
["tc", {}, ["p", {}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "3 周"]]]]
],
["tr", {},
["tc", {}, ["p", {}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "风险"]]]],
["tc", {}, ["p", {}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "低"]]]],
["tc", {}, ["p", {}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf", "color": "#C62828"}, "高:需新流程审批"]]]]
]
],
["h2", {}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "下一步"]]],
["p", {}, ["span", {"data-type": "text"},
["span", {"data-type": "leaf"}, "• "],
["span", {"data-type": "leaf", "highlight": "#FFF9C4"}, "待确认"],
["span", {"data-type": "leaf"}, " @负责人 完成权限配置"]
]]
]
```
**设计要点**:
- 根节点固定 `"root"`,不是 `"body"`
- 摘要用 `container`(callout),`metadata.bgcolor` 选浅绿表示"推荐结论"
- 表格用 `table → tr → tc`(无 th/td),表头底色用 `tc` 的 `"fill"` 属性
- 关键数据着色用 leaf 的 `"color"`(绿=好 / 红=风险)
- 状态标记用 leaf 的 `"highlight"`(黄=待确认、绿=完成、红=阻塞)
- uuid 必须显式提供——CLI 不再自动补充
## ⚠️ JSONML 结构严格约束(生成时必须遵守)
每个节点是一个 JSON 数组:`[tagName, attributes?, ...children]`
- **第一个元素**是字符串,表示标签名(如 `"p"`, `"h1"`, `"span"`, `"container"`)
- **第二个元素**(可选)是一个 JSON 对象,表示属性(如 `{"uuid": "abc"}`)。如果无属性,可以直接进入子节点
- **随后的元素**是子节点,可以是纯字符串(仅限 leaf span 内),也可以是另一个 JSONML 数组
- **所有 `[` 必须有对应 `]`,所有 `{` 必须有对应 `}`,数组元素之间用 `,` 分隔,最后一个元素后不加 `,`**
常见 LLM 生成错误(务必避免):
| 错误类型 | 示例 | 后果 |
|---------|------|------|
| 缺少闭合 `]` | `["p", {}, ["span", ...]` | JSON 解析失败 |
| 多余逗号 | `["p", {},]` | JSON 解析失败 |
| 缺少逗号 | `["p", {} ["span"]]` | JSON 解析失败 |
| 引号不匹配 | `["p", {"uuid": "abc}]` | JSON 解析失败 |
## 文本节点格式(最重要)
钉钉文档的文本是**三层结构**,不是裸字符串:
```json
["p", {"uuid": "xxx"},
["span", {"data-type": "text"},
["span", {"data-type": "leaf"}, "文本内容"]
]
]
```
- **text 容器**:`["span", {"data-type": "text"}, ...leaves]` — 包裹所有文本 leaf
- **leaf 节点**:`["span", {"data-type": "leaf", ...格式属性}, "文字"]` — 实际文本,可带 bold/italic 等
- 一个 block 节点只有一个 text 容器,但可以有多个 leaf(不同格式的文字片段)
**简写**:无格式纯文本可以省略格式属性:
```json
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "纯文本"]]
```
## 核心规则
1. **每个 block 节点应有 uuid**:`["tag", {"uuid": "唯一ID"}, ...children]`
- insert 时必须提供 uuid(可自行生成任意唯一字符串,后端会自动分配正式 uuid)
- update 时 uuid **必须**与 `--block-id` 一致
- uuid 必须显式提供,不再自动补充
2. **文本必须用 span + leaf 三层结构**,不要直接写裸字符串
- ✅ `["p", {"uuid": "x"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "hello"]]]`
- ❌ `["p", {"uuid": "x"}, "hello"]` — validator 会报错,请手动包成 ✅ 的形式
- ⚠️ `["p", {"uuid": "x"}, ["text", {}, "hello"]]` — `text` 是历史 inline tag,validator 不会报错,但建议改写为 ✅ 形式以与 `dws doc read --content-format jsonml` 的输出保持一致
3. **attrs 对象必须存在**(即使为空):`["p", {}, ...]` 不能省略 `{}`
> **严格模式(缺省)**:CLI 不做结构修复,裸字符串等错误会被 validator 以 `JSONPath + Suggestion` 形式逐条报错。如果输入来自 LLM 且可能有 JSON 语法错误(缺括号/逗号),用 `--fix-jsonml` 启用 JSON 语法修复。
## 段落 (p)
```bash
# 纯文本段落
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "new1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "这是一段普通文本"]]]'
# 带格式文本(多个 leaf)
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "new2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf", "bold": true}, "加粗"], ["span", {"data-type": "leaf"}, "普通"], ["span", {"data-type": "leaf", "italic": true}, "斜体"]]]'
# 多行文本(每行一个 p,同一 p 内的多个 span 不会换行)
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "line1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf", "bold": true}, "第一行标题"]]]' \
--element '["p", {"uuid": "line2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "第二行正文内容"]]]'
# 带链接(link 是与 text 并列的子节点)
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "new3"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "请访问"]], ["a", {"href": "https://example.com"}, "链接文字"]]'
```
**leaf 支持的格式属性**:
- `bold: true` — 加粗
- `italic: true` — 斜体
- `underline: {"value": "single"}` — 下划线(value: `single`/`dash`/`wave`/`double`/`none`,可选 `color`)
- `strike: true` — 删除线
- `dstrike: true` — 双删除线
- `color: "#ff0000"` — 文字颜色(`#rrggbb` 格式)
- `highlight: "#ffff00"` — 高亮背景色
- `sz: 14` / `szUnit: "pt"` — 字号(szUnit 默认 `"px"`,推荐显式写 `"pt"`)
`fonts: {"ascii": "Arial", "eastAsia": "SimHei"}` — 字体(四分区:ascii/hAnsi/cs/eastAsia,值必须使用 font-family 名称,见下方字体表)
- `vertAlign: "superscript"` — 上标(`"subscript"` 下标,`"baseline"` 基线)
- `spacing: 2` — 字间距(单位 pt)
**字体名称映射**(`fonts` 字段必须使用 font-family 值,不能写中文名):
| 用户说法 | font-family 值 | 用户说法 | font-family 值 |
|---------|---------------|---------|---------------|
| 宋体 | `SimSun` | 黑体 | `SimHei` |
| 微软雅黑 | `Microsoft YaHei` | 微软雅黑UI | `Microsoft YaHei UI` |
| 仿宋 | `FangSong` | 仿宋_GB2312 | `FangSong_GB2312` |
| 楷体 | `KaiTi` | 楷体_GB2312 | `KaiTi_GB2312` |
| 等线 | `DengXian` | 新宋体 | `NSimSun` |
| 宋体-简 | `SimSun SC` | 宋体-繁 | `SimSun TC` |
| 黑体-简 | `Heiti SC` | 黑体-繁 | `Heiti TC` |
| 华文宋体 | `STSong` | 华文黑体 | `STHeiti` |
| 华文楷体 | `STKaiti` | 华文仿宋 | `STFangsong` |
| 华文中宋 | `STZhongsong` | 华文行楷 | `STXingkai` |
| 华文隶书 | `STLiti` | 华文新魏 | `STXinwei` |
| 华文细黑 | `STXihei` | 华文琥珀 | `STHupo` |
| 苹方-简 | `PingFang SC` | 苹方-繁 | `PingFang TC` |
| 苹方-港 | `PingFang HK` | 冬青黑-简 | `Hiragino Sans GB` |
| 兰亭黑-简 | `Lantinghei SC` | 兰亭黑-繁 | `Lantinghei TC` |
| 凌慧体-简 | `LingWai SC` | 幼圆 | `YouYuan` |
| 思源黑体 | `Source Han Sans CN` | 思源宋体 | `Source Han Serif CN` |
| 思源等宽 | `Source Han Mono SC` | 思源黑体Regular | `Source Han Sans CN Regular` |
| 阿里普惠体2.0 | `"Alibaba PuHuiTi 2.0"` | 阿里普惠体3.0 | `"Alibaba PuHuiTi 3.0"` |
| 钉钉进步体 | `DingTalk JinBuTi` | Adobe仿宋 | `Adobe 仿宋 Std` |
| 方正小标宋_GBK | `FZXiaoBiaoSong-B05` | 方正小标宋简体 | `FZXiaoBiaoSong-B05S` |
| 方正黑体 | `FZHei-B01S` | 方正楷体 | `FZKai-Z03S` |
| 方正仿宋 | `FZFangSong-Z02S` | 方正仿宋_GBK | `FZFangSong-Z02` |
| PMingLiU | `PMingLiU` | — | — |
**英文字体**(font-family 值即为字体名):
`Arial` ・ `Calibri` ・ `Cambria` ・ `Centaur` ・ `Comfortaa` ・ `Comic Sans MS` ・ `Courier New` ・ `Franklin Gothic` ・ `Garamond` ・ `Georgia` ・ `Helvetica` ・ `Impact` ・ `Lora` ・ `Lucida Sans` ・ `Merriweather` ・ `Montserrat` ・ `Nunito` ・ `Oswald` ・ `Playfair Display` ・ `Roboto` ・ `Spectral` ・ `Times New Roman` ・ `Trebuchet MS` ・ `Verdana`
> **规则**:优先从上表匹配;用户指定的字体不在列表时,使用该字体在操作系统中的真实 font-family 名称(如"更纱黑体" → `Sarasa Gothic SC`)。
**leaf 组合示例**:
```json
["span", {"data-type": "leaf", "bold": true, "color": "#C62828", "sz": 16, "szUnit": "pt"}, "红色加粗大字"]
["span", {"data-type": "leaf", "strike": true, "color": "#9E9E9E"}, "已废弃内容"]
["span", {"data-type": "leaf", "vertAlign": "superscript"}, "[1]"]
["span", {"data-type": "leaf", "fonts": {"ascii": "Courier New", "eastAsia": "DengXian"}}, "等宽字体"]
```
**段落级排版属性**(写在 p/h1-h6 的 attrs 上):
- `jc: "center"` — 对齐(`left`/`center`/`right`/`both`/`justify`)
- `spacing: {"line": 1.5, "lineRule": "auto"}` — 行距(lineRule=auto 时 line 为倍数:1=单倍、1.5=1.5倍、2=双倍)
- `spacing: {"before": 12, "after": 8}` — 段前/段后间距(单位 pt)
- `ind: {"firstLine": 32}` — 首行缩进(≈ 2 中文字符)
- `ind: {"left": 96}` — 左缩进
**段落排版示例**:
```json
["p", {"uuid": "p1", "jc": "center"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "居中段落"]]]
["p", {"uuid": "p2", "spacing": {"line": 1.5, "lineRule": "auto"}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "1.5倍行距"]]]
["p", {"uuid": "p3", "spacing": {"line": 2, "lineRule": "auto", "before": 12, "after": 8}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "双倍行距+段前后间距"]]]
```
## 标题 (h1-h6)
```bash
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["h1", {"uuid": "new4"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "一级标题"]]]'
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["h2", {"uuid": "new5"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "二级标题"]]]'
# 更新已有标题
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content-format jsonml \
--element '["h2", {"uuid": "<BLOCK_ID>"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "修改后的标题"]]]'
```
## 列表 (list)
列表在 JSONML 中是 **带 `list` 属性的 `p` 节点**,不是独立 tag。
```bash
# 无序列表项
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "li1", "list": {"listId": "mylist1", "level": 0, "isOrdered": false}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "无序列表第一项"]]]'
# 有序列表项(仅第一项设 start,后续项不设,系统自动递增)
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "li2", "list": {"listId": "mylist2", "level": 0, "isOrdered": true, "start": 1}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "有序列表第一项"]]]'
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "li2b", "list": {"listId": "mylist2", "level": 0, "isOrdered": true}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "有序列表第二项"]]]'
# 缩进子项(level: 1)
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "li3", "list": {"listId": "mylist2", "level": 1, "isOrdered": true}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "子列表项"]]]'
# 待办列表(checkbox)
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "li4", "list": {"listId": "todo1", "level": 0, "isOrdered": false, "isTaskList": true, "isChecked": false}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "待办事项"]]]'
```
## 引用 (blockquote)
引用是 **带 `quote` 属性的 `p` 节点**。
```bash
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "q1", "quote": true}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "这是一段引用文字"]]]'
```
## 高亮块 / Callout (container)
```bash
# 蓝色高亮块
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["container", {"uuid": "co1", "subType": "colorBlocks", "metadata": {"bgcolor": "#E8F2FE", "border": "#B3D4FC"}}, ["p", {"uuid": "co1p1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "这是一段提示内容"]]]]'
# 黄色警告块(多段落)
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["container", {"uuid": "co2", "subType": "colorBlocks", "metadata": {"bgcolor": "#FFF2CC", "border": "#FFE599"}}, ["p", {"uuid": "co2p1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "⚠️ 注意事项"]]], ["p", {"uuid": "co2p2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "请仔细阅读以下内容"]]]]'
```
**常用颜色预设**:
| 含义 | bgcolor | border |
|------|---------|--------|
| 信息(蓝) | `#E8F2FE` | `#B3D4FC` |
| 成功(绿) | `#E6F7E6` | `#B7EB8F` |
| 警告(黄) | `#FFF2CC` | `#FFE599` |
| 危险(红) | `#FFF1F0` | `#FFA39E` |
| 紫色 | `#F3E8FF` | `#D3ADF7` |
## 代码块 (code)
代码内容存在 attrs.code 中,不需要 text/leaf 子节点。
```bash
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["code", {"uuid": "cd1", "syntax": "javascript", "code": "function hello() {\n return \"world\";\n}"}]'
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["code", {"uuid": "cd2", "syntax": "python", "code": "print(\"hello\")", "showLineNumber": true, "theme": "dracula"}]'
```
## 分割线 (hr)
```bash
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["hr", {"uuid": "hr1"}]'
```
## 表格 (table)
> colsWidth 单位为 **pt**(页宽约 650pt)。如配合 `tblW: {"type": "pct"}` 则为百分比权重。
```bash
# 2行2列表格(各列 200pt)
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["table", {"uuid": "tb1", "colsWidth": [200, 200]}, ["tr", {"uuid": "tr1"}, ["tc", {"uuid": "tc1", "colSpan": 1, "rowSpan": 1}, ["p", {"uuid": "tcp1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "标题A"]]]], ["tc", {"uuid": "tc2", "colSpan": 1, "rowSpan": 1}, ["p", {"uuid": "tcp2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "标题B"]]]]], ["tr", {"uuid": "tr2"}, ["tc", {"uuid": "tc3", "colSpan": 1, "rowSpan": 1}, ["p", {"uuid": "tcp3"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "数据1"]]]], ["tc", {"uuid": "tc4", "colSpan": 1, "rowSpan": 1}, ["p", {"uuid": "tcp4"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "数据2"]]]]]]]'
```
> 表格较复杂时建议写入文件后用 `--element "$(cat table.json)"` 传入。
## 图片 (img)
> `img` 是 inline 元素,必须包裹在 `p` 段落中才能作为 block 插入。
```bash
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["p", {"uuid": "p-img1"}, ["img", {"uuid": "img1", "src": "https://example.com/photo.png", "width": 400, "height": 300}], ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, ""]]]'
```
## 分栏布局 (columns)
分栏复用 table tag,通过 `sr: true` 区分。分栏的 `tc` 可设置 `fill`(背景色)和 `border`(边框)属性提升视觉效果。
```bash
# 两栏布局(带背景色)
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["table", {"uuid": "col1", "sr": true, "colsWidth": [300, 300]}, ["tr", {"uuid": "coltr"}, ["tc", {"uuid": "coltc1", "fill": "#EEF6FF", "vAlign": "top"}, ["p", {"uuid": "colp1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "左栏内容"]]]], ["tc", {"uuid": "coltc2", "fill": "#FFF3E0", "vAlign": "top"}, ["p", {"uuid": "colp2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "右栏内容"]]]]]]'
```
**分栏视觉属性**:
- `fill` — 单元格背景色(推荐淡色,如 `#EEF6FF` / `#FFF8E1` / `#F3E5F5`)
- `border` — 边框配置(可选)
- 分栏建议始终设置 `fill` 背景色,纯白底分栏视觉上与普通段落无异,读者无法感知分栏结构
## 嵌入块 (embed)
通用文件/iframe 嵌入。`embed` 是 void 块,仅含 attrs,无子节点。
```bash
# 嵌入文件预览
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["embed", {"uuid": "em1", "name": "design.pdf", "type": "pdf", "src": "https://example.com/design.pdf", "size": 524288, "viewType": "preview", "previewSize": {"height": 600}}]'
```
**关键 attrs**:
- `src`(**必填**)— 资源 URL
- `type` — `pdf` / `xlsx` / `html` 等
- `name` — 展示名
- `previewSize.height` — 预览高度(px)
## 在线视频 (onlineVideo)
外链视频(B 站 / 优酷 / 自定义 mp4 等)。
```bash
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["onlineVideo", {"uuid": "ov1", "src": "https://player.bilibili.com/player.html?aid=12345", "type": "bilibili", "poster": "https://example.com/poster.jpg"}]'
```
**关键 attrs**:
- `src`(**必填**)— 视频播放页或 mp4 URL
- `type` — 平台标识(`bilibili` / `youku` / `mp4` 等)
- `poster` — 封面图 URL
## 卡片 (card)
群名片 / 应用卡片等富交互卡片。`cardType` 决定渲染形态,`metadata` 内容随类型变化。
```bash
# 群名片
dws doc block insert --node <DOC_ID> --content-format jsonml \
--element '["card", {"uuid": "cd1", "cardType": "groupChatCard", "metadata": {"id": "63953109506", "name": "测试组", "inviteUrl": "https://qr.dingtalk.com/...", "expires": 1810865989}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, ""]]]'
```
> 注意:服务端 serialize 出来的 card 通常带一个空的 span/leaf 占位子节点,建议保留以避免反序列化差异。
## 目录 (toc)
目录块 attrs 上必带 4 个字段:`title` / `mode` / `styles` / `content`。如果不知道怎么填,**最简方式**是先在 web 端插入一个 toc,然后 `block list --content-format jsonml` 把现成结构拿下来改。
```bash
dws doc block insert --node <DOC_ID> --content-format jsonml --element '
["toc", {
"uuid": "toc1",
"title": "目录",
"mode": "outline",
"styles": {
"global": {"maxLevel": 5, "bgColor": "#F0EBF7", "css": {}},
"title": {"font": "DingTalk JinBuTi", "color": "#6940A5", "numbering": true, "css": {"fontWeight": "normal"}},
"item": {"symbol": "disc", "css": {}}
},
"content": []
}]'
```
**关键 attrs**:
- `mode` — `outline`(大纲)/ `column`(分栏)
- `styles.global.maxLevel` — 最大显示层级
- `content` — 目录条目数组;**留空数组即可**,服务端会基于文档 heading 自动重建
## 引用块 (refblock)
引用另一文档的内容片段。`refblock` 像容器一样包子节点。
```bash
dws doc block insert --node <DOC_ID> --content-format jsonml --element '
["refblock", {"uuid": "rb1", "docKey": "OTHER_DOC_NODE_ID", "refblockUUID": "BLOCK_UUID_IN_OTHER_DOC"},
["p", {"uuid": "rb1p1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "(引用预览内容,服务端会回填)"]]]
]'
```
**关键 attrs**:
- `docKey` — 被引用文档的 nodeId
- `refblockUUID` — 被引用块的 uuid
> 引用块的子节点是「快照」,真实内容由服务端按 docKey/refblockUUID 拉取覆写。
## 表格单元格嵌套块 (tableCell with nested blocks)
`tc` 的子节点是**块级节点**(不仅是 `p`)。可以塞多个段落、列表、代码块、甚至嵌套表格。
```bash
# 单元格内含多段落 + 代码块
dws doc block insert --node <DOC_ID> --content-format jsonml --element '
["table", {"uuid": "tbn1", "colsWidth": [400]},
["tr", {"uuid": "tbn1r1"},
["tc", {"uuid": "tbn1c1", "colSpan": 1, "rowSpan": 1},
["p", {"uuid": "tbn1p1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "标题段落"]]],
["p", {"uuid": "tbn1p2", "list": {"listId": "tbn1list", "level": 0, "isOrdered": false}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "列表项 1"]]],
["p", {"uuid": "tbn1p3", "list": {"listId": "tbn1list", "level": 0, "isOrdered": false}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "列表项 2"]]],
["code", {"uuid": "tbn1code", "syntax": "bash", "code": "echo hello"}]
]
]
]'
```
**规则**:
- `tc` 子节点 **必须是块节点数组**,不能直接放 `span` 或裸字符串
- 至少包含一个 `["p", {...}, ...]`(即使空也要),否则单元格无法渲染光标
- 单元格可嵌套 `table`,但嵌套时务必保证内层每个 `tc` 也满足上述规则
## Update 操作注意事项
1. **uuid 必须与 --block-id 一致**
2. Update 是**整块替换**,不是 patch — 需提供完整节点结构
3. 推荐流程:先 `block list --content-format jsonml --block-id <ID>` 获取当前结构,修改后写回
```bash
# 典型 update 流程
# 1. 获取当前结构
dws doc block list --node <DOC_ID> --content-format jsonml --block-id <BLOCK_ID>
# 2. 修改后写回(uuid 不变)
dws doc block update --node <DOC_ID> --block-id <BLOCK_ID> --content-format jsonml \
--element '["p", {"uuid": "<BLOCK_ID>"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "修改后的内容"]]]'
```
## 常见错误
| 错误写法 | 问题 | 正确写法 |
|---------|------|---------|
| `["p", {}, "文字"]` | 裸字符串。validator 会报 `段落子节点不能是裸字符串`,请手动包成右侧形式 | `["p", {}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "文字"]]]` |
| `["p", {}, ["text", {}, "文字"]]` | `text` 是历史 inline tag,validator 不报错但服务端实际渲染的 canonical 形式是 span/leaf;为与 `doc read` 输出一致,建议改写 | 同上,用 span + data-type |
| `["callout", {}, ...]` | 不存在 callout tag | `["container", {"subType": "colorBlocks", ...}, ...]` |
| `["list", {}, ...]` | 不存在 list tag | `["p", {"list": {...}}, ...]` |
| `["blockquote", {}, ...]` | 不存在 blockquote tag | `["p", {"quote": true}, ...]` |
| `["ul", {}, ["li", ...]]` | 不存在 ul/li tag | 多个 `["p", {"list": {...}}, ...]` |
## 快捷模板
为方便使用,以下是最常用节点的最小完整模板:
```
纯文本段落: ["p", {"uuid":"U"}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "TEXT"]]]
标题: ["h2", {"uuid":"U"}, ["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "TITLE"]]]
代码块: ["code", {"uuid":"U", "syntax":"LANG", "code":"CODE"}]
分割线: ["hr", {"uuid":"U"}]
```
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "DingTalk Document JSONML Body Schema",
"description": "钉钉文档 body JSONML 的结构校验 schema",
"type": "array",
"items": { "$ref": "#/definitions/blockNode" },
"definitions": {
"blockNode": {
"oneOf": [
{ "$ref": "#/definitions/paragraph" },
{ "$ref": "#/definitions/heading" },
{ "$ref": "#/definitions/hr" },
{ "$ref": "#/definitions/table" },
{ "$ref": "#/definitions/code" },
{ "$ref": "#/definitions/container" },
{ "$ref": "#/definitions/embed" },
{ "$ref": "#/definitions/onlineVideo" },
{ "$ref": "#/definitions/card" },
{ "$ref": "#/definitions/toc" },
{ "$ref": "#/definitions/refblock" }
]
},
"inlineContent": {
"oneOf": [
{ "type": "string" },
{ "$ref": "#/definitions/textNode" },
{ "$ref": "#/definitions/link" },
{ "$ref": "#/definitions/image" },
{ "$ref": "#/definitions/mention" },
{ "$ref": "#/definitions/formula" },
{ "$ref": "#/definitions/sticker" },
{ "$ref": "#/definitions/inlineCode" },
{ "$ref": "#/definitions/br" }
]
},
"textNode": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": [
{ "type": "string", "const": "text" },
{ "$ref": "#/definitions/textMarks" },
{ "type": "string" }
]
},
"textMarks": {
"type": "object",
"properties": {
"bold": { "type": "boolean" },
"italic": { "type": "boolean", "const": true },
"strike": { "type": "boolean" },
"dstrike": { "type": "boolean" },
"underline": {
"type": "object",
"properties": {
"value": { "type": "string", "enum": ["single", "dash", "wave", "double", "none"] },
"color": { "type": "string" }
},
"required": ["value"]
},
"color": { "type": "string" },
"highlight": { "type": "string" },
"shd": {
"type": "object",
"properties": {
"val": { "type": "string" },
"color": { "type": "string" },
"fill": { "type": "string" }
}
},
"sz": { "type": "number" },
"szUnit": { "type": "string", "enum": ["px", "pt"], "default": "px" },
"fonts": {
"type": "object",
"properties": {
"ascii": { "type": "string" },
"hAnsi": { "type": "string" },
"cs": { "type": "string" },
"eastAsia": { "type": "string" }
}
},
"vertAlign": { "type": "string", "enum": ["superscript", "subscript", "baseline"] },
"spacing": { "type": "number", "description": "字间距,单位 pt" }
},
"additionalProperties": false
},
"paragraphAttrs": {
"type": "object",
"properties": {
"jc": { "type": "string", "enum": ["left", "center", "right", "both", "distribute", "justify"] },
"ind": {
"type": "object",
"properties": {
"left": { "type": "number" },
"start": { "type": "number" },
"leftChars": { "type": "number" },
"right": { "type": "number" },
"end": { "type": "number" },
"rightChars": { "type": "number" },
"hanging": { "type": "number" },
"hangingChars": { "type": "number" },
"firstLine": { "type": "number" },
"firstLineChars": { "type": "number" }
},
"additionalProperties": false
},
"spacing": {
"type": "object",
"properties": {
"line": { "type": "number" },
"before": { "type": "number" },
"beforeLines": { "type": "number" },
"beforeAutospacing": { "type": "boolean" },
"after": { "type": "number" },
"afterLines": { "type": "number" },
"afterAutospacing": { "type": "boolean" },
"lineRule": { "type": "string", "enum": ["atLeast", "auto", "exact"] }
},
"additionalProperties": false
},
"shd": {
"type": "object",
"properties": {
"val": { "type": "string" },
"color": { "type": "string" },
"fill": { "type": "string" }
}
},
"blockquote": { "type": "boolean" },
"list": { "$ref": "#/definitions/listProperties" },
"refs": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": true
},
"listProperties": {
"type": "object",
"required": ["listId", "level"],
"properties": {
"listId": { "type": "string" },
"level": { "type": "integer", "minimum": 0 },
"isOrdered": { "type": "boolean", "default": false },
"isTaskList": { "type": "boolean", "default": false },
"isChecked": { "type": "boolean" },
"isCanceled": { "type": "boolean" },
"start": { "type": "integer", "minimum": 1 },
"listStyleType": { "type": "string" },
"hideSymbol": { "type": "boolean" },
"listStyle": {
"type": "object",
"properties": {
"format": { "type": "string", "enum": ["bullet", "decimal", "decimalZero", "lowerLetter", "lowerRoman", "upperLetter", "upperRoman", "chineseCountingThousand"] },
"text": { "type": "string" },
"align": { "type": "string", "enum": ["left", "start", "end", "center", "both", "right", "distribute"] }
},
"required": ["format", "text", "align"]
}
},
"additionalProperties": true
},
"paragraph": {
"type": "array",
"minItems": 2,
"items": [
{ "type": "string", "const": "p" },
{ "$ref": "#/definitions/paragraphAttrs" }
],
"additionalItems": { "$ref": "#/definitions/inlineContent" }
},
"heading": {
"type": "array",
"minItems": 2,
"items": [
{ "type": "string", "enum": ["h1", "h2", "h3", "h4", "h5", "h6"] },
{ "$ref": "#/definitions/paragraphAttrs" }
],
"additionalItems": { "$ref": "#/definitions/inlineContent" }
},
"hr": {
"type": "array",
"minItems": 1,
"maxItems": 2,
"items": [
{ "type": "string", "const": "hr" },
{
"type": "object",
"properties": {
"type": { "type": "string", "enum": ["single", "dotted", "dashed", "double", "wave", "doubleWave", "dotDash", "dotDotDash", "custom", "thickThinSmallGap", "thinThickThinMediumGap", "dashStroked", "dashDotStroked", "widthDoubleWave", "widthWave", "roundDot"] },
"sz": { "type": "number", "default": 1 },
"color": { "type": "string" },
"width": { "oneOf": [{ "type": "number" }, { "type": "string" }] }
},
"additionalProperties": true
}
]
},
"table": {
"type": "array",
"minItems": 2,
"items": [
{ "type": "string", "const": "table" },
{
"type": "object",
"properties": {
"colsWidth": { "type": "array", "items": { "type": "number" } },
"sr": { "type": "boolean" },
"jc": { "type": "string" },
"spacing": { "type": "number" }
}
}
],
"additionalItems": { "$ref": "#/definitions/tableRow" }
},
"tableRow": {
"type": "array",
"minItems": 2,
"items": [
{ "type": "string", "const": "tr" },
{ "type": "object" }
],
"additionalItems": { "$ref": "#/definitions/tableCell" }
},
"tableCell": {
"type": "array",
"minItems": 2,
"items": [
{ "type": "string", "const": "tc" },
{
"type": "object",
"properties": {
"colSpan": { "type": "integer", "minimum": 1, "default": 1 },
"rowSpan": { "type": "integer", "minimum": 1, "default": 1 },
"fill": { "type": "string" },
"vAlign": { "type": "string", "enum": ["top", "middle", "bottom"], "default": "middle" }
},
"additionalProperties": true
}
],
"additionalItems": { "$ref": "#/definitions/blockNode" }
},
"code": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": [
{ "type": "string", "const": "code" },
{
"type": "object",
"properties": {
"code": { "type": "string", "default": "" },
"syntax": { "type": "string", "default": "plaintext" },
"theme": { "type": "string", "enum": ["default", "light", "dracula", "github", "cobalt", "atomOneDark", "oneLightPro", "nightOwl", "githubDark", "realDracula"], "default": "default" },
"wrap": { "type": "boolean", "default": true },
"showLineNumber": { "type": "boolean", "default": true },
"title": { "type": "string", "maxLength": 1000 },
"fold": { "type": "boolean", "default": false }
},
"additionalProperties": true
}
]
},
"container": {
"type": "array",
"minItems": 2,
"items": [
{ "type": "string", "const": "container" },
{
"type": "object",
"required": ["subType"],
"properties": {
"subType": { "type": "string" },
"metadata": { "type": "object" }
}
}
],
"additionalItems": { "$ref": "#/definitions/blockNode" }
},
"embed": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": [
{ "type": "string", "const": "embed" },
{
"type": "object",
"properties": {
"name": { "type": "string" },
"type": { "type": "string" },
"src": { "type": "string" },
"size": { "type": "number" },
"viewType": { "type": "string" },
"previewSize": { "type": "object", "properties": { "height": { "type": "number" } } }
},
"additionalProperties": true
}
]
},
"onlineVideo": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": [
{ "type": "string", "const": "onlineVideo" },
{
"type": "object",
"properties": {
"src": { "type": "string" },
"type": { "type": "string" },
"poster": { "type": "string" }
},
"additionalProperties": true
}
]
},
"card": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": [
{ "type": "string", "const": "card" },
{
"type": "object",
"properties": {
"cardType": { "type": "string" },
"metadata": { "type": "object" },
"height": { "type": "number" }
},
"additionalProperties": true
}
]
},
"toc": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": [
{ "type": "string", "const": "toc" },
{
"type": "object",
"required": ["title", "mode", "styles", "content"],
"properties": {
"title": { "type": "string" },
"mode": { "type": "string", "enum": ["outline", "column"] },
"styles": {
"type": "object",
"required": ["global", "title", "item"],
"properties": {
"global": {
"type": "object",
"required": ["maxLevel", "bgColor"],
"properties": {
"maxLevel": { "type": "integer", "minimum": 1 },
"bgColor": { "type": "string" },
"css": { "type": "object" }
}
},
"title": {
"type": "object",
"properties": {
"font": { "type": "string" },
"color": { "type": "string" },
"numbering": { "type": "boolean" },
"css": { "type": "object" }
}
},
"item": {
"type": "object",
"properties": {
"symbol": { "type": "string", "enum": ["disc", "none"] },
"css": { "type": "object" }
}
}
}
},
"content": {
"type": "array",
"items": { "$ref": "#/definitions/tocItem" }
}
}
}
]
},
"tocItem": {
"type": "object",
"required": ["uuid", "anchorId", "level", "children"],
"properties": {
"uuid": { "type": "string" },
"anchorId": { "oneOf": [{ "type": "string" }, { "type": "null" }] },
"level": { "type": "integer" },
"children": { "type": "array", "items": { "$ref": "#/definitions/tocItem" } },
"text": { "type": "string" }
}
},
"refblock": {
"type": "array",
"minItems": 2,
"items": [
{ "type": "string", "const": "refblock" },
{
"type": "object",
"properties": {
"docKey": { "type": "string" },
"refblockUUID": { "type": "string" }
},
"additionalProperties": true
}
],
"additionalItems": { "$ref": "#/definitions/blockNode" }
},
"link": {
"type": "array",
"minItems": 2,
"items": [
{ "type": "string", "const": "a" },
{
"type": "object",
"properties": {
"href": { "type": "string" },
"cardInfo": { "type": "object" },
"metadata": { "type": "object" }
},
"additionalProperties": true
}
],
"additionalItems": { "$ref": "#/definitions/inlineContent" }
},
"image": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": [
{ "type": "string", "const": "img" },
{
"type": "object",
"properties": {
"src": { "type": "string" },
"width": { "type": "number" },
"height": { "type": "number" },
"rectClip": { "type": "object" },
"rotation": { "type": "number" },
"radius": { "type": "number" },
"shadow": { "type": "string" }
},
"additionalProperties": true
}
]
},
"mention": {
"type": "array",
"minItems": 2,
"items": [
{ "type": "string", "const": "span" },
{
"type": "object",
"required": ["data-type"],
"properties": {
"data-type": { "type": "string", "const": "mention" },
"id": { "type": "string" },
"name": { "type": "string" },
"login": { "type": "string" },
"metadata": { "type": "object" }
},
"additionalProperties": true
}
],
"additionalItems": { "type": "string" }
},
"formula": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": [
{ "type": "string", "const": "tag" },
{
"type": "object",
"required": ["tagType", "metadata"],
"properties": {
"tagType": { "type": "string", "const": "formula" },
"metadata": {
"type": "object",
"required": ["formula"],
"properties": {
"formula": { "type": "string" }
}
}
}
}
]
},
"sticker": {
"type": "array",
"minItems": 2,
"items": [
{ "type": "string", "const": "span" },
{
"type": "object",
"required": ["data-type"],
"properties": {
"data-type": { "type": "string", "const": "emoji" },
"code": { "type": "string" },
"newCode": { "type": "object" }
},
"additionalProperties": true
}
]
},
"inlineCode": {
"type": "array",
"minItems": 2,
"items": [
{ "type": "string", "const": "inlineCode" },
{ "type": "object", "properties": { "bgColor": { "type": "string" } }, "additionalProperties": true }
],
"additionalItems": { "$ref": "#/definitions/inlineContent" }
},
"br": {
"type": "array",
"minItems": 2,
"maxItems": 3,
"items": [
{ "type": "string", "const": "br" },
{ "type": "object" },
{ "type": "string" }
]
}
}
}
# 文档 JSONML 节点结构参考
> **权威定义**:合法节点类型、允许的子节点和属性约束以 `wukong/products/jsonml-schema-v2.json` 为准。本文为可读版摘要,若与 schema-v2.json 冲突以后者为准。
本文档定义钉钉文档 body JSONML 中所有节点类型的结构,供 agent 编辑文档时参考。
**写法范例**见 [doc-jsonml-cookbook.md](./doc-jsonml-cookbook.md);本文聚焦字段定义、枚举与约束。
## 格式说明
JSONML 是文档内容树的序列化格式:
```
[tag, attrs?, ...children]
```
- `tag` — 字符串,节点类型标识
- `attrs` — 可选对象,节点属性(写入时**强烈建议**始终传 `{}` 而非省略)
- `children` — 子节点数组;可以是嵌套节点或(仅 inline 上下文中)字符串
文档 body 是一个以 `"root"` 为根的 JSONML 节点,`dws doc read --content-format jsonml` 返回此格式:
```json
["root", {"sectPr": {"pgSz": {"w": 11906, "h": 16838}}},
["p", {"uuid": "p1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "第一段"]]],
["p", {"uuid": "p2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "第二段"]]]
]
```
- 第一个元素固定为 `"root"`
- 第二个元素为文档级属性对象(如 `sectPr` 页面设置),可选
- 后续元素为块级节点(每个 block 节点应带 `uuid`)
全量覆写(overwrite)时,CLI 要求 body 必须以 `["root", ...]` 为根节点:
1. `["root", {sectPr}, ...blocks]` — 服务端 canonical 形式,`doc read` 输出
2. `["root", {}, ...blocks]` — 无页面设置时用空 attrs
## CLI 行为概览(validator)
写入端(`doc create/update`、`block insert/update`)走 **validate** 一步,不做结构修复:
| 行为 | 缺省 | `--fix-jsonml` |
|------|------|----------------|
| JSON 语法修复(括号/逗号补全) | ✗ | ✓(打印 `[FIX]`) |
| validator 阻断(HasErrors → 拒绝发送) | ✓ | ✓ |
| validator 警告(warnings → 仅 stderr) | ✓ | ✓ |
| root 校验(仅 doc create/update) | ✓ | ✓ |
> `doc create/update` 要求 body 必须以 `["root", {attrs?}, ...blocks]` 为根节点。缺少 root 会报错而非自动包装。`doc block insert/update` 不要求 root。
报错格式(面向 agent):
```
$[2][2]: paragraph child must be span wrapper, got raw string.
Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your text>"]]
```
`$` 表示输入根,`[i]` 是数组下标,`.attrs.k` 是属性名。
## 文本节点(Text)
**Canonical 形式**(服务端 serialize 输出、`doc read --content-format jsonml` 返回的就是这个):
```json
["span", {"data-type": "text"},
["span", {"data-type": "leaf", "bold": true}, "加粗文本"],
["span", {"data-type": "leaf"}, "普通文本"]
]
```
- **text 容器**:`["span", {"data-type": "text"}, ...leaves]` — 包裹所有 leaf
- **leaf 节点**:`["span", {"data-type": "leaf", ...marks}, "<文本>"]` — 实际承载文字与样式
- 一个 block 通常只有一个 text 容器,可以含多个 leaf(不同样式片段)
- text 容器与 `["a", ...]`、`["img", ...]`、`["tag", ...]` 等其他 inline 节点**并列**作为 block 的子节点
### 文本样式属性(Marks,写在 leaf 的 attrs 上)
所有 marks 均为 optional,按需组合。
| 属性 | 类型 | 格式/枚举 | 说明 |
|------|------|-----------|------|
| `bold` | `boolean` | `true` / `false` | 加粗。`false` 可反向取消继承 |
| `italic` | `boolean` | `true` | 斜体 |
| `strike` | `boolean` | `true` / `false` | 单删除线 |
| `dstrike` | `boolean` | `true` / `false` | 双删除线(独立于 strike) |
| `underline` | `object` | `{value, color?}` | 下划线。value: `"single"` \| `"dash"` \| `"wave"` \| `"double"` \| `"none"` |
| `color` | `string` | `"#rrggbb"` | 文字颜色 |
| `highlight` | `string` | CSS 颜色 | 文字高亮背景色 |
| `shd` | `object` | `{val?, color?, fill?}` | OOXML 底纹(Word 导入保留) |
| `sz` | `number` | 数值 | 字号,配合 `szUnit` |
| `szUnit` | `string` | `"px"` \| `"pt"` | 字号单位,默认 `"px"` |
| `fonts` | `object` | `{ascii, hAnsi, cs, eastAsia}` | OOXML 四分区字体,值为 font-family 名称(如 `SimHei`),不能写中文名 |
| `vertAlign` | `string` | `"superscript"` \| `"subscript"` \| `"baseline"` | 上标/下标/基线 |
| `spacing` | `number` | 数值(pt) | 字间距 |
示例:
```json
["span", {"data-type": "text"},
["span", {"data-type": "leaf", "bold": true, "italic": true, "color": "#1a73e8"}, "加粗斜体蓝字"]
]
["span", {"data-type": "text"},
["span", {"data-type": "leaf", "underline": {"value": "single", "color": "#ff0000"}, "sz": 14, "szUnit": "px"}, "红色下划线"]
]
["span", {"data-type": "text"},
["span", {"data-type": "leaf", "strike": true, "color": "#9E9E9E"}, "删除线灰字"]
]
["span", {"data-type": "text"},
["span", {"data-type": "leaf", "fonts": {"ascii": "Arial", "eastAsia": "SimSun"}, "sz": 12, "szUnit": "pt"}, "指定字体"]
]
["span", {"data-type": "text"},
["span", {"data-type": "leaf"}, "H"],
["span", {"data-type": "leaf", "vertAlign": "subscript"}, "2"],
["span", {"data-type": "leaf"}, "O"]
]
```
### 历史/兼容形式
| 写法 | validator | 服务端 | 建议 |
|------|-----------|--------|------|
| `["span", {"data-type":"text"}, ["span", {"data-type":"leaf"}, "x"]]` | ✓ canonical | ✓ | ✅ 新内容首选 |
| `["text", {marks}, "x"]` | ✓(`text` 在 inline 白名单中) | ✓(兼容) | ⚠️ 历史 inline tag。`doc read` 不会输出这种形式;如需复制粘贴回写、保持与现有内容一致,建议改写为 canonical |
| `"raw string"` 作为 block 子节点 | ✗ 报错 `段落子节点不能是裸字符串` | — | 不要直接写。validator 会报错,请手动包成 canonical 形式 |
> Marks 表的属性集对 canonical 的 leaf 和 legacy 的 text 都适用;差别仅在承载位置(leaf 的 attrs vs text 的 attrs)。
---
## 块级节点
所有 block 节点的 tag 白名单(validator `validBlockTags`):
`p` / `h1` / `h2` / `h3` / `h4` / `h5` / `h6` / `hr` / `table` / `code` / `container` / `embed` / `onlineVideo` / `card` / `toc` / `refblock` / `cangjie-voidblock` / `cangjie-container`
未在白名单的 tag 会触发 `未知的块级 tag` 警告,并给出基于编辑距离 (Levenshtein ≤2) 的最接近建议(如 `"containr"` → `did you mean "container"?`)。
### paragraph(段落)
- **tag**: `"p"`
- **attrs**(全部 optional):
- `jc?: "left" | "center" | "right" | "both" | "distribute" | "justify"` — 对齐
- `ind?` — 缩进
- `left?: number`, `right?: number`, `firstLine?: number`, `firstLineChars?: number`, `hanging?: number`
- `spacing?` — 行间距(`lineRule=auto` 时 `line` 为倍数:1=单倍、1.5=1.5倍、2=双倍;`before`/`after` 单位 pt)
- `line?: number`, `before?: number`, `after?: number`
- `lineRule?: "atLeast" | "auto" | "exact"`
- `shd?: {val?, fill?, color?}` — 底纹
- `quote?: boolean` — 引用标识(注:服务端也接受 `blockquote: true` 的别名)
- `list?: object` — 列表标识(见下方 list 节点)
- `refs?: string[]` — 脚注引用 ID(footnote 标识)
- **children**: 一个 text 容器 + 可选的 inline 节点(link/img/tag/mention 等)
- **示例**:
```json
["p", {"uuid": "p1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "普通段落"]]]
["p", {"uuid": "p2", "jc": "center"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "居中段落"]]]
["p", {"uuid": "p3", "ind": {"firstLine": 32}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "首行缩进段落"]]]
["p", {"uuid": "p4", "spacing": {"line": 1.5, "lineRule": "auto"}}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "1.5倍行距"]]]
```
### heading(标题)
- **tag**: `"h1"` | `"h2"` | `"h3"` | `"h4"` | `"h5"` | `"h6"`
- **attrs**: 同 paragraph
- **children**: 同 paragraph
- **示例**:
```json
["h1", {"uuid": "h1a"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "一级标题"]]]
["h3", {"uuid": "h3a", "jc": "center"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "居中三级标题"]]]
```
### blockquote(引用)
- **tag**: `"p"` 或 `"h1"`~`"h6"`(不是独立 tag,是属性装饰)
- **标识**: `attrs.quote: true`(也接受 `blockquote: true`)
- **示例**:
```json
["p", {"uuid": "q1", "quote": true}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "这是一段引用"]]]
["h2", {"uuid": "q2", "quote": true}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "引用标题"]]]
```
### list(列表)
- **tag**: `"p"`(列表项在 JSONML 层面是扁平的段落,通过 `attrs.list` 标识)
- **attrs.list**:
- `listId: string` — **必传**。同一列表的项共享相同 ID。validator 报错:`必传字段缺失`
- `level: number` — **必传**。缩进层级(0-based,≥0)。validator 报错:`必传字段缺失` / `必须 ≥0`
- `isOrdered?: boolean` — 是否有序,默认 `false`
- `isTaskList?: boolean` — 是否任务列表,默认 `false`
- `isChecked?: boolean` — 任务是否完成(仅 isTaskList=true 时有意义)
- `isCanceled?: boolean` — 任务是否取消
- `start?: number` — 有序列表起始序号(≥1)。**仅在列表第一项设置**,后续项不设置此字段(系统自动递增)。validator 报错:`必须 ≥1`
- `listStyleType?: string` — 样式类型(31 种预设)
- `hideSymbol?: boolean` — 隐藏列表符号
- **示例**:
```json
["p", {"uuid": "li1", "list": {"listId": "abc", "level": 0, "isOrdered": false}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "无序列表项"]]]
["p", {"uuid": "li2", "list": {"listId": "def", "level": 0, "isOrdered": true, "start": 1}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "有序第一项"]]]
["p", {"uuid": "li2b", "list": {"listId": "def", "level": 0, "isOrdered": true}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "有序第二项(不设 start,自动编号 2)"]]]
["p", {"uuid": "li3", "list": {"listId": "ghi", "level": 1, "isOrdered": false}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "二级缩进"]]]
["p", {"uuid": "li4", "list": {"listId": "jkl", "level": 0, "isTaskList": true, "isChecked": false}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "待办事项"]]]
```
- **注意**: 列表是扁平结构,不是嵌套的 ol/ul/li
### hr(分割线)
- **tag**: `"hr"`
- **attrs**(全部 optional):
- `type?: TLineStyle` — 16 种枚举(`"single"` / `"dotted"` / `"dashed"` / `"double"` / `"wave"` 等),默认 `"single"`
- `sz?: number` — 粗细(px),默认 `1`
- `color?: string` — 颜色
- **children**: 构造时无需传子节点;服务端返回的真实文档中可能含内部配置数据子节点
- **示例**:
```json
["hr", {"uuid": "hr1"}]
["hr", {"uuid": "hr2", "type": "dashed", "color": "#ccc", "sz": 2}]
```
### table(表格)
- **tag**: `"table"`
- **attrs**:
- `colsWidth: number[]` — 列宽(语义必传)。缺失时 validator 警告 `table 应提供 colsWidth`
- 默认模式:值为各列绝对宽度,单位 **pt**(如 `[325, 325]` 总和≈页宽 650pt)
- 比例模式(配合 `tblW: {"type": "pct"}`):值为百分比权重(如 `[33.3, 33.3, 33.4]` 总和=100)
- `tblW?: {w?: number, type?: string}` — 表格宽度模式。`type: "pct"` 时 colsWidth 按比例解析
- `sr?: boolean` — `true` 表示这是分栏布局(columns),不是普通表格
- `jc?: string` — 对齐(源码注释"目前无消费")
- **children**: `["tr", ...]` 行节点
- **示例**:
```json
["table", {"uuid": "tb1", "colsWidth": [200, 200]},
["tr", {"uuid": "tr1"},
["tc", {"uuid": "tc1", "colSpan": 1, "rowSpan": 1},
["p", {"uuid": "tcp1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "单元格1"]]]],
["tc", {"uuid": "tc2", "colSpan": 1, "rowSpan": 1},
["p", {"uuid": "tcp2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "单元格2"]]]]
]
]
```
#### tr(表格行)
- **tag**: `"tr"`
- **attrs**: `{h?: number, isTblHeader?: boolean}`
#### tc(表格单元格)
- **tag**: `"tc"`
- **attrs**:
- `colSpan?: number` — 横跨列数,默认 `1`。validator 报错:`必须 ≥1`
- `rowSpan?: number` — 横跨行数,默认 `1`。validator 报错:`必须 ≥1`
- `fill?: string` — 填充色
- `vAlign?: "top" | "middle" | "bottom"` — 垂直对齐,默认 `"middle"`。validator 报错枚举不符(注意 `"center"` 不是合法值,应用 `"middle"`)
- `bdr?: object` — 单元格边框
- **children**: 任意块级节点数组;**至少包含一个 `p`**(即使空),否则单元格无法渲染光标
### code(代码块)
- **tag**: `"code"`
- **attrs**(全部 optional):
- `code?: string` — 代码内容,默认 `""`
- `syntax?: string` — 语言标识,默认 `"plaintext"`
- `theme?: string` — 主题枚举:`"default"` / `"light"` / `"dracula"` / `"github"` / `"cobalt"` / `"atomOneDark"` / `"oneLightPro"` / `"nightOwl"` / `"githubDark"` / `"realDracula"`。未知值 validator 警告 `未知主题`
- `wrap?: boolean` — 自动换行,默认 `true`
- `showLineNumber?: boolean` — 显示行号,默认 `true`
- `title?: string` — 标题(≤1000 字符)
- `fold?: boolean` — 是否折叠,默认 `false`
- **children**: 构造时无需传子节点(代码存在 `attrs.code` 中)
- **示例**:
```json
["code", {"uuid": "cd1", "syntax": "javascript", "code": "console.log('hello');"}]
["code", {"uuid": "cd2", "syntax": "python", "code": "print('hello')", "theme": "dracula"}]
```
### container(容器/高亮块)
- **tag**: `"container"`
- **attrs**:
- `subType: string` — **必传**。callout 为 `"colorBlocks"`。缺失时 validator 报错 `container 必须包含 subType`
- `metadata?: object` — 自定义元数据(callout 用 `{bgcolor, border}`)
- **children**: 任意块级节点
- **示例(callout)**:
```json
["container", {"uuid": "co1", "subType": "colorBlocks", "metadata": {"bgcolor": "#E8F2FE", "border": "#B3D4FC"}},
["p", {"uuid": "co1p1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "这是一段提示内容"]]]
]
```
### columns(分栏布局)
- **tag**: `"table"`(复用 table tag,通过 `sr: true` 区分)
- **attrs**:
- `sr: true` — 固定标识
- `colsWidth?: number[]` — 各列宽度(pt),前端按比例换算为页面宽度
- `spacing?: number` — 栏间距
- **children**: 单个 `["tr", ...]`,内含多个 `["tc", ...]`
- **示例**:
```json
["table", {"uuid": "col1", "sr": true, "colsWidth": [300, 300]},
["tr", {"uuid": "colr"},
["tc", {"uuid": "colc1"},
["p", {"uuid": "colp1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "左栏"]]]],
["tc", {"uuid": "colc2"},
["p", {"uuid": "colp2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "右栏"]]]]
]
]
```
### embed(嵌入文件)
- **tag**: `"embed"`(void 块)
- **attrs**:
- `src?: string` — 文件来源 URL(语义必传)
- `name?: string` — 文件名
- `type?: string` — 文件类型(`"pdf"` / `"xlsx"` / `"html"` / `"file"` 等)
- `size?: number` — 文件大小
- `viewType?: string` — 视图类型(如 `"preview"`)
- `previewSize?: {height: number}` — 预览高度(px)
- **children**: 无
- **示例**:
```json
["embed", {"uuid": "em1", "name": "report.pdf", "type": "pdf", "src": "https://example.com/report.pdf", "size": 2048, "viewType": "preview", "previewSize": {"height": 600}}]
```
### onlineVideo(在线视频)
- **tag**: `"onlineVideo"`(void 块)
- **attrs**(全部 optional,但 src 语义必传):
- `src?: string` — 视频地址
- `type?: string` — 平台标识(`"bilibili"` / `"youku"` / `"mp4"` 等)
- `poster?: string` — 封面图 URL
- **children**: 无
- **示例**:
```json
["onlineVideo", {"uuid": "ov1", "src": "https://example.com/video.mp4"}]
["onlineVideo", {"uuid": "ov2", "src": "https://player.bilibili.com/player.html?aid=1", "type": "bilibili", "poster": "https://example.com/poster.jpg"}]
```
### card(河图组件 / 富卡片)
- **tag**: `"card"`
- **attrs**:
- `cardType: string` — 卡片类型标识(如 `"groupChatCard"`、`"vote"`)
- `metadata: {id: string, ...}` — 组件元数据,必须包含 id
- `height?: number`
- **children**: 服务端 serialize 通常返回带一个空的 span/leaf 占位子节点,写入时建议保留以避免反序列化差异
- **示例**:
```json
["card", {"uuid": "cd1", "cardType": "vote", "metadata": {"id": "card_abc123"}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, ""]]]
```
- **注意**: card 节点的重数据存储在独立 parts 层,body 中只存轻量引用
### toc(目录)
- **tag**: `"toc"`(void 块)
- **attrs**(全部 **required**,缺失逐个 validator 报错):
- `title: string` — 目录标题
- `mode: "outline" | "column"` — 展示模式(其他值 validator 报错 `无效值`)
- `styles: object` — 样式配置
- `styles.global: {maxLevel: number, bgColor: string, css: object}`
- `styles.title: {font: string, color: string, numbering: boolean, css: object}`
- `styles.item: {symbol: "disc" | "none", css: object}`
- `content: TocItem[]` — 目录条目数组;**写入时填 `[]` 即可**,服务端基于文档 heading 自动重建
- 每项: `{uuid, anchorId: string|null, level, children: TocItem[]}`
- **children**: 无
- **示例**:
```json
["toc", {"uuid": "toc1", "title": "目录", "mode": "outline",
"styles": {
"global": {"maxLevel": 5, "bgColor": "#F0EBF7", "css": {}},
"title": {"font": "", "color": "#000", "numbering": false, "css": {}},
"item": {"symbol": "disc", "css": {}}
},
"content": []
}]
```
### refblock(引用块)
- **tag**: `"refblock"`
- **attrs**:
- `docKey?: string` — 所属/被引文档标识(语义必传)
- `refblockUUID?: string` — 引用块唯一标识(语义必传)
- **children**: 块级节点(降级显示快照;真实内容由服务端按 docKey/refblockUUID 拉取覆写)
- **示例**:
```json
["refblock", {"uuid": "rb1", "docKey": "doc123", "refblockUUID": "block456"},
["p", {"uuid": "rb1p1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "(引用预览内容,服务端会回填)"]]]
]
```
- **注意**: 主块 (host):`docKey === refblockUUID`;副块 (copy):`docKey !== refblockUUID`
---
## Inline 节点
所有 inline 节点 tag 白名单(validator `validInlineTags`):
`text`(legacy) / `a` / `img` / `span` / `tag` / `inlineCode` / `br` / `cangjie-textinline` / `cangjie-voidinline`
block tag 也可出现在 inline 上下文(如 `img` 既是 block 又是 inline)。未在白名单的 tag 会触发警告并给出 Levenshtein 建议。
### a(链接)
- **tag**: `"a"`
- **attrs**(全部 optional,但 href 语义必传):
- `href?: string` — 链接地址(缺失时 validator 警告 `link 缺少 href`)
- `cardInfo?: object` — 链接卡片信息
- `cardInfo.displayType?: "link" | "card"` — 展示模式
- `cardInfo.title?: string`, `cardInfo.desc?: string`, `cardInfo.imgURL?: string`
- `metadata?: object` — 扩展业务信息
- **children**: 链接的展示文本(直接字符串,与 block 上下文不同)
- **示例**(作为 paragraph 的 inline 子节点):
```json
["p", {"uuid": "p1"},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "请访问"]],
["a", {"href": "https://example.com"}, "链接文字"]
]
```
### img(图片)
- **tag**: `"img"`(既可作 block 也可作 inline 子节点)
- **attrs**(全部 optional,但 src 语义必传):
- `src?: string` — 图片地址
- `width?: number` — 宽度(px)
- `height?: number` — 高度(px)
- `rectClip?: {left?, right?, top?, bottom?}` — 裁剪比例(0-1)
- `rotation?: number` — 旋转角度
- `radius?: number` — 圆角(px)
- `shadow?: string` — CSS shadow
- `outline?: {width?, type?, color?}` — 边框
- **children**: 构造时无需传子节点;真实文档中可能含内部配置数据
- **示例**:
```json
["img", {"uuid": "img1", "src": "https://example.com/photo.png", "width": 400, "height": 300}]
```
- **注意**: 不存在 `layout` 属性,布局由 UI 层控制
### mention(@提及)
- **tag**: `"span"`
- **attrs**:
- `data-type: "mention"` — **必传**,固定标识
- `id?: string` — 被@用户 ID(语义必传)
- `name?: string` — 被@用户名(语义必传)
- `login?: string` — 登录名
- `metadata?: object` — 扩展元数据
- **children**: 显示文本
- **示例**:
```json
["span", {"data-type": "mention", "id": "user123", "name": "张三"}, "@张三"]
```
### tag(通用标签节点)
- **tag**: `"tag"`
- **attrs**:
- `tagType: string` — **必传**。子类型标识(如 `"formula"` / `"imTag"`)
- 其他属性依 tagType 而异
- **示例**:
```json
["tag", {"tagType": "imTag", "text": "#标签名"}]
```
### formula(公式)— `tag` 节点的特化
- **tag**: `"tag"`
- **attrs**:
- `tagType: "formula"` — **必传**,固定值
- `metadata: {formula: string}` — **必传**。LaTeX 代码(空串表示空公式)。缺失或类型错时 validator 报错
- **children**: 无
- **示例**:
```json
["tag", {"tagType": "formula", "metadata": {"formula": "E=mc^2"}}]
```
### sticker(表情贴纸)
- **tag**: `"span"`
- **attrs**:
- `data-type: "emoji"` — **必传**(注意:`"emoji"` 不是 `"sticker"`)
- `code?: string` — 表情纯文本标识(如 `"[微笑]"`)
- `newCode?: IEmoji` — 完整表情对象,5 种子类型:
- `{type: "dingding", id, name, url}`
- `{type: "unicode", value}`
- `{type: "custom", url}`
- `{type: "icon", id, color?}`
- `{type: "svg", id, url, color?}`
- **children**: 无或空文本
- **示例**:
```json
["span", {"data-type": "emoji", "code": "[微笑]", "newCode": {"type": "unicode", "value": "😊"}}]
```
### inlineCode(行内代码)
- **tag**: `"inlineCode"`
- **attrs**: `{bgColor?: string}` 或 `{}`
- **children**: 文本(inline 子节点)
- **示例**:
```json
["inlineCode", {}, "const x = 1"]
```
### br(换行符)
- **tag**: `"br"`(void inline)
- **attrs**: `{}`
- **children**: 空文本占位
- **示例**:
```json
["br", {}, ""]
```
### refer(行内引用)
- **tag**: `"span"`
- **attrs**:
- `data-type: "refer"` — 固定标识
- 其他业务属性(自由 key-value)
- **示例**:
```json
["span", {"data-type": "refer", "docId": "xxx", "blockId": "yyy"}, "引用内容"]
```
---
## Cangjie 系列(扩展块)
### attachment(附件)
- **tag**: `"cangjie-voidblock"` 或 `"cangjie-voidinline"`
- **attrs**:
- `subType: "attachment"` — 固定标识
- `data.viewType: "preview" | "abstractCard"` — 视图类型
- `data.fileData: {name?, src?, size?, fileType?, category: "media" | "file"}` — 文件数据
- `data.previewSize?: {width?, height?}` — 预览尺寸
- **children**: 无
- **示例**:
```json
["cangjie-voidblock", {"subType": "attachment", "data": {"viewType": "abstractCard", "fileData": {"name": "report.pdf", "src": "https://...", "size": 2048, "category": "file"}}}]
```
- **注意**: 反序列化时匹配 `"embed"` tag 并转换为 attachment
### footnote(脚注)
- **tag**: 宿主节点 tag(`"p"` / `"h1"`~`"h6"`),不是独立节点
- **标识**: `attrs.refs: string[]`(脚注引用 ID 数组)
- **示例**:
```json
["p", {"uuid": "fn1", "refs": ["footnote-id-1"]},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "正文文本"]]]
```
- **注意**: footnote 将 `refs` 属性注入到宿主 block 节点中,类似 blockquote 的属性装饰模式
### calendar(日程)
- **tag**: `"cangjie-voidinline"` 或 `"cangjie-voidblock"`
- **attrs**:
- `subType: "calendar"` — 固定标识
- `data.viewType: "inlineCalendar" | "blockCalendar"` — 形态
- `data.calendarId: string` — 日程 ID
- `data.subject: string` — 日程名称
- `data.detailUrl: string` — 日程详情链接
- **示例**:
```json
["cangjie-voidinline", {"subType": "calendar", "data": {"viewType": "inlineCalendar", "calendarId": "cal-001", "subject": "周会", "detailUrl": "https://..."}}]
```
### label(标签)
- **tag**: `"cangjie-textinline"`
- **attrs**:
- `subType: "label"` — 固定标识
- `data.bgColor?: string` — 标签背景色
- `data.color?: string` — 标签文字颜色
- `data.labelType?: "normal" | "note" | "spoiler"` — 标签模式
- **children**: 文本内容
- **示例**:
```json
["cangjie-textinline", {"subType": "label", "data": {"bgColor": "#FFE8CC", "color": "#D46B08", "labelType": "normal"}}, "重要"]
```
### templateButton(模板按钮)
- **tag**: `"cangjie-container"`
- **attrs**:
- `subType: "templateButton"` — 固定标识
- `metadata.direction: "top" | "bottom"` — 按钮方向
- `metadata.isOnce: boolean` — 是否一次性
- `metadata.title: string` — 按钮标题
- **children**: 块级节点数组
- **示例**:
```json
["cangjie-container", {"subType": "templateButton", "metadata": {"direction": "bottom", "isOnce": false, "title": "添加待办"}},
["p", {"uuid": "tb1p1", "list": {"level": 0, "isChecked": false, "isOrdered": false, "isTaskList": true, "listId": "abc"}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, ""]]]
]
```
### textSlot(文本插槽)
- **tag**: `"cangjie-textinline"`
- **attrs**:
- `subType: "textSlot"` — 固定标识
- `data.slotInfo.style.color: string` — 文字颜色(支持渐变色)
- **children**: 文本内容
- **示例**:
```json
["cangjie-textinline", {"subType": "textSlot", "data": {"slotInfo": {"style": {"color": "#1890ff"}}}}, "插槽文本"]
```
---
## 完整文档示例
```json
["root", {"sectPr": {"pgSz": {"w": 11906, "h": 16838}}},
["h1", {"uuid": "h1"},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "文档标题"]]],
["p", {"uuid": "p1"},
["span", {"data-type": "text"},
["span", {"data-type": "leaf"}, "这是一段普通文本,包含"],
["span", {"data-type": "leaf", "bold": true}, "加粗"],
["span", {"data-type": "leaf"}, "和"]],
["a", {"href": "https://example.com"}, "链接"],
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "。"]]],
["h2", {"uuid": "h2"},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "列表示例"]]],
["p", {"uuid": "li1", "list": {"listId": "l1", "level": 0, "isOrdered": true, "start": 1}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "第一项"]]],
["p", {"uuid": "li2", "list": {"listId": "l1", "level": 0, "isOrdered": true}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "第二项"]]],
["p", {"uuid": "li3", "list": {"listId": "l1", "level": 1, "isOrdered": false}},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "子项"]]],
["hr", {"uuid": "hr1"}],
["code", {"uuid": "cd1", "syntax": "javascript", "code": "function hello() {\n return 'world';\n}"}],
["container", {"uuid": "co1", "subType": "colorBlocks", "metadata": {"bgcolor": "#E8F2FE"}},
["p", {"uuid": "co1p1"},
["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "这是一个提示块"]]]],
["table", {"uuid": "tb1", "colsWidth": [200, 200]},
["tr", {"uuid": "tr1"},
["tc", {"uuid": "tc1", "colSpan": 1, "rowSpan": 1},
["p", {"uuid": "tcp1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "A"]]]],
["tc", {"uuid": "tc2", "colSpan": 1, "rowSpan": 1},
["p", {"uuid": "tcp2"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "B"]]]]
]
]
]
```
## 设计要点
1. **Canonical 文本是 span/leaf**:每段文字 = `["span", {"data-type":"text"}, ["span", {"data-type":"leaf", ...marks}, "..."]]`。legacy `["text", {marks}, "..."]` 仍被接受但不建议新写。
2. **裸字符串作 block 子节点违法**:validator 报错,请手动包成 canonical 形式。
3. **每个 block 必带 `uuid`**:手写 JSONML 时建议每个 block 自带 `uuid`(base32 alphanumeric,dws CLI 用 `dws` 前缀)。
4. **扁平列表**: 列表不嵌套,通过 `listId` + `level` 表达层级。
5. **属性装饰**: blockquote / list / footnote 不是独立 tag,是 paragraph 的属性。
6. **Void 节点**: hr / code / img / card / toc / embed / onlineVideo 在构造时不需要传子节点;服务端返回的真实文档中这些节点**可能包含内部配置数据子节点**,解析时应兼容。
7. **columns = table + sr:true**: 分栏复用表格结构。
8. **card 轻引用**: body 中只存 cardType + metadata.id,重数据在 parts 层。
9. **root 节点**: 服务端返回的完整 body 以 `["root", {sectPr...}, ...blocks]` 包裹。`doc create/update` 写入时必须以 root 为根节点,缺少会报错。`doc block insert/update` 不要求 root。
# 钉钉文档创建流程
本文只处理一件事:用 `dws doc create` 创建一篇钉钉文档,并确认内容真的写进去。资料采集、汇报生成、转发通知、权限分发等都不是本文范围,应由对应 recipe 或产品参考负责。
> 改写已有文档见 [doc-update-workflow.md](./doc-update-workflow.md)。排版规范见 [doc-style-guideline.md](./doc-style-guideline.md)。
## 前置必读
> **同时读取 [doc-style-guideline.md](./doc-style-guideline.md):**
> - **§2.0 类型判断决策表** → 锁定文档类型(决策型 / 执行型 / 说明型 / 知识沉淀型)和骨架
> - **§1 硬规则** → 全程生效(`--name` 已是 H1、不编造 URL、Markdown 草稿不写 callout 等)
### 关键词速查(用户意图 → 起稿路径)
| 用户关键词 | 文档类型 | 起稿路径 |
|-----------|---------|---------|
| 汇报 / 周报 / 月报 / 复盘 / 方案选型 / 决策 / 对比 | §2.1 决策型 | **→ JSONML 起稿** |
| 调研 / 技术方案 / 复盘报告(含对比/数据) | §2.4 知识沉淀型 | **→ JSONML 起稿** |
| SOP / Runbook / 接入指南 / 升级 / 操作手册 | §2.2 执行型 | → Markdown 起稿 |
| 接口文档 / 能力清单 / 参数说明 / 错误码 | §2.3 说明型 | → Markdown 起稿 |
| 用户原文含:颜色/高亮/美观/醒目/重点突出/像PPT | 任意类型 | **→ JSONML 起稿** |
## 适用边界
进入本文前,必须已经确认用户要创建的是钉钉文档 (`adoc`)。如果用户要的是钉钉表格、AI表格、文件上传、知识库空间管理或消息发送,不要套用本文。
本文覆盖:
- 文档标题和创建位置确认
- 正文草稿准备
- `doc create` 写入
- 写入后回读验收
- 内容缺失时的补救写入
本文不覆盖:
- 从群聊、日志、听记、表格等来源采集资料
- 生成日报、周报、月报等业务报告口径
- 文档权限分享、消息通知或待办创建
- 非钉钉文档的新建流程
## 创建前检查
创建前先锁定四个输入:
| 项目 | 要求 |
|------|------|
| 标题 | 用 `--name` 传入;正文不要再重复同名一级标题 |
| 位置 | 默认创建到我的文档;指定目录时只接受文档文件夹 `nodeId` 或 alidocs 文件夹 URL |
| 正文 | 多行、表格、代码块、特殊字符或长度 >= 2KB 时必须写入 UTF-8 临时 `.md` 文件 |
| 格式 | 按 §JSONML 起稿判定 决定起稿路径:命中 JSONML 起稿条件时**直接用 JSONML 构造**(跳过 markdown);未命中时用 Markdown 起稿,创建后按 [doc-update-workflow.md](./doc-update-workflow.md) 精修 |
禁止把纯数字 `dentryId`、drive `parent-id` 或 spaceId 填进 `--folder`。
## JSONML 起稿判定
在正文准备之前,先判断是否直接用 JSONML 起稿。**命中以下任一条件即走 JSONML 起稿路径**(跳过 markdown 草稿阶段):
### 文档类型触发
| 类型 | 触发条件 |
|------|---------|
| 决策型(§2.1) | **默认触发** — 汇报/方案/对比需要摘要 callout、彩色表头、决策时限标注 |
| 知识沉淀型(§2.4) | 含对比分析、多维度数据可视化、需要关键节点彩色 callout |
### 意图关键词触发
用户原文或需求描述中出现以下任一关键词:
- 颜色 / 配色 / 上色 / 高亮 / 醒目
- 字号 / 字体 / 加大 / 缩小
- 视觉效果 / 排版精美 / 好看 / 美观
- callout / 分栏 / 对比色 / 彩色表头
- "像 PPT 那样" / "有设计感" / "重点突出"
---
## 设计规划(JSONML 起稿前必做)
判定走 JSONML 路径后,**禁止立即动手写 JSONML**。先完成以下两阶段规划,各自产出一个持久文件作为后续阶段的锚点。
### 设计原则(全程生效)
1. **结构即信息** — 标题层级、表格 vs 分栏 vs 列表、callout 位置都应编码内容逻辑。问自己:“去掉这个结构元素,读者会丢失信息或体验变差吗?”— 丢失信息则必保留;不丢失信息但能提升可读性或美观度(如分割线分隔章节、分栏对比排版)也应保留;既不携带信息也不提升体验的装饰元素才删除。
2. **视觉层级引导阅读** — 每一屏必须让读者瞥一眼就能回答:“这块最重要的是什么?”字号/粗体/颜色形成明确梯度:标题 > 重点数据 > 正文 > 辅助信息。
3. **克制产生质感** — 遵循 60-30-10 配色比例:60% 中性底色(白/浅灰)、30% 辅助色、10% 强调色。多色系共存时需保持**同等饱和度**并各有语义角色(如淡蓝=信息、淡黄=提示、淡红=风险),同一色系内深浅变化自由。callout 不超过 2 个。
4. **设计先于执行** — 从规划阶段起每个结构块的样式就已确定,执行时(无论直接 JSONML 还是脚手架精修)只是落地已有设计,不是边写边想。
5. **同类同色、一色多阶** — 同类信息必须使用相同色系;单一色系按元素角色展开为深/中/浅/极浅四级(标题文字用深色、强调用中色、高亮/表头用浅色、背景用极浅色),不要全篇只用一个 hex 值。
### Phase 1:RFC — 需求理解与设计方向
**目标**:明确“做什么”和“为什么这样做”,形成方向性锚点。
#### 1.1 需求提取(全量列出用户显式要求)
通读用户 prompt,抽取两类要求并编为清单:
**内容要求**:
- 标题、字数、章节划分
- 数据来源、受众
- 语气/风格(如“大气”“专业”“轻松”)
**样式要求**(每一条都必须在最终输出中体现,不得遗漏):
- 字体:映射为 font-family 名称(参照 cookbook 字体映射表),区分“全文字体”和“特定元素字体”
- 字号、行距、对齐、颜色
- 强调手段(加粗、高亮、配色…)
- 约束(如“每部分不省略”)
> 用户没有明确指定的维度(如未指定行距、未指定表格样式)由 Phase 2 补充设计决策。
#### 1.2 内容-表现适配(每个章节的内容适合用什么元素)
对每个章节回答:“这个内容的核心是什么类型的信息?”→ 选择最佳元素:
**块级结构元素**:
| 信息类型 | 首选元素 | 不适合 |
|----------|---------|--------|
| 多个同类实体对比(≥ 4 项或 ≥ 3 维度) | 彩色表头表格 | 纯文本段落 |
| 少量实体对比(2-3 项× 少量维度) | 分栏(每栏一个实体,可设边框/背景色) | 大宽表格 |
| 时间序列/流程(行程/步骤) | 有序列表 + 粗体时间标签 | 无序列表 |
| 单个结论/推荐/重要提示 | callout(“花大胆”的地方) | 普通段落 |
| 描述性文字(背景/说明) | 正文段落 + 关键词粗体 | 表格 |
| 分类列举(特色/亮点) | 无序列表 | 表格(数据不够多列时) |
| 数值强调(评分/价格/统计) | 加粗 + 着色 | 跳过不强调 |
| 引用原文(用户评价/网友点评/官方说明) | 引用块 | 普通段落 |
| 任务/待办清单 | checklist(`- [ ]`) | 普通列表 |
| 章节分隔/主题转换 | 分割线(`hr`) | 空行 |
| 板块内子区域分隔(同一单元格/容器内多个逻辑段) | hr 内部分隔(在 tc 或 container 内部使用) | 空行或留白 |
| 结构化元信息(人/时间/地点/属性清单) | 键值对表格(窄标签列 ~15-20% + 宽内容列) | 多行段落 |
| 分类标签/状态标记 | 标签元素(tag) | 纯文本标记 |
**行内强调元素**:
- **emoji** — 用于 callout 前缀、状态标记、H2/H3 标题前;不在普通段落和列表项中滥用
- **加粗/高亮/着色** — 强调关键数据和结论
- **highlight 色带** — 在标题 span 上设 `"highlight": "#浅色"` 形成轻量色条标记,比 callout 更轻,适合区分多个并列板块的主题色
- **灰色辅助文字** — 用浅灰色(如 `#979A9B`)标记示例/说明/占位文字,与正文形成明确的主次层级
- **图片** — 实景照片、截图、示意图能显著提升理解时使用
**分栏选型补充**:分栏栏数无上限,但推荐 2-4 栏(≥5 栏会比较拥挤),可设置边框和背景色。**建议设置 `fill` 背景色**(纯白底分栏视觉上与普通段落无异,读者不易感知分栏结构)。适合场景:
- 2-3 个同类实体并排展示(每栏一个实体,含标题 + 描述 + 关键数据)
- 轻量对比:“优点 vs 缺点”、“方案 A vs B”、“Day 1 vs Day 2”
- 网格布局:多次插入同栏数分栏可形成卡片网格(如 2×3、3×2),适合 4-9 个结构相同、内容等长的卡片式实体。**约束**:每个格子内容必须结构一致且长度相近,否则高低不齐会很丑;实体数不能整除栏数时不使用
例如:“5 个实体多维度对比” → **彩色表头表格**;“两组信息并排” → **分栏**;“6 个结构相同的卡片” → **2×3 分栏网格**。
#### 1.3 起稿策略选择
根据文档复杂度和上方适配结果,确定起稿路径:直接 JSONML / Markdown 脚手架 + JSONML 精修 / 直接 JSONML 分段构造。具体条件和流程见下方「起稿」节的策略表。
#### 落盘
将以上内容写入 `<name>-rfc.md`,包括:
- 需求摘要(内容要求 + 样式要求)
- 每章展现策略 + 选择理由
- 起稿策略
---
### Phase 2:Spec — 精确设计参数
**目标**:将 RFC 的方向决策转化为可直接执行的参数。
#### 2.1 视觉体系设计(确定全局设计变量)
在以下四个维度做出明确选择,每个选择都必须能解释为什么适合这篇文档:
**色彩**(选定主色后展开为色阶):
用户指定颜色时(如"蓝色""绿色"),不要全篇只用一个 hex 值。将其展开为 4 级色阶,按元素角色分配:
| 角色 | 用途 | 色阶要求 |
|------|------|----------|
| 深色 | 标题文字、重点数据 `color` | 白底上高对比可读 |
| 中色 | 正文强调、链接 `color` | 辨识度高但不抢标题 |
| 浅色 | highlight 色带、表头 fill | 底色柔和,上方深色文字可读 |
| 极浅 | container bgcolor、大面积背景 | 接近白色,仅提供区域感 |
**规则**:
- 深→浅的层级关系不可颠倒(不能用极浅色做标题文字、不能用深色做背景)
- 同类板块/同类标题必须使用完全相同的色阶组合,通过色彩的重复形成视觉韵律
- 不同类别可用不同色系区分(如:任务=蓝系、风险=红系、成果=绿系)
- 遵循 60-30-10 配色比例:60% 中性底色、30% 辅助色、10% 强调色;用户指定的颜色值优先
**字体梯度**(形成明确层级):
- 主标题(h2):字体 / 字号 / 粗体 / 颜色
- 正文:字体 / 字号 / 行距
- 强调文字:粗体 + 颜色或高亮
- 辅助信息(注释/来源):字号偏小 / 灰色
**表格风格**(有表格时):
- 表头:底色 + 文字色 + 是否粗体
- 单元格:默认对齐 / 字号
**视觉重心**(克制原则落地):
全篇选 1-2 处给予最强视觉处理(配色 callout / 彩色表头 / 分栏对比),其余元素保持朴素。不要处处强调 — 处处强调等于没有强调。
callout 可通过 `"showstk": true, "sticker": "图标名"` 配置顶部贴纸图标(如“灯泡”“火”“钉子”),增强语义标识。设置了 sticker 后,高亮块内首个段落不要再以 emoji 开头,避免紧邻的位置出现两个图标。
#### 落盘与回验
1. 将以上内容写入 `<name>-design.md`,包括:
- 逐章元素映射(用什么标签、什么属性)
- 色阶具体 hex 值
- 字体梯度参数
- 表格风格细节
2. **回验**:Read `<name>-rfc.md`,逐条确认:
- [ ] RFC 中每条需求在 Spec 中有对应实现
- [ ] 展现策略在 Spec 中有具体参数支撑
- [ ] 配色遵循 60-30-10 比例、callout ≤ 2 个、多色系饱和度一致且有语义角色
回验通过后进入下一节开始构造 JSONML。
---
## JSONML 起稿(命中判定时使用)
根据 RFC 中确定的起稿策略执行:
| 策略 | 条件 | 执行流程 |
|------|------|----------|
| **直接 JSONML** | 短文档(≤ 15 块级节点)且结构简单 | 在 `/tmp/<name>.json` 手写完整 JSONML 树 → `doc create --content-format jsonml` |
| **Markdown 脚手架 + JSONML 精修** | 长文档且结构较线性 | ① Markdown 建立内容骨架 → `doc create --content-format markdown` ② `doc read --node <id> --content-format jsonml --output /tmp/<name>.json` 拉回 JSONML(已含 uuid) ③ **Read `<name>-rfc.md` + `<name>-design.md`** 回顾规划 ④ 逐章对照执行结构变换 + 叠加样式 |
| **直接 JSONML 分段构造** | 长文档且含大量富结构 | 按章节分段构造 JSONML,每段写完校验通过后再写下一段,最后拼接 |
> **脚手架策略警示**:Markdown 无法表达分栏/callout/色彩表头,拉回的 JSONML 只有纯文本骨架。精修阶段不是“在现有结构上加色”,而是“参照 RFC/Spec 重组结构”。
> **MUST READ**:动手写 JSONML 前,必须先用 Read 工具读取 [doc-jsonml-cookbook.md](../format/doc-jsonml-cookbook.md) — 其中 §决策型文档骨架范例 有可直接复制修改的完整模板。
> 节点类型和属性的权威定义见 [doc-jsonml-schema.md](../format/doc-jsonml-schema.md)。
### ⚠️ JSONML 降级约束
**禁止因一次校验失败就放弃 JSONML 降级为 Markdown。** 当用户需求已触发 JSONML 起稿判定时,JSONML 是实现其样式要求的首选路径。失败时的处理策略:
1. **校验报错** → 读取错误信息,定位具体节点,修复后重试
2. **JSON 语法错误** → 检查括号匹配、逗号、引号,修复后重试
3. **反复失败(≥3 次)** → 尝试简化结构(减少嵌套、拆分复杂节点)再试
4. **仍然失败** → 退化为「Markdown 脚手架 + JSONML 精修」路径(流程同上方策略表),并告知用户当前状况
> “由于 JSONML 结构复杂且容易出错,改用 Markdown” — 这不是合法降级理由。必须先充分重试,且降级后仍需通过精修补回样式。
### ⚠️ JSONML 结构严格约束(生成时必须遵守)
每个节点是一个 JSON 数组:`[tagName, attributes?, ...children]`
- **第一个元素**是字符串,表示标签名(如 `"p"`, `"h1"`, `"span"`, `"container"`)
- **第二个元素**(可选)是一个 JSON 对象,表示属性(如 `{"uuid": "abc"}`)。如果无属性,可以直接进入子节点
- **随后的元素**是子节点,可以是纯字符串(仅限 leaf span 内),也可以是另一个 JSONML 数组
- **所有 `[` 必须有对应 `]`,所有 `{` 必须有对应 `}`,数组元素之间用 `,` 分隔,最后一个元素后不加 `,`**
常见 LLM 生成错误(务必避免):
| 错误类型 | 示例 | 后果 |
|---------|------|------|
| 缺少闭合 `]` | `["p", {}, ["span", ...]` | JSON 解析失败 |
| 多余逗号 | `["p", {},]` | JSON 解析失败 |
| 缺少逗号 | `["p", {} ["span"]]` | JSON 解析失败 |
| 引号不匹配 | `["p", {"uuid": "abc}]` | JSON 解析失败 |
| 有序列表每项都设 `start:1` | `{"start":1}` 在每项重复 | 所有项编号重置为 1(显示为 a/a/a) |
| 列表 `level` 从 1 开始 | `"level": 1` 作为顶级 | 顶级列表项多一层缩进;建议:顶级 `"level": 0`,子级 `"level": 1` |
| 用 `fontFamily` 设字体 | `"fontFamily": "Arial"` | 校验报错;正确写法:`"fonts": {"ascii": "Arial", "eastAsia": "..."}` |
| 用多个 span “换行” | 同一 `p` 内放两个 `span` | 不会产生换行;每个换行必须是独立的 `p` 节点 |
### 基础结构
文件内容是一个裸 JSONML 数组,根节点为 `"root"`:
```json
["root", {},
["h2", {}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "章节标题"]]],
["p", {}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "正文段落"]]]
]
```
- 根节点固定 `"root"`(不是 `"body"`)
- `--name` 已是 H1,JSONML 从 `h2` 开始
- 表格结构是 `table → tr → tc`(无 `th`/`td`)
- 分栏是 `table` + `"sr": true`,`tc` 建议设 `fill` 背景色
- 有序列表:仅第一项设 `"start": 1`,后续项不设 `start`(系统自动递增)
- 列表 `level` 建议从 0 开始:顶级项 `"level": 0`,子项 `"level": 1`,以此类推
- uuid 必须显式提供(CLI 不自动生成)
- **每行内容对应一个 `p` 节点** — 同一 `p` 内的多个 `span` 不会换行,只会横向拼接;需要换行时必须拆分为多个 `p`
### 视觉设计要点
构造时主动使用这些属性实现视觉效果:
- **文字着色**:leaf 上 `"color": "#hex"`、`"highlight": "#hex"`
- **highlight 色带**:标题 leaf 上 `"highlight": "#浅色"` 可形成色条效果(比 callout 更轻量的板块标记)
- **字号**:leaf 上 `"sz": 14, "szUnit": "pt"`
- **callout**:`["container", {"subType": "colorBlocks", "metadata": {"bgcolor": "#E8F5E9", "border": "left"}}, ...blocks]`
- **callout + sticker**:`["container", {"subType": "colorBlocks", "metadata": {"bgcolor": "#FEF3F3", "showstk": true, "sticker": "火"}}, ...blocks]`
- **表格单元格底色**:tc 上 `"fill": "#hex"`
### 写入
```bash
dws doc create --name "<文档名>" --content-file /tmp/<name>.json --content-format jsonml
```
### 回读验收
```bash
dws doc read --node <nodeId> --content-format jsonml --output /tmp/<name>-readback.json
```
---
## 正文准备(未命中 JSONML 判定时)
正文草稿先在本地临时 Markdown 文件中完成,推荐路径形如 `/tmp/<name>.md`。
准备规则:
- 只使用用户已提供或对话中已确认的正文素材。
- 如果正文素材不足,先补齐文档目标、受众、章节和缺口;不要在本文中临时扩展跨产品采集流程。
- **先按 [doc-style-guideline.md §2.0 类型判断决策表](./doc-style-guideline.md) 确定文档类型,再用对应类型的骨架样板(§2.1 决策型 / §2.2 执行型 / §2.3 说明型 / §2.4 知识沉淀型)**。不要套通用三段式。
- **`--name` 已是 H1,正文从 `##` 开始**;正文内不要再写 `#` 一级标题(除非确实需要正文内再造一级 H1 并说明动机)。
- 摘要、bullet、引用块、callout 等元素的使用边界以 style-guideline §3-§7 为准。
- 同类信息保持一致:风险、状态、行动项各用一种元素 + 一种视觉语义(style-guideline §1.2 / §5)。
- 临时文件必须保留真实换行,不能把换行写成字面量 `\n`。
- Markdown 草稿阶段**不要**写 callout / 分栏 / 附件——这些留到「创建后的精修」用 `doc block insert` 操作(style-guideline §1.3)。
- **图片素材闭环(硬规则)**:正文需求含图片/截图/图文并茂时,**禁止**在 Markdown 中写 `` 图片语法(包括真实存在的 alidocs URL)。正确做法:Markdown 只写文本骨架和图片占位说明(如 `📌 此处插入:xxx 产品截图`),创建文档后逐个执行 `dws doc media insert --node <nodeId> --file <本地图片路径>` 插入,最后用 `dws doc block list --node <nodeId>` 验证图片块存在。图片来源如果是钉盘文件,必须先 `dws drive download --node <图片nodeId> --output /tmp/xxx.png` 下载到本地再 insert。
## 创建写入
优先用 `--content-file` 一次创建并写入:
```bash
dws doc create --name "<文档名>" --content-file /tmp/<name>.md --content-format markdown
```
创建到指定文件夹:
```bash
dws doc create --name "<文档名>" --content-file /tmp/<name>.md --folder <DOC_FOLDER_NODE_ID> --content-format markdown
```
创建到知识库:
```bash
dws doc create --name "<文档名>" --content-file /tmp/<name>.md --workspace <WS_ID> --content-format markdown
```
短纯文本才允许直接传 `--content`:
```bash
dws doc create --name "<文档名>" --content "短内容" --content-format markdown
```
返回后立即记录:
| 字段 | 用法 |
|------|------|
| `nodeId` | 后续 `doc read`、`doc update`、`doc block`、`doc media` 的目标 |
| `docUrl` | 最终交付给用户的链接;缺失时用 `doc info` 补查 |
| `chunksWritten` | 判断是否触发自动分片;大于 1 时重点检查章节顺序 |
## 回读验收
创建命令返回成功不等于正文完整。每次创建后都必须回读:
```bash
dws doc read --node <nodeId>
```
验收要点:
- 开头摘要、关键章节、表格表头、末尾章节都存在。
- 回读文本顺序和临时 Markdown 一致。
- 没有把字面量 `\n` 渲染成一整行。
- 如果返回 `chunksWritten > 1`,检查分片边界没有破坏表格、代码块或列表。
- 最终回复必须给用户 `docUrl`;如果只拿到 `nodeId`,说明链接字段未返回,并报告已尝试 `doc info`。
## 缺失补救
DWS 写入管道会自动处理长内容分片。只有出现以下情况才手工补片:
- 返回 `CONTENT_TRUNCATED`
- 命令超时或只写入部分分片
- 回读发现后半段缺失、章节乱序或表格损坏
补救流程:
1. 用 `doc read` 确认已经写到哪个章节。
2. 从原始临时 Markdown 中截取缺失部分,写入 `/tmp/<name>-resume.md`。
3. 追加缺失内容:
```bash
dws doc update --node <nodeId> --content-file /tmp/<name>-resume.md --mode append --content-format markdown
```
4. 再次 `doc read`,确认缺失章节已补齐。
## 创建后的精修
创建流程本身优先完成整篇正文。只有需要局部补充、插入附件、加 callout / 分栏、或无损结构调整时,才进入精修——**精修路径统一走 [doc-update-workflow.md](./doc-update-workflow.md)**。
精修常见入口(**按 [doc-update-workflow.md §1.3](./doc-update-workflow.md) 优先级排序:JSONML 首选**):
- 单 block JSONML 精修(首选):`doc block list --node <id> --content-format jsonml --block-id <uuid>` 取子树 → `doc block update --node <id> --block-id <uuid> --content-format jsonml --element '[...]'` 写回(uuid 必须 == --block-id;写入端默认执行 schema validate,详见 [doc-update-workflow.md §4.4](./doc-update-workflow.md))
- 整篇 JSONML 无损:`doc update --content-format jsonml --mode overwrite`(默认直接覆盖,适合一次改多处或改 root sectPr;担心并发覆盖时加 `--revision <N>` 触发并发检查)
- 插入附件 / 图片:`doc media insert`(无 JSONML 形态,直接走 element)
- element JSON 次选:`doc block insert` / `doc block update` 不带 `--content-format jsonml` 时按老接口 JSON 解析;仅在 JSONML 不支持某字段时使用
- markdown 兜底:`doc update --mode append`(末尾追加纯文本段落,无富结构需保留时)
字段结构以 [`doc.md`](../../doc.md) 为准;何时用何种精修路径见 [doc-update-workflow.md §3「改写路径速查」](./doc-update-workflow.md)。
## 交付口径
只报告已经验证过的信息:
- 文档标题
- `docUrl` 或 `nodeId`
- 已写入的正文范围
- 回读验收结果
- 如有缺失,说明缺失位置和补救状态
未回读前,不要说内容完整或任务完成。
# 钉钉文档排版规范
本文规定 DWS 创建或编辑钉钉文档时的排版判断方法。核心流程:**确定文档类型 → 选骨架 → 按读者任务选元素 → 按视觉语义统一表达 → 软约束自检**。
> 写入流程见 [doc-create-workflow.md](./doc-create-workflow.md)。改写老文档见 [doc-update-workflow.md](./doc-update-workflow.md)。callout / 分栏等块的字段以 [doc.md](../../doc.md) 与 [doc-jsonml-schema.md](../format/doc-jsonml-schema.md) 为准。
## 快速入口
按任务定位章节,不必通读全文:
| 任务 | 必读章节 |
|------|---------|
| 起稿前必读 | §2.0 + §2.0.1 + §3.0 |
| 不确定文档类型 | §2.0 类型判断决策表 |
| 写决策型(日报/汇报/方案选型) | §2.1 + §3 + §5 + §7 |
| 写执行型(SOP/Runbook/接入指南) | §2.2 + §3 + §4.4 + §5 + §7 |
| 写说明型(接口文档/能力清单) | §2.3 + §3 + §4.3/§4.4 + §7 |
| 写知识沉淀(调研/技术方案/复盘) | §2.4 + §3 + §4.6 |
| 颜色 / emoji 选择 | §5 |
| 何时插图 | §6 |
| 改写老文档 | 直接看 [doc-update-workflow.md](./doc-update-workflow.md) |
| 写完自检 | §8 判定表 |
---
## 一、硬规则
1. **`--name` 是 H1**:正文从 `##` 开始;正文内不写 `#`(除非确需正文内再造一级 H1 并说明动机)
2. **同类信息同表达**:风险、状态、行动项、证据,每类只用一种元素 + 一种视觉语义(见 §5)
3. **Markdown 草稿阶段只用稳定元素**:标题、段落、列表、checklist、表格、代码块;callout / 分栏 / 附件 / 复杂嵌套留到创建后用 `doc block insert` / `doc media insert` 精修
4. **引用块只用于原文**:用户原话、会议摘录、外部材料原文;不许包装作者自己的结论
5. **不编造 URL**:图片、链接、文档 ID 不确定时留 TODO 占位,向用户求证
6. **写入后必须回读**:见 [doc-create-workflow.md «回读验收»](./doc-create-workflow.md) 与 [doc-update-workflow.md §6](./doc-update-workflow.md)
---
## 二、按文档类型选骨架
### 2.0 类型判断决策表
按读者**第一个动作**选类型。若同时符合多类,按表中第一行优先:
| 读者第一个动作 | 类型 | 推荐格式 | 视觉锚点 | 跳转 |
|----|----|----|----|----|
| 按步骤操作(升级、部署、接入、上手) | 执行型 | markdown 起稿 + JSONML 精修(callout 标高风险) | 有序列表 / 代码块 / ⚠️ 高风险 callout | §2.2 |
| 拿结论做选择 / 决策 / 汇报判断 | 决策型 | **直接 JSONML 起稿**(不走 markdown → 精修;见 [doc-create-workflow.md §JSONML 起稿](./doc-create-workflow.md#jsonml-起稿判定)) | ✅ 推荐 callout / 对比表 / 数据加粗 | §2.1 |
| 查参数 / 能力 / 限制 / 错误码 | 说明型 | markdown(表格密集、callout 偶尔) | 参数表 / 错误码表 | §2.3 |
| 看推理链路 / 分析 / 调研过程 | 知识沉淀型 | 含对比/数据可视化时**直接 JSONML 起稿**;纯叙事时 markdown + 精修(见 [doc-create-workflow.md §JSONML 起稿](./doc-create-workflow.md#jsonml-起稿判定)) | ℹ️ 信息 callout / 引用块(原话)/ 流程图 | §2.4 |
| 以上都不像 | 兜底走知识沉淀型 §2.4 | — | — | — |
> **推荐格式列**:起稿统一用 markdown,富结构(callout/分栏/带颜色的对比/sectPr)一律走精修阶段的 JSONML;JSONML 形态优先级与命令见 [doc-update-workflow.md §1.3](./doc-update-workflow.md)。决策型默认进 JSONML 优先,因为汇报/方案的视觉锚点(callout + 彩色表头)markdown 表达不出来。
### 2.0.1 写前三问(草稿前 30 秒自答)
下笔前先答三句话;答不出第二、三句说明信息不足,回 [doc-create-workflow.md «创建前检查»](./doc-create-workflow.md) 补齐:
1. **读者**:谁打开这篇文档?读完要做什么动作(操作 / 选择 / 查参数 / 看推理)?
2. **唯一记忆点**:读者关掉文档后,最想让他记住的一句话是什么?这句话决定开头摘要 / callout 该写什么。
3. **形态**:按 §2.0 推荐格式列 + [doc-create-workflow.md §JSONML 起稿判定](./doc-create-workflow.md#jsonml-起稿判定) 决定路径。命中判定条件(决策型 / 含对比的知识沉淀型 / 用户意图关键词)→ **直接 JSONML 起稿**(但必须先完成 [doc-create-workflow.md §设计规划](./doc-create-workflow.md#设计规划jsonml-起稿前必做) 的 4 步规划);未命中 → markdown 起稿 + 创建后精修。
写完自检时回看这三个答案:开头有没有兑现「记忆点」、形态有没有兑现「推荐格式」。两条任一不兑现,按 §8 自检表对应行动。
### 2.1 决策型(日报、月报、复盘、方案选型、汇报)
**适用**:读者读完要拿到判断或做选择。
| 段位 | 内容 | 推荐元素 |
|------|------|----------|
| 开头 | 结论、推荐方案、关键数据 | 2-4 条 bullet 摘要 |
| 主体 | 选项 / 维度 / 风险 / 数据 | 对比表、风险表、关键指标 |
| 收尾 | 下一步、需用户决策事项 | callout(仅决策有时限或重大风险)|
**反推荐**:长背景铺垫、连续叙事、结论藏在文末。
样板:
~~~~markdown
## 摘要
- 推荐方案 A:上线快、依赖已有流程
- 主要风险:权限配置需补
- 决策时限:本周五前
## 方案对比
| 维度 | 方案 A | 方案 B | 建议 |
|------|--------|--------|------|
| ... | ... | ... | ... |
## 下一步
- [ ] @负责人 完成权限配置
~~~~
### 2.2 执行型(SOP、TODO、行动方案、接入指南、Runbook)
**适用**:读者读完要按步骤操作。
| 段位 | 内容 | 推荐元素 |
|------|------|----------|
| 开头 | 目标、范围、前置条件 | 短段落 + checklist(前置条件)|
| 主体 | 顺序步骤、操作命令、校验方法 | 有序列表、代码块、流程截图 |
| 收尾 | 异常处理、回滚方法 | 表格(错误码 → 处理)或 callout(高风险动作)|
**反推荐**:多动作压成一段、缺负责人、缺校验方法。
样板:
~~~~markdown
## 目标
将服务 X 从 v1 升级到 v2,零宕机切换。
## 前置条件
- [ ] 备份当前配置
- [ ] 通知下游
## 操作步骤
1. 拉取最新镜像:`docker pull x:v2`
2. 灰度切流:5% → 50% → 100%
3. 每步校验:观察 dashboard,错误率 < 0.1%
## 异常处理
| 错误码 | 含义 | 处理 |
|--------|------|------|
| ... | ... | ... |
~~~~
### 2.3 说明型(产品说明、新人手册、接口文档、能力清单)
**适用**:读者按需查阅,不一定从头读到尾。
| 段位 | 内容 | 推荐元素 |
|------|------|----------|
| 开头 | 适用对象、能力概要 | 短段落或 bullet |
| 主体 | 功能矩阵、参数表、使用示例 | 表格、代码块(带语言标识)|
| 收尾 | 限制、注意事项、变更记录 | callout(限制)、表格(变更记录)|
**反推荐**:长结论、未分类的功能混排、缺示例。
样板(其中"调用示例"位置应放一个 `bash` 语言标识的代码块演示 dws 命令):
~~~~markdown
## 适用对象
本接口供 DWS 内部模块调用,不暴露给外部租户。
## 能力清单
| 能力 | 说明 | 必要参数 |
|------|------|----------|
| ... | ... | ... |
## 调用示例
(此处放一个 bash 代码块演示 dws 命令)
## 限制
> ⚠️ 单次返回最多 1000 个 block,超出请分页。
~~~~
### 2.4 知识沉淀型(调研报告、技术方案、项目复盘、学习笔记)
**适用**:读者要看到推理链路,理解为什么是这个结论。
| 段位 | 内容 | 推荐元素 |
|------|------|----------|
| 开头 | 背景、问题、目标 | 短段落 |
| 主体 | 分析过程、对比、推理 | 小标题分层、表格、引用块(外部原文)|
| 收尾 | 结论、证据链、附件 | 附件(原始材料)|
**反推荐**:结论先行但缺证据链、引用块包装作者自己的判断。
---
## 三、按读者任务选元素(五列表)
### 3.0 AI 文档常见反模式
下笔前快速扫一遍,命中任何一条立刻按右列改:
| 反模式 | 信号 | 改法 |
|------|------|------|
| 万篇一律的「摘要-细节-总结」三段式 | 每篇文档第一节都是「## 摘要」+ 三条 bullet | 按 §2.0 选骨架;执行型不要写摘要,开门见山列前置条件 |
| 全篇 H2 平铺 | 标题层级单一、没有 H3 收纳同主题 block | 按 §7 量化标尺拆 §N.N 子标题 |
| 全列表无表无 callout | 风险、对比、数据全部塞进 bullet | 对比改表(§4.2)、风险改 callout(§4.7)、数据加粗(§5)|
| 结论藏文末 | 关键判断在最后一段才出现 | 按 §2.1 决策型骨架,结论 / 推荐方案前置到摘要 bullet |
| markdown 包不住富结构却硬包 | 草稿里出现 `> ⚠️ ...`、`【callout】`、表格里塞颜色 hex | 按 §2.0 推荐格式切到 JSONML 精修阶段;草稿留占位 |
| 同一语义两种视觉表达 | 风险既用 ⚠️ 又用红色文字也加 callout | §5 收敛到一组(emoji + 颜色 + 元素都按表对齐)|
| 装饰性 emoji 满天飞 | 段落、列表项、普通段每行都带 emoji | §5 规则:emoji 只在 callout / 状态标记 / `H2/H3` 标题前 |
| 编造 URL / 文档 ID / 图片地址 | 出现 `https://example.com/...`、`docs.dingtalk.com/xxx` 占位形 | §4.5 / §6 留 TODO 占位,向用户求证 |
### 3.1 读者任务对照
骨架定好后按读者要完成的具体动作选元素。扫"常见误用"列,命中则按"修复"列调整。
| 读者任务 | 推荐版式 | 避免 | 常见误用 | 修复方法 |
|----------|----------|------|----------|----------|
| 快速了解重点 | 开头 2-4 条 bullet 摘要;重大风险用 callout | 长背景;引用块包装摘要 | 三段式硬套,背景写了 5 段才到结论 | 结论提前到第一条 bullet,背景挪到末尾或删 |
| 做选择 | 多维比较用表格;轻量两项可分栏(精修阶段)| 只两个对象做大宽表 | 两个方案做 6 列对比表,每列一句话 | 改为两段并列短文,或保留 3 维核心对比 |
| 看状态 | 状态表(事项/状态/阻塞/下一步)或 checklist | 长段落描述多个状态 | "A 在做、B 卡住、C 完成…" 写一段 | 转为状态表,每行一个事项 |
| 执行动作 | 有序列表;待办用 checklist;分支用条件表 | 一段话写多个动作 | "先备份再升级然后切流" 写一段 | 拆为有序列表,每步独立 |
| 理解关系 | 小标题分层;复杂关系用图示或附件 | 把复杂关系压成连续段落 | 系统依赖关系写了三段叙事 | 建议用户上传架构图(§6)|
| 查证事实 | 引用块保留原文;真实文件用附件 | 引用块包装改写后的总结 | 作者自己的结论加 `>` 装成引用 | 改为普通段落或加粗 |
| 阅读背景 | 小标题 + 短段落;过长时拆列表 | 为结构化把叙事硬塞表格 | 把"项目历史"硬做时间表 | 用小标题分段叙事 |
**推荐表头**:
- 风险表:`风险 / 影响 / 缓解 / 负责人`
- 状态表:`事项 / 状态 / 阻塞点 / 下一步`
- 对比表:`维度 / 方案 A / 方案 B / 建议`
---
## 四、元素边界规范
### 4.1 标题与段落
- 正文从 `##` 开始(H1 已被 `--name` 占用)
- 标题层级 ≤ 4 层(§7)
- 单段过长先拆段,再考虑换元素
### 4.2 列表与 checklist
- 普通列表:并列要点
- 有序列表:顺序步骤
- checklist:待办状态(含 `- [ ]` / `- [x]`)
列表项里开始出现"负责人 / 截止时间 / 状态"这类字段时,改用表格。
### 4.3 表格
表格用于字段稳定的信息。
- 单元格写短句;解释超过两行放表格下方段落
- 列数 ≤ 6,行数 ≤ 20(§7 给具体拆法)
### 4.4 代码块
必须带语言标识(`bash` / `python` / `json` / `go` 等);无对应语言时用 `text`。
形如(用 \`\`\` 三反引号围栏 + 紧跟语言名 + 闭合 \`\`\`):
~~~~markdown
```bash
dws doc read --node abc123
```
~~~~
约束:
- 单块 ≤ 80 行;超长时拆为多个语义独立的块,或作为附件上传
- 与正文有强关联时,在代码块前后用一句话说明用途
- **禁止**在代码块里写敏感信息(token、密码、内部 IP)
### 4.5 链接与卡片
| 场景 | 用法 | 典型例 |
|------|------|--------|
| 行内引用 | `[文字](url)` | PR/Issue/外部博客 |
| 强调外部资源 | 创建后 `doc block insert` 插入卡片 | 外部 PRD、Figma、Notion 主页 |
| 引用钉钉文档 | 直接粘贴 alidocs URL | @文档(DWS 自动渲染卡片)|
**禁止**:编造看似真实的 URL;不确定的链接留 TODO。
### 4.6 引用块
只用于需保留原貌的内容:用户原话、会议摘录、外部材料原文、API 错误信息原文。
**禁止**把作者自己的摘要、推荐结论或判断写成引用块。
### 4.7 Callout
每篇文档 0-2 个(§7)。
| 场景 | 处理 |
|------|------|
| 创建前必须确认的限制 | ✅ 用 callout |
| 会改变结论的风险 | ✅ 用 callout |
| 需要用户立即决策的分歧 | ✅ 用 callout |
| 普通章节说明 | ❌ 普通段落 |
| 装饰性章节开头提示 | ❌ 删除 |
| 可放进摘要 bullet 的一般结论 | ❌ 放摘要 |
callout 是块级元素,**Markdown 草稿阶段不支持**。创建后用 `doc block insert` 精修。
**形态优先级(按 [doc-update-workflow.md §1.3](./doc-update-workflow.md))**:
1. **首选 JSONML**:`doc block insert --content-format jsonml --element '["container",{"uuid":"...","subType":"colorBlocks","metadata":{"bgcolor":"...","border":"..."}},["p",...]]'`,bgcolor/border 取本文 §5 颜色表
2. **次选 element JSON**:`doc block insert --element '{"blockType":"callout","callout":{...}}'`,字段以 [doc.md](../../doc.md) 为准;JSONML 节点完整结构见 [doc-jsonml-schema.md](../format/doc-jsonml-schema.md) 的 `container[subType="colorBlocks"]`,可复制范例见 [doc-jsonml-cookbook.md](../format/doc-jsonml-cookbook.md)
### 4.8 分栏(精修阶段)
分栏适合两个对象的轻量并置。**Markdown 草稿阶段无法直接写入**,必须创建后用 `doc block insert`。
**形态优先级(按 [doc-update-workflow.md §1.3](./doc-update-workflow.md))**:
1. **首选 JSONML**(保真度最高、和现有 block 结构一致):
```bash
dws doc block insert --node <nodeId> --content-format jsonml \
--element '["container",{"uuid":"cols1","subType":"columns","metadata":{"size":"2"}},["container",{"uuid":"cols1c1","subType":"column"},["p",{"uuid":"cols1c1p"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"左栏"]]]],["container",{"uuid":"cols1c2","subType":"column"},["p",{"uuid":"cols1c2p"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"右栏"]]]]]'
```
2. **次选 element JSON**(老接口,仅当 JSONML 不便构造时):
```bash
dws doc block insert --node <nodeId> --content-format element \
--element '{"blockType":"columns","columns":{"size":2},"children":[{"blockType":"paragraph","paragraph":{"text":"左栏"}},{"blockType":"paragraph","paragraph":{"text":"右栏"}}]}'
```
每栏需要多个字段时**改用表格**。轻量两项对比优先并列短段落,分栏只在视觉强对照需要时使用。完整 JSONML 字段见 [doc-jsonml-schema.md](../format/doc-jsonml-schema.md) 的 `container[subType="columns"]`,可复制范例见 [doc-jsonml-cookbook.md](../format/doc-jsonml-cookbook.md)。
### 4.9 附件与图片
```bash
dws doc media insert --node <nodeId> --file ./diagram.png
```
- 插入后在前后用一句话说明它支持哪个结论
- 插入后用 `doc block list` 验证存在
- **禁止**在 Markdown 里编造无法访问的图片 URL
- **禁止**把 `alidocs.dingtalk.com/i/nodes/...`、`alidocs.dingtalk.com/i/document/...` 或任何文档/节点页面 URL 当作图片 src——这些是页面链接,不是图片资源,写入后无法渲染。图片必须先下载到本地,再通过 `dws doc media insert --node <docId> --file <本地路径>` 插入
- 何时主动建议用户提供图见 §6
---
## 五、颜色与视觉语义
**全篇必须保持语义一致**——同一语义只用同一组视觉表达。
| 语义 | emoji 前缀 | callout bgcolor (hex) | callout border (hex) | 文字加粗 | 典型用法 |
|------|----------|------------------------|----------------------|---------|----------|
| 信息说明 | ℹ️ | `#E8F2FE`(淡蓝)| `#B3D4FC` | — | 普通提示、说明性补充 |
| 推荐结论 | ✅ | `#E3F8E2`(淡绿)| `#B7E4B5` | 是 | 推荐方案、已确认结论 |
| 风险/错误 | ⚠️ / ❌ | `#FDE2E0`(淡红)| `#F5C2C7` | 是 | 风险、错误码、不可逆操作 |
| 待确认 | ❗ | `#FFF6D9`(淡黄)| `#FFE69C` | — | 待用户决策、待补齐信息 |
| 中性辅助 | — | `#F4F5F7`(淡灰)| `#DEE2E6` | — | 非关键背景、变更记录 |
调用规则:
- callout 字段格式以 [doc.md](../../doc.md) / [doc-jsonml-schema.md](../format/doc-jsonml-schema.md) 为准
- 调 `doc block insert --element` 插入 callout 前若字段名不确定,先用 `doc block list --node <id> --block-type callout` 抓现有实例确认字段
- 颜色属性值只接受 hex;**禁止**把语义名(如 `light-blue`)当属性值
- 关键指标用加粗 + ↑↓ 或 +/- 同时标注方向(不仅依赖颜色,兼容色觉无障碍)
- emoji 只在 callout、状态标记、`H2/H3` 标题前使用,不在普通段落和列表项里滥用
---
## 六、何时使用图示
以下信息特征出现时,**主动建议用户提供截图或示意图**,不用纯文本承载:
| 内容特征 | 信号词 | 建议图示 |
|----------|--------|----------|
| 多步骤流程(≥4 步)| "先…然后…最后"、"步骤 1/2/3" | 流程图截图 |
| 系统/模块依赖 | "调用"、"依赖"、"上游/下游"、"请求→响应" | 架构图截图 |
| 时间线/里程碑 | "Q1/Q2"、"阶段一→阶段二"、日期序列 | 时间线图 |
| 数值趋势 | 带数字的时间序列、"增长/下降"、百分比变化 | 折线图/柱状图截图 |
| 占比分布 | "占比"、"份额"、百分比加总 ≈100% | 饼图/树状图截图 |
| 层级递进 | "基础→进阶→高级"、"L1/L2/L3"、"核心→外围" | 金字塔图 |
| 因果/根因 | "导致"、"根因"、"原因"、"影响因素" | 鱼骨图 |
| 闭环/飞轮 | "正循环"、"驱动"、"闭环"、"反馈" | 飞轮图 |
**规则**:
1. 关键流程/架构/趋势能图示就图示,不用纯文本承载
2. **禁止**在 Markdown 里编造图片 URL(如 ``)
3. 正文里留占位(如 `📌 待补充:架构图`),向用户主动询问能否提供截图
4. 用户提供后用 `dws doc media insert --node <id> --file ./xxx.png` 插入,并在图前后补一句说明
---
## 七、量化标尺(软约束)
超出时不一定要重写,但要按"超出处理"列采取具体动作:
| 维度 | 建议上限 | 超出处理(具体动作)|
|------|---------|---------------------|
| 单段行数 | ≤ 5 行 | 按句号拆段;或抽出并列要点转 bullet 列表 |
| 单章节 block 数 | ≤ 12 | 按子主题拆 `### N.N` 子标题;合并冗余 block |
| 标题层级 | ≤ 4 层 | 把最深层标题降级为加粗段落或列表 |
| 表格列数 | ≤ 6 | 按"维度类别"拆为多个子表,前 2 列保留作锚 |
| 表格行数 | ≤ 20 | 拆表(按类别);或转附件 `doc media insert --file table.xlsx` |
| callout 数量 | ≤ 2 / 篇 | 同语义 callout 合并;非关键 callout 降级为加粗或行内 emoji |
| 代码块行数 | ≤ 80 | 按"功能段"拆为多个独立块;超长输出转附件 |
| 连续纯文本段 | ≤ 3 段 | 中间穿插 `##/###` 标题、bullet、或表格分组 |
---
## 八、自检判定表
写完后逐条扫描,命中"判定"列的情况按"动作"列处理:
| 判定 | 动作 |
|------|------|
| 命中 §3.0 任一反模式 | 按 §3.0 对应行改 |
| 文档类型不属于 §2 四类之一 | 回 §2.0 决策表归类;仍归不出走 §2.4 兜底 |
| `--name` 之外正文里还有 `#` 一级标题 | 改为 `##`,除非确需且已说明动机(§4.1)|
| callout 数 > 2 | 按 §4.7 合并同语义;非关键 callout 降级 |
| 单段 > 5 行 | 按 §7 拆段或转列表 |
| 单章节 block 数 > 12 | 按 §7 拆 `### N.N` 子标题 |
| 表格列 > 6 或行 > 20 | 按 §7 拆表或转附件 |
| 代码块缺语言标识 | 补语言标识;无对应语言用 `text`(§4.4)|
| 代码块 > 80 行 | 按功能段拆,或转附件(§7)|
| 同一语义出现 ≥2 种视觉表达 | 按 §5 收敛到同一组 |
| 引用块里写的是作者自己的结论 | 改为加粗段落或普通段落(§4.6)|
| Markdown 草稿里写了 callout / 分栏 | 删掉,转到精修阶段用 `doc block insert`(§4.7 / §4.8)|
| 内容含 "先…然后…"、"调用/依赖"、"Q1/Q2"、"导致/根因" 等信号词但写成纯文本 | 按 §6 询问用户能否补图 |
| 出现编造的图片 URL / 文档 URL | 删除或改为 TODO 占位(§4.5 / §6)|
| 写入完成但未回读 | 按 [doc-create-workflow.md «回读验收»](./doc-create-workflow.md) 或 [doc-update-workflow.md §6](./doc-update-workflow.md) 回读 |
# 钉钉文档改写流程
本文只处理一件事:用户给已有 nodeId 或 alidocs 链接,需要改写、润色、补充章节、转换段落形态时,按本文操作。从零创建新文档见 [doc-create-workflow.md](./doc-create-workflow.md)。
## 适用边界
进入本文前,必须已经确认用户要改写的是已有钉钉文档 (`adoc`)。如果用户要新建、要操作表格 / AI 表格 / 文件 / 知识库空间 / 发消息,不要套用本文。
本文覆盖:
- 已有文档的局部改写、润色、章节补充
- 段落 ↔ 列表 ↔ 表格 的形态转换
- 块级精修(callout、分栏、附件插入)
- overwrite 整篇改写的风险提示与执行
- JSONML 无损结构改写的入口
本文不覆盖:
- 新建文档(见 [doc-create-workflow.md](./doc-create-workflow.md))
- 知识库空间管理
- 文档权限、消息分发、待办分派
---
## 一、核心原则
### 1.1 精准手术优于全量覆盖
**默认走精准手术**——只改用户指定的章节或 block,不动其他内容。具体路径见 §3 速查表。
### 1.2 保真约束
改写时必须**原样保留**以下要素,**不许**替换为纯文本/姓名/链接/占位符:
- `@人` 引用(用户、机器人、群)
- `@文档` / `@群` 等卡片引用
- 已上传的附件、图片
- 用户原话引用块
- 表格表头(除非语义错误且用户确认)
JSONML 模式下这些元素的节点结构见 [doc-jsonml-schema.md](../format/doc-jsonml-schema.md)。
### 1.3 编辑形态优先级
**改写已有文档优先 JSONML,markdown / element 只在 JSONML 不适用时兜底**:
| 优先级 | 形态 | 适用 |
|--------|------|------|
| ① 首选 | `--content-format jsonml` | 保真度最高;callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套结构都能 1:1 round-trip;写入端有 validator 兜底(§4.4) |
| ② 次选 | `--content-format element`(JSON,老接口) | JSONML 不支持某个块字段时;或快速插入 callout / 分栏不想构造 JSONML 时;不保真改写正文 |
| ③ 兜底 | markdown(不带 `--content-format` 即默认)| 纯文本追加、整篇重排骨架;callout / 分栏 / 颜色 / 部分属性会被 markdown 还原过程丢失 |
实操判断:
- 用户给已有 nodeId 要「改一段、改属性、加 callout、动结构」——走 §4.4 JSONML 路径
- 用户要「在末尾追加一节纯文本 / 整篇按新骨架重写」——走 §4.2 / §4.5 markdown 路径
- 同一次任务里两类需求都有——分别走对应路径,**不要**为了省事全部 markdown overwrite
### 1.4 写入风险提示
`doc update` 在以下场景可能产生**静默失败**(返回 success=true 但实际写入不完整):
- **overwrite 降级为 append**:大文档 overwrite 被后端静默降级,导致旧内容未清除、新内容追加在末尾
- **分块 append 内容截断**:超长文档分片写入时部分片段丢失或顺序错乱
- **编码/通道问题**:特殊终端下 UTF-8 内容传输乱码
因此 **每次 `doc update` 后必须回读校验**(见 §6)。整篇 overwrite 大文档前还要先向用户提示风险并等待确认。
---
## 二、读取策略
改写前必须先读现有内容,但要节省上下文。按粒度选读取方式(按 §1.3 优先级排序,**优先 JSONML**):
| 用户需求 | 读取方式 | 定位方法 |
|----------|----------|----------|
| 单块精修(首选)| `doc block list --node <id> --content-format jsonml` → 拿 uuid → `doc block list --node <id> --content-format jsonml --block-id <uuid>` 读子树 | 节点结构见 [doc-jsonml-schema.md](../format/doc-jsonml-schema.md) |
| 多处保真改写 / 改 root sectPr | `doc read --node <id> --content-format jsonml --output /tmp/doc.json` | 解析 JSON,按 schema 操作;担心并发覆盖时记下 `revision` 供 update 透传 |
| 整篇按新骨架重写(纯文本场景)| `doc read --node <id>`(markdown 输出)| 直接处理 markdown 全文,定位用 `grep -n "<章节关键词>"` |
| 末尾追加纯文本章节 | 不必读全文,直接 §4.2 append | 必要时 `doc read` 看末尾衔接 |
| 老接口快速找 BLOCK_ID(无需 jsonml 时)| `doc block list --node <id>` | 默认输出 JSON;用 `grep -B2 -A2 "<关键词>"` 在 children 里定位(结构 `{"blocks":[{...,"children":[...]}]}`,jq 需 `..\|.text? // empty` 递归查文本) |
读取后,把改写计划告诉用户(要改哪几节、走 JSONML 还是 markdown、改成什么形态),等用户确认后再写。
---
## 三、改写路径速查
按用户请求形态查表,跳到对应详细节执行(**按 §1.3 优先级排序:JSONML 路径在前,markdown / element 兜底在后**):
| 用户请求 | 推荐路径 | 详细节 |
|----------|----------|--------|
| 改某一章 / 某一节(首选) | block list 拿 uuid → block update --content-format jsonml | §4.4 路径 B |
| 改属性 / 改 mark / 改颜色不动文本 | block update --content-format jsonml | §4.4 路径 B |
| 插入 callout / 分栏 / 嵌套结构(首选) | block insert --content-format jsonml --element '[...]' | §4.4 路径 B |
| 多处保真改写 / 改 root sectPr | 整篇 JSONML overwrite(默认不带 --revision;并发敏感时再加) | §4.4 路径 A |
| 中间插一段纯文本 | block insert(element JSON 或 jsonml) | §4.3 / §4.4 |
| 末尾追加一节纯文本 | doc update --mode append(markdown) | §4.2 |
| 整篇按新骨架重写 | overwrite 全文(优先 JSONML;纯文本可用 markdown) | §4.5 |
| 段落转表格 / 表格转段落 | block update --content-format jsonml;或 markdown overwrite 单段 | §4.4 / §4.1 |
| 插入附件 / 图片 | doc media insert | §4.3 |
| 一次追加 >200KB 内容 | 分块 append + 用户风险确认 + 逐片记录 | §4.6 |
| 兜底:纯文本快速替换某段 | doc update --content overwrite(markdown) | §4.1 |
---
## 四、改写路径详细
> **首选 JSONML(§4.4)**——保真度最高且 validator 兜底;本节其余路径(markdown / element)仅在 §1.3 列出的"次选 / 兜底"场景下使用。
### 4.1 段落级 overwrite(markdown 兜底路径)
> 适用范围:**纯文本**改写一段或替换某节内容。若该段含 callout / 分栏 / 颜色 / @人 / 附件 / 嵌套结构,**改走 §4.4 路径 B**——markdown 还原会丢失这些元素。
```bash
dws doc update --node <nodeId> --content "<新内容>" --mode overwrite --content-format markdown
```
或写入临时文件:
```bash
dws doc update --node <nodeId> --content-file /tmp/<name>-section.md --mode overwrite --content-format markdown
```
> ⚠️ **overwrite 须用户确认**——尤其是整篇文档 overwrite。
### 4.2 追加章节(markdown)
> 适用范围:在文档末尾加 X 章 / 补充纯文本段落。追加内容若含 callout / 分栏等富结构,先用本节 append 一个占位段落,再用 §4.4 路径 B 的 `block insert --content-format jsonml` 替换/精修。
```bash
dws doc update --node <nodeId> --content-file /tmp/<name>-append.md --mode append --content-format markdown
```
按 [doc-style-guideline.md](./doc-style-guideline.md) 的元素选择规则准备追加内容。
### 4.3 块级精修(element JSON 次选路径)
> 适用范围:JSONML 不支持某个字段时,或快速插入 callout / 分栏不想构造 JSONML 时。**默认优先 §4.4 路径 B**(block update/insert `--content-format jsonml`),本节是老接口次选路径。
```bash
# 列出所有 block,定位 BLOCK_ID
dws doc block list --node <nodeId>
# 改一个 block 的文本
dws doc block update --node <nodeId> --block-id <BLOCK_ID> --text "替换后的内容" --content-format element
# 在某个 block 后插入
dws doc block insert --node <nodeId> --ref-block <BLOCK_ID> --where after --heading "补充说明" --level 2 --content-format element
# 插入复杂块(callout / 分栏)—— element 默认按 JSON 解析
dws doc block insert --node <nodeId> --ref-block <BLOCK_ID> --where after --content-format element \
--element '{"blockType":"callout","callout":{"emoji":"⚠️","bgColor":"#FDE2E0","content":[{"text":"高风险操作,先备份"}]}}'
# 若改写过程已经在用 JSONML,整段精修也可走 jsonml 路径(uuid 必须 == --block-id)
dws doc block insert --node <nodeId> --ref-block <BLOCK_ID> --where after --content-format jsonml \
--element '["container",{"uuid":"co_new","subType":"colorBlocks","metadata":{"bgcolor":"#FDE2E0","border":"#F5C2C7"}},["p",{"uuid":"co_new_p1"},["span",{"data-type":"text"},["span",{"data-type":"leaf"},"高风险操作,先备份"]]]]'
```
字段结构以 [doc-block.md](../doc-block.md) 为准,不要猜。callout 字段名不确定时,先用 `doc block list --node <id> --block-type callout` 抓现有 callout 实例看真实字段。整段 JSONML 形态与可复制范例见 §4.4 与 [doc-jsonml-cookbook.md](../format/doc-jsonml-cookbook.md)。
### 4.4 JSONML 无损改写(**首选路径**)
> 改写已有文档**默认走本节**——保真度最高,callout / 分栏 / 表格 / @人 / 附件 / 颜色 / 嵌套都能 1:1 round-trip;写入端有 validator 兜底。其他路径(§4.1/4.2/4.3/4.5 markdown)仅在 §1.3 列出的"次选 / 兜底"场景下使用。
两条子路径:
**路径 B:单 block JSONML 精修(最常用——只动一个 block 时的默认选择)**
```bash
# 1. 列出所有 block 拿到 uuid
dws doc block list --node <nodeId> --content-format jsonml
# 2. 读单个 block 完整子树
dws doc block list --node <nodeId> --content-format jsonml --block-id <BLOCK_UUID>
# 3. 改完后写回(uuid 必须 == --block-id)
dws doc block update --node <nodeId> --block-id <BLOCK_UUID> --content-format jsonml \
--element '["p", {"uuid": "<BLOCK_UUID>"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "新内容"]]]'
# 在某个 block 前/后插入新 block
dws doc block insert --node <nodeId> --ref-block <BLOCK_UUID> --where after --content-format jsonml \
--element '["container", {"uuid": "co1", "subType": "colorBlocks", "metadata": {"bgcolor": "#E8F2FE", "border": "#B3D4FC"}}, ["p", {"uuid": "co1p1"}, ["span", {"data-type": "text"}, ["span", {"data-type": "leaf"}, "提示内容"]]]]'
```
**路径 A:整篇 JSONML overwrite(一次改多处、改 root 级 sectPr 才用)**
```bash
# 1. 读出完整 JSONML 结构(输出含 revision,普通改写场景下不需要)
dws doc read --node <nodeId> --content-format jsonml --output /tmp/doc.json
# 2. 解析 JSON,修改 jsonml 数组中的目标节点
# 节点结构见 doc-jsonml-schema.md,可复制范例见 doc-jsonml-cookbook.md
# 3. 写回临时文件 /tmp/doc_modified.json,格式 {"jsonml": [...]}
# 4. 提交修改(默认直接覆盖,不做并发检查)
dws doc update --node <nodeId> --content-file /tmp/doc_modified.json \
--content-format jsonml --mode overwrite
```
> **并发安全模式(担心被并发覆盖时使用)**:如果担心多 agent 同时改这篇文档,可以把第 1 步 read 返回的 `revision` 通过 `--revision <N>` 透传给第 4 步:服务端会做并发检查,版本不一致返回 `VersionConflict`,此时回到第 1 步重读重写即可。普通单 agent 改写场景默认不传 `--revision`。
#### JSONML 写入端的 validator
写入命令(`doc create/update` + `doc block insert/update`)走 **validate** 一步,不做结构修复:
| 行为 | 缺省 | `--fix-jsonml` |
|------|------|----------------|
| JSON 语法修复(括号/逗号补全) | ✗ | ✓(打印 `[FIX]`) |
| validator 阻断(HasErrors → 拒发) | ✓ | ✓ |
| root 校验(仅 doc create/update) | ✓ | ✓ |
报错格式(agent 友好):
```
$[2][2]: paragraph child must be span wrapper, got raw string.
Suggestion: ["span",{"data-type":"text"},["span",{"data-type":"leaf"},"<your text>"]]
```
设计要点:
- 缺省为严格模式:不做结构修复,裸字符串、缺 uuid 等错误会被 validator 抦下。
- `doc create/update` 要求 body 必须以 `["root", ...]` 为根节点,缺少会报错。`doc block insert/update` 不要求 root。
- `--fix-jsonml`:启用 JSON 语法修复(修复 LLM 遗漏的括号/逗号),推荐 agent 调用。
**何时不走本节、改用 markdown**:纯文本追加章节(§4.2)、整篇按全新骨架重写(§4.5,且无富结构需要保留时)、只在乎"加一段文字"且确认目标段落无 callout / 分栏 / 颜色 / @人 / 附件。其余场景默认本节。
字段细节见 [doc-jsonml-schema.md](../format/doc-jsonml-schema.md);可复制范例见 [doc-jsonml-cookbook.md](../format/doc-jsonml-cookbook.md)。
### 4.5 整篇 overwrite
适合「按新风格重写整篇」「按新骨架重组结构」。
**形态选择(按 §1.3 优先级)**:
- 若原文档含 callout / 分栏 / 颜色 / @人 / 附件 / 嵌套结构且需要保留——**走 §4.4 路径 A**(整篇 JSONML overwrite;默认不带 `--revision`,担心并发时再加)
- 若是纯文本骨架重写、原文档没有富结构需要保真——走本节 markdown overwrite
执行前必须先向用户**显式提示**:
> 注意:本次操作将覆盖整篇文档内容(约 {size})。可能存在以下风险:
> - 大文档 overwrite 可能被后端静默降级为 append,导致**旧内容残留 + 新内容追加在末尾**
> - markdown overwrite 会丢失原文档的 callout / 分栏 / 颜色等富结构;如需保真改走 §4.4 路径 A
> - 写入完成后我会回读校验,发现异常会主动报告
>
> 是否继续?
得到确认后执行(markdown 兜底路径):
```bash
dws doc update --node <nodeId> --content-file /tmp/<name>-full.md --mode overwrite --content-format markdown
```
**写入后必须回读**(§6)。如果发现旧内容残留,按 §6 的修复路径处理。
### 4.6 超长内容追加(分块 append)
当一次性追加内容 **超过 200KB** 时,必须拆分为多片 `--mode append`,并在执行第一片**之前**向用户发出截断风险提示等待确认。
完整规范(提示话术模板、触发条件、失败处理)见 [04-document.md «分块 append 截断风险提示»](../../../best_practices/04-document.md)。
update 场景下的额外约束:
1. 按段落/标题边界切分,**禁止**在表格、代码块、列表内部截断
2. 每写一片记录已写入的最后一个标题/段落标记,供 §6 回读比对
3. 与既有内容衔接位置不能产生悬空标题或断列表
---
## 五、改写时的样式约束
按 [doc-style-guideline.md](./doc-style-guideline.md) 处理:
- **文档类型保持不变**;用户明确要求转型时除外(如从「执行型 SOP」改成「说明型接口文档」)
- **同类信息保持一致**:改写时不要把原本统一的元素改为多种表达
- **颜色/emoji 语义**:改写后仍满足 style guideline §5「颜色与视觉语义」的一致性
- **不删除附件/图片**;用户明确要求时除外
---
## 六、回读验收
**所有 `doc update` 完成后都必须回读**,无论 overwrite 还是 append:
```bash
dws doc read --node <nodeId> --content-format jsonml
```
校验要点:
- 改写章节的关键标题、段落首句、表格表头是否符合预期
- overwrite 后旧内容是否真的被清除
- append 后新内容是否在期望位置
- 表格、代码块、列表跨块元素是否完整
- @人、附件、图片等保真要素是否原样保留
### 异常处理
| 现象 | 可能原因 | 处理 |
|------|----------|------|
| overwrite 后旧内容残留 + 新内容追加在末尾 | overwrite 被静默降级为 append | 告知用户 overwrite 降级,按下方「先清空再重建」路径修复 |
| append 后部分片段缺失 | 分块写入丢失 | 定位缺失片段,针对该段单独再 append 一次 |
| @人 / 附件被替换为纯文本 | 改写时未走保真约束 | 用 JSONML 无损编辑修复(§4.4)|
| 整篇内容乱序 | 写入顺序异常 | 报告给用户;若可重做,按下方「先清空再重建」路径修复 |
**禁止**在未回读的情况下向用户报告"已完成"。
---
## 七、交付口径
只报告已经验证过的信息:
- 改写涉及的章节范围
- 改写后的 nodeId 与 docUrl
- 回读验收结果(哪些章节确认改写成功、保真要素是否完整)
- 如有缺失或异常,说明具体位置和已采取的修复动作
未回读前,不要说「内容完整」「改写完成」。
# 钉盘 (drive) 命令参考
## 查询命令帮助
当你不确定某个命令的具体参数、格式或可选项时,**优先执行 `--help` 查询**,不要猜测参数名或凭记忆编造。
```bash
# 查看 drive 下所有子命令
dws drive --help
# 查看具体命令的完整参数说明
dws drive list --help
dws drive search --help
dws drive upload --help
dws drive download --help
```
规则:
- 参数名不确定时 → 先 `--help`,再调用
- 报错 "unknown flag" 时 → `--help` 确认正确的 flag 名称
- 不确定某个功能是否存在时 → `dws drive --help` 查看命令列表
## 命令总览
### 获取文件/文件夹列表
```
Usage:
dws drive list [flags]
Example:
dws drive list --limit 20
dws drive list --limit 20 --folder <dentryUuid> --order-by name --order asc
Flags:
--limit int 每页返回数量,默认 20,最大 50 (可选)
--cursor string 分页游标,首次不传 (可选)
--order string 排序方向: asc|desc,默认 desc (可选)
--order-by string 排序字段: createTime|modifyTime|name (可选)
--folder string 父节点 ID (dentryUuid),不传则列出空间根目录 (可选)
--space-id string 空间 ID,不传则使用「我的文件」对应 spaceId (可选)
--thumbnail 是否返回缩略图信息 (可选)
```
### 获取钉盘空间列表
```
Usage:
dws drive list-spaces [flags]
Example:
dws drive list-spaces
dws drive list-spaces --space-type mySpace
dws drive list-spaces --space-type orgSpace --limit 20 --cursor <TOKEN>
Flags:
--space-type string 空间类型: orgSpace=企业空间(默认), mySpace=我的文件 (可选)
--limit int 每页返回数量 (默认 20,最大 50),仅 spaceType 为 orgSpace 时有效
--cursor string 分页游标,仅企业空间支持分页 (可选)
```
spaceType 筛选规则:
- `orgSpace`(默认/不传):返回企业空间列表,支持 `nextToken` 分页
- `mySpace`:返回用户的"我的文件"个人空间(单个,不支持分页)
返回字段说明:
- `spaceId` — 空间 ID,用于 `list`/`info`/`upload` 等命令的 `--space-id`
- `spaceName` — 空间名称(如"全员文件夹"、"我的文件")
- `rootFolderId` — 空间根目录的 dentryUuid,可作为 `doc copy/move` 的 `--folder` 参数
- `spaceType` — 空间类型(如 `orgSpace`)
- `nextToken` — 若不为空,表示还有更多空间可查询(仅企业空间)
### 搜索钉盘文件/文件夹/空间
按关键词在钉盘中搜索文件、文件夹或团队空间。不同于 `list`(需要明确的 spaceId/parentId 逐层遍历),`search` 用于不知道具体位置、只记得名称/关键词的场景。
```
Usage:
dws drive search [flags]
Example:
dws drive search --query "季度汇报"
dws drive search --query "合同" --target file --extensions pdf,docx
dws drive search --query "项目" --target space
dws drive search --query "方案" --created-from 1700000000000 --created-to 1710000000000
dws drive search --query "周报" --creator-uids 012345
dws drive search --query "报告" --limit 30 --cursor <pageToken>
Flags:
--query string 搜索关键词 (必填)
--target string 搜索目标: all(默认) | file | space (可选)
--file-types strings 按文件内容类型过滤,逗号分隔: alidoc,document,image,video,audio,archive (仅 target=file/all 生效)
--extensions strings 按文件扩展名过滤,不含点号,逗号分隔 (如 pdf,docx,adoc)
--creator-uids strings 按创建者用户 ID 过滤,逗号分隔
--created-from int 创建时间起始 (毫秒时间戳,含)
--created-to int 创建时间截止 (毫秒时间戳,含)
--modified-from int 修改时间起始 (毫秒时间戳,含)
--modified-to int 修改时间截止 (毫秒时间戳,含)
--limit int 每页返回数量(默认 10,最大 30)
--cursor string 分页游标,从上次返回的 nextCursor 获取 (可选)
```
搜索目标 (`--target`) 选择规则:
- `all`(默认):同时搜文件与空间,返回混合结果 — 不确定目标是文件还是空间时使用
- `file`:只搜文件 / 文件夹,支持 `--file-types` / `--extensions` 过滤 — 明确是找文件时使用
- `space`:只搜团队空间 — 明确知道空间名、需快速定位空间 spaceId/rootFolderId 时使用
返回结果中 `type` 字段区分:`SPACE`(空间)、`FILE`(普通文件)、`FOLDER`(文件夹)、`ALIDOC`(钉钉在线文档)。
> **提示**:结果按相关性排序,首页未命中时优先调整关键词 / 补充 `--file-types`/`--extensions` 缩小范围 / 加上时间范围,而非反复翻页。
### 获取文件元数据信息
```
Usage:
dws drive info [flags]
Example:
dws drive info --node <dentryUuid>
Flags:
--node string 节点 ID (dentryUuid) (必填)
--space-id string 节点所属空间 ID (可选)
```
### 文件内容获取路由规则
> 当用户请求"分析/查看/读取某个钉盘文件内容"时,**必须先调用 `dws drive info` 获取文件元数据**,再根据返回的 `extension` 字段选择对应链路。
> 注意:若检测到钉钉文档类型(adoc/axls/amind/adraw),会自动跟进调用 `doc info` 返回更准确的文档信息。
| extension | 文件类型 | 操作 | 命令 |
|-----------|---------|------|------|
| adoc | 在线文档 | 在线获取 Markdown 内容 | `dws doc read --node <fileId>` |
| axls | 在线表格 | 在线读取表格数据 | `dws sheet get-all-sheets` → `dws sheet get-range` |
| able | 多维表格 | 在线查询记录 | `dws aitable get-tables` → `dws aitable query-records` |
| 其他(pdf/docx/txt/png 等) | 普通文件 | **不支持在线分析**,需用户主动下载后本地查看 | `dws drive download` |
### 下载文件到本地
下载流程一步到位:获取下载 URL → HTTP GET 下载文件二进制内容到本地。
```
Usage:
dws drive download [flags]
Example:
dws drive download --node <dentryUuid> --output ./report.pdf
dws drive download --node <dentryUuid> --output ~/downloads/
Flags:
--node string 文件 ID (dentryUuid) (必填)
--output string 本地保存路径 (必填),可以是文件路径或目录;如果指定目录,文件名从下载 URL 中自动推断
--space-id string 文件所属空间 ID (可选)
```
> **注意**:`--output` 是必填参数,不传会报错。
### 创建文件夹
```
Usage:
dws drive mkdir [flags]
Example:
dws drive mkdir --name "项目资料"
dws drive mkdir --name "子目录" --folder <dentryUuid>
Flags:
--name string 文件夹名称,最长 50 字符 (必填)
--folder string 父节点 ID (dentryUuid),不传则在空间根目录下创建 (可选)
--space-id string 目标空间 ID,不传则使用「我的文件」 (可选)
```
### 上传本地文件到钉盘
> **注意:** 上传文件必须使用 `dws drive upload` 命令,禁止使用 `upload-info` + `curl` + `commit` 三步流程。
```
Usage:
dws drive upload [flags]
Example:
dws drive upload --file ./report.pdf
dws drive upload --file ./slides.pptx --file-name "Q1汇报.pptx"
dws drive upload --file ./data.xlsx --folder <dentryUuid>
Flags:
--file string 本地文件路径 (必填)
--file-name string 文件显示名称 (默认使用文件名)
--space-id string 目标空间 ID,不传则使用「我的文件」 (可选)
--mime-type string 文件 MIME 类型,不传则自动推断 (可选)
--folder string 父节点 ID (dentryUuid),不传则上传到空间根目录 (可选)
```
`upload` 命令内部自动完成三步流程(获取凭证 → OSS PUT → 提交入库),无需手动分步操作。
### 删除文件/文件夹到回收站
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws drive delete [flags]
Example:
dws drive delete --node <dentryUuid> --format json # 查询 fileId: dws drive list
Flags:
--node string 文件/文件夹 ID (dentryUuid),即 drive list 返回的 fileId (必填)
```
注意:`--node` 使用的是 `drive list` 返回结果中的 `fileId` 字段(即 `dentryUuid`),**不是** `dentryId` 字段。
## 意图判断
用户说"我的文件/钉盘/网盘/云盘" → `list`
用户说"钉盘空间/团队文件/有哪些空间/空间列表/团队文件列表" → `list-spaces`
用户说"搜索钉盘文件/钉盘里找个文件/查找某个钉盘文件/钉盘中搜索" → `search`
用户说"文件详情/文件信息" → `info`
用户说"下载文件" → `download`
用户说"新建文件夹/创建目录" → `mkdir`(钉盘空间)/ `folder create`(文档空间)
用户说"上传文件/传文件到钉盘" → `upload`(必须使用此命令,自动完成三步流程)
用户说"复制文件/移动文件/搬到/移到" → `copy` / `move`
用户说"重命名/改名" → `rename`
用户说"删除文件/删除文件夹/移到回收站" → `delete`(危险操作,需确认)
用户说"给文档授权/分享权限" → `permission add`
关键区分: drive(文件管理) vs doc(文档内容读写) vs wiki(空间管理)
**drive search vs wiki node search**: 用户提到"钉盘/网盘/我的文件里搜" → `drive search`;提到"知识库/文档空间/workspace 里搜" → `wiki node search`;未明确目标时优先问明。
**drive upload**: 文件上传统一走 `drive upload`。上传到知识库/文档空间时加 `--workspace` 参数。
**drive permission vs wiki member**: "给某篇文档/文件授权" → `drive permission add`(节点级);"给某个知识库整体加成员" → `wiki member add`(空间级)
**创建在线文档/表格/脑图**: drive 不支持创建文件,需走 `wiki node create --type <type>`(创建空节点)或 `doc create`(创建并写入内容)。
**导出文档/导出为Word**: 导出是内容层操作,走 `doc export`,不属于 drive。
## 核心工作流
```bash
# 1. 浏览「我的文件」根目录
dws drive list --limit 20 --format json
# 2. 进入子目录 — 提取 dentryUuid 作为 folder
dws drive list --limit 20 --folder <dentryUuid> --format json
# 3. 查看文件元数据
dws drive info --node <dentryUuid> --format json
# 4. 下载文件到本地
dws drive download --node <dentryUuid> --output /tmp/ --format json
# 5. 创建文件夹
dws drive mkdir --name "项目资料" --format json
# 6. 上传文件(必须使用 upload 命令,禁止手动分步操作)
dws drive upload --file ./报告.pdf --format json
dws drive upload --file ./报告.pdf --folder <dentryUuid> --format json
# 7. 删除文件/文件夹到回收站(危险操作:必须先向用户确认,用户同意后才加 --yes 执行)
# 正确流程:1.向用户展示"即将删除「文件名」到回收站" → 2.等用户确认 → 3.执行下面命令
dws drive delete --node <dentryUuid> --yes --format json
```
## 文档空间管理命令
> 以下命令操作的是**文档空间**(知识库 / 我的文档),底层路由到 doc MCP server。
> 与钉盘命令(list / mkdir / upload 等)的区别:钉盘命令操作钉盘空间(spaceId 纯数字),文档空间命令操作知识库/我的文档(workspaceId 加密 string)。
### 复制/移动/重命名文件
```
Usage:
dws drive copy --node <ID> [--folder <TARGET>] [--workspace <WS>]
dws drive move --node <ID> [--folder <TARGET>] [--workspace <WS>]
dws drive rename --node <ID> --name "新名称"
Flags:
--node string 文档/文件 ID 或 URL (必填)
--folder string 目标文件夹 nodeId
--workspace string 目标知识库 ID
--name string 新名称 (仅 rename 必填)
```
> **字段选择**:`drive list` 返回中有 `dentryId`(数字格式)和 `fileId`(UUID 格式),**必须使用 `fileId`(UUID 格式)**作为 `--node` 和 `--folder` 参数值。
### 创建文件夹(文档空间)
```
Usage:
dws drive folder create --name "文件夹名"
Flags:
--name string 名称 (必填)
--folder string 父文件夹 nodeId
--workspace string 目标知识库 ID
```
### 权限管理(文档节点级)
> 仅适用于文档空间节点,不适用于钉盘文件。
```
Usage:
dws drive permission add --node <ID> --users uid1,uid2 --role READER
dws drive permission update --node <ID> --users uid1 --role EDITOR
dws drive permission list --node <ID>
dws drive permission remove --node <ID> --users uid1
Flags:
--node string 目标节点 ID 或 URL (必填)
--users string 用户 userId 列表,逗号分隔
--role string 角色: MANAGER / EDITOR / DOWNLOADER / READER
--limit int 返回成员数上限 (仅 list,默认 30,最大 200)
--filter-role string 按角色过滤 (仅 list)
```
> **注意**:`drive export` 不存在。导出仅对自研文档 (adoc) 有意义,属于内容层操作,应使用 `doc export`。
### 目标位置参数规则
| 目标位置 | 参数传递方式 | 前置步骤 |
|---------|-----------|---------|
| 未指定目标(默认) | `--folder <rootFolderId>` | 先 `dws drive list-spaces --space-type mySpace` 获取「我的文件」的 `rootFolderId` |
| 知识库空间根目录 | `--workspace <workspaceId>` | 无需额外步骤 |
| 钉盘 space 根目录 | `--folder <rootFolderId>` | 先 `dws drive list-spaces` 获取目标 space 的 `rootFolderId` |
| 钉盘 space 下的子文件夹 | `--folder <fileId>` | 先 `dws drive list --space-id <spaceId>` 逐层浏览 |
### 工作流示例
```bash
# ── 场景 默认: 复制/移动到「我的文件」根目录 ──
dws drive list --space-id <SPACE_ID> --format json
dws drive list-spaces --space-type mySpace --format json
dws drive copy --node <源文件dentryUuid> --folder <我的文件rootFolderId> --format json
# ── 场景 A: 复制到知识库空间根目录 ──
dws drive copy --node <源文件dentryUuid> --workspace <TARGET_WS_ID> --format json
# ── 场景 B: 移动到另一个钉盘 space 根目录 ──
dws drive list-spaces --format json
dws drive move --node <源文件dentryUuid> --folder <目标space的rootFolderId> --format json
# ── 场景 C: 复制到钉盘子文件夹 ──
dws drive list --space-id <TARGET_SPACE_ID> --format json
dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
| ------------- | ---------------------------- | -------------------------------------------------------- |
| `list` | **`fileId`**(UUID 格式,注意:不是 `dentryId`) | info / download / mkdir / delete / list 的 --node 或 --folder;`drive copy/move` 的 --node 或 --folder |
| `list` | `spaceId` | info / download / mkdir / commit 的 --space-id |
| `list-spaces` | `rootFolderId` | `drive copy/move` 的 --folder(复制/移动到钉盘 space 根目录时) |
| `list-spaces` | `spaceId` | list / info / download / mkdir / upload 的 --space-id |
| `search` | **`fileId`**(文件/文件夹结果) | info / download / delete 的 --node;list 的 --folder |
| `search` | `spaceId` / `rootFolderId`(空间结果) | list 的 --space-id;`drive copy/move` 的 --folder |
| `search` | `nextCursor` | search 的 --cursor(翻页) |
| `mkdir` | `fileId`(UUID 格式) | list 的 --folder |
> **重要**:`drive list` 返回结果中同时包含 `dentryId` 和 `fileId` 两个字段。所有需要传 `--node` 的命令(info / download / delete)必须使用 `fileId`(即 dentryUuid),**不要使用** `dentryId`。
## 注意事项
- 不传 `--space-id` 时默认使用「我的文件」空间
- 不传 `--folder` 时默认操作空间根目录
- `--folder` 只能使用父文件夹的 `dentryUuid`。不要把 `drive info` 返回的数字型 `dentryId` 当作父目录;`dentryId` 只用于 `chat message send --dentry-id`
- **`--limit` 最大值为 50**,禁止传入超过 50 的值(如 `--limit 100`)。用户要求超过 50 条时,应使用 `--limit 50` 配合 `--cursor` 分页查询,不要直接传大于 50 的值
- `--order-by` 支持: `createTime`、`modifyTime`、`name`
- **上传文件必须使用 `dws drive upload` 命令**,禁止使用 `upload-info` + `curl` + `commit` 三步手动流程
- `--file-name` 必须包含扩展名(如 `report.pdf`)
## 自动化脚本
| 脚本 | 场景 | 用法 |
| ------------------------------------------------------ | ----------- | ------------------------------------- |
| [drive_tree_list.py](../../scripts/drive_tree_list.py) | 递归列出钉盘目录树结构 | `python drive_tree_list.py --depth 2` |
## 相关产品
- [doc](./doc.md) — 文档内容读写/知识库空间,不是文件存储
- [chat](./chat.md) — 上传文件到 drive 后可通过 Markdown 语法发送图片/文件消息
# 邮箱 (mail) 命令参考
## 命令速查目录
| 命令 | 功能简述 |
|------|----------|
| `dws mail mailbox list` | 查询**当前用户自己**的可用邮箱列表 |
| `dws mail message search` | 搜索邮件(KQL 语法,按主题/发件人/日期等) |
| `dws mail message get` | 查看邮件完整内容(含正文) |
| `dws mail message send` | 发送邮件(支持附件/内联图片) |
| `dws mail message reply` | 回复邮件(支持附件/内联图片) |
| `dws mail message reply-all` | 回复所有人(支持附件/内联图片) |
| `dws mail message forward` | 转发邮件(支持附件/内联图片) |
| `dws mail message batch-move` | 批量移动邮件到指定文件夹 |
| `dws mail message batch-delete` | 批量删除邮件 |
| `dws mail draft create` | 创建草稿(保留在草稿箱,不发送) |
| `dws mail draft update` | 更新草稿内容(保留在草稿箱,不发送) |
| `dws mail draft send` | 发送草稿箱中已有的草稿 |
| `dws mail folder list` | 列举邮件文件夹 |
| `dws mail attachment list` | 列举指定邮件的所有附件 |
| `dws mail attachment download` | 下载邮件附件到本地(**仅支持逐个下载,不支持批量下载**) |
| `dws mail tag list` | 列举邮件标签 |
| `dws mail thread get` | 获取会话详情 |
| `dws mail user search` | 搜索通讯录用户(**按姓名查他人邮箱**,不是搜邮件) |
> **查找他人邮箱**(如「获取严龙的邮箱」)→ **不要用 `mailbox list`**,应走三路并发查询,详见「查找他人邮箱地址」章节。
---
## 默认邮箱选择规则(重要)
所有 mail 相关命令,**除非用户明确要求使用个人邮箱,否则一律默认使用企业邮箱**。
**适用范围:** 任何需要传入 `--email` / `--from` / `--sender` 参数的 mail 子命令一律适用。
**默认选择策略:**
1. 调用 `dws mail mailbox list --format json` 获取当前用户的所有邮箱。
2. 从返回的 `mailboxes` 中**优先选择企业邮箱**(账号类型为企业邮箱、域名非 `@dingtalk.com` 的邮箱),将其作为 `--email` / `--from` 的默认值。
3. 仅当用户在指令中**明确指定**「用我的个人邮箱」「用 dingtalk.com 邮箱」「用我的私人邮箱」等表述时,才选择个人邮箱(`@dingtalk.com` 域名)。
4. 若用户同时拥有多个企业邮箱(如分属多家公司),优先选择与当前会话上下文匹配的企业邮箱;若仍无法判断,向用户确认后再操作。
5. 若用户**仅拥有个人邮箱**(无企业邮箱),可直接使用个人邮箱,但需注意 `mail user search` 等仅企业邮箱可用的命令会因权限报错,需走「查找他人邮箱地址」章节的替代路径。
**触发个人邮箱的关键词举例:** 「我的个人邮箱」「私人邮箱」「dingtalk.com 邮箱」「@dingtalk 的邮箱」「我的 personal 邮箱」。
> 该规则覆盖文档后续所有命令示例:示例中虽以 `[email protected]` 等占位邮箱书写,实际执行时**必须按上述策略动态选择企业邮箱**,不要直接照抄示例中的邮箱字面量,更不要默认使用 `@dingtalk.com` 个人邮箱。
---
## 命令总览
### 查询可用邮箱地址
> **注意:** 仅返回当前登录用户**自己的**邮箱列表,不能用于查找他人邮箱。查找他人邮箱请使用三路并发流程(见"查找他人邮箱地址"章节)。
```
Usage:
dws mail mailbox list [flags]
Example:
dws mail mailbox list
```
**返回字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `mailboxes` | `List[]` | 邮箱列表,每条包含邮箱地址、账号类型、所属企业 |
### 查找他人邮箱地址(通讯录查人)
> **这不是 `mailbox list`。** 当需要获取**某人**的邮箱地址时,必须走以下三路并发查询,取最先返回有效邮箱的结果。禁止臆测邮箱地址。
**触发场景:** 用户说「获取/查找/得到 某人的邮箱地址」、「给某人发邮件」、「某人发给我的邮件」等任何涉及按姓名找邮箱的场景。
**三路并发查询流程:**
```bash
# 同时发起以下三路,取最先返回有效邮箱的结果
# 路径 1:aisearch + contact user get
dws aisearch person --keyword "姓名" --dimension name --format json
# → 取 userId,再执行:
dws contact user get --ids <userId> --format json
# → 提取 orgAuthEmail 字段
# 路径 2:mail user search(仅企业邮箱可用,个人邮箱会报权限错误可忽略)
dws mail user search --email <当前邮箱> --keyword "姓名" --format json
# → 提取 users[].email
# 路径 3:contact user search
dws contact user search --keyword "姓名" --format json
# → 提取用户邮箱字段
```
若三路均无有效邮箱,必须 `ask_human` 请用户手动提供,**严禁臆测**。
### 搜索邮件 (KQL 语法)
```
Usage:
dws mail message search [flags]
Example:
dws mail message search --email [email protected] --query "subject:\"周报\"" --limit 20
dws mail message search --email [email protected] --query "from:alice AND date>2025-06-01T00:00:00Z" --limit 10
Flags:
--cursor string 邮件的起始偏移标识, 其值取自响应中的nextCursor字段。""表示从头开始
--email string 搜索目标邮箱地址 (必填)
--query string KQL 查询表达式 (必填), 其中 date 格式需遵循 ISO8601 规范
--limit string 每页返回数量(最大限制 100, 默认 20)
```
KQL 查询字段: date, size, tag, folderId, isRead, hasAttachments, subject, attachname, body, from, to
常用文件夹 ID: 1=已发送, 2=收件箱, 3=垃圾邮件, 5=草稿, 6=已删除
### KQL 查询字段说明
| 字段 | 类型 | 说明 | 正确示例 | 错误示例 |
|------|------|------|----------|----------|
| `date` | ISO8601 日期时间 | 邮件日期,支持 `>` `<` `>=` `<=` 比较运算符 | `date>2025-06-01T00:00:00Z` | `date>2025-06-01`(缺少时间部分) |
| `size` | 整数(字节数) | 邮件大小,支持 `>` `<` `>=` `<=` 比较运算符 | `size>1024` | `size>"1024"`(值不需要引号) |
| `tag` | 字符串 | 邮件标签 | `tag:important` | `tag:""` |
| `folderId` | 整数 | 文件夹 ID(1=已发送, 2=收件箱, 3=垃圾邮件, 5=草稿, 6=已删除) | `folderId:2` | `folderId:"收件箱"`(必须用数字 ID) |
| `isRead` | 布尔 `true`/`false` | 是否已读 | `isRead:false` | `isRead:0`、`isRead:"false"`(不支持数字或字符串形式) |
| `hasAttachments` | 布尔 `true`/`false` | 是否有附件 | `hasAttachments:true` | `hasAttachments:yes` |
| `subject` | 字符串 | 邮件主题,含空格须加双引号 | `subject:周报`、`subject:"项目 进展"` | `subject:项目 进展`(含空格未加引号) |
| `attachname` | 字符串 | 附件文件名,含空格须加双引号 | `attachname:report.pdf`、`attachname:"月度 报告.xlsx"` | `attachname:月度 报告.xlsx`(含空格未加引号) |
| `body` | 字符串 | 邮件正文内容,含空格须加双引号 | `body:会议纪要`、`body:"Q1 总结"` | `body:Q1 总结`(含空格未加引号) |
| `from` | 字符串(邮件地址或名称) | 发件人,支持:纯邮件地址、纯名称(含空格须加双引号)、`"名称<邮件地址>"` 格式 | `from:[email protected]`、`from:"张 三"`、`from:"alice<[email protected]>"` | `from:张 三`(含空格未加引号) |
| `to` | 字符串(邮件地址或名称) | 收件人,支持:纯邮件地址、纯名称(含空格须加双引号)、`"名称<邮件地址>"` 格式 | `to:[email protected]`、`to:"李 四"`、`to:"alice<[email protected]>"` | `to:李 四`(含空格未加引号) |
**组合查询说明:**
- 支持 `AND` / `OR` / `NOT` 逻辑运算符(大写)
- 括号用于分组:`(from:alice OR from:bob) AND folderId:2`
- 排除特定文件夹:`(NOT folderId:3) AND (NOT folderId:6)`
### message search 返回值说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `messages` | `List[]` | 邮件列表,每条包含邮件 ID 及元信息(不含正文) |
| `total` | `int32` | 符合条件的总邮件数 |
| `nextCursor` | `string` | 下一页游标,传入 `--cursor` 翻页;值为 `$` 表示已到达列表尾部 |
**翻页示例:**
```bash
# 第一页
dws mail message search --email [email protected] --query "folderId:2" --limit 20 --format json
# 取返回中的 nextCursor,传入下一次请求(nextCursor="$" 时停止)
dws mail message search --email [email protected] --query "folderId:2" --limit 20 --cursor <nextCursor> --format json
```
### 查看邮件完整内容
```
Usage:
dws mail message get [flags]
Example:
dws mail message get --email [email protected] --id <messageId>
Flags:
--email string 邮件所属邮箱地址 (必填)
--id string 邮件 ID (必填)
```
**返回字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `message` | `object` | 邮件完整信息,包含主题、发件人、收件人、正文、附件等 |
### 发送邮件
```
Usage:
dws mail message send [flags]
Example:
dws mail message send --from [email protected] --to [email protected] \
--subject "周报" --content "本周完成任务A和任务B"
dws mail message send --from [email protected] --to [email protected] \
--subject "周报" --content "见附件" --attachment ./report.pdf
dws mail message send --from [email protected] --to [email protected] \
--subject "周报" --content "见附件" --attachment ./a.pdf --attachment ./b.xlsx
dws mail message send --from [email protected] --to [email protected] \
--subject "图表周报" --content "图表如下:[inline:chart.png]" --inline-attachment ./chart.png
dws mail message send --from [email protected] --to [email protected] \
--subject "带图文档" --content "见附件,图表:[inline:img.png]" --attachment ./doc.pdf --inline-attachment ./img.png
Flags:
--content string 邮件正文 (必填)
--cc string 抄送人列表
--from string 发件人邮箱 (必填),别名: --sender
--subject string 邮件标题 (必填)
--to string 收件人列表 (必填)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
**附件发送说明:**
当指定 `--attachment` 或 `--inline-attachment` 时,CLI 自动执行以下编排流程:
1. 创建邮件草稿(若有内联图片,正文自动转为 HTML 并注入 `<img>` 标签)
2. 为每个普通附件调用 `create_upload_session`(`isInline=false`),从响应的 `uploadUrl` 字段获取完整上传地址,HTTP POST 上传文件内容
3. 为每个内联图片调用 `create_upload_session`(`isInline=true`,传入 contentId),从响应的 `uploadUrl` 字段获取完整上传地址,HTTP POST 上传文件内容
4. 调用 `send_draft` 发送草稿
> **注意:** 附件必须通过 `--attachment` / `--inline-attachment` 参数传入,**严禁使用钉钉媒体存储(media upload)上传附件**。
**内联图片说明(`--inline-attachment`):**
- 仅支持图片类型:`jpg` / `jpeg` / `png` / `gif` / `webp` / `bmp` / `svg`
- CLI 自动生成 contentId,格式:`inline-{文件名(不含扩展名)}-{序号}@alimail.com`,例:`[email protected]`
- 在 `--content` 中使用占位符 `[inline:文件名]` 引用图片,CLI 自动替换为 `<img src="cid:...">` 标签
- 若 body 中没有对应占位符,内联图片会自动追加到正文末尾
- 非图片类型(PDF、视频、音频等)请改用 `--attachment`
### 列举邮件文件夹
```
Usage:
dws mail folder list [flags]
Example:
dws mail folder list --email [email protected]
dws mail folder list --email [email protected] --folder-id <folderId>
Flags:
--email string 邮件所属邮箱地址 (必填)
--folder-id string 父文件夹唯一标识,不传则返回顶层文件夹 (可选)
```
不传 `--folder-id` 返回顶层文件夹列表;传入则返回该文件夹的子文件夹列表。
**返回字段(`folders` 数组):**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | `string` | 文件夹唯一标识 |
| `displayName` | `string` | 文件夹显示名称 |
| `parentFolderId` | `string` | 父文件夹 ID |
| `childFolderCount` | `int` | 子文件夹数量 |
| `totalItemCount` | `int` | 邮件总数 |
| `unreadItemCount` | `int` | 未读邮件数量 |
### 列举邮件附件
> **重要:** 不存在 `attachment download_batch` / `download_all` 等批量下载命令。如需下载多封邮件的所有附件,必须按以下流程逐个下载:1) `message search` 搜索邮件获取 messageId 列表 → 2) 对每封邮件 `attachment list` 获取 attachmentId + name → 3) 对每个附件逐个调用 `attachment download`。
```
Usage:
dws mail attachment list [flags]
Example:
dws mail attachment list --email [email protected] --id <messageId>
Flags:
--email string 用户邮箱地址 (必填)
--id string 邮件唯一标识 messageId (必填)
```
列出指定邮件的所有附件信息。
**返回字段(`attachments` 数组):**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | `string` | 附件唯一标识 |
| `name` | `string` | 附件文件名 |
| `contentType` | `string` | 附件 MIME 类型 |
| `size` | `int` | 附件大小(字节) |
### 下载邮件附件
> **重要:** `attachment download` 每次只能下载**一个**附件。不存在 `download_batch` / `download_all` / `batch_download` 等批量下载命令,不要编造不存在的命令。如需下载多封邮件的所有附件,必须循环执行:对每封邮件先 `attachment list` 获取附件列表,再对每个附件逐个调用 `attachment download`。
```
Usage:
dws mail attachment download [flags]
Example:
# 先列出附件获取 id 和 name
dws mail attachment list --email [email protected] --id <messageId>
# 再下载指定附件到当前目录(每次只能下载一个附件)
dws mail attachment download --email [email protected] --message-id <messageId> --attachment-id <attachmentId> --name report.pdf
# 下载到指定目录
dws mail attachment download --email [email protected] --message-id <messageId> --attachment-id <attachmentId> --name img.png --output /tmp
Flags:
--email string 用户邮箱地址 (必填)
--message-id string 邮件唯一标识 messageId (必填)
--attachment-id string 附件唯一标识,取自 attachment list 的 id 字段 (必填)
--name string 保存到本地的文件名,取自 attachment list 的 name 字段 (必填)
--output string 保存目录,默认为当前目录
```
下载指定邮件的某个附件到本地。CLI 自动执行以下编排流程:
1. 调用 `create_download_session`,从响应的 `downloadUrl` 字段获取完整下载地址
2. 通过 HTTP GET 下载附件内容并保存到本地
> **注意:** `--name` 和 `--attachment-id` 均来自 `attachment list` 的返回结果,建议先执行 `attachment list` 再执行 `attachment download`。
### 列举邮件标签
```
Usage:
dws mail tag list [flags]
Example:
dws mail tag list --email [email protected]
Flags:
--email string 用户的邮箱地址 (必填)
```
列出指定邮箱下的所有邮件标签,返回标签的 ID 和元信息。
**返回字段(`tags` 数组):**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | `string` | 标签唯一标识 |
| `name` | `string` | 标签显示名称 |
| `parentId` | `string` | 父标签 ID |
| `totalItemCount` | `int` | 标签下邮件总数 |
| `unreadItemCount` | `int` | 标签下未读邮件数量 |
### 获取会话详情
```
Usage:
dws mail thread get [flags]
Example:
dws mail thread get --email [email protected] --id <conversationId>
Flags:
--email string 会话所属邮箱地址 (必填)
--id string 会话唯一标识 conversationId (必填)
```
**返回字段(`conversation` 对象):**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | `string` | 会话唯一标识 |
| `subject` | `string` | 会话主题 |
| `summary` | `string` | 会话摘要信息 |
| `lastModifiedDateTime` | `string (date-time)` | 会话最后修改时间 |
| `messageCount` | `int32` | 会话邮件数量 |
| `tags` | `array[string]` | 会话 tag 信息 |
| `senders` | `List[{email, name}]` | 会话发件人列表 |
| `isRead` | `boolean` | 会话是否已读(全部已读/未读) |
| `priority` | `string` | 会话重要性,取会话内邮件最高优先级(`PRY_HIGH` / `PRY_NORMAL`) |
| `flag` | `string` | 会话标识,取会话内最近邮件的标识(`FLAG_NONE` / `FLAG_REPLY` / `FLAG_FORWARD`) |
| `hasAttachments` | `boolean` | 会话是否包含附件(不含 inline 资源) |
### 回复邮件
```
Usage:
dws mail message reply [flags]
Example:
dws mail message reply --from [email protected] --id <messageId>
dws mail message reply --from [email protected] --id <messageId> --subject "Re: 周报" --content "已收到,谢谢!"
Flags:
--from string 发件人邮箱 (必填),别名: --sender
--to string 收件人列表(可选)
--id string 要回复的邮件 ID (必填)
--subject string 回复邮件标题(可选)
--content string 回复正文(可选)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
**附件发送说明:**
当指定 `--attachment` 或 `--inline-attachment` 时,CLI 自动执行以下编排流程:
1. 调用 `create_reply_draft` 创建回复草稿(若有内联图片,正文自动转为 HTML 并注入 `<img>` 标签)
2. 为每个普通附件创建上传会话并上传(`isInline=false`)
3. 为每个内联图片创建上传会话并上传(`isInline=true`,传入自动生成的 contentId)
4. 发送草稿
**返回字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `messageId` | `string` | 新生成的回复邮件 ID |
### 回复所有人
```
Usage:
dws mail message reply-all [flags]
Example:
dws mail message reply-all --from [email protected] --id <messageId>
dws mail message reply-all --from [email protected] --id <messageId> --subject "Re: 周报" --content "感谢大家的参与!"
Flags:
--from string 发件人邮箱 (必填),别名: --sender
--to string 收件人列表(可选,包含发件人及所有原始收件人)
--id string 要回复的邮件 ID (必填)
--subject string 回复邮件标题(可选)
--content string 回复正文(可选)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
**附件发送说明:**
当指定 `--attachment` 或 `--inline-attachment` 时,CLI 自动执行以下编排流程:
1. 调用 `create_replyall_draft` 创建回复全部草稿(若有内联图片,正文自动转为 HTML 并注入 `<img>` 标签)
2. 为每个普通附件创建上传会话并上传(`isInline=false`)
3. 为每个内联图片创建上传会话并上传(`isInline=true`,传入自动生成的 contentId)
4. 发送草稿
**返回字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `messageId` | `string` | 新生成的回复邮件 ID |
### 转发邮件
```
Usage:
dws mail message forward [flags]
Example:
dws mail message forward --from [email protected] --id <messageId>
dws mail message forward --from [email protected] --to [email protected] --id <messageId> --subject "Fwd: 周报"
Flags:
--from string 发件人邮箱 (必填),别名: --sender
--to string 转发收件人列表(可选)
--id string 要转发的邮件 ID (必填)
--subject string 转发邮件标题(可选)
--content string 转发附言(可选)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
**附件发送说明:**
当指定 `--attachment` 或 `--inline-attachment` 时,CLI 自动执行以下编排流程:
1. 调用 `create_forward_draft` 创建转发草稿(若有内联图片,正文自动转为 HTML 并注入 `<img>` 标签)
2. 为每个普通附件创建上传会话并上传(`isInline=false`)
3. 为每个内联图片创建上传会话并上传(`isInline=true`,传入自动生成的 contentId)
4. 发送草稿
**返回字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `messageId` | `string` | 新生成的转发邮件 ID |
### 批量移动邮件到指定文件夹
```
Usage:
dws mail message batch-move [flags]
Example:
dws mail message batch-move --email [email protected] --ids <id1>,<id2> --folder 6
Flags:
--email string 邮件所属邮箱地址 (必填)
--ids string 要移动的邮件 ID 列表,逗号分隔 (必填)
--folder string 目标文件夹 ID (必填)
```
常用文件夹 ID: 1=已发送, 2=收件箱, 3=垃圾邮件, 5=草稿, 6=已删除
### 批量删除邮件
```
Usage:
dws mail message batch-delete [flags]
Example:
dws mail message batch-delete --email [email protected] --ids <id1>,<id2>
Flags:
--email string 邮件所属邮箱地址 (必填)
--ids string 要删除的邮件 ID 列表,逗号分隔 (必填)
```
### 创建草稿
```
Usage:
dws mail draft create [flags]
Example:
dws mail draft create --from [email protected] --to [email protected] \
--subject "草稿标题" --content "草稿正文"
dws mail draft create --from [email protected] --subject "草稿标题"
dws mail draft create --from [email protected] --subject "带附件草稿" \
--content "见附件" --attachment ./report.pdf
dws mail draft create --from [email protected] --subject "带图片草稿" \
--content "图表:[inline:chart.png]" --inline-attachment ./chart.png
Flags:
--from string 发件人邮箱 (必填),别名: --sender
--subject string 邮件标题 (必填)
--to string 收件人列表(可选,有确定收件人时才传)
--cc string 抄送人列表(可选,有确定抄送人时才传)
--content string 邮件正文(可选,有正文内容时才传)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
> **注意:** `--to`、`--cc`、`--content` 均为可选参数,**仅在用户明确提供对应信息时才传入**。若用户未指定收件人,不要传 `--to ""`(空字符串)。
**附件说明:**
指定 `--attachment` 或 `--inline-attachment` 时,CLI 自动完成草稿创建和附件上传,**草稿保留在草稿箱,不会发送**。内联图片用法同 `message send`(`--content` 中使用 `[inline:文件名]` 占位符)。
**返回字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `messageId` | `string` | 新建草稿的邮件 ID |
### 更新草稿
```
Usage:
dws mail draft update [flags]
Example:
dws mail draft update --from [email protected] --id <messageId> --subject "新标题" --content "新正文"
dws mail draft update --from [email protected] --id <messageId> --content "见附件" --attachment ./report.pdf
dws mail draft update --from [email protected] --id <messageId> \
--content "图表:[inline:chart.png]" --inline-attachment ./chart.png
Flags:
--from string 发件人邮箱 (必填),别名: --sender
--id string 草稿邮件 ID (必填)
--to string 收件人列表(可选)
--cc string 抄送人列表(可选)
--subject string 邮件标题(可选)
--content string 邮件正文(可选)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
**附件说明:**
指定 `--attachment` 或 `--inline-attachment` 时,CLI 自动完成草稿更新和附件上传,**草稿保留在草稿箱,不会发送**。内联图片用法同 `message send`(`--content` 中使用 `[inline:文件名]` 占位符)。
### 发送草稿
```
Usage:
dws mail draft send [flags]
Example:
dws mail draft send --from [email protected] --id <messageId>
Flags:
--from string 发件人邮箱 (必填),别名: --sender
--id string 草稿邮件 ID (必填)
```
将草稿箱中已有的草稿发送出去。草稿 ID 来自 `draft create` 或 `message search`(`folderId:5`)的返回结果。
### 搜索邮箱用户(通讯录)
```
Usage:
dws mail user search [flags]
Example:
dws mail user search --keyword "张三"
dws mail user search --email [email protected] --keyword "张三"
dws mail user search --email [email protected] --keyword "alice" --limit 10
dws mail user search --email [email protected] --keyword "alice" --cursor <nextCursor>
Flags:
--email string 搜索目标邮箱地址 (可选)
--keyword string 搜索关键词 (必填)
--cursor string 分页游标,取自响应中的 nextCursor 字段(可选)
--limit string 每页返回数量(可选)
```
> **重要区别:**
> - `mail user search` — 搜索**通讯录联系人/邮箱用户**(按姓名/关键词找人),用于获取某人的邮箱地址
> - `mail message search` — 搜索**邮件内容**(按 KQL 语法搜邮件,如主题、发件人、日期等)
>
> 不要混淆:查找"某人的邮箱地址"用 `user search`;查找"某封邮件"用 `message search`。
>
> 仅企业邮箱(非 `@dingtalk.com` 个人邮箱)可使用 `user search`;使用个人邮箱调用将因无权限而报错。
**返回字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `users` | `List[]` | 匹配的用户列表,每条包含用户 ID、邮箱地址、姓名、昵称、工号、职位、工作地 |
| `nextCursor` | `string` | 下一页游标,传入 `--cursor` 翻页 |
| `hasMore` | `boolean` | 是否还有更多数据 |
**user 对象字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | `string` | 用户 ID |
| `email` | `string` | 展示使用的邮件地址 |
| `name` | `string` | 用户名(人名) |
| `nickname` | `string` | 用户昵称(或者花名) |
| `employeeNo` | `string` | 工号 |
| `jobTitle` | `string` | 职位 |
| `workLocation` | `string` | 工作地 |
## 通用错误说明
以下错误适用于所有 mail 命令。
| 错误标识 | 含义 | 处理建议 |
|----------|------|----------|
| `domain.notFound` | 该用户的邮箱不是由钉钉邮箱托管,无法完成操作 | 确认邮箱是否已开通钉钉企业邮箱服务 |
## 意图判断
用户说"我的邮箱/邮箱地址" → `mailbox list`(**仅限查询自己的邮箱,不能查他人**)
用户说"获取/查找/得到 某人的邮箱地址" → **不是 `mailbox list`**,走三路并发查询流程(见「查找他人邮箱地址」章节)
用户说"找邮件/搜邮件/查邮件" → `message search`
用户说"看邮件/打开邮件/邮件内容" → 先 `message search` 获取 messageId,再 `message get`
用户说"发邮件/写邮件" → 先 `mailbox list` 获取发件地址,再 `message send`
用户说“给(某人名字)发邮件” / “查询某人发给我的邮件” / “查询发给某人的邮件” / 任何涉及按人名查找邮箱的场景 →
**第一步**:并发同时发起以下三路查询,取最先返回有效邮箱的结果;若三路均无有效邮箱,ask_human 请用户提供,禁止臆测:
1. `aisearch person --keyword <姓名>` → `contact user get --ids <userId>`,提取 `orgAuthEmail`
2. `mail user search --email <当前邮箱> --keyword <姓名>`,提取 `users[].email`(仅企业邮箱可用)
3. `contact user search --keyword <姓名>`,提取用户邮箱字段
**第二步**:用获得的目标邮箱拼入 KQL(如 `from:<email>` 或 `to:<email>`)执行 `message search`,或用于 `message send`
用户说"发带附件的邮件/发邮件附件" → 先 `mailbox list` 获取发件地址,再 `message send --attachment <文件路径>`
用户说"给(某人名字)发邮件" → 先 `aisearch person` 获取 userId,再 `contact user get` 获取收件人邮箱,再 `message send`
用户说"查看附件/邮件附件/有什么附件" → 先 `message search` 获取 messageId,再 `attachment list`
用户说"下载附件/保存附件/把附件存到本地/把所有附件下载到..." → 先 `message search` 获取 messageId,再 `attachment list` 获取 attachmentId 和 name,最后逐个 `attachment download`(**不支持批量下载,不存在 download_batch/download_all 命令,必须逐个下载**)
用户说"把XX邮件的所有附件都下载" / "批量下载附件" / "下载4月所有发票邮件的附件" → **不存在批量下载命令**,必须按以下流程循环执行:1) `message search` 搜索匹配邮件获取 messageId 列表 → 2) 对每封邮件 `attachment list` 获取 attachmentId + name → 3) 对每个附件逐个调用 `attachment download`。不要编造 `download_batch` / `download_all` / `batch_download` 等不存在的命令
用户说"查看会话/获取会话/看这封邮件的会话" → 先 `message search` 或 `message get` 获取邮件中的 `conversationId`,再 `thread get`
用户说"搜索/查找/联系 邮箱用户/联系人/某人的邮箱地址" → `user search`(搜索通讯录人员,不是搜邮件内容)
用户说"发送草稿/把草稿发出去/发这封草稿" → 先 `message search --query "folderId:5"` 找到草稿 messageId,再 `draft send`
用户说"翻页继续搜索联系人/通讯录" → `user search --cursor <nextCursor>`(注意:不是 `message search`)
**`user search` vs `message search` 关键区别:**
- `user search`:搜索的是**人**(通讯录联系人),入参是 `--keyword 姓名`,返回用户信息
- `message search`:搜索的是**邮件**(邮件内容),入参是 `--query KQL表达式`,返回邮件列表
## 严格禁止 (NEVER DO)
- 明确禁止猜测、假设、推断发件人和收件人邮箱
- 无法获取邮箱时,强引导ask_human,由用户确认,不要通过假设或其他方式继续执行
- **严禁在用户未明确指定使用个人邮箱时,默认选择 `@dingtalk.com` 个人邮箱作为 `--email` / `--from`**;默认必须从 `mailbox list` 中挑选企业邮箱
- **涉及带附件的邮件操作时,严禁上传到钉钉媒体存储(media upload)**;必须使用对应命令的 `--attachment` / `--inline-attachment` 参数,由 CLI 内部完成附件处理
- **严禁编造不存在的批量下载命令**(如 `attachment download_batch`、`attachment download_all`、`attachment batch_download` 等)。下载附件只有 `attachment download` 一条命令,每次只能下载一个附件;需要批量下载时必须循环调用
## 核心工作流
```bash
# 1. 查看可用邮箱 — 提取邮箱地址
dws mail mailbox list --format json
# 2. 搜索邮件 — 提取 messageId
dws mail message search --email [email protected] \
--query "subject:\"周报\" AND date>2025-06-01T00:00:00Z" --limit 10 --format json
# 3. 查看邮件详情
dws mail message get --email [email protected] --id <messageId> --format json
# 4. 发送邮件(纯文本)
dws mail message send --from [email protected] --to [email protected] \
--subject "周报" --content "本周完成…" --format json
# 4b. 发送带附件的邮件(自动编排:创建草稿→上传附件→发送草稿)
dws mail message send --from [email protected] --to [email protected] \
--subject "周报" --content "见附件" --attachment ./report.pdf --format json
# 4c. 发送带内联图片的邮件(正文自动转 HTML,<img> 标签自动注入)
dws mail message send --from [email protected] --to [email protected] \
--subject "图表周报" --content "本周图表如下:[inline:chart.png]" \
--inline-attachment ./chart.png --format json
# 5. 下载邮件附件到本地(每次只能下载一个附件,不支持批量下载)
# 步骤 5.1:搜索匹配的邮件,获取 messageId 列表
# 示例:下载4月所有发票邮件的附件
dws mail message search --email [email protected] \
--query "subject:发票 AND date>2025-04-01T00:00:00Z AND date<2025-05-01T00:00:00Z AND hasAttachments:true" --limit 50 --format json
# 步骤 5.2:对每封邮件,列出附件获取 attachmentId 和 name
# (对搜索结果中的每封邮件都要执行一次)
dws mail attachment list --email [email protected] --id <messageId> --format json
# 步骤 5.3:对每个附件逐个下载(没有批量下载命令,必须循环调用)
dws mail attachment download --email [email protected] \
--message-id <messageId> --attachment-id <attachmentId> --name report.pdf --output ~/invoices/
# 5. 获取邮件所属会话详情(thread)
# 步骤 5.1:先通过 message search 或 message get 获取邮件中的 conversationId
dws mail message search --email [email protected] \
--query "subject:\"周报\"" --limit 5 --format json
# 从返回的邮件列表中提取 conversationId 字段
# 步骤 5.2:用 conversationId 获取会话详情
dws mail thread get --email [email protected] --id <conversationId> --format json
# 步骤 5.3(可选):同时返回会话内所有邮件列表
dws mail thread get --email [email protected] --id <conversationId> --select messages --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `mailbox list` | 邮箱地址 | message search/get/send/thread get 的 --email/--from |
| `message search` | `messageId` | message get 的 --id |
| `message search` | `conversationId` | thread get 的 --id |
| `message search` | `messageId` | attachment list 的 --id |
| `attachment list` | `attachments[].id` / `attachments[].name` | attachment download 的 --attachment-id / --name |
| `message get` | `conversationId` | thread get 的 --id |
| `aisearch person` → `contact user get` / `contact user search` / `mail user search` | 用户邮箱 (orgAuthEmail / email) | message send 的 --to/--cc(三路并发,取先到结果) |
| `user search` | 用户邮箱 (email) | message send 的 --to/--cc |
## 注意事项
- `mailbox list` 返回用户所有邮箱(含个人和企业),每条记录包含邮箱地址、账号类型、所属企业。**默认一律选择企业邮箱**(除非用户明确指定使用个人邮箱);若有多个企业邮箱可选,优先匹配用户当前所在企业的那一个;仍无法判断时向用户确认后再操作。详见文档顶部「默认邮箱选择规则」章节
- `message search` 返回邮件 ID 和元信息(不含正文),需 `message get` 获取完整内容
- KQL 查询支持 AND/OR/NOT 组合,字段值含空格时需用双引号
- `--cc` 抄送人支持多人,逗号分隔
- 收件人邮箱获取:用户只知道同事名字时,**并发**同时执行以下三路查询,取最先返回有效邮箱的结果,无需等待其他路完成:
1. `dws aisearch person --keyword "名字" --dimension name` → `dws contact user get --ids <userId>`,提取 `orgAuthEmail`
2. `dws mail user search --email <发件人邮箱> --keyword "名字"`,提取 `users[].email`(仅企业邮箱账号可调用,个人 @dingtalk.com 邮箱会报权限错误可忽略)
3. `dws contact user search --keyword "名字"`,提取用户邮箱字段
若三路均无有效邮箱,必须 ask_human 请用户手动提供收件人邮箱,严禁臆测和假设
- `thread get` 无法直接通过邮箱地址查询会话列表,**必须先有 conversationId**;conversationId 来自 `message search` 或 `message get` 返回的邮件字段 `conversationId`
- `thread get` 默认不返回邮件列表,如需查看会话内所有邮件,需加 `--select messages`;如需同时返回多个可选字段,用英文逗号分隔,如 `--select messages,internetMessageId`
- `thread get` 返回的 `messages` 列表中,邮件正文(`body`)、收件人(`toRecipients`)等字段默认不包含,需在 `--select` 中额外指定
- `user search` 仅支持企业邮箱(非 `@dingtalk.com` 个人邮箱),使用个人邮箱将因无权限报错;搜到的用户邮箱(`email` 字段)可直接用于 `message send` 的 `--to`/`--cc` 参数
# AI听记 (minutes) 命令参考
## 命令层级结构(防混淆,必须先读)
minutes 模块的命令是**两级或三级结构**,不同层级之间不能混用、跳级或遗漏:
> **默认 scope 规则**:`dws minutes list` 后**必须**跟 scope 子命令。若用户未明确指定查询范围,**一律默认补 `all`**(查询我可访问的所有听记 = 我创建的 + 他人共享给我的,覆盖面最广)。
>
> **`list` 不带 scope 的陷阱(0519 P2 Golden Case 提炼)**:
> - 裸 `dws minutes list`(不跟 mine/shared/all)虽然不会报错,但返回结果**不完整**(仅返回最近少量条目,约 913 字节)
> - `dws minutes list all` 才能返回完整列表(约 3362 字节)
> - **AI 严禁使用裸 `dws minutes list`**,必须始终带 scope 子命令
> - 如果 LLM 不确定用哪个 scope,**一律用 `all`**
>
> **scope 语义辨析(高频误判场景)**:
> - `mine` = **仅**我自己创建/发起的听记(范围最窄)
> - `shared` = **仅**他人共享给我的听记(不含我自己创建的)
> - `all` = 我可访问的**所有**听记(mine + shared 的并集,范围最广)
>
> **关键区分**:用户说"我可访问的"/"我能看到的"/"我有权限的"/"所有听记" → 选 `all`(不是 `mine`!);用户说"我自己创建的"/"我发起的"/"我录的" → 才选 `mine`。
```
dws minutes
├── list <scope> # 查询听记列表(list 后必须跟 mine / shared / all)
│ ├── mine [--query] [--start] [--end] [--max] [--cursor] # 仅查询我自己创建/发起的听记(不含他人共享给我的)
│ ├── shared [--query] [--start] [--end] [--max] [--cursor] # 仅查询他人共享给我的听记(不含我自己创建的)
│ └── all [--query] [--start] [--end] [--max] [--cursor] # 查询我可访问的所有听记(= mine ∪ shared,覆盖面最广,默认 scope)
├── get <subcommand> # 获取听记详情(get 后必须跟子命令名)
│ ├── info --id <uuid> # 获取听记基础信息(标题、时长、参与人、状态等)
│ ├── summary --id <uuid> # 获取听记 AI 摘要(结构化纪要)
│ ├── transcription --id <uuid> # 获取听记语音转写原文(逐字稿,支持分页)
│ ├── keywords --id <uuid> # 获取听记关键词
│ ├── todos --id <uuid> # 获取听记中的待办事项(Action Items)
│ ├── audio --id <uuid> # 获取听记音频/视频下载地址
│ └── batch --ids <uuid1,uuid2> # 批量获取多篇听记的基础信息
├── update <subcommand> # 修改听记内容(update 后必须跟子命令名)
│ ├── title --id <uuid> --title "..." # 修改听记标题
│ └── summary --id <uuid> --content "..." # 修改听记摘要内容
├── record <subcommand> # 录音控制(record 后必须跟子命令名)
│ ├── start # 开始录音,发起新听记
│ ├── pause --id <uuid> # 暂停录音
│ ├── resume --id <uuid> # 恢复录音
│ └── stop --id <uuid> # 停止录音,结束听记
├── speaker <subcommand> # 发言人管理
│ ├── replace --id <uuid> --from "..." --to "..." # 替换发言人名称(如"发言人1"→"张三")
│ └── summary <subcommand> # 按发言人生成/获取摘要
│ ├── create --ids <uuid1,uuid2> # 创建按发言人维度的摘要
│ └── get --ids <uuid1,uuid2> # 获取按发言人维度的摘要
├── mind-graph <subcommand> # 脑图管理
│ ├── create --id <uuid> # 根据听记内容生成脑图
│ └── status --id <uuid> # 查询脑图生成状态
├── hot-word <subcommand> # 个人热词管理
│ ├── add --words "..." # 添加热词(提升语音识别准确率)
│ └── list # 查询我的热词列表
├── replace-text --id <uuid> --search "..." --replace "..." # 替换转写文本中的错误文字
├── upload <subcommand> # 音频上传(上传本地音频文件生成听记)
│ ├── create --file-name "..." --file-size <n> # 创建上传会话,获取上传地址
│ ├── complete --session-id <sid> # 确认上传完成
│ └── cancel --session-id <sid> # 取消上传会话
└── permission <subcommand> # 权限管理(添加/移除听记成员权限)
├── add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy <0-4> [--cover] [--sub-resources "..."] # 添加成员权限
└── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2> # 移除成员权限
```
**高频错误命令 vs 正确命令对照(真实 badcase 提炼):**
| 错误写法 | 错在哪 | 正确写法 |
|------------|--------|------------|
| `dws minutes info --id <uuid>` | `info` 不是顶层子命令,应在 `get` 下 | `dws minutes get info --id <uuid>` |
| `dws minutes summary --id <uuid>` | `summary` 不是顶层子命令,应在 `get` 下 | `dws minutes get summary --id <uuid>` |
| `dws minutes transcription --id <uuid>` | `transcription` 不是顶层子命令,应在 `get` 下 | `dws minutes get transcription --id <uuid>` |
| `dws minutes get --uuid <uuid>` | `get` 后缺少子命令(info/summary/transcription 等) | `dws minutes get summary --id <uuid>` |
| `dws minutes get --task-uuid <uuid>` | 同上,`get` 后缺子命令。注意:`--task-uuid` 在子命令层是合法别名,但 `get` 层级不认识它 | `dws minutes get info --id <uuid>` |
| `dws minutes detail --id <uuid>` | `detail` 不是合法子命令——**不存在此命令**。LLM 高频幻觉 | `dws minutes get info --id <uuid>` |
| `dws minutes list --start "2026-04-01"` | `list` 后缺少 scope(mine/shared/all),**缺省时默认补 `all`** | `dws minutes list all --start "2026-04-01"` |
| `dws minutes list all --start-time "..."` | 参数名是 `--start` 不是 `--start-time` | `dws minutes list all --start "2026-04-01"` |
| `dws minutes list all --end-time "..."` | 参数名是 `--end` 不是 `--end-time` | `dws minutes list all --end "2026-04-30"` |
| `dws minutes list --page-size 10` | `--page-size` 不存在,分页用 `--max`;且 `list` 后缺 scope | `dws minutes list mine --max 10` |
| `dws minutes list --date-range 2026-05-04 2026-05-10` | `--date-range` 不存在,时间范围用 `--start` + `--end` 两个参数;且 `list` 后缺 scope | `dws minutes list mine --start "2026-05-04T00:00:00+08:00" --end "2026-05-10T23:59:59+08:00"` |
| `dws minutes list mine --limit 10` | `--limit` 尚未注册(跨产品规约 Primary 为 `--limit`,但 minutes CLI 当前只接受 `--max`)。**待 alias 注册后两者等价** | `dws minutes list mine --max 10` |
| `dws minutes upload create --json '{"fileName":...}'` | `--json` 不存在,cli 不接受 JSON 作为输入格式 | `dws minutes upload create --file-name "xxx.mp3" --file-size 61565431` |
| `dws minutes upload create -f json '{"fileName":...}'` | `-f json` / `--format json` 是**输出格式**控制,不是输入参数 | `dws minutes upload create --file-name "xxx.mp3" --file-size 61565431 --format json` |
| `dws minutes get transcription --id <uuid> \| head -c 2000` | Windows 沙箱无 `head` 命令,**严禁使用 shell 管道截断** | `dws minutes get transcription --id <uuid> --format json`(由 AI 在内存中截断处理) |
| `dws minutes get summary --id "https://shanji.dingtalk.com/app/transcribes/xxx"` | `--id` 只接受 taskUuid(hex 字符串),**不接受完整 URL**。AI 必须自动提取,不要让用户手动抠 | AI 自动从 URL 提取 taskUuid,再 `dws minutes get summary --id <taskUuid>`(详见「URL → taskUuid 自动提取规则」) |
| `dws minutes get transcription --id "https://shanji.dingtalk.com/meeting/minutes?taskUuid=xxx"` | 同上,`--id` 不接受 URL,且这是 `?taskUuid=` 格式的 URL | AI 自动提取 `taskUuid=` 后的值,再 `dws minutes get transcription --id <taskUuid> --format json` |
| `dws minutes get transcription --id <uuid>` 只调用一次就"总结整篇听记" | **单次调用最多返回 50 段**,不翻页就只能看到前 1/3~1/8 的内容 | **必须自动循环翻页**:检查返回中的 `nextToken`,非空则继续调用 `--next-token <token>`,直到拉完(详见「转写接口分页机制」) |
| `dws minutes get summary --id A & dws minutes get summary --id B & wait` | Windows cmd 下 `&` 是命令分隔符不是 background,`wait` 不存在 | 逐条串行调用,或让 AI 在内存中合并结果;**严禁使用 shell 并行/管道/重定向** |
| `dws minutes get transcription --id <uuid> --next-token <token>` 报 unknown flag | **不会报错**——`--next-token` 是 `get transcription` 的合法参数(见命令总览) | 确保写法正确:`dws minutes get transcription --id <uuid> --next-token <token> --format json` |
| `dws minutes get transcription --id <uuid> --page-token <token>` | `--page-token` 不存在,正确参数名是 `--next-token` | `dws minutes get transcription --id <uuid> --next-token <token>` |
| `dws minutes transcribe --url <听记url>` | `transcribe` 不是合法子命令,`--url` 参数也不存在。LLM 凭印象编造 | 先从 URL 提取 taskUuid(见「URL → taskUuid 自动提取规则」),再 `dws minutes get transcription --id <taskUuid> --format json` |
| `dws minutes get transcription --url <听记url>` | `--url` 参数不存在,`--id` 只接受纯 taskUuid | 同上:从 URL 提取 taskUuid 后用 `--id` |
| `dws minutes summary --uuid <uuid>` | `summary` 不是顶层子命令(应在 `get` 下),且顶层不识别 `--uuid`。**0519 高频错误** | `dws minutes get summary --id <uuid>` |
| `dws minutes list`(不跟 scope) | `list` 后**必须**跟 scope(mine/shared/all)。裸 `list` 返回结果不完整且行为未文档化 | `dws minutes list all`(默认 scope) |
| `dws report inbox --format json`(无时间窗口) | `inbox` 是兼容入口,等价于 `inbox list`;仍必须带 `--start` / `--end` | `dws report inbox list --start "2026-05-06T00:00:00+08:00" --end "2026-05-13T23:59:59+08:00" --format json` |
| `dws report inbox list --format json`(不带 --start) | inbox list **必须**带 `--start` / `--end` 参数,否则报 "flag --start is required" | `dws report inbox list --start "2026-05-06T00:00:00+08:00" --end "2026-05-13T23:59:59+08:00" --format json` |
> **参数别名说明**:`--id`、`--uuid`、`--task-uuid` 三者等价,均可使用。推荐统一用 `--id`,但 `--uuid` 和 `--task-uuid` 也是合法别名,不会报错。
**跨产品参数规约映射(dws-cli-param-spec 对齐):**
> minutes 模块参数与跨产品规约 Primary 的映射关系:
>
> | 语义 | 规约 Primary | minutes 当前 CLI 实际参数 | alias 注册状态 | 说明 |
> |------|-------------|------------------------|--------------|------|
> | 单页大小 (Group 13) | `--limit` | `--limit`(别名 `--max`) | 已对齐 | `list mine/shared/all` 用 `--limit`,`--max` 为别名 |
> | 续页标识 (Group 14) | `--cursor` | `list`: `--cursor`;`get transcription`: `--next-token` | 部分对齐 | **list 系列用 `--cursor`,get transcription 用 `--next-token`,不要混用** |
> | 搜索关键词 (Group 11) | `--query` | `--query` | 已对齐 | 无差异 |
> | 起始时间 (Group 15) | `--start` | `--start` | 已对齐 | 无差异 |
> | 结束时间 (Group 16) | `--end` | `--end` | 已对齐 | 无差异 |
>
> **跨模块参数名差异速查(防止混用):**
> - 分页大小:minutes `list` 用 `--limit`(别名 `--max`),与其他模块一致
> - 续页标识:minutes `list mine/shared/all` 用 `--cursor`;minutes `get transcription` 用 `--next-token`;aitable/drive/doc 用 `--cursor`
> - 起止时间:minutes/report 用 `--start`/`--end`,calendar 用 `--start-time`/`--end-time`(跨模块最高频错误)
**铁律:禁止编造 taskUuid(0512 P0 Golden Case 提炼)**
用户未直接提供 taskUuid / URL 时,**必须**先调用 `dws minutes list mine`(或 `list all`)获取列表,从返回结果中按标题/参会人/时间匹配选出正确条目,再用其 taskUuid 调用后续命令。**绝不允许凭空编造 hex 字符串作为 --id 的值。**
典型错误链路:
```
用户: "帮我看一下上次和 Tony 开会的听记摘要"
错误: LLM 直接编造: dws minutes get summary --id "7632756964..." -- 这个 id 是捏造的
正确: 先 dws minutes list mine → 从结果按标题/参会人匹配 → 再 get summary --id <真实id>
```
**意图→命令决策表(0512 P0 Golden Case 提炼):**
| 用户意图关键词 | 对应命令 | 说明 |
|--------------|---------|------|
| 总结、摘要、重点、概述、主要内容、按模板整理 | `get summary` | 返回结构化摘要 |
| 逐字稿、全文、原文、完整记录、转写、录音文字 | `get transcription` | 返回完整转写原文 |
| "帮我看/总结某次会议"(未给 id) | 先 `list all`(默认) | 按标题/时间匹配后再 get |
| "我可访问的听记"/"我能看到的"/"我有权限的"/"所有听记" | `list all` | **不是 `list mine`!** `all` = 我可访问的全部(mine + shared),`mine` 仅限我自己创建的 |
| "我的听记"(模糊表述) | `list all` | "我的"通常指用户可见的全部听记,默认走 `all`;仅当明确说"我创建/发起的"时才 `mine` |
| "我自己创建的听记"/"我发起的听记"/"我录的听记" | `list mine` | 明确限定创建者是自己时才走 `mine` |
| "上上周/两周前的会议" | `list all --start --end` | 需正确计算时间范围 |
| 添加参会者、批量添加参会者、添加成员、批量添加成员、加参会人 | `permission add` | 听记无独立"添加成员"接口,统一走权限接口 |
**多步流程错误传播规则(0512/0514 P0 Golden Case 提炼):**
> 当执行 list → get summary/transcription → 套模板/写文档 等多步流程时:
> 1. 任何一步 dws 命令返回错误,**必须立即告知用户**卡在哪一步、具体错误信息
> 2. **严禁**跳过失败步骤假装成功
> 3. **严禁**用虚构数据填补失败步骤的空缺
> 4. 如果是权限问题 (NoPermission),建议用户检查听记是否为自己创建或已被共享
> 5. 最终交付物中需注明哪些数据成功获取、哪些被权限/参数阻断
> 6. **严禁多步流程中途放弃**(0514 P0 新增):前置步骤(如 `contact user get-self` 获取 userId)成功后,**必须**继续执行后续步骤(如 `calendar event list`、`minutes list mine`)。不能只完成前置步骤就停下来,回复"我打算帮你查…"却不实际执行
**诉求偏移防护规则(0514 P1 Golden Case 提炼):**
> 用户明确要求"基于听记内容做 XX"(如生成会议纪要、汇报材料、周报)时:
> 1. **必须先实际调用** `dws minutes list` → `get summary/transcription` 获取真实数据
> 2. **严禁**跳过数据获取步骤直接"创建 skill"或"生成模板"——用户要的是**基于真实听记数据**的输出,不是空壳工具
> 3. 如果数据获取失败,应告知用户具体原因,而非转向其他不相关的操作
>
> **典型错误链路(0514 P1 bug 复现):**
> ```
> 用户: "基于钉钉听记内容重新生成 Word 格式会议纪要"
> [错误] 模型创建了一个 skill 模板,但从未调用 dws minutes get summary/transcription 获取真实数据
> [正确] 先 dws minutes list mine → 从结果选匹配条目 → get summary --id <uuid> → 基于返回数据生成纪要
> ```
**跨模块组合注意事项(周报生成等场景,0512/0513/0514 P0 Golden Case 提炼):**
> - 周报生成等场景需并行拉取 minutes / report / calendar / todo / doc 多类数据
> - **各模块参数名独立**,不可互相套用(如 doc 的参数不适用于 minutes)
> - **跨模块参数名交叉污染是 0514 最高频错误**(3/7 个 case 涉及):切换模块时必须重新确认该模块的参数名,不要从上一个模块"顺手带过来"。典型错误对:`--start-time`(calendar) vs `--start`(minutes/report)、`--task-uuid`(无) vs `--id`(minutes)
> - **禁止**使用管道命令 `| jq`,Windows/macOS 客户端不保证 jq 可用,应由 LLM 直接解析 JSON
> - `report inbox list` **必须**带 `--start` / `--end` 参数(格式 `2026-05-04T00:00:00+08:00`),否则报错;`report outbox list` 不传时间窗口默认最近 20 天
> - `dws report inbox` 是兼容入口,仍可执行但已 deprecated;新计划必须写 `dws report inbox list --start ...`
> - `report inbox list` 不传 `--start` 会直接报错 "flag --start is required",**不要让用户手动补**——AI 应自动推断最近 7 天范围填入
> - 旧命令 `report list / inbox / sent / created / detail / create / stats / template detail` 仍可执行但已 deprecated,新计划一律使用 `inbox list / outbox list / entry get / entry submit / entry stats / template get`
> - doc create 参数名是 `--content`(非 `--markdown`)
**钉钉听记 URL → taskUuid 自动提取规则(0513/0514 P0 Golden Case 提炼):**
> **`--id` 参数只接受纯 taskUuid(hex 字符串),绝不接受完整 URL。** 但用户几乎总是直接粘贴完整 URL 过来,AI **必须自动完成 URL → taskUuid 的提取**,对用户完全透明——用户不需要知道"要抠出 hex 字符串"。
>
> **严禁**:
> - 直接把完整 URL 传入 `--id`(会报错)
> - 要求用户自己从 URL 中手动提取 taskUuid(用户体验极差,这是 AI 应自动完成的工作)
> - 把 URL 中的 query parameter(如 `?taskUuid=xxx`)和 path segment(如 `/transcribes/xxx`)搞混
>
> **已知的钉钉听记 URL 格式全表(AI 必须全部识别):**
>
> | URL 格式 | taskUuid 提取方式 | 示例 |
> |---------|-----------------|------|
> | `https://shanji.dingtalk.com/app/transcribes/<taskUuid>` | 取 `/transcribes/` 后面的**整段路径** | `.../transcribes/7632756964323839...5f32` → `7632756964323839...5f32` |
> | `https://shanji.dingtalk.com/meeting/minutes?taskUuid=<taskUuid>` | 取 `taskUuid=` 后面的 query parameter 值 | `...?taskUuid=7632756964323839...5f32` → `7632756964323839...5f32` |
> | `https://shanji.dingtalk.com/app/transcribes/<taskUuid>?xxx=yyy` | 取 `/transcribes/` 和 `?` 之间的部分(忽略 query string) | `.../transcribes/763275...5f32?from=share` → `763275...5f32` |
> | 纯 hex 字符串(无 URL 前缀) | 直接使用,无需提取 | `7632756964323839...5f32` → 直接传 `--id` |
>
> **提取伪代码(AI 必须内化此逻辑):**
> ```
> input = 用户提供的字符串(可能是 URL,也可能是纯 uuid)
>
> if input 包含 "/transcribes/":
> taskUuid = input 中 "/transcribes/" 之后、"?" 之前(如有)的部分
> elif input 包含 "taskUuid=":
> taskUuid = input 中 "taskUuid=" 之后、"&" 之前(如有)的部分
> elif input 匹配纯 hex 字符串模式 (只含 0-9a-f 且长度 > 20):
> taskUuid = input # 已经是纯 uuid
> else:
> 告知用户格式不识别,请提供听记链接或 taskUuid
>
> # 然后使用: dws minutes get summary --id <taskUuid> --format json
> ```
>
> **典型错误链路(0514 P0 bug 复现):**
> ```
> 用户: "帮我看看这个听记 https://shanji.dingtalk.com/app/transcribes/7632756964323839..."
>
> [错误 1] dws minutes get summary --id "https://shanji.dingtalk.com/app/transcribes/763..." → 报错 invalid id
> [错误 2] "请你把 URL 中的 taskUuid 提取出来给我" → 不应该让用户手动做
> [错误 3] AI 把整个 URL decode 当作 taskUuid → 格式不对
> [正确] AI 自动提取 → dws minutes get summary --id "7632756964323839..." --format json
> ```
>
> **多链接批量场景**:用户一次性粘贴多个 URL 时,逐一提取 taskUuid,按顺序串行调用对应的 get 命令。**严禁使用 shell `&` 并行或 `|` 管道**。
**权限错误处理策略(0513 P0 Golden Case 提炼):**
> `get info` / `get summary` / `get transcription` / `get batch` 访问他人发起的听记时,可能报以下权限错误:
>
> | 错误信息 | 含义 | AI 应如何处理 |
> |---------|------|-------------|
> | `[AUTH_PERMISSION_DENIED] Permission denied` | 当前用户对该听记无权限(不是创建者,且未被共享) | 告知用户:"该听记由他人创建且未共享给你。请联系听记创建者在钉钉听记详情页将你加为共享者,然后再次尝试。" |
> | `[MCP_TOOL_ERROR] B_PERMISSION_NoPermission` | 同上,后端 dingOpenErrcode=14000011 | 同上处理 |
> | `[UNCLASSIFIED] user is not minutes creator` | batch 接口单条权限失败 | 告知用户哪几条 id 是自己的(可正常获取)、哪几条无权限,**不要整批放弃** |
>
> **关键原则**:
> - 权限报错 ≠ 命令写法有误——**不要**因为权限失败就去改参数名或换命令路径
> - batch 调用中如果部分成功部分权限失败,应**展示成功的结果** + **列出失败的 id 和原因**
> - `list shared` 返回的听记是**已被共享给我的**,这些 id 后续调用 get 不应报权限错误
> - `list all` 返回的所有 id 都是**当前有权限的**,可放心调用
**Shell 命令安全规则(0513 P0 Golden Case 强化):**
> dws cli 运行在用户本地设备(Windows/macOS/Linux),**严禁使用任何 shell 特性**:
>
> | 禁止写法 | 原因 | 正确替代 |
> |---------|------|----------|
> | `dws ... \| head -20` | Windows cmd 无 `head` | 由 AI 在内存中截断 |
> | `dws ... \| grep "xxx"` | Windows cmd 无 `grep` | 由 AI 在返回 JSON 中筛选 |
> | `dws ... 2>&1 \| head` | 混合重定向+管道 | 直接读 dws 输出 |
> | `dws A & dws B & wait` | cmd 下 `&` 是分隔符不是 background | 逐条串行执行 |
> | `dws ... > output.txt` | 不确定用户 shell 是否支持 | 由 AI 直接处理返回结果 |
>
> **铁律:所有 dws 命令必须单条、独立、无管道地执行,AI 自行处理输出内容。**
**Upload 模块错误诊断(0513 P0 Golden Case 提炼):**
> `dws minutes upload create` 失败时的常见原因与处理:
>
> | 错误表现 | 真实原因 | 处理方式 |
> |---------|---------|---------|
> | `business error: success=false`(无详细信息) | 默认输出不含详细错误,需检查是否有并发会话 | 告知用户:"上传失败,可能原因包括:① 已有一个进行中的上传会话未完成/取消;② 文件名含特殊字符;③ 文件格式不支持。" |
> | `concurrent_limit: user already has an active upload session` | 前一次上传会话未结束(未 complete 也未 cancel) | 需要先 `dws minutes upload cancel --session-id <sid>` 取消上一个会话。若 session-id 未知,建议用户在钉钉客户端手动取消或等待超时 |
> | `missing required flag(s): --session-id` | cancel 命令必须传 session-id | 从之前 upload create 的返回中提取 session-id;若丢失则暂无法取消 |
>
> **上传流程三步走**(AI 必须按此顺序执行,不可跳步):
> 1. `dws minutes upload create --file-name "xxx.mp3" --file-size <字节数> --format json` → 获取 `sessionId` + `uploadUrl`
> 2. 将文件通过 `uploadUrl` 直传(由客户端侧完成,AI 不介入)
> 3. `dws minutes upload complete --session-id <sid> --format json` → 确认上传完成
>
> **注意**:`--file-size` 单位是**字节**(不是 KB/MB),AI 不要帮用户估算大小,应让用户确认文件实际字节数。
## 指令树
```
dws minutes
├── list <scope> # 二级:list 后必须跟 mine / shared / all
│ ├── mine [--query] [--start] [--end] [--max] [--cursor]
│ ├── shared [--query] [--start] [--end] [--max] [--cursor]
│ └── all [--query] [--start] [--end] [--max] [--cursor]
├── get <subcommand> # 二级:get 后必须跟子命令名
│ ├── info --id <uuid>
│ ├── summary --id <uuid>
│ ├── transcription --id <uuid>
│ ├── keywords --id <uuid>
│ ├── todos --id <uuid>
│ ├── audio --id <uuid>
│ └── batch --ids <uuid1,uuid2>
├── update <subcommand> # 二级:update 后必须跟子命令名
│ ├── title --id <uuid> --title "..."
│ └── summary --id <uuid> --content "..."
├── record <subcommand> # 二级:record 后必须跟子命令名
│ ├── start
│ ├── pause --id <uuid>
│ ├── resume --id <uuid>
│ └── stop --id <uuid>
├── speaker <subcommand>
│ ├── replace --id <uuid> --from "..." --to "..."
│ └── summary <subcommand>
│ ├── create --ids <uuid1,uuid2>
│ └── get --ids <uuid1,uuid2>
├── mind-graph <subcommand>
│ ├── create --id <uuid>
│ └── status --id <uuid>
├── hot-word <subcommand>
│ ├── add --words "..."
│ └── list
├── replace-text --id <uuid> --search "..." --replace "..."
├── upload <subcommand>
│ ├── create --file-name "..." --file-size <n>
│ ├── complete --session-id <sid>
│ └── cancel --session-id <sid>
└── permission <subcommand>
├── add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy <0-4> [--cover] [--sub-resources "..."]
└── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>
```
## 命令总览
### 查询我创建的听记列表
```
Usage:
dws minutes list mine [flags]
Example:
dws minutes list mine
dws minutes list mine --max 10
dws minutes list mine --max 10 --cursor <nextCursor>
dws minutes list mine --query "周会"
Flags:
--max float 查询的听记篇数 (默认 10,--limit 的别名)
--cursor string 分页游标 (首页留空,后续填写前次返回的 nextCursor)
--query string 关键字筛选 (可选)
--start string 开始时间 ISO-8601 (可选)
--end string 结束时间 ISO-8601 (可选)
```
查询我创建的听记列表,支持 `--max` 和 `--cursor` 分页,支持按关键字和时间范围筛选。
### 查询他人共享给我的听记列表
```
Usage:
dws minutes list shared [flags]
Example:
dws minutes list shared
dws minutes list shared --max 20
dws minutes list shared --max 5 --cursor <nextCursor>
Flags:
--max float 查询的听记篇数 (默认 10,--limit 的别名)
--cursor string 分页游标 (首页留空,后续填写前次返回的 nextCursor)
--query string 关键字筛选 (可选)
--start string 开始时间 ISO-8601 (可选)
--end string 结束时间 ISO-8601 (可选)
```
查询他人共享给我的听记列表,支持 `--max` 和 `--cursor` 分页,支持按关键字和时间范围筛选。
### 查询我有权限访问的所有听记列表
```
Usage:
dws minutes list all [flags]
Example:
dws minutes list all
dws minutes list all --max 20
dws minutes list all --query "周会" --max 20
dws minutes list all --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T23:59:59+08:00"
dws minutes list all --max 10 --cursor <nextCursor>
Flags:
--end string 结束时间 ISO-8601 (可选)
--query string 关键字筛选 (可选)
--max float 查询的听记篇数 (默认 10,--limit 的别名)
--cursor string 分页游标 (首页留空,后续填写前次返回的 nextCursor)
--start string 开始时间 ISO-8601 (可选)
```
查询我有权限访问的所有听记列表(包括我创建的、他人共享给我的等所有有权限的听记)。支持按关键字和时间范围筛选。时间范围和关键字为可选参数,不传则返回所有有权限的听记。支持使用 `--max` 和 `--cursor` 进行分页查询。
### 获取听记基础信息
```
Usage:
dws minutes get info [flags]
Example:
dws minutes get info --id <taskUuid>
Flags:
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
```
返回字段: 创建人、开始时间、截止时间、听记标题、听记访问链接URL
**发言人列表输出规范(查询详情后必须执行)**:
调用 `get info` 和 `get transcription`(首页即可)后,**必须**从返回数据中提取并展示发言人列表:
1. **提取发言人**:从转写数据的 `speakerName` / `speakerId` 字段收集所有不同的发言人
2. **分类展示**:
- **已识别**:声纹已标注真实姓名的发言人(直接显示姓名)
- **未识别**:仅有匿名编号的发言人(显示为「发言人 1」「发言人 2」等)
3. **引导逻辑(存在未识别发言人时)**:
展示发言人列表后,**必须**向用户提供以下选项:
> 本次听记共有 M 位发言人,其中 N 位尚未识别身份:
>
> | # | 当前标注 | 发言占比 |
> |---|---------|---------|
> | 1 | 张三(已识别) | 35% |
> | 2 | 发言人 1(未识别) | 30% |
> | 3 | 发言人 2(未识别) | 20% |
> | 4 | 李四(已识别) | 15% |
>
> 你可以:
> 1. **智能匹配** — 我来帮你推断未识别的发言人身份(基于通讯录、聊天记录等)
> 2. **提供日程/参会人** — 告诉我这次会议对应的日程链接或参会人名单,我用成员列表来匹配
> 3. **手动设置** — 直接告诉我对应关系,如「发言人1:王五, 发言人2:赵六」
> 4. **跳过** — 暂不处理发言人识别
根据用户选择分别处理:
- 选 1 → 进入 `minutes-speaker-summarize` recipe 的智能推断流程(见 [10-minutes-speaker-match.md](../best_practices/10-minutes-speaker-match.md))
- 选 2 → 请用户提供日程链接或参会人姓名列表:
- 有日程链接 → 从链接提取 eventId → `dws calendar participant list --event <eventId>` 获取参与人 → 与转写发言人数量/顺序对照匹配 → 展示匹配结果请用户确认 → 确认后逐条执行 `speaker replace`
- 有参会人名单(用户直接列出姓名)→ 与转写中匿名发言人做数量对照 → 结合发言内容/角色推断匹配 → 展示匹配结果请用户确认 → 确认后逐条执行 `speaker replace`
- 选 3 → 用户提供映射关系(如「发言人1:张三」格式)→ 按「发言人映射模式识别」流程执行(见下方"修改说话人"章节)
- 选 4 → 正常结束,不做发言人处理
4. **引导逻辑(所有发言人均已识别时)**:
> 所有发言人均已识别。是否需要查看某位发言人的详细发言总结?请输入姓名。
用户选定 → 进入 `minutes-speaker-summarize` recipe;用户不需要 → 正常结束。
> **禁止**:跳过发言人列表直接输出摘要;将匿名编号的内部格式(Speaker_X)暴露给用户;未识别发言人时不给任何引导就结束。
### 获取听记 AI 摘要
```
Usage:
dws minutes get summary [flags]
Example:
dws minutes get summary --id <taskUuid>
Flags:
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
```
返回 Markdown 格式摘要,涵盖会议主题、核心结论、关键讨论点等
### 获取听记关键字列表
```
Usage:
dws minutes get keywords [flags]
Example:
dws minutes get keywords --id <taskUuid>
Flags:
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
```
### 获取听记语音转写原文
```
Usage:
dws minutes get transcription [flags]
Example:
dws minutes get transcription --id <taskUuid>
dws minutes get transcription --id <taskUuid> --direction 1
dws minutes get transcription --id <taskUuid> --next-token <nextToken> --format json
Flags:
--direction string 排序方向: 0=正序, 1=倒序 (默认 0)
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
--next-token string 下一页的token 首次查询可空 后续查询需填写前次请求返回的nextToken
```
每条记录包含: 发言人信息、转写文本、对应时间戳
**关键:转写接口每次最多返回 50 段,必须自动翻页才能拿到完整原文!**
> **这是最高频的 silent failure:** `get transcription` 单次调用**最多只返回 50 段**。一篇 30 分钟会议的转写通常有 150-400 段,如果不翻页就只能看到前 1/3 甚至 1/8 的内容。
>
> **分页机制(必须掌握):**
> - 首次调用:`dws minutes get transcription --id <uuid> --format json`(不传 `--next-token`)
> - 检查返回 JSON 中是否包含 `nextToken` 字段(非空字符串)
> - 如果有 `nextToken`:**必须立即发起下一次调用**,传入 `--next-token <上次返回的nextToken值>`
> - 循环直到返回中**不再包含 `nextToken`**(或 `nextToken` 为空),表示所有段落已拉取完毕
> - **拼合所有页的段落**后,才算拿到了完整转写原文
>
> **自动翻页伪代码(AI 必须内化此逻辑):**
> ```
> all_paragraphs = []
> next_token = "" # 首次为空
> loop:
> if next_token == "":
> result = dws minutes get transcription --id <uuid> --format json
> else:
> result = dws minutes get transcription --id <uuid> --next-token <next_token> --format json
> all_paragraphs.append(result.paragraphs)
> next_token = result.nextToken # 可能为空或不存在
> if next_token 为空或不存在:
> break # 全部拉取完毕
> # 此时 all_paragraphs 才是完整转写原文
> ```
>
> **典型错误(导致"总结整篇听记"只看到前 50 段的 P0 bug):**
> - [错误] 只调用一次 `get transcription`,看到返回了内容就以为拿全了 → 实际只有前 50 段
> - [错误] 看到返回 JSON 里有 `nextToken` 字段但不知道这是什么、不知道要传 `--next-token` → 默默丢弃了后续页
> - [错误] 用 `--help` 查参数但在 Windows 下输出被截断,没看到 `--next-token` 参数 → 以为不支持分页
> - [正确] **正确做法:无条件按上述循环逻辑自动翻页,直到 nextToken 为空**
**重要 — 转写原文拉取策略(AI 必须严格遵守):**
转写原文数据量通常很大,**是否拉取、拉取多少**需要根据用户意图智能判断:
1. **用户明确要求查看/分析转写原文时 → 默认拉取全部原文**(自动翻页,不需要用户手动说"第一页")
- 示例:"帮我看看转写原文"、"分析一下这篇听记的原文"、"把逐字稿给我"、"转写内容是什么"
- 实现:首次调用不传 `--next-token`,如果返回中包含 `nextToken`,**自动继续调用**直到拉取完所有页,最终拼合后展示给用户
- **字符上限保护**:在循环拉取过程中,如果已累积的转写文本总量**超过 12000 字符(1.2w)**,必须**暂停自动翻页**,向用户提示当前已处理的字符数已达到上限,并询问是否继续拉取后续分页内容。用户确认后才继续拉取,用户拒绝则停止并展示已拉取的内容
**超长会议翻页优化策略(0519 P0 Golden Case 提炼 — 70min 会议翻页 15+ 次):**
> 当会议时长超过 45 分钟时,转写原文通常需要 **10-20 次翻页**(每次 50 段 ≈ 23k 字),每次 CLI 调用都消耗 LLM 上下文 token,极度浪费。
>
> **AI 必须遵守的翻页效率规则:**
> 1. **翻页次数预估**:首次调用返回后,如果 `nextToken` 非空,AI 应根据已拉取段落数和会议总时长估算剩余翻页次数
> 2. **超过 5 次翻页时**:自动启用"静默累积模式"——不在每次翻页时都向用户汇报进度,直接循环拉取并在内存中累积
> 3. **超过 10 次翻页时**:在第 10 次翻页完成后暂停,主动告知用户:
> > "这篇听记较长,已拉取前 500 段(约 X 字符),预计还需 N 次翻页。是否继续拉取完整原文,还是基于已有内容先做分析?"
> 4. **严禁**:翻页中途放弃后改用 shell 重定向(如 `> tmp/file.json`)——这违反 Shell 命令安全规则
> 5. **严禁**:因翻页次数多就跳过后续页,在摘要中只基于前几页内容得出结论
>
> **用户意图为"按发言人分析"时的优化路径:**
> - 如果用户的最终目的是"区分发言人"/"看某人讲了什么",**不一定需要拉取全部转写原文**
> - 可优先使用 `dws minutes get summary --id <uuid>` 获取结构化摘要(含发言人信息)
> - 或使用 `dws minutes speaker summary get --ids <uuid>` 直接获取按发言人维度的摘要
> - 仅当用户明确要求"逐字稿"/"完整原文"/"每句话"时,才需完整翻页拉取
2. **用户未明确要求查看原文时 → 不要主动拉取转写原文**
- 示例:"查一下和悟空相关的听记"、"帮我看看这个听记的摘要"、"这个会议讲了什么"
- 这些场景下用户的意图是查列表、看摘要等,**不需要也不应该**把大量转写原文全部拉出来,否则会造成信息过载和不必要的性能开销
- 如果用户问"这个会议讲了什么",应优先使用 `get summary` 返回摘要,而非 `get transcription`
**判断原则:只有用户的意图明确指向"原文/转写/逐字稿/录音文字"时,才调用 `get transcription`;其他场景(查列表、看摘要、看待办等)严禁自动附带拉取转写原文。**
**重要 — 转写原文返回后默认按"时间线"组织各段落,AI 必须主动引导发言人聚类与关联(必须严格遵守):**
`get transcription` 默认返回的段落是按"时间戳正序/倒序"穿插展示的(每条记录包含 `speakerNick + 时间戳 + 文本`),**对人不友好**——同一发言人的内容散落在多个时间点,难以快速看清"某个人主要讲了什么"。因此,AI **在拉取完成(含分页全部完成)后**必须主动启动以下"发言人聚类 → 关键词模糊匹配 → 引导确认 → 调用 `speaker replace`"四阶段工作流:
#### 阶段 1:拉取完成后主动询问"是否按发言人聚类"
拉取(含自动翻页)结束后,AI 必须**主动**追问用户一次(不要默认强制聚类,避免用户只想看时间线原文时被打扰):
> "已拉取完整转写原文(共 N 段,X 个发言人)。当前默认按时间线返回。是否需要我帮你**按发言人分组聚类**,并提取每位发言人的**核心发言要点**?"
- 用户确认("好/可以/需要/聚类一下/按发言人分一下"等)→ 进入阶段 2
- 用户拒绝 → 直接展示时间线原文,结束流程,**不再追问**
#### 阶段 2:按发言人聚类 + 提取核心内容
AI 基于已拉取的转写数据,**本地完成**聚类与摘要(无需新调用 dws 命令),输出结构如下:
```
[发言人] 发言人1(共 12 段,约 1820 字)
核心要点:
- 介绍 Q3 战略规划与组织调整方向
- 强调 AI 化转型的三个关键里程碑
- 提出对供应链效率的具体改进目标
[发言人] 发言人2(共 8 段,约 960 字)
核心要点:
- 汇报当前业务的财务数据与利润率
- 分析竞品在华东市场的最新动作
[发言人] 张三(共 5 段,约 410 字)
核心要点:
- 同步研发团队的招聘进度
- 提出对测试资源的支持诉求
```
**约束:**
- 聚类必须包含**全部发言人**(包括"发言人1/发言人2"占位符与已关联真实姓名的发言人,便于后续替换)
- 每位发言人**最多列出 3-5 条**核心要点,避免冗长
- 输出后必须**主动追问**用户:
> "如果你能告诉我『某某人主要讲了什么』(例如『李总主要讲了战略规划』『王经理主要负责供应链』),我可以根据关键词帮你**自动匹配**对应的发言人,并把『发言人1/发言人2』替换成真实姓名。"
#### 阶段 3:基于用户提供的关键词做模糊匹配
当用户提供形如『某某人主要讲了 XX』的输入时(例如『李总主要讲了战略规划』『拾光负责供应链效率改进』),AI 必须:
1. **提取关键词**:从用户输入中抽取核心实体/主题词(如『战略规划』『供应链效率』『AI 化转型』),可允许多关键词
2. **在阶段 2 已聚类的发言人核心要点中做模糊匹配**:
- 优先匹配「核心要点」中包含或语义相近的发言人
- 必要时回看该发言人的原始转写文本进行二次确认
- 支持**同义词/近义词**容忍(如『战略』~『规划』、『供应链』~『物流』)
3. **匹配结果分级处理:**
- **唯一高置信匹配**(一个发言人显著命中)→ 进入阶段 4 引导确认
- **多个候选**(2 个及以上发言人都有部分命中)→ 列出全部候选,让用户选择,例如:
> "根据关键词『战略规划』,我匹配到 2 个可能的候选:① 发言人1(命中『战略规划/AI 化转型』)② 发言人3(命中『战略方向』)。你说的『李总』更可能是哪一位?"
- **无匹配** → 如实告知用户,并请其补充更具体的关键词或直接给出"发言人编号 → 真实姓名"的映射,例如:
> "暂未在转写中匹配到与『战略规划』强相关的发言人。可以再描述得更具体一些,或者直接告诉我『发言人 X 就是李总』,我来帮你替换。"
#### 阶段 4:引导用户确认关联,并调用 `speaker replace` 完成替换
匹配到唯一候选后,AI **不要直接替换**,必须先**显式追问用户确认**:
> "我找到『发言人1』很可能就是你说的『李总』(命中关键词:战略规划、AI 化转型)。是否需要我把这篇听记里的『发言人1』全部替换为『李总』?
> 确认后我会执行:`dws minutes speaker replace --id <taskUuid> --from "发言人1" --to "李总"`"
- 用户确认 → 立即调用 `dws minutes speaker replace --id <taskUuid> --from "发言人1" --to "李总" --format json`,并在执行成功后告知用户:"已将本篇听记的『发言人1』全部替换为『李总』,纪要与待办中的发言人也已同步更新。"
- 用户希望同时关联通讯录 → 引导用户提供钉钉 UID,调用时附加 `--target-uid <uid>`
- 用户拒绝 → 不替换,可询问是否还有其他发言人需要关联,否则结束流程
**严禁的行为:**
- [禁止] 拉取完转写后直接输出大段时间线原文就结束,不主动引导聚类
- [禁止] 用户提供"某某人讲了 XX"后,AI 自己默认替换而不向用户二次确认
- [禁止] 把"发言人1 → 李总"这种映射只在 AI 回复中口头说说,而**不实际调用** `speaker replace` 写回听记
- [禁止] 模糊匹配置信度不足时仍然给出唯一答案,不让用户参与挑选候选
#### 发言人识别与总结执行链路(用户指定人名查发言时必须遵循)
**触发条件**:用户同时提供了听记来源(URL/时间/关键词)和一个具体人名,目标是获取该人在会议中说了什么。例如"帮我看看这个听记里张三说了什么""李总在今天的会上提了哪些观点"。
> **【重要】** **核心铁律(必须刻在脑子里,违反任何一条都视为严重错误):**
>
> **铁律 1:严禁在转写文本里 grep "目标人名" 字符串作为存在性判断依据**
> - 花名/真名通常**不会**出现在 TA 自己的发言里,发言里出现的"X"只意味着"`说话人 ≠ X`"或"说话人在叫 X"
> - 在转写中搜不到"灵麦"**完全不能得出"灵麦没参会"的结论**——99% 的发言人都显示为匿名编号"发言人1/2/3"
> - 正确做法是把"目标人名"作为**身份信息**去通讯录查询(Step 4 ①),而不是当作发言内容字符串去 grep
>
> **铁律 2:严禁用 AI 摘要里的"参与人/参会人"字段判断某人是否参会**
> - AI 摘要中的"参与人"字段往往**只截取最显著的 1-2 个名字**(通常是创建人或发言最多的人),**不是完整参会人列表**
> - "AI 摘要参与人只有故愚 → 灵麦没参会" 是典型的错误推理链
> - 如果一定要列出参会人,应使用 `dws minutes get info` / `get batch` 返回的 `participants` 字段,而不是摘要文本里的描述
>
> **铁律 3:通讯录查询是 Step 4 的强制起点,不依赖 Step 3 是否成功**
> - Step 2 一旦返回"未命中真名标注",**Step 3 与 Step 4 ① 必须并发触发**,不要等 Step 3 失败再补查
> - 通讯录查询 1 个调用就能锁定"目标人物的部门 + 职级 + 上级"——这是身份推断**最低成本、最高收益**的信号源
> - 跳过通讯录直接得出"找不到 X"的结论 = 100% 失败链路
>
> **铁律 4:找不到字面匹配 ≠ 不存在**
> - "发言人识别"功能存在的本质就是**根据角色特征把匿名编号映射到真实人**——"找不到字面匹配"恰恰是这个功能要解决的问题,而不是退出的理由
> - 一旦想说"找不到 X"前,先自问:通讯录查了吗?文档查了吗?聊天记录查了吗?基于角色在转写里做模式匹配了吗?四个全是"没"则禁止给"找不到"的结论
**完整执行链路:**
```
Step 1: 定位听记并读取转写原文
↓
Step 2: 声纹标注检查
├─ [目标人名已被系统标注] → 直接跳 Step 6
└─ [仅有匿名编号"发言人1/2/3"] ↓
Step 3: 转写原文内推断(优先,能判断就不走外部查询)
├─ [高置信命中] → 直接跳 Step 6
└─ [无法确定] ↓
Step 4: 多路并发身份推断
↓
Step 5: 定向匹配 + 置信度分支
├─ 置信度 ≥ 50% → 展示文本片段请用户确认
└─ 置信度 < 50% → 提供候选片段请用户辨认
↓
Step 6: 结构化总结输出
↓
Step 7: 引导用户替换发言人(调用 speaker replace 写回听记)
```
##### Step 1: 定位听记并读取转写原文
- **有 URL** → 从 URL 提取 taskUuid → `dws minutes get transcription --id <uuid> --format json`(自动翻页拉取全部)
- **有时间/关键词** → `dws minutes list all --query "关键词" --start "..." --end "..." --format json` 筛选 → 获取 taskUuid → 拉取转写
- **什么都没给** → **必须询问用户**提供听记链接、时间或关键词
##### Step 2: 声纹标注检查
检查转写原文中目标人物是否已被系统识别并标注了真实姓名(即 `speakerNick` 直接就是用户提到的人名):
- **已标注**(某条 `speakerNick` 字面就是"木兰")→ 直接跳到 Step 6,零确认步骤
- **仅有匿名编号**(发言人1/发言人2/发言人3)→ **必须**继续 Step 3 与 Step 4,**严禁在此处退出**
> **【重要】** **关键认知(违反则案例 7 重现):**
> - **未命中真名标注 ≠ 目标人物没参会**——绝大多数听记的发言人都是匿名编号,"未命中"是**默认场景**,恰恰是发言人识别功能要解决的问题
> - **不要在转写文本里 grep 目标人名**作为存在性判断——花名/真名通常不会出现在 TA 自己的发言里(参见铁律 1)
> - **不要把 `dws minutes get summary` 摘要文本里写到的"参与人"当作完整参会列表**——AI 摘要里的"参与人"字段只截取最显著的 1-2 个名字,不是完整名册(参见铁律 2)
> - 唯一能下"目标人物没参会"结论的场景是:`get info` / `get batch` 返回的结构化 `participants` 字段里**完整列出参会人且不含目标**,且 `dws contact user search` 也搜不到该人 → 才允许告知用户"该人不在参会列表"
##### Step 3: 转写原文内推断(优先)
**核心原则:先基于转写原文做逻辑推断,能直接判断就不调外部接口。**
充分利用转写文本中的所有可用信息进行综合推断:
- **称呼线索**:其他人称呼"张总/李工/王老师"等
- **自我介绍**:"我是 XX 部门的""我负责 XX"
- **上下文指代**:前文提到"张三你来说一下",紧接着的发言人大概率是张三
- **发言内容特征**:用户说"李总负责战略",而某发言人大量讨论战略方向
- **发言顺序**:主持人/领导通常先发言或总结性发言
只要能从原文**高置信度**地确定发言人,直接跳 Step 6 输出总结。如果仅凭原文无法确定,继续 Step 4。
##### Step 4: 多路并发身份推断
> **【重要】** **强制规则(违反则案例 7 重现):**
> - **路径 ① 通讯录查询是必跑项,不依赖 Step 3 是否成功**——Step 2 一旦判定"未命中真名标注",**Step 3 与 Step 4 ① 必须并发触发**,**严禁等 Step 3 失败再补查**
> - 通讯录查询单次调用即可拿到"部门 + 职级 + 上级 + 真名"——这是身份推断**最低成本、最高收益**的信号源
> - 路径 ②③④ 是**增量信号**,按需触发(如通讯录返回的部门是"产品设计部"这类多角色部门时,再补查 ② 文档)
**触发顺序:**
| 阶段 | 必跑 / 可选 | 触发时机 |
|------|-------------|----------|
| 路径 ① 通讯录组织架构 | **必跑** | Step 2 判定"未命中真名"后立即并发触发,与 Step 3 同时进行 |
| 路径 ② 本人创建的文档 | 可选 | ① 返回的部门是"产品设计部"这类多角色部门,需要更精确的角色信号时 |
| 路径 ③ 近期日程类型 | 可选 | ① ② 都不充分,需要补充职能边界判断时 |
| 路径 ④ 聊天记录 | 可选 | ①②③ 都不充分,需要语言风格/工作内容线索作为最后一道印证时 |
| 路径 | 命令 | 得到什么 |
|------|------|----------|
| ① 通讯录组织架构 | `dws contact user search --keyword "目标人名"` → 部门/职级/上级/真名 | 职能大类(技术/产品/设计/管理)+ 是否存在该人 |
| ② 本人创建的文档 | `dws doc search --keyword "目标人名/真名"` 至少获取 3 篇标题 | 角色精确信号(PM写PRD、研发写技术方案、设计师写视觉规范)|
| ③ 近期日程类型 | `dws calendar event list` | 职能边界(参加什么类型的会)|
| ④ 聊天记录 | `dws chat message list` 获取与目标人的近期 IM 消息 | 语言风格/工作内容/职责线索 |
**判定规则**:
- ① 命中(通讯录里搜到该人)→ 至少能拿到"部门 + 职级"信号,置信度起步 ≥ 30%
- ① + ② / ③ / ④ 任一路印证 → 置信度 ≥ 50%
- 两路以上独立信号一致 → 置信度 ≥ 70%
- ① 完全搜不到该人(且通讯录工具本身可用、未报错)→ 才允许告知用户"该人不在通讯录中,请确认花名是否正确"
> **关键约束 1**:部门名 ≠ 角色("产品设计部"里有 PM、设计师、研究员),必须结合文档产出等信号区分。
>
> **关键约束 2**:① 通讯录查询调用一次就能锁定身份范围,**严禁省略**。常见的失败模式是:在转写文本里反复 grep 目标人名找不到 → 直接放弃 → 告诉用户"找不到"——这是 100% 错误链路(参见案例 7)。
##### Step 5: 定向匹配 + 置信度分支
基于 Step 4 推断的角色,在转写原文中寻找匹配的发言模式:
| 角色 | 典型发言特征 |
|------|----------|
| 产品经理 | 提需求、讲用户场景、定优先级 |
| 研发 | 技术约束、方案评估、排查问题 |
| 管理者 | 发言占比高、最终决策、分配任务 |
| 设计师 | 视觉方案、交互细节、体验讨论 |
**分支 A:置信度 ≥ 50%(文本确认)**
选取最具代表性的连续片段(≥ 2 句完整句子,避免"嗯/对/好"等纯语气词),展示给用户:
> "根据分析,以下发言最可能是 [人名] 的:
> 「[片段内容]」
> 确认是 TA 吗?"
- 用户确认 → Step 6
- 用户否认 → 换下一候选(最多 3 个)
- 3 个全否 → 告知无法仅通过文本确认,建议在听记详情页播放录音辅助辨认
**分支 B:置信度 < 50%(多候选展示)**
当身份推断把握不足时,列出所有候选发言人及其代表性片段,让用户挑选:
> "无法确定哪位是 [人名]。以下是几位候选发言人的代表性内容:
> ① 发言人1:「[片段]」
> ② 发言人3:「[片段]」
> 哪位更像 [人名]?或者你可以在听记详情页播放录音辅助确认。"
- 用户选定 → Step 6
- 用户无法确认 → 告知可在听记详情页点击对应段落播放原始录音来辨认,结束流程
##### Step 6: 结构化总结输出
提取已确认的该发言人的全部发言,结合会议上下文进行综合总结。根据实际内容灵活组织输出结构,例如:
```
[人名] 在本次会议中的发言总结(共 N 段,约 X 字)
核心观点:
- 观点1...
- 观点2...
- 观点3...
关键决策/结论:
- ...
提出的待办/行动项:
- ...
```
##### Step 7: 引导用户替换发言人(必须执行,不可跳过)
总结输出完成后,如果该发言人在转写中仍显示为匿名编号(如"发言人1"),**必须主动引导用户替换**:
> "目前这篇听记中 [人名] 的发言仍显示为『发言人X』。要我帮你把听记里的『发言人X』全部替换为『[人名]』吗?替换后纪要和待办中的发言人也会同步更新。
> 确认后我会执行:`dws minutes speaker replace --id <taskUuid> --from "发言人X" --to "[人名]"`"
- 用户确认 → **先主动调用通讯录模糊查询** `dws contact user search --query "[人名]" --format json` 获取该人员的 userId(长整型 dingUid):
- 唯一匹配 → 告知用户匹配结果,确认后执行 `dws minutes speaker replace --id <taskUuid> --from "发言人X" --to "[人名]" --target-uid <userId> --format json`
- 多个匹配 → 列出候选(姓名+部门+userId)让用户选择后执行
- 无匹配 → 提示用户未在通讯录中找到此人,执行不带 `--target-uid` 的替换:`dws minutes speaker replace --id <taskUuid> --from "发言人X" --to "[人名]" --format json`
- 用户拒绝 → 不替换,询问是否还有其他发言人需要处理
**追问是否还有其他人需要识别:**
> "还有其他发言人需要我帮你识别和替换吗?比如告诉我『某某人主要讲了什么内容』,我可以帮你匹配。"
**严禁的行为:**
- [禁止] 总结完就结束,不引导用户替换发言人——用户下次看听记时发言人还是"发言人1",体验极差
- [禁止] AI 自行决定替换而不向用户确认
- [禁止] 只在回复中口头说"发言人1就是张三",但不实际调用 `speaker replace` 写回听记
### 获取听记中提取的待办事项
```
Usage:
dws minutes get todos [flags]
Example:
dws minutes get todos --id <taskUuid>
Flags:
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
```
每条记录包含: 待办内容、待办唯一ID、参与人信息、待办时间
### 批量查询听记详情
```
Usage:
dws minutes get batch [flags]
Example:
dws minutes get batch --ids uuid1,uuid2,uuid3
Flags:
--ids string 听记 taskUuid 列表,逗号分隔 (必填)
```
返回字段: 听记标题、时长、参与人列表、创建时间、taskUuid、听记状态
### 修改听记标题
```
Usage:
dws minutes update title [flags]
Example:
dws minutes update title --id <taskUuid> --title "Q2 复盘会议"
Flags:
--id string 听记 taskUuid (必填),取值逻辑参考 ## 注意事项
--title string 新标题 (必填)
```
### 发起听记(开始录音)
```
Usage:
dws minutes record start [flags]
Example:
dws minutes record start
dws minutes record start --session-id <sessionId>
Flags:
--session-id string AI 助理会话 ID (可选)
```
### 暂停听记录音
```
Usage:
dws minutes record pause [flags]
Example:
dws minutes record pause --id <taskUuid>
dws minutes record pause --id <taskUuid> --session-id <sessionId>
Flags:
--id string 听记 taskUuid (必填)
--session-id string AI 助理会话 ID (可选)
```
### 恢复听记录音
```
Usage:
dws minutes record resume [flags]
Example:
dws minutes record resume --id <taskUuid>
dws minutes record resume --id <taskUuid> --session-id <sessionId>
Flags:
--id string 听记 taskUuid (必填)
--session-id string AI 助理会话 ID (可选)
```
### 结束听记录音
```
Usage:
dws minutes record stop [flags]
Example:
dws minutes record stop --id <taskUuid>
dws minutes record stop --id <taskUuid> --session-id <sessionId>
Flags:
--id string 听记 taskUuid (必填)
--session-id string AI 助理会话 ID (可选)
```
### 更新纪要内容
```
Usage:
dws minutes update summary [flags]
Example:
dws minutes update summary --id <taskUuid> --content "新的纪要内容"
Flags:
--id string 听记 taskUuid (必填)
--content string 新的纪要内容 (必填)
```
用传入的摘要文本全量覆盖听记的纪要内容,不触发 AI 重新生成。适用于用户手动编辑或 AI Agent 修改纪要的场景。
**重要 — 修改纪要的完整流程(必须严格执行):**
当用户要求"精简纪要/优化纪要/修改纪要内容"时,**必须完成以下三步,缺一不可**:
1. **读取**:先调用 `get summary --id <taskUuid>` 获取当前纪要原文
2. **修改**:AI 根据用户要求对纪要内容进行修改(如精简、重新整理、格式优化等),但必须遵守以下约束:
- **图片必须保留**:原文中的所有 Markdown 图片(如 ``)必须完整保留,不得删除、漏掉、替换为纯文本或打乱语义位置
- **仅优化文本内容**:可以调整标题层级、段落结构、列表与措辞,但不得破坏图片与对应上下文的关联关系
3. **校验**:写回前必须执行 Markdown 格式检查,确保输出结构合理、可渲染、无明显格式错误(如未闭合代码块、列表层级混乱、标题层级异常等)
4. **写回**:将修改后的完整纪要内容通过 `update summary --id <taskUuid> --content "修改后的完整纪要"` **写回听记**,确保修改持久化
**严禁只读取和修改纪要而不调用 `update summary` 写回**,否则用户看到的仍然是原始纪要,修改不会生效。
### 创建思维导图
```
Usage:
dws minutes mind-graph create [flags]
Example:
dws minutes mind-graph create --id <taskUuid>
Flags:
--id string 听记 taskUuid (必填)
```
触发创建听记思维导图任务。触发成功后,可通过 `mind-graph status` 轮询任务状态。状态:0=进行中,1=成功,2=失败。
**重要:当用户要求"生成思维导图/创建脑图"时,必须调用此命令(`mind-graph create`),严禁自行生成 HTML 或其他格式的思维导图。** 思维导图由服务端专业引擎生成,AI 不应尝试自己构造思维导图内容。
### 查询思维导图状态
```
Usage:
dws minutes mind-graph status [flags]
Example:
dws minutes mind-graph status --id <taskUuid>
Flags:
--id string 听记 taskUuid (必填)
```
查询指定听记的思维导图生成状态。返回任务状态:0=进行中,1=成功,2=失败。如果没有返回任务状态,也视为成功。
### 替换发言人
```
Usage:
dws minutes speaker replace [flags]
Example:
dws minutes speaker replace --id <taskUuid> --from "张三" --to "李四"
dws minutes speaker replace --id <taskUuid> --from "张三" --to "李四" --target-uid <uid>
Flags:
--id string 听记 taskUuid (必填)
--from string 源发言人昵称 (必填)
--to string 目标发言人昵称 (必填)
--target-uid string 目标发言人钉钉 UID (可选)
```
批量替换听记转写中指定发言人,将源发言人(speakerNick)精确匹配的所有段落替换为目标发言人。支持同时替换 nickName 和 subSpeakerNickname 两种匹配方式,并自动更新纪要、待办中的发言人信息。
**重要:**
- 此命令支持替换**任意发言人**,包括已关联通讯录信息的发言人(如"张三"、"李四"等真实姓名),不仅限于"发言人1"之类的占位符
- `--from` 填写当前听记中显示的发言人名称(无论是"发言人1"还是真实姓名),`--to` 填写要替换成的目标名称
- 如果用户希望将发言人关联到通讯录中的具体联系人,可通过 `--target-uid` 传入目标用户的钉钉 UID
### 触发创建发言人段落总结任务
```
Usage:
dws minutes speaker summary create [flags]
Example:
dws minutes speaker summary create --ids <uuid1,uuid2>
dws minutes speaker summary create --task-uuids <uuid1,uuid2>
Flags:
--ids string 听记 taskUuid 列表,逗号分隔 (必填)
--task-uuids string --ids 的别名,同样接受逗号分隔的 taskUuid 列表
```
触发创建发言人的段落总结任务,将听记中每位发言人的所有发言内容汇总总结。触发后需调用 `speaker summary get` 查询总结结果。
> **参数别名说明**:`--ids` 和 `--task-uuids` 等价,均可使用。推荐统一用 `--ids`。
### 查询发言人段落总结结果
```
Usage:
dws minutes speaker summary get [flags]
Example:
dws minutes speaker summary get --ids <uuid1,uuid2>
dws minutes speaker summary get --task-uuids <uuid1,uuid2>
Flags:
--ids string 听记 taskUuid 列表,逗号分隔 (必填)
--task-uuids string --ids 的别名,同样接受逗号分隔的 taskUuid 列表
```
查询发言人段落总结任务的结果,返回每位发言人的发言汇总。需先调用 `speaker summary create` 触发任务。
> **参数别名说明**:`--ids` 和 `--task-uuids` 等价,均可使用。推荐统一用 `--ids`。
**speaker summary 异步轮询策略(必须严格遵守):**
`speaker summary create` 只是触发后端异步任务,**不会立即返回总结结果**。AI 必须按以下策略轮询 `speaker summary get`:
1. 调用 `speaker summary create --ids <uuids>` 触发任务
2. **等待至少 5 秒**后,首次调用 `speaker summary get --ids <uuids>` 查询结果
3. 如果返回结果为空(无内容),**继续等待 5 秒**后重试
4. **最大轮询次数不超过 20 次**(即最长等待约 100 秒)
5. 如果 20 次轮询后仍然为空,视为**任务未完成或无内容**,告知用户:"发言人段落总结任务可能仍在处理中,请稍后再试。"
```
伪代码:
speaker summary create --ids <uuids>
wait 5s
for i in 1..20:
result = speaker summary get --ids <uuids>
if result 不为空:
break # 拿到结果,继续后续流程
wait 5s
if 仍为空:
告知用户任务可能仍在处理中
```
**严禁**:
- 调用 `create` 后立即调用 `get`(不等待)——后端异步任务需要时间生成
- 只轮询 1-2 次就放弃——正常任务可能需要 10-30 秒
- 轮询超过 20 次——避免无限等待
**speaker summary 典型应用场景 — 通过发言主题匹配发言人:**
当用户只知道某个人的发言主题或关联内容(如"讲战略规划的那个人是谁"),但不知道对应的发言人编号时,可以通过 speaker summary 获取每位发言人的段落总结,再用关键词匹配确认身份。
**完整链路(发言人段落总结 → 关键词匹配 → 确认 → 替换):**
1. **确定听记 taskUuid**:从 `list mine/all` 或用户提供的 URL 中获取
2. **触发发言人段落总结**:`dws minutes speaker summary create --ids <taskUuid> --format json`
3. **延迟轮询获取结果**:等待 5s 后调用 `dws minutes speaker summary get --ids <taskUuid> --format json`,最多轮询 20 次
4. **AI 按发言人分组展示总结**:将每位发言人的段落总结以结构化方式呈现给用户
5. **关键词匹配**:从用户描述中抽取关键词(如"战略规划"、"供应链"),在各发言人的段落总结中做模糊匹配
6. **引导用户确认**:
- 唯一高置信命中 → "『发言人1』的段落总结涉及『战略规划、AI化转型』,很可能就是你说的『李总』,确认替换吗?"
- 多候选 → 列出所有命中的发言人编号 + 匹配的总结关键词,让用户挑选
- 无匹配 → 请用户补充更具体的关键词
7. **用户确认后执行替换**:`dws minutes speaker replace --id <taskUuid> --from "发言人1" --to "李总"`
**自然语言触发示例:**
| Query | AI 处理策略 |
|-------|-----------|
| "帮我看看这个会议里每个人主要说了什么" | speaker summary create → 轮询 get → 按发言人分组展示 |
| "讲战略规划的那个人是谁" | speaker summary create → 轮询 get → 关键词匹配 → 引导确认 |
| "帮我把每个发言人的核心观点总结一下" | speaker summary create → 轮询 get → 结构化输出 |
| "我想知道会上谁讲了供应链相关内容" | speaker summary create → 轮询 get → "供应链"关键词匹配 |
| "那个讲招聘进度的人应该是张三" | speaker summary create → 轮询 get → "招聘进度"匹配 → 确认 → speaker replace |
> **与 `get transcription` 聚类方案的区别**:`speaker summary` 是后端 AI 生成的结构化总结,信息密度更高、匹配更精准;而 `get transcription` 聚类是 AI 在本地按 speakerNick 分组原文再提取要点,适合需要看原文细节的场景。两者可配合使用:先用 speaker summary 快速定位,再用 transcription 看具体原文。
### 添加个人热词
```
Usage:
dws minutes hot-word add [flags]
Example:
dws minutes hot-word add --words "钉钉"
dws minutes hot-word add --words "OKR,钉钉,Copilot"
Flags:
--words string 要添加的热词,多个用逗号分隔 (必填)
```
添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。支持一次添加多个热词(逗号分隔),每个热词长度不超过 10 个汉字或 5 个英文单词。
### 查询我的热词列表
```
Usage:
dws minutes hot-word list
Example:
dws minutes hot-word list
```
查询当前用户配置的所有听记热词列表。无需传入额外参数,系统自动识别当前用户身份。返回用户已添加的全部热词,适用于查看已有热词、去重检查等场景。
### 查找替换听记文字
```
Usage:
dws minutes replace-text [flags]
Example:
dws minutes replace-text --id <taskUuid> --search "旧文字" --replace "新文字"
Flags:
--id string 听记 taskUuid (必填)
--search string 要查找的文字 (必填)
--replace string 替换为的新文字 (必填)
```
把听记中所有出现的原文字替换为目标文字,包括转写段落和纪要摘要中出现的原文字都会被替换。区分大小写,精确匹配。
**重要 — 执行前必须检查特殊字符并提示用户确认:**
用户输入的原始文本(`--search`)或目标文本(`--replace`)中可能携带特殊字符(如引号 `"` `'` `"` `"`、书名号 `《》`、括号 `【】()()`、星号 `*`、反斜杠 `\`、换行符、Markdown 格式符号等)。这些特殊字符在转写原文中**通常不存在**,会导致精确匹配失败(替换 0 处)。
AI **必须在执行 `replace-text` 之前**检查用户输入,若发现特殊字符,**先向用户确认**:
> "我注意到你输入的文本中包含特殊字符(如 `…`),转写原文中通常不会包含这些字符,直接匹配可能找不到。建议去掉特殊字符后再替换:
>
> - 原始文本:`XXX` → 建议改为:`YYY`
> - 目标文本:`AAA` → 建议改为:`BBB`
>
> 使用去掉特殊字符的版本执行替换?[是] [否,使用原始输入]"
- 用户确认 → 使用清理后的文本执行替换
- 用户选择原始输入 → 按原样执行,尊重用户意图
- **严禁不提示就自行去掉特殊字符**——用户可能确实需要匹配带特殊字符的文本
**重要 — 执行后必须主动引导用户添加热词(避免长期反复识别错):**
`replace-text` 仅修正**当前这一篇听记**的文字,**不会影响后续新听记的语音识别结果**。如果用户替换的是一个**长期容易被识别错的专有名词、人名、产品名**(如把"付工"改成"悟空"、把"非书"改成"飞书"),AI **必须在 `replace-text` 成功后主动追问用户**:
> "我已经把这篇听记里的『旧文字』替换为『新文字』。如果这个词以后也容易被识别错,建议把它加到个人热词里,后续新听记就不会再识别错了。要我现在帮你执行 `dws minutes hot-word add --words "新文字"` 吗?"
用户确认后立即调用 `hot-word add`。**严禁只做替换不引导**——这会让用户每次都要手动改一次,体验非常差。
### 创建文件上传会话或者文件转听记或者链接转听记
```
Usage:
dws minutes upload create [flags]
Example:
dws minutes upload create --file-name "meeting.mp4" --file-size 102400
dws minutes upload create --file-name "meeting.mp4" --file-size 102400 --title "周会录音"
dws minutes upload create --file-name "meeting.mp4" --file-size 102400 --input-language "zh" --enable-message-card
Flags:
--file-name string 文件名(含后缀),如 meeting.mp4 (必填)
--file-size int 文件大小(字节)(必填,正整数)
--title string 听记标题,不传时默认使用文件名去掉后缀 (可选)
--template-id string 纪要生成使用的模板 ID (可选)
--input-language string ASR 识别的源语言 (可选)
--enable-message-card 是否推送闪记卡片消息 (可选,默认 false)
```
创建文件上传会话,获取预签名上传 URL。调用方拿到 URL 后,直接用 HTTP PUT 将文件上传到该 URL。必须与 `upload complete` 配合使用:
1. 调用 `upload create` 获取预签名上传 URL 和 sessionId
2. HTTP PUT 预签名上传 URL 上传文件(不带 HEADER)
3. 调用 `upload complete` 传入 sessionId 完成创建
### 完成文件上传并创建听记
```
Usage:
dws minutes upload complete [flags]
Example:
dws minutes upload complete --session-id <sessionId>
Flags:
--session-id string 上传会话 ID,来自 upload create 返回的 sessionId (必填)
```
文件上传完成后,调用此命令创建听记。必须在 `upload create` 之后、预签名 URL 上传完成后调用。幂等:同一 sessionId 重复调用直接返回已有任务,不会重复创建。
### 取消文件上传会话
```
Usage:
dws minutes upload cancel [flags]
Example:
dws minutes upload cancel --session-id <sessionId>
Flags:
--session-id string 要取消的会话 sessionId (必填)
```
取消 `upload create` 创建的上传会话,释放服务端资源。用于在上传前或上传失败后取消会话。
### 批量添加听记成员并设置权限
```
Usage:
dws minutes permission add [flags]
Example:
dws minutes permission add --ids <uuid1,uuid2> --member-uids 123456,789012 --policy 3
dws minutes permission add --ids <uuid> --member-uids 123456 --policy 2 --cover
dws minutes permission add --ids <uuid> --member-uids 123456 --policy 3 --sub-resources "OrigContent,Summary"
dws minutes permission add --uuids <uuid1,uuid2> --member-uids 123456 --policy 3
dws minutes permission add --task-uuids <uuid1,uuid2> --member-uids 123456 --policy 3
Flags:
--ids string 听记 taskUuid 列表,逗号分隔 (必填)
--uuids string --ids 的别名
--task-uuids string --ids 的别名
--member-uids string 成员钉钉 UID 列表,逗号分隔 (必填)
--policy int 权限类型: 0=管理员, 1=所有者, 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)
--cover 是否覆盖已有权限 (可选,默认 false)
--sub-resources string 权限子模块,逗号分隔: OrigContent/Summary/Analysis/Note (可选)
```
批量给多个听记增加成员,并设置成员的权限。
**权限类型说明:**
| --policy 值 | 含义 | 说明 |
|------------|------|------|
| 0 | 管理员 | 可管理听记的所有设置和成员权限 |
| 1 | 所有者 | 听记的所有者,拥有最高权限 |
| 2 | 可编辑 | 可编辑听记的纪要、待办等内容 |
| 3 | 可查看/下载 | 可查看和下载听记内容,不可编辑 |
| 4 | 仅查看 | 仅可查看听记内容,不可下载 |
**权限子模块说明(--sub-resources,可选):**
| 子模块 | 含义 |
|--------|------|
| OrigContent | 原始内容(转写原文) |
| Summary | 纪要(AI 摘要) |
| Analysis | 分析 |
| Note | 笔记 |
不传 `--sub-resources` 时,默认对所有子模块生效。传入后仅对指定的子模块授权。
**典型使用场景:**
- 会议结束后将听记共享给未参会的同事查看
- 给团队成员批量授权某几篇听记的编辑权限
- 限制只共享纪要而不共享原始转写内容
### 批量移除听记成员权限
```
Usage:
dws minutes permission remove [flags]
Example:
dws minutes permission remove --ids <uuid1,uuid2> --member-uids 123456,789012
dws minutes permission remove --ids <uuid> --member-uids 123456
dws minutes permission remove --uuids <uuid1,uuid2> --member-uids 123456
dws minutes permission remove --task-uuids <uuid1,uuid2> --member-uids 123456
Flags:
--ids string 听记 taskUuid 列表,逗号分隔 (必填)
--uuids string --ids 的别名
--task-uuids string --ids 的别名
--member-uids string 成员钉钉 UID 列表,逗号分隔 (必填)
```
批量移除多个听记的成员权限。移除后,对应成员将失去对这些听记的访问权限。
**注意事项:**
- 移除权限后,该成员将无法再访问对应的听记内容
- 如果成员是听记的创建者(所有者),无法通过此命令移除其权限
- 建议在移除前先确认成员的当前权限,避免误操作
## 意图判断
### 发起听记
用户说"开始听记/开始录音/发起一个听记/启动听记/开始听记录这次会议/我要开始听记了/录音开始/我要听记" → `record start`
**自然语言示例:**
- "开始听记开启听记"
- "开始录音"
- "发起一个听记"
- "启动听记"
- "开始听记录这次会议"
- "我要开始听记了"
- "录音开始"
### 暂停/继续录制
用户说"暂停一下/暂停录音" → `record pause`
用户说"继续/继续录音" → `record resume`
**自然语言示例:**
- "暂停一下"
- "继续"
### 结束录制
用户说"结束听记/结束录音/停止录音/结束" → `record stop`
**自然语言示例:**
- "结束听记"
- "结束录音"
- "停止"
### 文件转听记
用户说"把文件转成听记/上传音频文件/上传录音/把这个 mp3 转成听记/帮我把会议录音转写一下/把附件里的音频文件做转写" → `upload create` → HTTP PUT → `upload complete`
**自然语言示例:**
- "把这个文件转写生成纪要"
- "帮我把这段录音转成文字"
- "上传一个音频文件,帮我转写"
- "我有一个录音文件,帮我生成听记"
- "把这个 mp3 转成听记"
- "帮我把会议录音转写一下"
- "这是昨天的录音,帮我整理成纪要"
- "把附件里的音频文件做转写"
用户说"取消上传" → `upload cancel`
### 查询我的热词列表
用户说"看看我的热词/我加了哪些热词/热词列表/查热词" → `hot-word list`
**自然语言示例:**
- "我之前加过哪些热词"
- "看看我的热词列表"
- "查一下我配置的热词"
- "查一下我听记的热词"
### 添加热词
用户说"加热词/添加热词/设置热词/把某个词加到热词里/这个词总是识别错" → `hot-word add`
**自然语言示例:**
- "转写不准,应该是'悟空'而不是'付工'"
- "帮我把'钉钉'加到热词里"
- "这个词总是识别错,加一下热词"
- "'飞书'总被识别成'非书',加个热词"
- "帮我设置一下热词,'悟空'经常识别不对"
- "我们公司有个产品叫'魔镜',加一下热词避免识别错"
- "把'AI听记'加进热词库"
- "这个专有名词总转写错,帮我加到热词"
### 查找替换
用户说"把所有的A替换成B/查找替换/批量替换文字" → `replace-text`,**执行成功后必须主动引导用户:"要不要把『新文字』加到热词里,避免后续新听记再识别错?" 用户确认后再调用 `hot-word add`**
**自然语言示例:**
- "把所有的A,替换成B"
- "把听记里的'付工'都改成'悟空'"
- "这一篇里把'非书'替换成'飞书'"
**正确的对话节奏(必须遵守):**
1. **检查特殊字符**:检查用户提供的原始文本和目标文本是否包含特殊字符(引号 `"` `'` `"` `"`、书名号 `《》`、括号 `【】()()`、星号 `*`、反斜杠 `\`、Markdown 格式符号等)。若包含,先提示用户确认:
> "你输入的文本中包含特殊字符 `…`,转写原文中通常不包含这些字符,直接匹配可能替换不到。建议去掉特殊字符后替换:原始文本 `XXX` → 建议 `YYY`。使用去掉特殊字符的版本?[是] [否,使用原始输入]"
- 用户确认 → 使用清理后文本
- 用户选择原始输入 → 按原样执行
- **严禁不提示就自行去掉特殊字符**
2. 执行 `replace-text` 完成本篇替换
3. 立即追问:"如果这个词以后也容易被识别错,建议加个热词,后续新听记就不会再错了。要现在加吗?"
4. 用户确认 → 调用 `hot-word add --words "<新文字>"`
5. 用户拒绝 → 直接结束,不再追问
### 修改说话人
用户说"替换发言人/修改发言人/换发言人名字/把说话人改成某某/发言人标注错了" → `speaker replace`
**关键:支持替换任意发言人,包括已关联通讯录信息的真实姓名(如"张三"、"拾光"等),不限于"发言人1"等占位符。** 用户提到"把 XX 改成 YY"时,XX 即为 `--from`,YY 即为 `--to`。
**默认引导通讯录查询获取 dingUid(推荐流程):**
替换发言人时,`--target-uid` 参数可以将发言人关联到钉钉通讯录真实身份。**AI 应默认引导用户通过通讯录模糊查询获取目标人员的 dingUid**,而非让用户自行提供。流程如下:
1. 用户提供目标姓名(如"张三")后,**AI 主动调用通讯录模糊查询**:
```
dws contact user search --query "张三" --format json
```
2. 从返回结果中提取匹配的人员列表,每条包含 `userId`(长整型数字,即 dingUid)和姓名:
- **唯一匹配** → 直接向用户确认:"通讯录中找到『张三(userId: 123456789)』,确认将发言人替换为此人并关联通讯录身份?"
- **多个匹配** → 列出候选让用户选择:
> 通讯录中找到多个匹配,请选择目标人员:
>
> | # | 姓名 | 部门 | userId |
> |---|------|------|--------|
> | 1 | 张三 | 产品部 | 123456789 |
> | 2 | 张三丰 | 技术部 | 987654321 |
>
> 请输入序号选择。
- **无匹配** → 提示用户:"通讯录中未找到『张三』,可以直接替换发言人名称(不关联通讯录身份),或提供更精确的姓名重新查询。直接替换?[是] [重新查询]"
3. 获取到 userId 后,在 `speaker replace` 中附加 `--target-uid <userId>`,实现发言人关联通讯录身份
> **注意**:`dws contact user search` 返回的 `userId` 是**长整型数字**(如 `123456789`),这就是 `--target-uid` 需要的值。不要与 `staffId`、`unionId` 等其他 ID 混淆。
**发言人映射模式识别(高频场景):**
用户经常使用如下格式批量告知发言人对应关系:
- `发言人1:张三`
- `发言人1:张三`(中文冒号)
- `发言人1->张三`
- `发言人1 -> 张三`
- `发言人1 = 张三`
- 多条换行或逗号分隔:`发言人1:张三, 发言人2:李四, 发言人3:王五`
**AI 识别到上述模式时,必须执行以下流程:**
1. **解析映射关系**:提取所有 `源发言人 → 目标姓名` 的配对
2. **批量通讯录查询**:对每个目标姓名调用 `dws contact user search --query "<姓名>" --format json`,获取 userId(dingUid)。将查询结果汇总后一并向用户确认
3. **向用户确认**:展示解析结果(含通讯录匹配)并请求确认,格式如下:
> 我识别到以下发言人对应关系,并已从通讯录中匹配到对应人员:
>
> | 当前标注 | 替换为 | 通讯录匹配 | userId |
> |---------|--------|-----------|--------|
> | 发言人1 | 张三 | 张三(产品部) | 123456789 |
> | 发言人2 | 李四 | 李四(技术部) | 987654321 |
>
> 替换后,发言人将关联到通讯录真实身份,该听记中所有对应的发言段落标注、纪要和待办中的发言人信息都会同步更新。
>
> 确认执行?[确认] [取消]
4. **用户确认后**,逐条执行 `speaker replace`(附带 `--target-uid`):
```
dws minutes speaker replace --id <taskUuid> --from "发言人1" --to "张三" --target-uid 123456789 --format json
dws minutes speaker replace --id <taskUuid> --from "发言人2" --to "李四" --target-uid 987654321 --format json
```
如果某个姓名未在通讯录中找到匹配,则该条不带 `--target-uid`:
```
dws minutes speaker replace --id <taskUuid> --from "发言人3" --to "王五" --format json
```
5. **全部完成后**,主动追问是否需要同时执行文本替换(`replace-text`),因为纪要正文中可能也残留了"发言人1"等文字:
> 发言人标注已全部替换完成。纪要正文中如果还有残留的「发言人1」「发言人2」等文字,是否也一并替换?[是] [否]
6. 用户确认后,对每条映射执行 `replace-text`:
```
dws minutes replace-text --id <taskUuid> --search "发言人1" --replace "张三" --format json
```
**禁止行为:**
- 禁止不确认就直接执行替换(发言人替换不可逆)
- 禁止只执行 `speaker replace` 不引导 `replace-text`(纪要正文可能有残留)
- 禁止只执行 `replace-text` 不执行 `speaker replace`(转写段落的发言人标注不会被 replace-text 修改)
- 禁止跳过通讯录查询直接要求用户提供 dingUid——应由 AI 主动查询并引导选择
**自然语言示例:**
- "把这一段内容的说话人全部改成张三"
- "帮我把发言人1改成李总"
- "这个说话人标注错了,改成王伟"
- "把所有'发言人2'替换成'小明'"
- "说话人识别错了,帮我修改一下"
- "把这段的说话人改成'拾贝'"
- "发言人1是我,帮我把名字改过来"
- "把'未知说话人'全都改成张总"
- "把这里面的发言人1,替换成拾光"
- "张三其实是李四,帮我改一下"
- "发言人1:张三 发言人2:李四 发言人3:王五"
- "发言人1->张三, 发言人2->李四"
- "发言人1是张三,发言人2是李四"
### 查某人在听记中说了什么(发言人识别与总结)
**触发条件**(必须同时满足):
1. 用户提供了听记来源(URL / 时间 / 关键词可定位到具体听记)
2. 用户指定了一个具体人名(花名/真名/关系称谓均可)
3. 用户的目标是获取该人在会议中说了什么
**不触发**(走其他链路):
- 用户只要整体纪要、未指定特定人物 → `get summary`
- 用户直接说"把发言人1改成张三" → `speaker replace`(无需走推断链路)
- 用户想提取待办 → `get todos`
用户说"帮我看看某人说了什么/某人在会上提了哪些观点/某人有什么发言" → 走**发言人识别与总结**完整链路(详见 [获取听记语音转写原文](#获取听记语音转写原文) 章节中的"发言人识别与总结执行链路")
**自然语言示例:**
| Query | 关键线索 |
|-------|----------|
| "帮我看看这个听记里张三说了什么" + URL | 听记URL + 人名 |
| "李总在今天的会上提了哪些观点" | 时间(今天) + 人名 + 观点 |
| "上周产品评审里王五有什么发言" | 时间(上周) + 关键词(产品评审) + 人名 |
| "这个会议里我老板说了啥" | 听记上下文 + 关系称谓(需解析为人名) |
| "帮我找一下设计师小陈在访谈里的观点" | 角色提示(设计师) + 人名 + 场景 |
- "帮我看看这个听记里张三说了什么"
- "李总在今天的会上提了哪些观点"
- "上周产品评审里王五有什么发言"
- "这个会议里我老板说了啥"
- "帮我找一下设计师小陈在访谈里的观点"
- "拾光在这个会议里讲了什么"
- "帮我总结一下这次会议里李总的核心发言"
- "王经理今天开会说了啥重点"
**关键约束(必须遵守):**
- 这是一个**完整的识别 → 推断 → 确认 → 总结 → 引导替换**链路,不要只做总结就结束,**必须引导用户将「发言人1」等占位符替换为真实姓名**
- 完整执行链路见 [获取听记语音转写原文](#获取听记语音转写原文) 章节中的"发言人识别与总结执行链路"
### 通过关键词模糊匹配确认发言人(与 `get transcription` 联动)
当用户在拉取完转写原文(且已按发言人聚类)之后,提供形如『某某人主要讲了 XX』『某某人负责 YY』『某某人这次的核心是 ZZ』的描述时,**不要直接做替换**,而是按下面的链路推进:
1. **从用户输入中抽取关键词**(如『战略规划』『供应链效率』『AI 化转型』『招聘进度』)
2. **在已聚类的发言人核心要点中做模糊匹配**(支持同义词与近义词)
3. **匹配结果引导用户确认:**
- 唯一高置信命中 → 提示用户:"『发言人1』很可能就是你说的『李总』,是否需要执行 `dws minutes speaker replace --id <taskUuid> --from "发言人1" --to "李总"`?"
- 多候选 → 列出所有命中的发言人编号 + 命中关键词,让用户挑选
- 无匹配 → 请用户补充更具体的关键词或直接给出"发言人编号 → 真实姓名"的映射
4. **用户确认后**才调用 `speaker replace` 写回;如同时希望关联通讯录,引导用户提供 UID,附加 `--target-uid <uid>`
**自然语言示例(必须能正确识别为"模糊匹配 → 确认 → 替换"链路):**
- "李总主要讲了战略规划"
- "拾光是负责供应链的那位"
- "讲 AI 化转型那个人是王经理"
- "这里面提到招聘进度的应该是张三"
- "提到财务数据的是 CFO 老周"
- "讲华东市场竞品分析的那个人是李四"
详细工作流见 [获取听记语音转写原文](#获取听记语音转写原文) 章节中的"四阶段工作流"。
### 更新/修改纪要
用户说"修改纪要/更新纪要/编辑纪要内容/重新整理纪要/纪要格式优化/纪要精简" → 先 `get summary` 获取纪要原文,AI 修改后再 `update summary` **写回**
**关键:修改纪要必须是"读取 -> 修改 -> 校验 -> 写回"四步完整流程,最终必须调用 `update summary` 将修改后的内容写回听记,否则修改不会生效。严禁只展示修改结果而不写回。修改时必须保留原文中的所有 Markdown 图片,不得删除或打乱图片位置。**
**自然语言示例:**
- "帮我把纪要按 xxxx 格式优化一下"
- "帮我重新整理一下这份纪要"
- "纪要太长了,帮我精简一下"
- "按照 STAR 格式重新写一下纪要"
- "帮我把纪要改得更正式一些"
- "这份纪要结构不清晰,帮我重新整理"
- "把纪要里的口语化内容改成书面语"
- "按照决策/行动/结论三段式重新输出纪要"
- "把这个纪要内容变得更精简一点"
### 生成思维导图
用户说"生成思维导图/创建脑图/帮我添加思维导图/基于这个听记做思维导图/把会议内容做成脑图" → `mind-graph create`
**关键约束(必须严格遵守,违反视为严重错误):**
1. **思维导图必须且只能通过 `dws minutes mind-graph create` 命令生成**,由服务端写回到听记本身。这是"听记内置的思维导图能力",生成结果会直接挂在听记详情页上,用户在听记里就能看到。
2. **严禁以下任何"自行构造"的替代方案**(这些都是错误做法):
- [禁止] 先 `get summary` / `get transcription` 拿到内容,再用 AI 自己整理出思维导图结构(Markdown / OPML / JSON 等)展示给用户
- [禁止] 调用 app-development-skill / ai-app / 任何前端构建能力,用 `@antv/g6`、`markmap`、`jsmind`、`mermaid`、ECharts 等库生成 HTML/网页版思维导图
- [禁止] 调用 `generate_image` 或任何绘图工具生成思维导图图片
- [禁止] 路由到其他 Agent(如 ai-app)来"创建一个思维导图应用"
- [禁止] 任何形式的"我帮你画一个/生成一个网页/部署一个在线预览链接"
3. **唯一正确做法**:从用户输入中拿到 taskUuid(URL 场景见下方"URL 直达"),直接调用 `mind-graph create`,然后用 `mind-graph status` 轮询直到状态为 1(成功),最后告知用户"思维导图已生成,可在听记详情页查看"即可。**不需要**先调 `get summary` 等任何读取命令——服务端会基于听记自身内容生成。
**URL 直达场景:**
当用户输入形如 `https://shanji.dingtalk.com/app/transcribes/{taskUuid}` 的链接 + "创建思维导图/生成脑图"等诉求时:
1. 直接从 URL 路径末段提取 taskUuid
2. **直接** `dws minutes mind-graph create --id <taskUuid> --format json`
3. 用 `mind-graph status` 轮询
4. 严禁中间插入 `get summary` / `get transcription` 等任何"先读取内容"的步骤,更严禁基于读到的内容自行构造思维导图
**自然语言示例:**
- "帮我添加思维导图"
- "生成思维导图"
- "帮我创建一个脑图"
- "把这个听记做成思维导图"
- "生成这个会议的思维导图"
- "基于这个听记创建思维导图"
- "把会议内容整理成脑图"
- "<听记URL> 创建思维导图"
- "<听记URL> 帮我生成脑图"
用户说"思维导图状态/脑图进度/思维导图好了吗" → `mind-graph status`
### 查询听记列表
用户说"我创建的听记/我发起的听记/我自己录的听记" → `list mine`(**仅**返回我自己创建的,不含他人共享给我的)
用户说"别人给我的听记/共享听记/共享给我的" → `list shared`(**仅**返回他人共享给我的,不含我创建的)
用户说"我可访问的听记/我能看到的听记/我有权限的听记/所有听记/全部听记" → `list all`(返回我可访问的**所有**听记 = mine + shared)
用户说"我的听记" → `list all`(**注意**:"我的听记"是模糊表述,用户通常期望看到所有可访问的听记,而不仅是自己创建的,默认走 `all`;仅当上下文明确表示"我自己创建/发起的"时才走 `mine`)
用户说"某时间段内的听记/按时间查听记/按关键词查听记" → 根据所属范围选择 `list mine`/`list shared`/`list all`,附加 `--start`、`--end`、`--query` 参数
**自然语言示例:**
- "查一下我最近的听记"
- "帮我找一下上周的听记"
- "有没有关于'周会'的听记"
- "看看别人共享给我的听记"
- "我这个月的听记有哪些"
- "帮我从今天听记里找需求评审"
- "上月听记里有没有提到OKR"
- "从我的听记里搜一下技术方案"
- "这周有没有关于复盘的会"
- "帮我找上周关于项目排期的听记"
> **路由提示**:
> - 用户说"从我创建的/我发起的听记里找XX" → `list mine --query`
> - 用户说"我的听记里找XX"/"我可访问的听记"/"帮我找关于XX的听记" → `list all --query`("我的"是模糊表述,默认走 `all` 覆盖最广)
> - **仅当**用户明确强调"我自己创建/录制/发起的"时才走 `mine`
> - 含时间词(今天/上周/上月/这周/本月)时,必须同时附加 `--start` + `--end` 参数
### 获取听记内容
用户说"听记详情/听记信息" → `get info`
用户说"摘要/总结/会议纪要/纪要" → `get summary`
用户说"关键字/关键词" → `get keywords`
用户说"原文/转写/录音文字/逐字稿" → `get transcription`(**默认拉取全部原文**,自动翻页直到拉完,**拉完后必须主动询问用户是否按发言人聚类**,详见 [获取听记语音转写原文](#获取听记语音转写原文) 的"四阶段工作流")
用户说"会议待办/听记待办/待办事项" → `get todos`
用户说"批量查询/查多个听记" → `get batch`
用户说"音频地址/音频链接/录音文件/下载录音/音频下载/视频文件/媒体地址" → `get audio`
**转写原文拉取时机判断(必须遵守):**
- **明确要看原文** → 调用 `get transcription`,自动翻页拉取所有原文;**累积超过 12000 字符时暂停,询问用户是否继续**
- 示例:"帮我看看转写原文"、"分析一下这篇听记的原文"、"把逐字稿发给我"
- **未明确要看原文** → **不要**调用 `get transcription`,用其他更合适的命令响应
- 示例:"查一下和悟空相关的听记" → 应走 `list`,不需要拉原文
- 示例:"这个会议讲了什么" → 应走 `get summary`,不需要拉原文
- 示例:"这个听记有哪些待办" → 应走 `get todos`,不需要拉原文
**自然语言示例:**
- "帮我看看这个听记的摘要"
- "这个会议讲了什么"
- "帮我看看会议的转写原文"
- "分析一下这篇听记的转写原文" → **默认拉全部**
- "这个听记有哪些待办"
- "帮我提取一下会议的关键词"
- "帮我看看这个听记的基本信息"
- "把这个听记的录音文件给我"
- "帮我下载这个听记的音频"
- "这个听记的音频地址是什么"
- "我想下载这次会议的录音"
- "帮我拿一下这个听记的视频文件"
- "给我这个听记的媒体下载链接"
### 添加/管理听记成员权限
用户说"把某人加到这个听记中/共享听记给某人/给某人添加听记权限/把听记分享给同事/让某人也能看这个听记" → `permission add`
用户说"添加参会者/批量添加参会者/添加成员/批量添加成员/加参会人/加入成员" → `permission add`(**注意:听记模块没有独立的"添加参会者/成员"命令,统一走权限接口**)
用户说"移除某人的权限/取消共享/不让某人看这个听记了" → `permission remove`
**自然语言示例:**
- "把张三加到这个听记中"
- "帮我把这个听记共享给李四"
- "让小王也能看这个听记"
- "给团队的同事们添加这个听记的查看权限"
- "把这几个听记共享给 UID 123456 的同事,设置为可编辑"
- "帮我把这个听记分享给参会的人"
- "给这个听记加个协作者"
- "把听记的编辑权限给我同事"
- "添加张三为这个听记的参会者"
- "帮我批量添加参会者:张三、李四、王五"
- "给这个听记添加几个成员"
- "批量添加成员到这个听记"
- "把张三加为参会人"
- "移除张三对这个听记的权限"
- "取消小王对这个听记的访问"
- "不让某人看这个听记了"
**路由规则:**
- 用户提到"加/添加/共享/分享/让...看/让...访问"等增加权限语义 → `permission add`
- 用户提到"添加参会者/加参会者/批量添加参会者/添加成员/加成员/批量添加成员/加参会人/添加参会人"等成员语义 → `permission add`(听记没有独立的"添加成员"接口,**统一走权限接口**)
- 用户提到"移除/取消/删除/不让...看"等移除权限语义 → `permission remove`
- 如果用户未指定权限类型,默认使用 `--policy 4`(可查看/下载/编辑)
- 如果用户未提供 member-uids,需要先引导用户提供目标成员的钉钉 UID(可通过 `dws contact user search --query "姓名"` 查询)
**典型执行链路:**
1. 用户说"把张三加到这个听记中" → AI 需获取张三的 UID
2. 调用 `dws contact user search --query "张三" --format json` 获取 UID
3. 调用 `dws minutes permission add --ids <taskUuid> --member-uids <uid> --policy 4 --format json`
### 修改听记标题
用户说"改听记标题/重命名听记/修改标题" → `update title`
**自然语言示例:**
- "把这个听记的标题改成'Q2 复盘会议'"
- "帮我重命名一下这个听记"
### URL 识别
- **格式**: `https://shanji.dingtalk.com/app/transcribes/{taskUuid}`
- **示例**: `https://shanji.dingtalk.com/app/transcribes/76327569643231383535353939365f3436383537393431335f32`
用户传入听记 URL(如 `https://shanji.dingtalk.com/app/transcribes/xxx`),从 URL 提取 taskUuid,再执行对应的 get/update 操作
## 核心工作流
```bash
# 0. 发起听记(开始录音)
dws minutes record start --format json
# 1. 查看我的听记列表 — 提取 taskUuid
dws minutes list mine --format json
dws minutes list mine --max 10 --cursor <nextCursor> --format json
dws minutes list mine --query "周会" --format json
# 1b. 查看共享给我的听记
dws minutes list shared --max 20 --format json
dws minutes list shared --query "日报" --format json
# 1c. 查看我有权限访问的所有听记(支持关键字和时间范围筛选)
dws minutes list all --format json
dws minutes list all --query "周会" --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T23:59:59+08:00" --format json
# 2. 获取 AI 摘要
dws minutes get summary --id <taskUuid> --format json
# 3. 查看完整转写原文(拉完后默认按时间线返回,AI 必须主动追问"是否按发言人聚类")
dws minutes get transcription --id <taskUuid> --format json
# 3a. 用户确认聚类 → AI 在本地按 speakerNick 分组并提取核心要点(无需新调用 dws)
# 3b. 用户提供"某某人讲了 XX" → AI 模糊匹配关键词后,引导确认替换发言人
dws minutes speaker replace --id <taskUuid> --from "发言人1" --to "李总" --format json
# 4. 提取待办事项
dws minutes get todos --id <taskUuid> --format json
# 4b. 获取音频/视频地址(用于下载或播放原始媒体文件)
dws minutes get audio --id <taskUuid> --format json
# 5. 修改标题
dws minutes update title --id <taskUuid> --title "新标题" --format json
# 6. 更新纪要内容
dws minutes update summary --id <taskUuid> --content "新的纪要内容" --format json
# 7. 录音控制(基于 start 返回的 taskUuid)
dws minutes record pause --id <taskUuid> --format json
dws minutes record resume --id <taskUuid> --format json
dws minutes record stop --id <taskUuid> --format json
# 8. 思维导图
dws minutes mind-graph create --id <taskUuid> --format json
dws minutes mind-graph status --id <taskUuid> --format json
# 9. 替换发言人
dws minutes speaker replace --id <taskUuid> --from "张三" --to "李四" --format json
# 9b. 发言人段落总结(异步:create 触发 → 延迟 5s → 轮询 get,最多 20 次)
dws minutes speaker summary create --ids <taskUuid1,taskUuid2> --format json
# 等待至少 5 秒...
dws minutes speaker summary get --ids <taskUuid1,taskUuid2> --format json
# 若返回为空,继续等待 5s 后重试,最多 20 次
# 10. 添加个人热词
dws minutes hot-word add --words "OKR,钉钉,Copilot" --format json
# 11. 查找替换听记文字
dws minutes replace-text --id <taskUuid> --search "旧文字" --replace "新文字" --format json
# 12. 文件上传转听记(三步流程)
# 12a. 创建上传会话,获取预签名 URL 和 sessionId
dws minutes upload create --file-name "meeting.mp4" --file-size 102400 --format json
# 12b. 用 HTTP PUT 上传文件到预签名 URL(不带 HEADER)
curl -X PUT "<presignedUrl>" -T "/path/to/meeting.mp4"
# 12c. 通知服务端上传完成,创建听记
dws minutes upload complete --session-id <sessionId> --format json
# 12d.(可选)取消上传会话
dws minutes upload cancel --session-id <sessionId> --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list mine` | `taskUuid`、`nextCursor` | get/update 的 --id;翻页时 --cursor |
| `list shared` | `taskUuid`、`nextCursor` | get/update 的 --id;翻页时 --cursor |
| `list all` | `taskUuid`、`nextCursor` | get/update 的 --id;翻页时 --cursor |
| `get batch` | 各听记 `taskUuid` | 进一步查询详情 |
| `get audio` | 音频/视频 OSS 地址 | 用 HTTP GET 下载录音文件 / 在浏览器播放 |
| `record start` | `taskUuid`/`uuid` | record pause/resume/stop 的 --id |
| `upload create` | `sessionId`、`presignedUrl` | HTTP PUT 上传文件;upload complete/cancel 的 --session-id |
| `mind-graph create` | 任务状态 | mind-graph status 轮询 |
## 错误响应诊断指南
当 `get info` / `get summary` / `get transcription` / `get todos` / `get keywords` / `get audio` 返回异常时,按以下决策表快速判断原因并决定下一步动作,避免盲目重试浪费轮次:
### 错误快速决策表
| 错误现象 | 可能原因 | 正确处理 | 禁止动作 |
|----------|----------|----------|----------|
| `dingOpenErrcode=300` 且 `error_msg` 含 "taskUuid is invalid" | taskUuid 格式错误、不存在、或从上下文猜测/拼凑而来 | **立即停止当前 uuid,切换到 list 策略**:调用 `dws minutes list mine --max 10 --format json` 获取真实 uuid 列表,让用户选择或自动匹配最相关的一条。**严禁用同一个无效 uuid 重试**——真实案例中模型用同一个错 uuid 重试了 20 次全部失败 | 禁止用同一个无效 ID 重试哪怕 1 次;禁止从历史对话/文档/链接中猜测 uuid;禁止从十六进制编码字符串里截取数字拼凑新 uuid |
| stdout 完全为空,error_msg 也为空 | 鉴权过期 / 服务端临时不可用 | 最多重试 1 次;仍为空则告知用户「服务暂时不可用,请稍后再试」 | 禁止连续重试超过 2 次 |
| `dingOpenErrcode=403` 或含 "permission" / "forbidden" | 无权限访问该听记 | 告知用户无权限,建议联系听记创建者共享权限 | 禁止重试(权限问题不会因重试改变) |
| `dingOpenErrcode=404` 或含 "not found" | 听记已被删除或不存在 | 告知用户该听记不存在或已被删除 | 禁止重试 |
| 返回 JSON 但关键字段(如 `result`)为 null | 听记处理中(ASR/AI 摘要尚未完成) | 告知用户听记内容正在生成中,建议等待 1-2 分钟后再查询 | 禁止立即重试(处理需要时间) |
| 网络超时 / 连接错误 | 网络不稳定 | 最多重试 1 次 | 禁止连续重试超过 2 次 |
### 重试约束(必须遵守)
- 同一个 taskUuid + 同一个命令,最多重试 **1 次**(总计最多调用 2 次)
- 重试前必须检查:错误是否属于「可重试」类别(仅 stdout 为空 / 网络超时 属于可重试)
- `dingOpenErrcode=300/403/404` 均为「不可重试」错误,立即停止并给出明确诊断
- 如果第一次调用返回了结构化错误(含 errcode),禁止换一个别名 flag(如 --task-uuid 换 --id)重试——问题不在 flag 名称
### Flag 别名兼容说明
所有需要传入 taskUuid 的子命令(`get info/summary/keywords/transcription/todos/audio`、`mind-graph create/status` 等)均支持以下 flag 名称,自动降级到 `--id`:
| Flag 名称 | 状态 | 说明 |
|-----------|------|------|
| `--id` | 推荐 | 唯一的正式 flag 名称,文档和示例中统一使用 |
| `--url` | 隐藏别名 | 兼容听记 URL 传入场景 |
| `--task-uuid` | 隐藏别名 | 兼容钉钉 OpenAPI 原生字段名 taskUuid |
| `--uuid` | 隐藏别名 | 兼容通用 UUID 命名习惯 |
传入 `--task-uuid` / `--uuid` / `--url` 时,CLI 会自动降级到 `--id` 处理,无需报错后用 `--id` 重试。
## 关键词搜索与筛选最佳实践
### 服务端筛选优先原则(必须遵守)
`list mine` / `list shared` / `list all` 均支持 `--query`(关键词)、`--start`(开始时间)、`--end`(结束时间)三个筛选参数,筛选在服务端完成,效率远高于全量拉取后本地过滤。
**禁止全量拉取后本地过滤**:当用户提供了搜索关键词或时间范围时,必须使用 `--query` / `--start` / `--end` 参数在服务端筛选,严禁先 `list all` 拉全量再在 prompt 中用 Python/grep 做本地关键词匹配。
### 自然语言到参数映射表
| 用户表述 | 映射参数 | 示例命令 |
|----------|----------|----------|
| "今天的听记" | `--start` 今日 00:00 `--end` 当前时间 | `dws minutes list all --start "2026-05-11T00:00:00+08:00" --end "2026-05-11T23:59:59+08:00"` |
| "上周的听记" | `--start` 上周一 00:00 `--end` 上周日 23:59 | `dws minutes list all --start "2026-05-04T00:00:00+08:00" --end "2026-05-10T23:59:59+08:00"` |
| "上月的听记" | `--start` 上月 1 日 `--end` 上月最后一天 | `dws minutes list all --start "2026-04-01T00:00:00+08:00" --end "2026-04-30T23:59:59+08:00"` |
| "最近的听记" | 不传时间参数,使用默认排序 | `dws minutes list mine --max 10` |
| "关于XX的听记" | `--query "XX"` | `dws minutes list all --query "双叶汽车"` |
| "找XX相关的听记" | `--query "XX"` | `dws minutes list all --query "安利"` |
| "本月关于XX的听记" | `--query` + `--start` + `--end` | `dws minutes list all --query "ROI" --start "2026-05-01T00:00:00+08:00" --end "2026-05-31T23:59:59+08:00"` |
| "帮我从今天听记里找XX" | `--query` + `--start` + `--end`(今日范围) | `dws minutes list mine --query "需求评审" --start "2026-05-11T00:00:00+08:00" --end "2026-05-11T23:59:59+08:00"` |
| "上月听记里有没有提到XX" | `--query` + `--start` + `--end`(上月范围) | `dws minutes list mine --query "OKR" --start "2026-04-01T00:00:00+08:00" --end "2026-04-30T23:59:59+08:00"` |
| "从我的听记里搜XX" | `--query "XX"`(走 `list mine`) | `dws minutes list mine --query "技术方案"` |
| "这周有没有关于XX的会" | `--query` + `--start` + `--end`(本周范围) | `dws minutes list mine --query "复盘" --start "2026-05-05T00:00:00+08:00" --end "2026-05-11T23:59:59+08:00"` |
### 组合筛选示例
```bash
# 按关键词搜索
dws minutes list all --query "神威数智需求变更沟通会议" --format json
# 按时间范围搜索
dws minutes list all --start "2026-04-01T00:00:00+08:00" --end "2026-04-30T23:59:59+08:00" --format json
# 关键词 + 时间范围组合
dws minutes list all --query "精益生产" --start "2026-04-01T00:00:00+08:00" --end "2026-04-30T23:59:59+08:00" --format json
# 按关键词搜索共享听记
dws minutes list shared --query "安利" --max 20 --format json
# 从我的听记里按关键词 + 今日时间范围搜索("帮我从今天听记里找需求评审")
dws minutes list mine --query "需求评审" --start "2026-05-11T00:00:00+08:00" --end "2026-05-11T23:59:59+08:00" --format json
# 从我的听记里按关键词 + 上月时间范围搜索("上月听记里有没有提到OKR")
dws minutes list mine --query "OKR" --start "2026-04-01T00:00:00+08:00" --end "2026-04-30T23:59:59+08:00" --format json
# 从我的听记里仅按关键词搜索("从我的听记里搜技术方案")
dws minutes list mine --query "技术方案" --format json
```
### 搜索无结果时的 Fallback 策略
1. `--query` 无结果 --> 尝试缩短关键词(如「神威数智需求变更沟通会议」缩短为「神威数智」)
2. 仍无结果 --> 扩大搜索范围(`list mine` 换为 `list all`)
3. 仍无结果 --> 放宽时间范围(扩大 `--start` / `--end`)
4. 最终无结果 --> 如实告知用户未找到匹配的听记,建议用户确认关键词或时间范围
## 转写翻页异常处理
### 翻页空响应防御规则(必须遵守)
`get transcription` 使用 `--next-token` 进行分页查询。在自动翻页过程中,可能遇到以下异常情况:
| 异常现象 | 含义 | 处理方式 |
|----------|------|----------|
| 返回 JSON 中不包含 `nextToken` 字段 | 已到达最后一页,无更多数据 | 正常终止翻页,拼合已拉取内容 |
| `nextToken` 字段值为空字符串 `""` | 等同于无 nextToken,已到达最后一页 | 正常终止翻页 |
| 使用同一个 `next-token` 值连续 2 次返回 stdout 为空 | 服务端临时异常或该 token 已失效 | 立即终止翻页,不再重试;基于已拉取的内容进行分析 |
| 返回 JSON 但 `paragraphList` 为空数组 `[]` | 当前页无内容(可能是中间空页) | 如果有 nextToken 则继续翻页;如果无 nextToken 则终止 |
### 翻页流程伪代码
```
已累积文本 = ""
当前token = ""(首页不传)
连续空响应计数 = 0
上一次token = null
loop:
调用 get transcription --id <uuid> [--next-token 当前token]
if stdout 为空 or 返回无效:
if 当前token == 上一次token:
连续空响应计数 += 1
if 连续空响应计数 >= 2:
终止翻页,输出已累积内容
break
else:
连续空响应计数 = 1
上一次token = 当前token
continue
连续空响应计数 = 0
拼合本页转写文本到已累积文本
if len(已累积文本) > 12000:
暂停翻页
询问用户:"当前已拉取约 N 字符,已达上限,是否继续?"
if 用户拒绝: break
if 返回中无 nextToken 或 nextToken 为空:
break(最后一页)
上一次token = 当前token
当前token = 返回的 nextToken
```
### 严禁的翻页行为
- [禁止] 同一个 next-token 值连续重试超过 2 次
- [禁止] 翻页失败后换用不同的 flag 名(如 --next-token 换成 --nextToken)重试
- [禁止] 累积超过 12000 字符后不暂停,继续拉取所有页
- [禁止] 拉到空页就立即放弃全部已累积内容,应基于已有内容进行分析
## 注意事项
- `taskUuid` 是听记的唯一标识,所有 get/update 操作均以此为入参
- `record start` 对应 MCP 工具 `execute_listening_note_command` 的 `cmd=create`,通常会返回可继续控制录音的 `taskUuid/uuid`
- `record pause` / `record resume` / `record stop` 对应 `cmd=pause/resume/end`,需要传入 `--id`(映射 MCP 入参 `uuid`)
- 如果用户传入听记 URL(格式: `https://shanji.dingtalk.com/app/transcribes/<taskUuid>`),直接从路径末段提取 taskUuid 作为 `--id` 参数,无需再调用 list 查询
- `list mine`、`list shared`、`list all` 统一走 `list_by_keyword_and_time_range` 链路,通过 `belongingConditionId` 区分(`created` / `shared` / `noLimit`)
- 三个 list 命令均支持 `--max`(=--limit)、`--cursor` 分页及 `--query`、`--start`、`--end` 筛选
- `list mine`、`list shared` 默认每页 20 条,`list all` 默认每页 10 条
- `get summary` 返回 AI 生成的结构化 Markdown 摘要
- `get transcription` 的 `--direction` 控制时间排序: 0=正序(默认), 1=倒序;当用户明确要求查看/分析转写原文时,默认自动翻页拉取全部原文(不需要用户手动说"拉第一页"),如果用户意图不是专门看原文(如查列表、看摘要),则不应主动调用此命令
- `get transcription` 默认按"时间线"返回各段落,**拉完后 AI 必须主动追问用户"是否需要按发言人分组聚类并提取核心内容"**;用户确认后 AI 在本地完成聚类与摘要,并进一步引导用户通过关键词模糊匹配(如"李总主要讲了战略规划")确认"发言人编号 ↔ 真实姓名"的映射,最终调用 `speaker replace` 写回。完整工作流见对应命令章节的"四阶段工作流"
- `get batch` 支持一次查询多个听记,用逗号分隔 taskUuid
- `get audio` 返回听记原始音频/视频文件的 OSS 地址,操作人需拥有该听记"读"权限及以上;以下场景不返回地址:听记已被删除、A1 无痕模式听记、临存过期的听记(媒体未准备好或临时存储已过期)
- `update summary` 全量覆盖纪要内容,不触发 AI 重新生成;适用于手动编辑或 AI Agent 修改纪要
- `mind-graph create` 触发异步任务,需通过 `mind-graph status` 轮询状态(0=进行中,1=成功,2=失败)
- `speaker replace` 精确匹配源发言人昵称,替换所有段落并自动更新纪要和待办中的发言人信息
- `hot-word add` 支持逗号分隔批量添加,每个热词不超过 10 个汉字或 5 个英文单词
- `replace-text` 区分大小写精确匹配,同时替换转写段落和纪要摘要中的文字
- 文件上传流程为三步:`upload create` → HTTP PUT 上传 → `upload complete`;`upload complete` 幂等,同一 sessionId 重复调用不会重复创建
- `upload create` 返回的 `presignedUrl` 用于 HTTP PUT 上传文件,上传时不需要带任何 HEADER
- 所有需要 taskUuid 的子命令均支持 --task-uuid / --uuid / --url 作为 --id 的隐藏别名,传入后自动降级,无需报错重试
- 同一个 taskUuid + 同一个命令,最多重试 1 次(总计最多调用 2 次),dingOpenErrcode=300/403/404 为不可重试错误
- 当用户提供关键词或时间范围时,必须使用 --query / --start / --end 在服务端筛选,严禁全量拉取后本地过滤。**严禁在 dws 命令后拼接 shell 管道做本地过滤**(如 `| grep "关键词"` / `| head -50` / `python -c "import json;..."` / `> /tmp/xxx.txt && grep ...`),这些写法在 Windows 沙箱里 100% 失败(`head`/`grep`/`wait` 不是 Windows 内置命令),即使在 macOS 上也会因 list 本身失败导致整个管道链路断掉。正确做法是始终使用 `--query` / `--start` / `--end` / `--max` 等 cli 内置参数在服务端完成筛选
- `dws minutes list` 后面**必须跟 `mine` / `shared` / `all` 子命令**,不能直接 `dws minutes list --start ... --end ...`(会报 `unknown flag: --start`)。正确写法:`dws minutes list mine --start ... --end ...` 或 `dws minutes list all --query "关键词" --start ... --end ...`
- get transcription 翻页时,同一个 next-token 连续返回空 2 次即终止翻页,不再重试
- **跨平台兼容性**:悟空运行环境可能是 Windows cmd / PowerShell / macOS bash,**严禁在 dws 命令中使用任何依赖特定 shell 的工具或管道**,包括但不限于:`| head`、`| grep`、`| tail`、`| wc`、`& wait`、`> %TEMP%\xxx`、`timeout /t 5`、`Start-Sleep`、`python -c "..."` 等。所有数据筛选、截断、过滤都必须通过 dws cli 自身的参数完成(`--query`/`--start`/`--end`/`--max`/`--cursor`,get transcription 翻页用 `--next-token`)
### 错误恢复策略(AI 遇到以下错误时必须自动执行对应恢复动作)
| 错误类型 | 错误信息特征 | 恢复动作 | 严禁行为 |
|----------|-------------|----------|----------|
| 命令结构错误 | `unknown command "info"` / `unknown command "get"` (后无子命令) / `error[validation]: unknown flag: --start` (在 `list` 而非 `list mine/shared/all` 上) | **立即参照本文档顶部"命令层级结构"纠正**。常见错误:① `dws minutes info` → 应为 `dws minutes get info`;② `dws minutes get --id` → `get` 后缺子命令,应为 `get info/summary/transcription` 等;③ `dws minutes list --start` → `list` 后缺 scope,应为 `list mine/shared/all --start` | 严禁在命令结构报错后只换参数名不修正命令层级;严禁把 `get` 当作独立命令使用(后面必须跟 info/summary/transcription/keywords/todos/audio/batch) |
| 参数名错误 | `unknown flag: --task-uuid` / `unknown flag: --uuid` / `unknown flag: --start-time` / `unknown flag: --end-time` | 立即调用 `dws minutes <子命令> --help` 查询正确参数名;minutes 模块统一用 `--id`,时间统一用 `--start` / `--end`(不是 `--start-time` / `--end-time`) | 严禁用同一个错误的参数名重试;严禁在不同子命令间尝试 --uuid / --task-uuid / --id 三种名称反复试错;严禁凭记忆猜测参数名,必须查 --help |
| UUID 无效 | `taskUuid is invalid` / `dingOpenErrcode=300` | **第一时间切换策略**:调用 `dws minutes list mine --max 10 --format json` 获取真实可用的 uuid 列表,让用户选择或自动匹配最相关的一条。**真实案例中模型用同一个错 uuid 重试了 20 次全部失败——这是 minutes 模块失败率最高的错误模式,必须零容忍** | 严禁用同一个无效 uuid 重试哪怕 1 次(errcode=300 是不可重试错误);严禁从历史对话/文档/链接中猜测 uuid;严禁从十六进制编码字符串里截取部分数字拼凑新 uuid;严禁不切 list 就放弃 |
| 命令返回空 stdout | `get summary` / `get transcription` 返回空内容(stdout 为空,error_msg 也为空) | 1) 先调用 `dws minutes get info --id <uuid> --format json` 确认听记是否存在且状态正常;2) 如果 info 也为空或报错,说明 uuid 本身有问题,回退到 list 命令重新获取 | 严禁在 stdout 为空时重复调用同一命令超过 2 次;严禁把空返回当作"没有内容"直接告知用户而不做任何排查 |
| 翻页 next-token 返回空 | `get transcription --next-token xxx` 返回空内容 | 同一个 next-token 返回空 **1 次**即视为到达末尾(has_more=false),立即终止翻页,基于已累积内容继续处理 | 严禁同一个 next-token 连续重试超过 2 次;严禁换不同的 next-token 值盲目尝试 |
| 时间格式解析失败 | `cannot parse time` / `parse fail` | 标准格式三选一:`2026-03-23T14:00:00+08:00`(ISO-8601 带时区)/ `2026-03-23 14:00:00`(无时区默认 +08:00)/ `2026-03-23`(纯日期) | 严禁使用 Unix 时间戳、`2026/03/23`、`Mar 23, 2026` 等非标准格式 |
| 权限不足 | `AUTH_PERMISSION_DENIED` / `Permission denied` / `user is not minutes creator` | 1) 如果是 `get transcription` 报权限错误,**自动降级**尝试 `get summary`(摘要通常不需要下载权限);2) 如果降级也失败,引导用户:"这份听记是别人分享给你的,你可以让原作者将你加为协作者,或者用 `dws minutes list shared` 查看共享给你的听记";3) 如果需要其他人的听记内容,可先用 `dws minutes list shared` 确认有无共享记录 | 严禁权限报错后用同一 uuid 反复重试;严禁只回复"Permission denied"而不给用户可操作的下一步 |
| 资源不存在 | `P_DataNotFound` / `dingOpenErrcode=404` | 与 `taskUuid is invalid`(uuid 格式错误)**区分对待**:`P_DataNotFound` 表示 uuid 格式正确但对应的听记不存在(可能已被删除或过期)。告知用户"该听记可能已被删除或已过期",然后建议用 `dws minutes list mine` 或 `list all` 查找替代 | 严禁把 `P_DataNotFound` 和 `taskUuid is invalid` 混为一谈;严禁 `P_DataNotFound` 后还换 uuid 格式重试 |
| 认证过期 | `not_authenticated` / `未登录` / `认证信息已失效` | 直接**重试刚才失败的命令**(最多两次),系统会自动刷新或重新授权;不要让用户重新提问。**注意**:长会话(>100 步)中 token 可能中途过期,这不是用户的问题 | 严禁认证过期后直接放弃整个任务;严禁让用户自己处理认证 |
| 上传业务报错 | `business error: success=false` / `Server Code: ERROR`(出现在 `upload create`) | 1) 检查文件名是否含特殊字符(建议用纯英文/数字文件名重试);2) 检查 `--file-size` 单位是否正确(必须是**字节**,不是 KB/MB);3) 确认音频格式是否支持(mp3/mp4/wav/m4a/aac);4) 最多重试 **1 次**,仍然失败则如实告知用户"上传服务暂时不可用",给出错误 Trace ID 供排查 | 严禁同一参数重试超过 2 次;严禁在错误信息未变化时反复重试期望随机成功;严禁尝试 `--json` / `-f json '{"...":...}'` 等不存在的输入格式——`--format json` / `-f json` 仅控制**输出格式**,不是输入参数 |
### 间接意图识别(用户 query 未直接提及听记命令时的调用策略)
当用户的 query 不是直接要求"查听记/看摘要",而是隐含需要听记数据的高阶任务时,AI 必须自动拆解并先完成听记数据采集。
> **【核心判断原则】** 用户 query 中只要涉及"会议/讨论/沟通"等**口头交流**产出的信息,听记就是不可替代的数据源。即使用户没说"听记"二字,只要任务的完成依赖于"会议中说了什么",就必须走 `dws minutes` 采集。**文档、日程、聊天记录无法替代听记**——文档是书面产出,日程只有标题和时间,聊天记录是文字沟通,唯有听记才包含会议的完整发言内容和 AI 摘要。
#### 间接意图 query 模式速查表
| 用户 query 模式 | 典型真实 query 示例 | 隐含的听记需求 | 正确的第一步 |
|----------------|-------------------|---------------|-------------|
| **报告/分析类**:要求基于某个主题或业务重新撰写报告 | "从悟空的商业模式分析,重新写一下市场感知报告" | 用户的报告素材来源于历史会议讨论,需要从听记中提取相关主题的会议内容作为写作输入 | `dws minutes list all --query "<主题关键词>" --max 20 --format json` → 对匹配的听记逐篇 `get summary` 提取素材 → 再基于素材撰写报告 |
| **分类总结类**:要求按类别(问题/事故/AI工作等)汇总工作成果 | "根据我的工作情况总结,分成问题应收、异常事故处理、ai相关工作和其他几部分总结";"最新文档啥也没有啊,我要你根据我的工作情况总结" | 用户需要的"工作情况"不仅存在于文档中——会议讨论、评审结论、决策记录都在听记里。尤其当用户明确说"文档啥也没有"时,听记是**唯一剩余的结构化工作记录来源** | `dws minutes list mine --start <起始日期> --end <截止日期> --format json` → 逐篇 `get summary` → 按用户要求的分类维度(问题应收/事故处理/AI工作/其他)对摘要内容进行归类整理 |
| **多源聚合类**:用户明确要求补充听记/会议数据源,或抱怨当前数据源不全 | "你这个好像只是基于日程,我要的是我的对话、私聊、群聊、日程、会议、文档修改等相关的所有动作";"根据今天的聊天记录和听记总结工作日报" | 用户**点名要求**多数据源聚合,其中"会议"对应的数据源就是听记。即使 query 中还提到了聊天、日程、文档等,听记侧的数据采集**不能被省略** | 听记侧**必跑**:`dws minutes list mine --start <起始日期> --end <截止日期> --format json` → 逐篇 `get summary`;再并行采集其他数据源(聊天/日程/文档)→ 最终汇总 |
| **Skill 封装类**:要求把数据聚合逻辑封装成可复用的 skill 或脚本 | "把这个封装成日报skill";"做成自动化脚本" | 封装之前必须先**验证底层数据获取链路是否通畅**,不能跳过数据验证直接写 skill 框架 | 先用 `dws minutes list mine --start <today> --format json` 验证听记数据可达且格式正确,再设计 skill 结构 |
| **日报/周报/月报类**:要求生成工作汇报 | "帮我写日报/周报";"帮我生成本周工作汇报" | 近期听记摘要是工作记录的核心来源之一 | `dws minutes list mine --start <起始日期> --end <截止日期> --format json` → 逐篇 `get summary` → 汇总写入报告 |
| **客户/项目类**:按客户名或项目名搜索相关信息 | "外资背景的客户信息总结";"某某客户相关的所有信息" | 需要按关键词搜索听记 | `dws minutes list all --query "<客户名/项目名>" --format json` → `get summary` |
| **时间范围批量类**:梳理一段时间内的所有会议记录 | "梳理 4 月至今所有会议记录";"本周所有听记" | 明确时间范围的批量听记获取 | `dws minutes list mine --start "2026-04-01T00:00:00+08:00" --end "2026-05-08T23:59:59+08:00" --max 50 --format json` |
#### 间接意图识别的三条铁律
**铁律 A:用户的任务包含"会议/讨论/沟通"类信息需求 → 听记采集是必跑前置步骤,不可跳过**
- 即使用户 query 中没有出现"听记"二字,只要任务完成需要"会议上说了什么/讨论了什么/达成了什么结论",就必须走 `dws minutes` 采集
- 典型信号词:报告、分析、总结、汇总、复盘、回顾、工作情况、商业模式、市场感知、客户沟通
**铁律 B:用户说"文档/数据啥也没有" → 听记的优先级反而更高**
- 当用户明确反馈文档不足时,听记(会议记录)往往是**唯一还有内容的结构化数据源**
- 此时 AI 的正确反应不是"那我也没办法",而是主动切到听记侧采集:"让我先看看你最近的听记记录中有没有相关内容。"
**铁律 C:多源聚合场景中,听记侧 0 调用 = 严重失败**
- 当用户 query 中同时提到"聊天记录 + 听记 / 会议 + 文档"等多个数据源时,每个被提及的数据源都必须有对应的采集动作
- 听记侧的采集不能因为"其他数据源已经够了"而被省略——用户点名要了就必须给
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [minutes_recent_summary.py](../../scripts/minutes_recent_summary.py) | 获取最近听记的 AI 摘要并合并 | `python minutes_recent_summary.py --max 5` |
| [minutes_extract_todos.py](../../scripts/minutes_extract_todos.py) | 从听记中提取待办事项汇总 | `python minutes_extract_todos.py --max 5` |
## 反例 / 回归案例
> 本节固化历史 badcase 与正确做法,遇到形似场景请直接对照参考,避免再次走偏。
### 案例 1:听记 URL + "创建思维导图"
**用户输入:**
```
https://shanji.dingtalk.com/app/transcribes/76327569643236343831373737345f3634383131373937375f39
创建思维导图
```
**[错误] 错误处理(真实 badcase):**
1. 提取 taskUuid 后,先调用 `get summary` 获取听记摘要内容
2. 判定"思维导图不是 dws 直接支持的功能",路由到 app-development-skill / ai-app
3. 用 `@antv/g6`(或 `markmap` / `jsmind` / `mermaid`)构建 HTML 思维导图网页
4. 部署后向用户返回类似 `https://xxx.ai-app.pub` 的"在线预览链接"
5. 输出"思维导图已生成!"附带网页特性介绍、配色方案等
上述每一步都违反了 [生成思维导图](#生成思维导图) 小节的"关键约束"。`mind-graph create` 是听记**内置**的服务端能力,并非"dws 不支持"。
**[正确] 正确处理:**
1. 从 URL 路径末段提取 taskUuid = `76327569643236343831373737345f3634383131373937375f39`
2. **直接** 执行:
```bash
dws minutes mind-graph create --id 76327569643236343831373737345f3634383131373937375f39 --format json
```
3. 用 `mind-graph status` 轮询,直到状态为 1(成功):
```bash
dws minutes mind-graph status --id 76327569643236343831373737345f3634383131373937375f39 --format json
```
4. 告知用户:"思维导图已生成,可在听记详情页直接查看。"
**绝对禁止:**
- 调用 `get summary` / `get transcription` 先读内容(服务端会基于听记自身生成,无需前置读取)
- 调用 app-development-skill / ai-app / generate_image 等任何外部生成能力
- 自行用前端库或 Markdown / OPML / JSON 构造思维导图结构展示给用户
- 返回任何形式的"在线预览链接 / 网页应用 / 图片"
### 案例 2:替换文字后未引导添加热词
**用户输入:**
```
把这篇听记里所有"付工"替换成"悟空"
```
**[错误] 错误处理:**
1. 调用 `replace-text --id <taskUuid> --search "付工" --replace "悟空"` 后直接回复"已替换完成"
2. 没有告知用户:本次替换**只对这一篇生效**,后续新听记还会再次把"悟空"识别成"付工"
3. 没有主动引导用户使用 `hot-word add` 把"悟空"加到个人热词里
**[正确] 正确处理:**
1. **检查特殊字符**:检查用户提供的原始文本和目标文本是否包含特殊字符(引号、书名号、括号、Markdown 符号等)。本例中"付工"和"悟空"均为纯文本,无特殊字符,直接执行。若用户输入的是 `把"付工"替换成「悟空」`(带引号/括号),需先提示:
> "你输入的文本中包含引号/括号等特殊字符,转写原文中通常不包含这些字符,直接匹配可能替换不到。建议去掉特殊字符后替换:`"付工"` → `付工`。使用去掉特殊字符的版本?[是] [否,使用原始输入]"
2. 执行 `replace-text` 完成本篇替换
3. **立即追问**:
> "我已经把这篇听记里的『付工』替换为『悟空』。如果『悟空』这个词以后也容易被识别错,建议加到个人热词里,后续新听记就不会再识别错了。要我现在帮你执行 `dws minutes hot-word add --words "悟空"` 吗?"
4. 用户确认 → 调用 `hot-word add --words "悟空"`;用户拒绝 → 结束,不再追问
### 案例 3:不必要地拉取全部转写原文
**用户输入:**
```
查一下和悟空相关的听记
```
**[错误] 错误处理:**
1. 调用 `list mine --query "悟空"` 查到听记列表
2. 对每条听记依次调用 `get transcription` 拉取全部转写原文
3. 把大量原文全部展示给用户
用户只是想查一下列表,根本不需要看转写原文。大量拉取原文既造成不必要的性能开销,也会让用户被信息淹没。
**[正确] 正确处理:**
1. 调用 `list mine --query "悟空"` 或 `list all --query "悟空"` 返回听记列表
2. 直接把列表结果展示给用户
3. 不调用 `get transcription`——因为用户没有要求看原文
**另一组对比:**
**用户输入:**
```
帮我分析一下这篇听记的转写原文
```
**[错误] 错误处理:**
1. 调用 `get transcription --id <taskUuid>` 只拉了第一页就停了
2. 展示部分原文,告诉用户"如果要看更多请传入 next-token"
用户说的是"分析转写原文",意图很明确是要看完整原文,不应该让用户手动翻页。
**[正确] 正确处理:**
1. 调用 `get transcription --id <taskUuid>`,拿到第一页和 `nextToken`
2. **自动继续调用** `get transcription --id <taskUuid> --next-token <nextToken>`
3. 每次拼合后检查累积字符数:
- **未超过 12000 字符** → 继续自动翻页
- **超过 12000 字符** → 暂停,提示用户:"当前已拉取约 X 字符的转写内容,已达到单次处理上限。是否继续拉取后续内容?"
4. 用户确认继续 → 接着翻页;用户拒绝 → 停止翻页,基于已拉取内容进行分析展示
### 案例 4:拉完转写后只输出时间线原文,未引导发言人聚类与替换
**用户输入:**
```
帮我把这篇听记的转写原文拉出来分析一下
```
**[错误] 错误处理(真实 badcase):**
1. 调用 `get transcription` 自动翻页拉完全部原文
2. 直接把按时间戳穿插的"发言人1: ... / 发言人2: ... / 发言人1: ..."大段原文全部丢给用户,结束流程
3. 用户后续追问"李总主要讲了什么",AI 又要重新读一遍原文才能回答
4. 即使用户后来说"发言人1 就是李总",AI 也只是在自己回复里口头改一改,**不调用 `speaker replace`**,听记本身的发言人映射没有任何变化
按时间线穿插的原文对人**极其不友好**——同一个发言人的内容散落在不同时间点,用户很难看清"某个人讲了什么"。而且听记里的『发言人1/发言人2』占位符如果一直不被替换为真实姓名,用户每次回看都要靠脑补才能对上号。
**[正确] 正确处理:**
1. 调用 `get transcription` 自动翻页拉完全部原文(注意 12000 字符上限保护)
2. **拉完后立即追问**:"已拉取完整转写原文(共 N 段,X 个发言人)。当前默认按时间线返回。是否需要我帮你**按发言人分组聚类**,并提取每位发言人的**核心发言要点**?"
3. 用户确认 → AI **本地完成聚类**(按 `speakerNick` 分组),每位发言人输出 3-5 条核心要点,再追问:"如果你能告诉我『某某人主要讲了什么』(如『李总主要讲了战略规划』),我可以根据关键词帮你**自动匹配**对应的发言人,并把『发言人1/发言人2』替换成真实姓名。"
4. 用户回复『李总主要讲了战略规划』→ AI 抽取关键词『战略规划』,在已聚类的各发言人核心要点里做模糊匹配;找到唯一高置信候选『发言人1』→ 引导确认:"『发言人1』很可能就是你说的『李总』(命中关键词:战略规划、AI 化转型)。是否需要把这篇听记里的『发言人1』全部替换为『李总』?确认后我会执行 `dws minutes speaker replace --id <taskUuid> --from "发言人1" --to "李总"`"
5. 用户确认 → **立即调用** `dws minutes speaker replace --id <taskUuid> --from "发言人1" --to "李总" --format json`,执行成功后告知用户"已替换,纪要与待办中的发言人也已同步更新"
**绝对禁止:**
- 拉完转写就只丢一大段时间线原文给用户,不做聚类、不主动引导
- 用户提供"某某人讲了 XX"后,AI 在自己脑内/回复里"假装替换"了,但**不实际调用** `speaker replace`
- 在置信度不足(多候选)时仍然给出唯一答案,不让用户参与挑选
- 把『发言人1 → 李总』的映射写到用户回复里就完事,听记本身没有任何写回
### 案例 5:用户查某人在听记中说了什么,总结完不引导替换发言人
**用户输入:**
```
帮我看看这个听记里张三说了什么
https://shanji.dingtalk.com/app/transcribes/76327569643231383535353939365f3436383537393431335f32
```
**[错误] 错误处理(真实 badcase):**
1. 从 URL 提取 taskUuid,调用 `get transcription` 拉取转写原文
2. 发现转写中只有"发言人1/发言人2/发言人3",没有"张三"
3. AI 凭原文推断"发言人2 可能是张三"(因为内容提到了产品需求),直接把发言人2 的内容当作张三的输出给用户
4. 总结输出完就结束了,**没有引导用户确认推断是否正确**
5. **没有引导用户替换发言人**——听记本身还是显示"发言人2",下次用户打开听记还是看不出谁是张三
上述做法有两个严重问题:① 推断结果未经用户确认就当作事实输出,可能张冠李戴;② 即使推断正确,也没有调用 `speaker replace` 写回听记,用户下次看还是"发言人2"。
**[正确] 正确处理:**
1. 从 URL 提取 taskUuid,调用 `get transcription` 自动翻页拉取全部转写
2. **Step 2 声纹标注检查**:检查转写中是否已有"张三"作为 speakerNick → 本例中没有,只有匿名编号
3. **Step 3 转写原文推断**:在原文中寻找线索——例如其他人说"张三你来汇报一下",紧接着"发言人2"开始发言;或者"发言人2"的内容大量涉及产品需求(与用户描述的张三角色吻合)
4. 推断出"发言人2"可能是张三后,**必须向用户确认**:
> "根据转写内容分析,以下发言最可能是张三的:
> 「接下来我汇报一下 Q3 的产品规划,主要有三个方向...」
> 确认是张三吗?"
5. 用户确认 → **Step 6 结构化总结输出**:提取发言人2 的全部发言,输出张三的核心观点、关键决策、待办等
6. **Step 7 引导替换发言人**(必须执行):
> "目前这篇听记中张三的发言仍显示为『发言人2』。要我帮你把听记里的『发言人2』全部替换为『张三』吗?替换后纪要和待办中的发言人也会同步更新。"
7. 用户确认 → **先通讯录查询获取 dingUid**:调用 `dws contact user search --query "张三" --format json`
- 唯一匹配(如返回 userId=123456789)→ 执行 `dws minutes speaker replace --id <taskUuid> --from "发言人2" --to "张三" --target-uid 123456789 --format json`
- 多个匹配 → 列出候选(姓名+部门+userId)让用户选择后执行
- 无匹配 → 提示用户通讯录未找到,执行不带 `--target-uid` 的替换:`dws minutes speaker replace --id <taskUuid> --from "发言人2" --to "张三" --format json`
8. 替换成功后追问:"还有其他发言人需要我帮你识别和替换吗?"
**绝对禁止:**
- 推断"发言人X 是张三"后不向用户确认就直接输出总结——可能张冠李戴
- 总结完就结束,不引导替换发言人——用户下次看听记还是"发言人2"
- 只在回复里说"发言人2 就是张三"但不调用 `speaker replace`——听记本身没有任何变化
- Step 3 推断不出来时直接告知"找不到张三"就结束——应继续走 Step 4 多路并发推断(通讯录/文档/日程/聊天记录)
### 案例 6:通过通讯录 + 部门角色 + 转写线索三路印证推断发言人(真实复盘)
> 这是一次**完整走完 Step 1~7 全流程**的真实案例,重点演示 Step 4 多路并发身份推断如何与 Step 3 转写原文线索互相印证,以及如何识别"花名相似但不是同一人"的陷阱。
**用户输入:**
```
https://shanji.dingtalk.com/app/transcribes/<taskUuid> 分析下木兰讲了什么
```
**[正确] 完整执行链路:**
**Step 1:定位听记并读取转写**
- 从 URL 末段提取 taskUuid
- `dws minutes get transcription --id <uuid> --format json` 自动翻页拉取全部(注意 12000 字符上限保护)
**Step 2:声纹标注检查**
- 转写中所有 `speakerNick` 都是匿名编号(发言人1/2/3/4),**未命中** → 进入 Step 3
**Step 3:转写原文内推断(同时并发启动 Step 4,不串行等待)**
先做粗粒度的发言人画像,列出每位发言人的发言量、主题、关键互动信号:
| 发言人 | 发言特征 | 互斥线索(说明 TA "不是谁")|
|--------|----------|------|
| 发言人1 | 发言较多,集中在 UI/交互设计:A/B 面切换、按钮位置、页面层级、设备号承接页 | 1013s 提到「**木风**也给了一些各种状态」→ 木风 ≠ 发言人1 |
| 发言人2 | 发言最多且最主导,讨论 agent 架构、skill 设计、记忆系统、定时任务 | 1063s 叫「青锋给大家看」、2113s 叫「行远」、多次提「虎哥」「陈林」→ 这几人都不是发言人2 |
| 发言人3 | 中等发言量,讨论功能号、对话框、班主任场景 | 3549s 提到「木风挺调皮」→ 木风 ≠ 发言人3 |
| 发言人4 | 发言较少,技术实现相关(接口、链路、安卓、蓝牙)| —— |
**Step 4:多路并发身份推断(与 Step 3 同时进行)**
| 路径 | 命令 | 结果 |
|------|------|------|
| ① 通讯录组织架构 | `dws contact user search --keyword "木兰"` | 木兰 = **王佳明**,X 事业群-X 事业部-X-X-**产品设计部**,上级临渊(王临一)|
| ② 文档产出 | `dws doc search --keyword "王佳明"`(按需)| 多为设计稿/原型,进一步印证设计师角色 |
**Step 5:定向匹配 + 置信度判断**
把 Step 4 拿到的"产品设计部 / 设计师角色"信号回投到 Step 3 的发言人画像:
- 候选锁定 **发言人1**——其特征(UI 设计、A/B 面切换、页面层级、汇报设计进展)与"产品设计部设计师"高度吻合
- **额外强信号**:发言人1 在 1856s 说「最开始跟**拾光**做 A 店」——拾光 = 王刘明(当前用户),属同部门协作关系,与"木兰也在产品设计部"完全对得上
- **同名陷阱排查**:发言人1 在 1013s 说「**木风**也给了一些各种状态」——`木风 ≠ 木兰`(两个不同花名,木风是另一位设计同事),**这条线索看似矛盾,实则强化了"发言人1 是木兰本人在转述木风的产出"的判断**
- 综合置信度 **≈ 75%**(>70%),走**分支 A:文本片段确认**
**Step 5 → 用户确认环节**(实际对话中必须有这一步):
> 根据分析,发言人1 很可能就是木兰(王佳明,产品设计部)。最具代表性的发言片段:
> 「最开始跟拾光做 A 店就是在功能号做的……当前首页内容层级太多,无论怎么改都解决不了根本问题」
> 推断依据:UI 设计视角 + 与拾光有合作经历 + 同部门关系
> 确认是木兰吗?是 / 不是
**Step 6:结构化总结输出(四段式模板)**
确认后按以下结构组织——这套四段式适用于绝大多数会议场景:
```
[人名] 在本次会议中的发言总结
**核心观点**
- 观点1(带具体上下文,不要只写关键词)
- 观点2
- ...
**提出的问题 / 关注点**
- 问题1
- ...
**Action Item / 承诺事项**
- 时间节点 + 具体动作
- ...
**立场 / 态度**
- 对核心议题的明确态度(支持/反对/务实/保留)
- ...
```
**Step 7:引导替换发言人(必须执行)**
> 目前这篇听记中木兰的发言仍显示为『发言人1』。要我帮你把听记里的『发言人1』全部替换为『木兰』吗?
> 通讯录中已匹配到:木兰(王佳明,产品设计部,userId: 123456789),替换后将关联到通讯录真实身份,纪要和待办中的发言人也会同步更新。
> 确认后我会执行:`dws minutes speaker replace --id <taskUuid> --from "发言人1" --to "木兰" --target-uid 123456789`
用户确认 → 立即调用 `dws minutes speaker replace`(附带 `--target-uid`),并追问"还有其他发言人需要识别吗?"
**本案例固化的关键经验(必须吸收):**
1. **花名相似不等于同一人**:`木风 ≠ 木兰`、`拾光 ≠ 拾贝`、`临渊 ≠ 临川` 这类 1 字之差的花名极易误判。**遇到候选人花名出现在某发言人原话里时,必须先确认这是"自指(在自我介绍)"还是"他指(在叫别人)",再做互斥推断**。在原文中"A 提到 B"通常意味着 `A ≠ B`,不要反过来判定 `A = B`。
2. **Step 3 与 Step 4 必须并发**:先做发言人画像(Step 3)的同时**异步发起**通讯录查询(Step 4 ①),等通讯录返回了"角色/部门"信号再回投到画像里做匹配,这样不串行等待、不浪费时间。
3. **同部门协作关系是强信号**:当发言人 X 提到了某个同事的花名(如「跟拾光做 A 店」),而通讯录显示"目标人物"和该同事**同部门**,这是非常强的身份匹配信号,可以直接把置信度提升到 70%+。
4. **置信度 ≥ 70% 即可走分支 A**:不要一味追求 90%+,否则会陷入"再查一路、再印证一次"的死循环。70% 是经验阈值,分支 A 本身就有"用户文本确认"作为兜底,错了用户会立即纠正。
5. **结构化总结用四段式模板**:核心观点 / 关注点 / Action Item / 立场态度——这套模板适用于绝大多数会议场景(产品评审、技术方案、复盘会、双周会)。每条要点必须**带上下文**(如"她认为底部那一排功能导航必须去掉,因为与上方内容严重重复"),不要只写"反对底部导航"这种干巴巴的关键词。
6. **Step 7 必须执行,不能跳过**:本案例如果只输出总结就结束,下次用户打开听记看到的还是"发言人1",依然要靠脑补对应木兰——这正是发言人识别功能存在的意义被完全抹掉的反例。
**绝对禁止:**
- 看到发言人说「木风也给了状态」就直接判定"那 TA 就是木风的同事/下属"等过度推断——只能得出 `TA ≠ 木风` 这一条互斥信息
- Step 3 还在分析就阻塞住,等画像分析完再串行去查通讯录——必须并发
- 总结写成"木兰讨论了 UI 设计、A/B 面切换、功能号方向"这种关键词堆砌——必须展开成带上下文的完整观点
- 置信度 70% 就纠结要不要再查文档/聊天记录——分支 A 的文本确认本身就是兜底,不要无谓地继续查
### 案例 7:跳过通讯录、在转写文本里 grep 花名 → 误判"目标人物没参会"(真实反面教材)
> 这是**与案例 6 输入完全相同**但执行链路完全错误的真实 badcase,重点演示"没走 Step 4 通讯录查询"会带来怎样灾难性的失败结论。**强烈建议每次执行发言人识别任务前,对照本案例自检一遍**。
**用户输入:**(与案例 6 完全相同)
```
https://shanji.dingtalk.com/app/transcribes/<taskUuid> 分析下木兰讲了什么
```
**[错误] 真实失败链路(每一步都要识别为反模式):**
```
Step 1 **[完成]** get transcription 拉取了多页转写
↓
Step 2 **[禁止]** 看到全是匿名编号 → 没有继续走 Step 3-4,反而开始在转写里 grep "木兰"
↓
错误动作 A:连续多次"搜索所有日志文件中是否有'木兰'这个名字"
↓
错误动作 B:转写里搜不到 → 调用 get summary 看 AI 摘要里的"参与人"
↓
错误动作 C:AI 摘要"参与人=拾光" → 推理"参与人只有拾光 → 木兰没参会"
↓
错误结论:告诉用户"在这篇听记中,没有找到名为木兰的发言人。可能木兰没参加这次会议"
↓
错误兜底:把责任甩给用户:"请你确认是哪位发言人,或在客户端看参会人列表"
```
**这条链路违反了几乎所有铁律:**
| 反模式 | 违反的铁律 | 后果 |
|--------|------------|------|
| 在转写文本里 grep "木兰" 字符串作为存在性判断 | 铁律 1 | 99% 听记的发言人都是匿名编号,搜不到字面是默认场景,根本不构成"没参会"的证据 |
| 用 AI 摘要的"参与人=拾光"推断"木兰没参会" | 铁律 2 | AI 摘要"参与人"字段只截取最显著的 1-2 人,**不是**完整参会名册 |
| 全程没调用过一次 `dws contact user search --keyword "木兰"` | 铁律 3 | 通讯录查询是 Step 4 的必跑项,单次调用就能拿到"木兰=王佳明,产品设计部" |
| 一旦字面搜不到就放弃身份推断,把任务甩给用户 | 铁律 4 | 这恰恰把发言人识别功能的核心价值(把匿名编号映射到真实人)完全抹掉了 |
**[正确] 应该这样执行(与案例 6 一致):**
1. **Step 1**:从 URL 提取 taskUuid → `dws minutes get transcription` 自动翻页拉全部
2. **Step 2**:检查 `speakerNick` 字段是否含"木兰"——发现全是匿名编号 → **不要在转写文本里 grep "木兰",立即并发触发 Step 3 + Step 4 ①**
3. **Step 3**(与 Step 4 ① 并发):在转写里做发言人画像(每位发言人的发言量、主题、互斥线索)
4. **Step 4 ①**(与 Step 3 并发,必跑):`dws contact user search --keyword "木兰"` → 拿到"木兰=王佳明,产品设计部,上级临渊"
5. **Step 5**:把"产品设计部 + 设计师角色"信号回投到画像 → 锁定发言人1(UI/交互设计视角高度匹配)+ 与拾光的同部门协作信号 → 置信度 ≈ 75% → 走分支 A
6. **Step 5 用户确认**:展示发言人1 的代表性片段请用户确认
7. **Step 6**:四段式结构化总结(核心观点/关注点/Action Item/立场态度)
8. **Step 7**:引导调用 `speaker replace` 把"发言人1"替换为"木兰"
**关键经验(强制吸收,案例 6 已讲过的不再重复,本案例独有的):**
1. **"在转写里搜不到目标人名"绝不构成"没参会"的证据**:花名/真名通常不出现在 TA 自己的发言里,这是默认场景而非例外。99% 的听记发言人都是匿名编号——这正是发言人识别功能要解决的问题。
2. **AI 摘要的"参与人"字段是低保真信号,不能作为参会判断依据**:`get summary` 返回的是 AI 生成的自然语言摘要,里面提到的"参与人"通常只是最显著的 1-2 人;要拿完整参会列表,应使用 `get info` / `get batch` 返回的结构化 `participants` 字段。
3. **`dws contact user search` 是 Step 4 ① 的必跑项,单次调用就能突破死局**:本案例的整个失败链路只要有 1 次通讯录查询就能立即扭转——拿到"木兰=王佳明,产品设计部"后,Step 5 的角色匹配就有了锚点,再也不会得出"没参会"的错误结论。
4. **想说"找不到 X"前的四个自检问题**(任何一个回答"没"都禁止给"找不到"结论):
- 通讯录查了吗?(`dws contact user search --keyword "X"`)
- 文档查了吗?(`dws doc search --keyword "X"`)
- 聊天记录查了吗?(`dws chat message list`)
- 基于角色在转写里做模式匹配了吗?(设计师 vs 研发 vs 管理者的发言特征)
5. **`get summary` 不能替代发言人识别**:摘要是"会议讲了什么"的总览,**不是**"谁讲了什么"的精细切分。混淆这两个能力会导致 Step 4 直接跳过。
**绝对禁止:**
- **[禁止]** Step 2 看到匿名编号后,直接在转写文本里 grep 目标人名 → 没找到就退出
- **[禁止]** 用 `get summary` 摘要里写到的"参与人"判断某人是否参会
- **[禁止]** 全流程不调用 `dws contact user search` 就给出"找不到 X"的结论
- **[禁止]** 把身份推断的责任甩回给用户:"请你告诉我哪位是木兰" / "请去客户端看参会人"——发言人识别功能的存在意义就是 AI 来做这件事
### 案例 8:听记/纪要类 query 不走 dws 技能(基础评测集 16 例 badcase 复盘)
> 这是一组**最高频的失败模式**——用户提出听记/纪要/会议总结/待办提取/链接解析等典型 dws 场景请求,AI 却用 `session_search` / `memory_search` / `activity:search` / `browser_use` / `read_file` / 直接反问 等"伪替代"路径绕过 dws 技能,导致核心链路 0 命中。下面把基础评测集(minutes-base, evalrun_4ab46f8da846)中**全部 16 个该模式失败 query 完整列出**,遇到形似输入请直接对照本案例处理。
#### 一、五类典型 badcase 模式(按失败动作归类)
**模式 A:模糊/省略型 query → AI 直接反问要细节,不主动 list**
涉及 query:
| case_id | 用户原始 query |
|---------|----------------|
| `dws_minutes_hotquery_0049` | 按关键词搜索我的听记 |
| `dws_minutes_hotquery_0057` | 查列表+看摘要 |
| `dws_minutes_hotquery_0063` | 周会回顾整理 |
| `dws_minutes_hotquery_0064` | 评测工作复盘 |
| `dws_minutes_hotquery_0042` | 把所有听记内容添加到汇报中 |
**典型错误动作**:`tool_calls = []`,AI 回复"请告诉我具体的关键词/时间范围/会议名"就停下,等待用户补充。
**模式 B:用 `session_search` / `memory_search` 搜索历史会话假装"找过了"**
涉及 query:
| case_id | 用户原始 query |
|---------|----------------|
| `dws_minutes_hotquery_0003` | 帮我查一下最近一次会议的纪要内容 |
| `dws_minutes_hotquery_0017` | 总结下我的会议 |
| `dws_minutes_hotquery_0020` | 我昨天那个会的重点帮我提炼一下 |
| `dws_minutes_hotquery_0027` | 把最近一次会议的待办整理出来 |
**典型错误动作**:调用 `session_search` 搜以前的对话记录,把以前 AI 自己生成过的"会议纪要文件描述"当作真实数据复述出来;从未触发 `dws minutes list / get summary / get todos`。
**模式 C:用 `activity:search` / web 搜索把"找听记"做成"搜网页"**
涉及 query:
| case_id | 用户原始 query |
|---------|----------------|
| `dws_minutes_hotquery_0025` | 调取某某项目讨论的两个听记内容 |
| `dws_minutes_hotquery_0060` | 搜索+摘要+关键词 |
**典型错误动作**:调用 `activity:search` 搜公网,返回的是"钉钉 AI 听记产品介绍"页面,与用户的私人听记数据毫不相关。
**模式 D:钉钉听记/文档 URL 走 `browser_use` / `read_file` 而非 `dws`**
涉及 query:
| case_id | 用户原始 query |
|---------|----------------|
| `dws_minutes_hotquery_0045` | `https://shanji.dingtalk.com/meeting/minutes?taskUuid=sample004` |
| `dws_minutes_hotquery_0046` | `https://alidocs.dingtalk.com/i/nodes/sampleDocNode01` 帮我读取这个文档内容 |
| `dws_minutes_hotquery_0047` | 这个听记链接你能打开看内容吗 `https://shanji.dingtalk.com/meeting/minutes?taskUuid=sample006` |
**典型错误动作**:`browser_use` 打开页面遇到登录墙就回复"需要登录";或 `read_file` 当本地文件读 → 失败 → 把锅甩给用户。完全没有意识到 dws 技能本身已携带账号态,能直接通过 taskUuid/dentryUuid 拿到内容。
**模式 E:多源数据生成日报/汇报/总结/报告,听记侧 0 调用**
涉及 query:
| case_id | 用户原始 query |
|---------|----------------|
| `dws_minutes_hotquery_0040` | 根据今天的聊天记录和听记总结工作日报 |
| `dws_minutes_hotquery_0005` | 把会议纪要写入钉钉文档 |
| `minutes-001` (P0) | 从悟空的商业模式分析,重新写一下市场感知报告 |
| `minutes-002` (P0) | 最新文档啥也没有啊,我要你根据我的工作情况总结,分成问题应收、异常事故处理、ai相关工作和其他几部分总结 |
| `minutes-004` (P0) | 你这个好像只是基于日程,我要的是我的对话,发出去的文字,私聊,群聊,日程,会议,文档修改等相关的所有动作,参考这个生成日报,并且把这个封装成日报skill |
**典型错误动作**:识别出"日报/报告/总结/写文档"场景后,只想着调用周报技能 / 文档写入技能,**完全跳过听记数据获取**这一步。更隐蔽的变体是:用户 query 中**没有出现"听记"二字**(如"根据工作情况总结"、"重新写市场感知报告"),但任务的完成**实际依赖会议讨论内容**,AI 却完全没有意识到需要从听记中采集素材,结果输出空洞的报告或反问"请告诉我会议纪要内容"。
**P0 case 暴露的三种变体(必须全部识别):**
- **变体 1(minutes-001)**:用户要写报告,报告素材来源于历史会议讨论 → AI 没识别出需要查听记
- **变体 2(minutes-002)**:用户要分类总结工作,且明确说"文档啥也没有" → AI 没切到听记作为替代数据源
- **变体 3(minutes-004)**:用户明确抱怨"只是基于日程"并点名要求"会议"等多数据源 → AI 仍然只用日程数据
#### 二、五类共性反模式(必须全部识别为禁止动作)
| 反模式 | 错误根因 | 正确做法 |
|--------|----------|----------|
| **R1:模糊请求 → 反问要细节** | AI 把"信息不足"当成必须澄清的前置条件 | 听记类模糊请求 → **默认先调用 `dws minutes list mine --max 10 --format json`** 把最近的听记列出来,让用户从列表里挑,而不是反问关键词 |
| **R2:用 `session_search`/`memory_search` 替代 dws** | AI 把"历史会话里聊过的纪要描述"误认为是真实数据源 | 历史会话只能回忆"以前我们聊过什么",**不是真实听记数据**。听记数据**必须**从 `dws minutes` 实时拉取 |
| **R3:用 `activity:search` 通用 web 搜索听记** | 把"听记"理解成公网信息 | 听记是用户私人钉钉数据,**只能**通过 `dws minutes list/get` 获取,公网搜不到也不该搜 |
| **R4:钉钉 URL 走 browser_use/read_file** | 把钉钉 URL 当成普通网页 | 钉钉听记 URL(`shanji.dingtalk.com/meeting/minutes?taskUuid=xxx` 或 `shanji.dingtalk.com/app/transcribes/xxx`)→ **提取 taskUuid → 走 `dws minutes get summary/get transcription`**;钉钉文档 URL(`alidocs.dingtalk.com/i/nodes/xxx`)→ **提取 dentryUuid → 走 `dws doc read`** |
| **R5:日报/汇报/总结/报告场景跳过听记数据采集** | 只看到"日报/报告/总结"就直奔输出端,忘了用户指定的(或隐含的)数据源包含会议/听记。**不仅限于用户说了"听记"二字的场景**——"根据我的工作情况总结"(minutes-002)、"重新写一下市场感知报告"(minutes-001)、"我要的是我的对话...会议...生成日报"(minutes-004)都属于此类 | query 中出现"总结/报告/汇报/日报/周报/工作情况/商业分析/市场感知/复盘"等产出类关键词时,必须自检:**用户的产出是否依赖会议讨论内容?** 如果是 → **第一步必须** `dws minutes list mine` 拿听记 → `get summary` 逐篇拉摘要 → 再汇总。详见「间接意图识别」章节 |
#### 三、五类 badcase 的统一正确链路(速查表)
| 用户 query 形态 | 第一步必跑命令 | 关键说明 |
|------------------|------------------|----------|
| 模糊请求:"总结下我的会议" / "周会回顾整理" / "查列表+看摘要" / "评测工作复盘" / "按关键词搜索我的听记" | `dws minutes list mine --max 10 --format json` | 拿到最近听记列表后,对前 1~3 篇 `get summary`,引导用户挑选目标 |
| 含时间词:"最近一次/昨天/本周/上周的会议" | `dws minutes list mine --start <ISO> --end <ISO> --max 20 --format json` | 时间范围按用户描述折算,不要让用户自己提供日期 |
| 含主题/项目关键词:"某某项目讨论" / "搜索+摘要+关键词" | `dws minutes list all --query "<关键词>" --max 20 --format json` | 用 `--query` 而不是 `activity:search` |
| 钉钉听记 URL(`shanji.dingtalk.com/...?taskUuid=xxx`) | `dws minutes get summary --id <taskUuid> --format json` | 从 URL 提取 taskUuid,禁用 browser_use |
| 钉钉文档 URL(`alidocs.dingtalk.com/i/nodes/xxx`) | `dws doc read --node <url 或 dentryUuid> --format json` | 走 doc 技能而非 read_file/browser_use |
| 多篇听记对比:"对比一下这几个听记 [URL1] [URL2]" | 对每个 URL 分别 `dws minutes get summary --id <uuid> --format json` | 失败的 URL 给出明确说明,不要把锅全甩给用户 |
| 日报/汇报含"听记"/"会议纪要"关键词 | 先 `dws minutes list mine --start <今日 0 点> --max 20`,再对每篇 `get summary`,最后才汇总 | 听记数据采集是必跑前置,不能直接跳到周报技能 |
| 报告/分析类(query 未显式提"听记"但依赖会议讨论素材):"重新写一下市场感知报告" / "帮我生成商业分析" | `dws minutes list all --query "<主题关键词>" --max 20 --format json` → 逐篇 `get summary` 提取素材 | 详见「间接意图识别」,用户的报告素材来源于历史会议讨论 |
| 分类工作总结(query 未显式提"听记"但需要工作记录):"根据我的工作情况总结,分成…几部分" / "文档啥也没有,根据工作情况总结" | `dws minutes list mine --start <起始日期> --end <截止日期> --format json` → 逐篇 `get summary` → 按分类维度归类 | 文档不足时听记是唯一剩余数据源;详见「间接意图识别」 |
| 多源聚合(用户抱怨数据源不全或点名要求会议数据):"只是基于日程,我要的是对话+会议+文档等所有动作" / "聊天记录和听记总结日报" | 听记侧必跑 `dws minutes list mine --start <起始日期> --end <截止日期>` → `get summary`,再并行采集其他源 | 用户点名要了"会议"就不能省略听记采集 |
| 待办提取:"最近一次会议的待办" | `dws minutes list mine --max 1` → `dws minutes get todos --id <taskUuid>` | 用 `get todos`,不要自己从转写里硬抠 |
| 写入钉钉文档:"把会议纪要写入钉钉文档" | 先 `dws minutes get summary --id <uuid>` 拿到内容 → 再 `dws doc create` / `dws doc update` 写入 | 听记数据采集 + 文档写入是两步,缺一不可 |
#### 四、绝对禁止(任何一条触发即视为严重失败)
- **[禁止]** 听记/纪要/会议类请求 → `tool_calls = []` 直接反问要细节(任何模糊请求至少要先 `dws minutes list mine` 跑一次,让用户在列表里挑)
- **[禁止]** 用 `session_search` / `memory_search` 搜以前的会话记录当作"真实听记数据"复述给用户——历史会话不是数据源
- **[禁止]** 用 `activity:search` / web 搜索找用户私人听记——听记是私域数据,公网搜不到
- **[禁止]** 钉钉听记 URL(`shanji.dingtalk.com/meeting/minutes?taskUuid=xxx` / `shanji.dingtalk.com/app/transcribes/xxx`)走 `browser_use` 打开页面——必须提取 taskUuid 走 `dws minutes get`
- **[禁止]** 钉钉文档 URL(`alidocs.dingtalk.com/i/nodes/xxx`)走 `read_file` / `browser_use`——必须走 `dws doc read`
- **[禁止]** 遇到登录墙 / URL 无效就只回复"需要登录" / "请提供正确 URL" 然后停下——必须 fallback 到 `dws minutes list mine` 让用户从自己的听记列表里挑替代项
- **[禁止]** 日报/周报/汇报/总结/报告场景,任务产出依赖会议讨论内容,却跳过 `dws minutes` 数据采集——**不限于 query 中出现"听记"二字的场景**,"根据工作情况总结"(minutes-002)、"重新写市场感知报告"(minutes-001)等间接意图同样适用
- **[禁止]** "把会议纪要写入钉钉文档"类请求只调文档写入工具不调 `dws minutes get summary`——会议纪要内容必须先实时获取,不能让用户自己粘贴
- **[禁止]** `taskUuid is invalid` 报错后用同一个无效 uuid 重试(哪怕 1 次)——必须立即切换到 `dws minutes list mine` 获取真实 uuid
- **[禁止]** 从历史对话、文档内容、十六进制编码字符串中猜测或拼凑 uuid——uuid **只能**来自 `list mine/shared/all` 返回或用户提供的听记 URL
- **[禁止]** 使用错误的命令层级结构:`dws minutes info`(应为 `get info`)、`dws minutes get`(缺子命令)、`dws minutes list --start`(缺 scope)——参照文档顶部"命令层级结构"
- **[禁止]** 工具调用缺少 `--format json` 参数——所有 `dws` 命令都应带上以便结构化解析
#### 五、自检清单(执行前 5 秒强制走一遍)
收到任何含以下信号词的 query 时,**第一个动作必须是 `dws minutes` 或 `dws doc`,否则视为走错链路**:
> **直接信号词**(query 中直接提到听记相关概念):
> "听记 / 纪要 / 摘要 / 转写 / 录音 / 会议 / 周会 / 日会 / 评审会 / 复盘 / 回顾 / 待办 / Action Item / 关键词 / 我的会 / 昨天那个会 / 上周的会 / 共享听记 / shanji.dingtalk.com / alidocs.dingtalk.com"
> **间接信号词**(query 未提"听记"但任务产出依赖会议讨论内容,需要额外判断——详见「间接意图识别」章节):
> "报告 / 市场感知 / 商业分析 / 工作情况总结 / 分类总结 / 日报 / 周报 / 月报 / 工作汇报 / 文档啥也没有 / 只是基于日程 / 所有动作 / 封装成 skill"
> **判断标准**:用户的产出是否依赖"会议上说了什么/讨论了什么"?如果是 → 听记采集是必跑前置步骤
强制问自己 5 个问题(任何一个回答"否"都禁止开始执行):
1. 我接下来的第一个 tool call 是 `dws minutes ...` 或 `dws doc ...` 吗?
2. 我有没有在用 `session_search` / `memory_search` / `activity:search` / `browser_use` / `read_file` 替代 dws?
3. 用户给了钉钉 URL 时,我有没有从 URL 提取 taskUuid/dentryUuid 后走 dws,而不是 browser_use?
4. 用户的请求模糊(如"总结下我的会议")时,我是先 `dws minutes list mine` 列出来,还是反问要细节?
5. 用户要写报告/总结/日报但没提"听记"时,我有没有判断"任务产出是否依赖会议讨论内容"?如果依赖 → 听记采集不能省略(参见间接意图识别三条铁律)
# OA 审批 (oa) 命令参考
## 命令总览
### 查询待我处理的审批
```
Usage:
dws oa approval list-pending [flags]
Example:
dws oa approval list-pending --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00"
dws oa approval list-pending --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --query 关键词
Flags:
--end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填)
--page string 分页页码 (可选)
--size string 每页大小 (可选)
--start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填)
--query string 关键字搜索 (可选)
```
### 获取审批实例详情
```
Usage:
dws oa approval detail [flags]
Example:
dws oa approval detail --instance-id <processInstanceId>
Flags:
--instance-id string 审批实例 ID (必填)
```
### 同意审批
> **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。
```
Usage:
dws oa approval approve [flags]
Example:
dws oa approval approve --instance-id <id> --task-id <taskId>
dws oa approval approve --instance-id <id> --task-id <taskId> --remark "同意"
Flags:
--instance-id string 审批实例 ID (必填)
--remark string 审批意见 (可选)
--task-id string 审批任务 ID (必填)
```
### 拒绝审批
> **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。
```
Usage:
dws oa approval reject [flags]
Example:
dws oa approval reject --instance-id <id> --task-id <taskId> --remark "不同意"
Flags:
--instance-id string 审批实例 ID (必填)
--remark string 审批意见 (可选)
--task-id string 审批任务 ID (必填)
```
### 撤销已发起的审批
```
Usage:
dws oa approval revoke [flags]
Example:
dws oa approval revoke --instance-id <id> --yes
dws oa approval revoke --instance-id <id> --remark "误发起" --yes
Flags:
--instance-id string 审批实例 ID (必填)
--remark string 撤销说明 (可选)
```
### 获取审批操作记录
```
Usage:
dws oa approval records [flags]
Example:
dws oa approval records --instance-id <processInstanceId>
Flags:
--instance-id string 审批实例 ID (必填)
```
### 查询已发起的审批实例列表
```
Usage:
dws oa approval list-initiated [flags]
Example:
dws oa approval list-initiated --process-code <code> --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --next-token 0 --max-results 20
Flags:
--end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填)
--max-results string 每页大小,最大 20 (必填)
--next-token string 分页游标,首次传 0 (必填)
--process-code string 表单 processCode (必填)
--start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填)
```
### 获取当前用户可见的审批表单列表
```
Usage:
dws oa approval list-forms [flags]
Example:
dws oa approval list-forms --cursor 0 --size 100
Flags:
--cursor string 分页游标,首次传 0 (默认 "0")
--size string 每页大小,最大 100 (默认 "100")
```
MCP 工具: `list_user_visible_process`;参数: cursor, pageSize(对应 --cursor/--size)。返回结果含 processCode,可用于 list-initiated 的 --process-code。
### 查询待我审批的任务 ID
```
Usage:
dws oa approval tasks [flags]
Example:
dws oa approval tasks --instance-id <processInstanceId>
Flags:
--instance-id string 审批实例 ID (必填)
```
MCP 工具: `list_pending_tasks`。
### 查询我处理过的审批单
```
Usage:
dws oa approval list-executed [flags]
Example:
dws oa approval list-executed --limit <pageSize> --page <pageNumber> --query 关键词
Flags:
--page string 分页页码,可选,默认是 1
--limit string 分页大小,可选,默认是 20
--query string 查询关键词,可选
```
### 查询我已经提交的审批单
```
Usage:
dws oa approval list-submitted [flags]
Example:
dws oa approval list-submitted --limit <pageSize> --page <pageNumber> --query 关键词
Flags:
--page string 分页页码,可选,默认是 1
--limit string 分页大小,可选,默认是 20
--query string 查询关键词,可选
```
### 查询抄送我的审批单
```
Usage:
dws oa approval list-cc [flags]
Example:
dws oa approval list-cc --limit <pageSize> --page <pageNumber> --query 关键词
Flags:
--page string 分页页码,可选,默认是 1
--limit string 分页大小,可选,默认是 20
--query string 查询关键词,可选
```
### 转交审批任务
```
Usage:
dws oa approval redirect-task [flags]
Example:
dws oa approval redirect-task --task-id <taskId> --to-actioner-id <userId>
dws oa approval redirect-task --task-id <taskId> --to-actioner-id <userId> --remark "请帮忙处理"
Flags:
--task-id string 审批任务 ID (必填)
--to-actioner-id string 转交目标用户 ID (必填)
--remark string 转交说明 (可选)
```
MCP 工具: `redirect_task`;参数: taskId, toActionerId, remark(对应 --task-id/--to-actioner-id/--remark)。taskId 可通过 `tasks` 命令获取,toActionerId 可通过 `dws contact user search` 获取。
### 对审批实例添加评论
```
Usage:
dws oa approval oa-comments [flags]
Example:
dws oa approval oa-comments --instance-id <processInstanceId> --text "同意,请尽快处理"
Flags:
--instance-id string 审批实例 ID (必填)
--text string 评论内容 (必填)
```
MCP 工具: `dingflow_comments`;参数: processInstanceId, text(对应 --instance-id/--text)。processInstanceId 可通过 `list-pending` 或 `detail` 获取。
### 对审批实例进行抄送
```
Usage:
dws oa approval oa-cc-noticer [flags]
Example:
dws oa approval oa-cc-noticer --instance-id <processInstanceId> --user-list "68674200835816"
dws oa approval oa-cc-noticer --instance-id <processInstanceId> --user-list "userId1,userId2"
Flags:
--instance-id string 审批实例 ID (必填)
--user-list string 抄送用户 ID 列表,多个用逗号分隔 (必填)
```
MCP 工具: `oa_cc_noticer`;参数: processInstanceId, userList(对应 --instance-id/--user-list)。processInstanceId 可通过 `list-pending` 或 `detail` 获取,抄送用户 ID 可通过 `dws contact user search` 获取。
## 意图判断
用户说"待审批/待处理审批" → `approval list-pending`
用户说"审批详情/看审批" → `approval detail`
用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve`
用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject`
用户说"撤回审批/取消审批" → `approval revoke`
用户说"审批记录/操作历史" → `approval records`
用户说"我发起的审批" → `approval list-initiated`(需 --process-code,可从 list-forms 或 detail 获取)
用户说"有哪些审批表单/可见表单" → `approval list-forms`
用户说"我有哪些待审的任务" → `approval tasks`
用户说"我发起的审批单" -> `approval list-submitted`
用户说"我审批/处理过的审批单" -> `approval list-executed`
用户说"抄送我的审批单" -> `approval list-cc`
用户说"转交审批/转交任务" → `approval redirect-task`(需 --task-id 和 --to-actioner-id)
用户说"评论审批/添加评论/写评论" → `approval oa-comments`(需 --instance-id 和 --text)
用户说"抄送审批/添加抄送人" → `approval oa-cc-noticer`(需 --instance-id 和 --user-list)
## 核心工作流
```bash
# 1. 查看待我处理的审批 — 提取 processInstanceId
dws oa approval list-pending --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --format json
# 2. 查看审批详情 — 了解审批内容
dws oa approval detail --instance-id <processInstanceId> --format json
# 3. 获取待审批任务 ID — 提取 taskId
dws oa approval tasks --instance-id <processInstanceId> --format json
# 4a. 同意审批
dws oa approval approve --instance-id <id> --task-id <taskId> --remark "同意" --format json
# 4b. 拒绝审批
dws oa approval reject --instance-id <id> --task-id <taskId> --remark "不符合要求" --format json
# 5. 撤销自己发起的审批
dws oa approval revoke --instance-id <id> --remark "误发起" --format json
# 6. 查看审批操作记录
dws oa approval records --instance-id <processInstanceId> --format json
# 7. 获取可见审批表单(得到 processCode)
dws oa approval list-forms --cursor 0 --size 100 --format json
# 8. 查看自己发起的审批列表(--process-code 来自 list-forms 或 detail)
dws oa approval list-initiated --process-code <code> \
--start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" \
--next-token 0 --max-results 20 --format json
# 9. 我处理过的审批单
dws oa approval list-executed --limit <pageSize> --page <pageNumber> --query 关键词 --format json
# 10. 我发起的审批单
dws oa approval list-submitted --limit <pageSize> --page <pageNumber> --query 关键词 --format json
# 11. 抄送我的审批单
dws oa approval list-cc --limit <pageSize> --page <pageNumber> --query 关键词 --format json
# 12. 转交审批任务(taskId 来自 tasks,toActionerId 来自 contact user search)
dws oa approval redirect-task --task-id <taskId> --to-actioner-id <userId> --format json
dws oa approval redirect-task --task-id <taskId> --to-actioner-id <userId> --remark "请帮忙处理" --format json
# 13. 对审批实例添加评论(processInstanceId 来自 list-pending 或 detail)
dws oa approval oa-comments --instance-id <processInstanceId> --text "同意,请尽快处理" --format json
# 14. 对审批实例进行抄送(processInstanceId 来自 list-pending 或 detail)
dws oa approval oa-cc-noticer --instance-id <processInstanceId> --user-list "68674200835816" --format json
dws oa approval oa-cc-noticer --instance-id <processInstanceId> --user-list "userId1,userId2" --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list-pending` | `processInstanceId` | detail / tasks / records / revoke / oa-comments / oa-cc-noticer 的 --instance-id |
| `tasks` | `taskId` | approve / reject / redirect-task 的 --task-id |
| `detail` | `processCode` | list-initiated 的 --process-code |
| `list-forms` | `processCode` | list-initiated 的 --process-code |
## 注意事项
- `--start` / `--end` 使用 ISO-8601 格式(如 2026-03-10T00:00:00+08:00)
- `approve` / `reject` / `redirect-task` 需先通过 `tasks` 获取 `taskId`
- `redirect-task` 的 `--to-actioner-id` 可通过 `dws contact user search` 获取目标用户 userId
- `revoke` 只能撤销自己发起的审批
- `--remark` 审批意见虽为可选,但建议填写以留存审批痕迹
- `list-initiated` 的 `--process-code` 可从 `list-forms` 或 `detail` 返回中提取
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [oa_pending_review.py](../../scripts/oa_pending_review.py) | 查看待审批列表+逐条显示详情 | `python oa_pending_review.py --days 7` |
| [oa_batch_approve.py](../../scripts/oa_batch_approve.py) | 批量同意/拒绝审批项 | `python oa_batch_approve.py --action approve --days 7` |
# 日志 (report) 命令参考
> **注意**:钉钉日志 = 「OA 周报应用」(按模版填报 / 收件箱 / 已发列表 / 统计),不是通用日志或记录系统。`dws report` 与其别名 `dws log` 自动由 envelope 注册——`dws report --help` 输出会列出全部别名,文档不再单独声明。
## 载体辨义(首屏必读)
`dws report` 管理「钉钉日志」OA 应用(按模版填报、收到列表、已发列表、统计),**不是**钉钉在线文档。Agent 在拿到用户 query 时按下表选择命令族:
| 用户原话信号 | 命令族 | 不走 |
|-------------|--------|------|
| 钉钉日志 / OA 周报 / 周报模板 / 日报模板 / 我的钉钉日志 / 写日志 / 提交周报 / 填模版 | `dws report` | 不走 dws doc |
| 在线文档 / 写一篇文档 / 整理成文档 / 周报文档 / 月报文档 / 用文档保存 | `dws doc` | 不走 dws report |
| (无强信号)写日报 / 写周报 / 写月报 | **默认** `dws doc` | 仅当用户后续明确指定钉钉日志时才切到 dws report |
- 默认走 `dws doc`:长文本编辑、富文本、可分享链接的场景。
- 切到 `dws report`:query 中出现「钉钉日志 / OA 周报模板 / 钉钉日报应用 / 我的钉钉日志」等强信号时(不依赖词典扩张,依赖语义判断 + 必要时反问澄清)。
- 信号歧义时应先反问「您指的是钉钉日志(OA 周报应用)还是钉钉在线文档?」而不是默认选一个。
## 查日志快速调度(Agent 首选)
当用户说「查日志 / 看日志 / 找日报 / 查看周报 / 我发过的日志 / 收到的日志」且语义指向钉钉日志 OA 应用时,Agent 不要先解释概念,直接按下面指令调度:
- `dws report inbox list` = 列出**我收到**的日报(别人发给我的)。
- `dws report outbox list` = 列出**我发出**的日报(我创建或提交的)。
- `dws report entry get --report-id <reportId>` = 读取单份日报正文 + 钉钉跳转链接。
- `dws report entry stats --report-id <reportId>` = 读取单份日报的已读统计。
- `dws report entry submit --template-id ... --contents-file ...` = 按模版提交一份新日报。
- `dws report template list` = 列出可用日报模版。
- `dws report template get --name "<模版名>"` = 读取单个模版的字段定义(contents 拼装来源)。
| 用户意图 | 第一条有效指令 | 后续动作 |
|----------|----------------|----------|
| 查我发过的日志 / 我创建的日志 | `dws report outbox list --cursor 0 --size 20 --format json` | 从返回里取 `reportId`,再执行 `dws report entry get --report-id <reportId> --format json` |
| 查我收到的日志 / 别人发给我的日志 | `dws report inbox list --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --cursor 0 --size 20 --format json` | 必须先按用户时间词补齐完整 ISO 起止时间;取 `reportId` 后调用 `entry get` |
| 查看某条日志正文 / 日志详情 | `dws report entry get --report-id <reportId> --format json` | 如果用户没给 `reportId`,先用 `outbox list` 或 `inbox list` 找候选 |
| 查某条日志统计 / 已读统计 | `dws report entry stats --report-id <reportId> --format json` | 如果用户没给 `reportId`,先用 `outbox list` 或 `inbox list` 找候选 |
| 查日志模版 / 有哪些周报模板 | `dws report template list --format json` | 需要字段定义时继续 `dws report template get --name "<模版名>" --format json` |
| 提交 / 填写 / 创建钉钉日志 | `dws report template list --format json` | 再 `template get` 取字段定义,最后 `entry submit`;禁止直接编 contents |
效率约束:
- 不要先调用 `dws report --help` / `dws report inbox list --help`。本页已经给出可执行命令。
- 查询收到的日志统一使用 `dws report inbox list`;查询发出的日志统一使用 `dws report outbox list`。
- 不要为了格式化结果创建脚本。直接从 JSON 里抽取关键字段,返回用户可读列表。
- 对"最近一周"这类常用查询,直接把当前日期换算成 7 天窗口后执行一次 `inbox list`;如返回分页标记再继续翻页。
时间窗口默认规则:
- `outbox list` 不传 `--start` / `--end` 时 CLI 默认最近 20 天,适合"我发过的日志 / 最近日志"。
- `inbox list` 必须传 `--start` / `--end`;用户说"最近一周/今天/本周/昨天"时,Agent 必须先转成 `YYYY-MM-DDT00:00:00+08:00` 到 `YYYY-MM-DDT23:59:59+08:00`。
- 查更早日志时每次窗口不要超过 20 天,按窗口滚动查询。
时间参数硬约束:
- 只允许使用 `--start` 和 `--end` 两个 flag;禁止写 `--start-date`、`--end-date`、`--date`、`startDate`、`endDate`。
- 时间值推荐使用完整 ISO-8601 + 时区格式:`YYYY-MM-DDTHH:mm:ss+08:00`。
- Agent 不要只传裸日期;即使 CLI 能兼容 `YYYY-MM-DD`,生成命令时也必须展开成当天起止时间,避免工具层把日期误解析成普通字符串。
- "最近一周"固定展开为:起始日 `T00:00:00+08:00`,结束日 `T23:59:59+08:00`。
错误示例(不要生成):
```bash
dws report inbox list --start-date 2026-05-04 --end-date 2026-05-11 --format json
dws report inbox list --date 2026-05-11 --format json
```
正确示例:
```bash
dws report inbox list --start "2026-05-04T00:00:00+08:00" --end "2026-05-11T23:59:59+08:00" --cursor 0 --size 20 --format json
```
快速决策:
```bash
# 我发过 / 我创建的日志
dws report outbox list --cursor 0 --size 20 --format json
dws report entry get --report-id <reportId> --format json
# 我收到的日志
dws report inbox list --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --cursor 0 --size 20 --format json
dws report entry get --report-id <reportId> --format json
# 已知 reportId
dws report entry get --report-id <reportId> --format json
```
## 日志列表展示规范
面向用户展示时,默认不要把 `reportId` / `report_id` / `report_Id` 作为主列。日志 ID 只给 Agent 后续调用 `detail` / `stats` 使用,用户一般不需要看。
优先展示这些字段:
| 展示列 | 取值优先级 |
|--------|------------|
| 日期 | `createTime` / `modifiedTime` / `gmtCreate`,转成本地可读时间 |
| 标题 | `report_name` / `reportName` / `title` / `summary` / `report_template_name`,没有标题时用「<发送人>的日志」 |
| 发送人 | `creatorName` / `senderName` / `userName` |
| 已读状态 | `readStatus` / `isRead` / `hasRead` / `read`;字段缺失时不展示,不要编造 |
| 模板 | `report_template_name` / `templateName`,用户关心分类时展示 |
| 查看详情 | 从 `_internalDetailCommands[].command` 取内部详情命令;面向用户展示为“查看详情”,不要展示命令本身 |
| 钉钉链接 | 详情接口返回 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl` / `result.url` 后,展示成 markdown 链接 |
CLI 列表命令只返回 JSON-first 数据,不把 Markdown 表作为裸文本直接输出。`report inbox list` / `report outbox list` 会对当前页 `result[]` 中所有带 `reportId` 的日志逐条调用 `dws report entry get --report-id <reportId> --format json` 补齐展示字段;返回 JSON 会携带 `agentDisplayRequired=true`、`agentDisplayRequiredColumns`、`agentDisplayLinkColumn=钉钉链接`、`agentDisplayMarkdownRequired=true`、`agentDisplayMarkdown`、`agentDisplayMarkdownHeader` 等字段,这些是面向 Agent 的强制展示合同,不是可选建议。Agent 必须在 final reply 直接原样输出 `agentDisplayMarkdown`,让客户端按 Markdown 渲染出带可点击 `钉钉链接` 列的表格;不要自行重组列,不要改表头。
展示列按方向区分:`inbox list` 是收件箱,只展示 `日期 | 标题 | 发送人 | 状态 | 钉钉链接`,**禁止返回或展示日志正文、完整内容或日志内容摘要**;`outbox list` 是发件箱,可展示 `日期 | 标题 | 发送人 | 状态 | 日志内容 | 钉钉链接`,其中 `日志内容` 来自详情补齐,方便用户快速确认自己发出的内容。凡用户说"列出 / 找到 / 查询 / 搜索 / 看看日志",默认都要在 final reply 渲染 Markdown 表;只有用户明确表示"不关心列表内容 / 只要原始 JSON / 只要数量 / 只要 ID"时,才可以不渲染表。推荐用户可读输出:
| 日期 | 标题 | 发送人 | 状态 | 钉钉链接 |
|------|------|--------|------|----------|
| 2026-05-09 23:08 | 张成强的周报 | 张成强 | 未读 | [在钉钉中查看日志](...) |
操作列规则:
- `inbox list` 列表阶段:每条 `result[]` 只带 `日期` / `标题` / `发送人` / `状态` / `钉钉链接` 五个展示字段;使用 `result[].钉钉链接` 作为可点击操作列。禁止额外返回或展示 `日志内容` / `日志内容摘要`。
- `outbox list` 列表阶段:每条 `result[]` 可带 `日志内容`,表头固定为 `日期 | 标题 | 发送人 | 状态 | 日志内容 | 钉钉链接`。如果用户点名某一条或说"打开第 N 条/看正文",Agent 用 `_internalDetailCommands[N].command` 调 `dws report entry get --report-id ... --format json`。
- 详情阶段:`entry get` 返回里如果有 `dingtalkOpenMarkdownLink`,优先把操作列替换为该 markdown 链接;否则用 `dingtalkOpenUrl` 或 `result.url` 包成 `[在钉钉中查看日志](url)`。
- `钉钉链接` 是强制列:禁止省略、改名、合并到标题里,也禁止改成不含链接列的摘要表。
- 不要在用户表格里展示 `_internalDetailCommands`、raw `reportId`、raw `dingtalk://...`。链接必须是 markdown 可点击文本。
- `dws report inbox list` 默认会对当前页所有可查看日志自动补 `entry get`,但只返回日期、标题、发送人、状态与可点击钉钉链接;需要正文时必须显式调用 `dws report entry get --report-id ... --format json`。
- `dws report outbox list` 默认会对当前页所有可查看日志自动补 `entry get`,并可把 `日志内容` 纳入发件箱表格展示。
只有在用户明确要求"给我日志 ID / 方便我后续查询"时,才额外展示 `reportId`。否则 final reply 应保留可读信息,并说明"需要看正文或打开钉钉,我可以继续打开某一条"。
## 提交链路硬约束(必读)
当用户意图涉及"填模板 / 提交日志 / 提交日报 / 提交周报"等需要 submit 的场景时,**必须**按以下步骤执行,**禁止跳步**:
1. `dws report template list --format json` — 取 `report_template_id` 与可见模版名
2. `dws report template get --name "<模版名>" --format json` — 取 `result.report_template_fields[]`,每项含 `field_name` / `field_sort` / `field_type`
3. `dws report entry submit --template-id <id> --contents-file <tmp.json> --format json` — contents 数组按上面「字段映射」严格对齐第 2 步:`field_name → key`,`field_sort → sort`,`field_type → type`,再填 `content` 与 `contentType`;CLI 提交成功后会自动反查详情并追加钉钉打开链接字段,返回中直接取 `reportId` 与 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl`
4. 仅当第 3 步返回中缺少 `dingtalkOpenUrl` 时,执行 `dws report entry get --report-id <reportId> --format json` 补取 `result.url`(`dingtalk://...` 协议深链接)。final reply 中优先使用 `dingtalkOpenMarkdownLink`,否则用 `[在钉钉中查看日志](dingtalkOpenUrl)`。**禁止把 raw `dingtalk://...` URL 原样写进回复**,必须包成 markdown link 让用户可点击跳转钉钉客户端
跳步风险(已实证):
- 跳过第 1 步直接编 templateId → 服务端返回 `PARAM_ERROR`,且**不告诉你哪个 ID 错**;
- 跳过第 2 步用 LLM 经验编 `key` 名 → 服务端返回 `PARAM_ERROR`,且**不告诉你哪个字段错**;服务端 PARAM_ERROR 信号弱,事后无法定位,**只能靠前置 schema 同步避免**;
- 未取到 `dingtalkOpenUrl` 且不补查 `entry get` → 用户拿不到跳转链接,无法在钉钉客户端打开刚提交的日志查看 / 修改;
- 用 `--contents` 直传长 JSON → shell 引号转义破坏 JSON → `INPUT_INVALID_JSON`。**长内容务必走 `--contents-file <path>` 或 `--contents -` (stdin)**。
- contents JSON 大小限制为 10MB,**不支持分批次提交**。超过限制需精简内容或拆分为多个独立日志提交。
推荐:Agent 在多轮场景中应在内存里持久化第 1/2 步的结果,避免每轮重新跑。
## 命令总览
### 获取日志模版列表
```
Usage:
dws report template list [flags]
Example:
dws report template list
```
### 读取单个日志模版的字段定义
```
Usage:
dws report template get [flags]
Example:
dws report template get --name <templateName>
Flags:
--name string 模版名称 (必填)
```
### 提交日报(按模版)
```
Usage:
dws report entry submit [flags]
Example:
# 推荐:长内容走文件,避免 shell 引号问题
dws report entry submit --template-id <templateId> --contents-file ./report.json --format json
# stdin 输入
cat report.json | dws report entry submit --template-id <templateId> --contents - --format json
# 内联(短内容)
dws report entry submit --template-id <templateId> \
--contents '[{"key":"今日完成","sort":"0","content":"完成了需求评审","contentType":"markdown","type":"1"}]' \
--format json
Flags:
--template-id string 日志模版 ID (必填),从 template list 返回中取
--contents string 日志内容 JSON 数组 (必填,或用 --contents-file);传 `-` 表示从 stdin 读取
--contents-file string 从文件读取 contents JSON(推荐用于含中文/换行/Markdown 的长内容)
--dd-from string 创建来源标识 (默认 dws)
--to-chat 是否发送到日志接收人单聊 (默认 false,传本 flag 则为 true)
--to-user-ids string 接收人 userId,逗号分隔 (可选)
```
**`contents` 数组元素**(与 MCP `create_report` 一致):
| 字段 | 类型 | 说明 |
|------|------|------|
| `key` | string | 控件名,**与 `template get` 返回的 `field_name` 完全一致**(不要自己改写) |
| `sort` | string | 控件排序,对齐 `template get` 的 `field_sort`(建议传字符串 `"0"`/`"1"` 等) |
| `type` | string | 控件类型,对齐 `template get` 的 `field_type`:`1` 文本 / `2` 数字 / `3` 单选 / `5` 日期 / `7` 多选 |
| `content` | string | 填写值;`type=1` 文本类支持 Markdown |
| `contentType` | string | `type=1` 时通常用 `markdown`,其余用 `origin` |
**字段名对齐**:`template get` 返回 `result.report_template_fields[].{field_name, field_sort, field_type}`(snake_case),拼 `--contents` 时 **逐一映射**:`field_name → key`、`field_sort → sort`、`field_type → type`,再填 `content` 与 `contentType`。**不要自己编 key 名**,必须从 get 返回值取,否则服务端会返回不可定位的 `PARAM_ERROR`。
**长内容传参优先级**:`--contents-file <path>` > `--contents -` (stdin) > `--contents '<json>'`。任何含中文换行 / Markdown / 引号的场景都应走 `--contents-file` 避免 shell 引号转义。
### 读取单份日报正文(含字段明细 + 跳转链接)
```
Usage:
dws report entry get [flags]
Example:
# 先通过 report inbox list / outbox list / entry submit 取得 reportId,再查正文与跳转链接
dws report entry get --report-id <reportId>
Flags:
--report-id string 日志 ID (必填)
```
**关键返回字段**:
| 字段 | 含义 | Agent 用法 |
|------|------|-----------|
| `result.url` | `dingtalk://dingtalkclient/action/openapp?...` 协议深链接 | **必须包成 markdown link**:`[查看日报](result.url)`。点击后会在钉钉客户端打开该日志详情页。**禁止**把 raw `dingtalk://...` URL 直接粘到回复里——多数终端 / 聊天界面无法对裸协议自动可点击 |
| `result.report_content[]` | 各控件正文(`key` / `value` / `richTextValue` 等)| 需要展示正文时读这里;`richTextValue` 是富文本编码,普通展示用 `value` |
| `result.report_name` / `result.creatorName` / `result.createTime` | 日志元信息 | final reply 给用户摘要时一并展示 |
### 收件箱:列出我收到的日报
```
Usage:
dws report inbox list [flags]
Example:
dws report inbox list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20
dws report inbox list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --sender-user-ids staffId1,staffId2
# 禁止:--start-date / --end-date / --date
# 从列表结果提取 reportId 后,再查正文
dws report entry get --report-id <reportId>
Flags:
--cursor int 分页游标,首次传 0 (默认 0)
--end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00) (必填)
--sender-user-ids strings 发送人 staffId 列表,逗号分隔,用于过滤指定发送人的日志 (可选)
--size int 每页条数,最大 20 (默认 20),别名: --limit
--start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00) (必填)
```
`inbox list` 默认返回 JSON-first 结构:不会把 Markdown 表作为裸文本直接输出,但 JSON 里会返回 `agentDisplayMarkdown`。Agent 必须在 final reply 直接原样输出 `agentDisplayMarkdown`,让客户端按 Markdown 渲染出表格;不要基于个人偏好重新摘要。表头固定且必须逐字为 `| 日期 | 标题 | 发送人 | 状态 | 钉钉链接 |`,并按返回顺序展示(CLI 已按创建时间从近到远排序)。返回 JSON 中的 `agentDisplayRequired=true`、`agentDisplayRequiredColumns`、`agentDisplayLinkColumn=钉钉链接`、`agentDisplayMarkdownRequired=true`、`agentDisplayMarkdownHeader` 都是强制展示合同。凡用户说"列出 / 找到 / 查询 / 搜索 / 看看日志",final reply 默认必须渲染这张 Markdown 表;只有用户明确表示"不关心列表内容 / 只要原始 JSON / 只要数量 / 只要 ID"时,才可以不渲染表。`result[]` 每项都包含且只展示这五个字段:`日期`、`标题`、`发送人`、`状态`、`钉钉链接`。`钉钉链接` 是强制可点击 markdown 链接列,禁止省略、改名、合并到标题里;`inbox list` 禁止返回或展示 `日志内容` / `日志内容摘要`。`reportId` 不在主结果里,只通过 `_internalDetailCommands` 保留给 Agent 后续调用 `entry get` / `entry stats`;final reply 禁止展示 `_internalDetailCommands`、毫秒时间戳、raw ID 或日志正文。
### 读取单份日报的已读统计
```
Usage:
dws report entry stats [flags]
Example:
dws report entry stats --report-id <reportId>
Flags:
--report-id string 日志 ID (必填)
```
### 发件箱:列出我发出的日报
```
Usage:
dws report outbox list [flags]
Example:
dws report outbox list --cursor 0 --size 20
dws report outbox list --cursor 0 --size 20 --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00"
dws report outbox list --cursor 0 --size 20 --template-name "日报"
# 从列表结果提取 reportId 后,再查正文
dws report entry get --report-id <reportId>
Flags:
--cursor int 分页游标,首次传 0 (默认 0)
--size int 每页条数,最大 20 (默认 20),别名: --limit
--start string 创建开始时间 ISO-8601 (默认最近 20 天;服务端单次查询跨度上限 20 天)
--end string 创建结束时间 ISO-8601 (默认最近 20 天;服务端单次查询跨度上限 20 天)
--modified-start string 修改开始时间 ISO-8601 (可选)
--modified-end string 修改结束时间 ISO-8601 (可选)
--template-name string 日志模版名称 (可选,不传查全部)
```
`outbox list` 同样主要返回已发送日志的 ID 和摘要;要查看正文,继续用 `entry get`。
**默认时间窗口**:`--start` / `--end` 未传时,CLI 自动回退到最近 20 天(服务端 `get_send_report_list` 单次查询跨度**上限 20 天**,超过会被服务端拒绝),并在 stderr 输出一行 informational:
```
# info: --start / --end not provided, defaulting to last 20 days (<start_iso> ~ <end_iso>); server caps single-query span at 20 days, pass explicit --start to shift the window
```
查更早数据需**多次调用**并显式滚动 `--start` / `--end`(每次跨度 ≤ 20 天),不能一次性传超过 20 天范围;不要假定 `outbox list` 不传时间窗口就是全量。
## 两步读取正文
读取已有日志内容时,统一按下面两步走,不要把列表接口当正文接口:
1. `dws report inbox list ...` 或 `dws report outbox list ...`,先拿到目标日志的 `reportId`
2. `dws report entry get --report-id <reportId>`,再读取正文和字段明细
适用场景:
- "看我今天收到的某条周报正文"
- "把我发过的日报正文拉出来继续汇总"
- "先按时间范围筛日志,再读取具体内容"
## 意图判断
用户说"查日志/看日报/看正文" → `inbox list` 或 `outbox list` 获取列表,再 `entry get`
用户说"写日报/提交周报/发日志/填日志" → 先 `template list` / `template get` 取 `templateId` 与各控件 `key`/`sort`/类型,拼 `--contents` JSON,再 `entry submit`
用户说"日志统计/已读统计" → `entry stats`
用户说"有什么日志模版" → `template list` 或 `template get`
用户说"我发过的日志/我创建的日志" → `outbox list`
用户说"别人发给我的日志/我收到的日志" → `inbox list`
关键区分: report(钉钉日志模版汇报,含提交) vs doc(文档编辑) vs todo(待办任务)
## 核心工作流
```bash
# 1. 获取当前用户可用的日志模版
dws report template list --format json
# 2. 按名称读取模版字段定义
dws report template get --name "日报" --format json
# 2b. 提交日志(从步骤 1/2 取 templateId 与 contents 字段)— 推荐 --contents-file 传入避免 shell 引号
dws report entry submit --template-id <templateId> --contents-file ./report.json --format json
# submit 成功会自动反查详情并追加 dingtalkOpenMarkdownLink / dingtalkOpenUrl;
# final reply 直接使用 dingtalkOpenMarkdownLink: [在钉钉中查看日志](dingtalk://...)
# 2c. 仅当 submit 返回中缺少 dingtalkOpenUrl 时,手动补取 dingtalk:// 跳转链接
dws report entry get --report-id <submit 返回的 reportId> --format json
# → 取 result.url,final reply: [在钉钉中查看日志](result.url)
# 3. 查看收到的日报列表 — 提取 reportId
dws report inbox list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" \
--cursor 0 --size 20 --format json
# 4. 查看日报详情(正文/字段明细)
dws report entry get --report-id <reportId> --format json
# 5. 查看日报已读统计
dws report entry stats --report-id <reportId> --format json
# 6. 查看我发出的日报列表
dws report outbox list --cursor 0 --size 20 --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `template list` | template 名称(result.items[].report_template_name) | `template get` 的 --name |
| `template list` | `report_template_id` | `entry submit` 的 --template-id |
| `template get` | `result.report_template_fields[].field_name` / `field_sort` / `field_type` | 拼 `entry submit` 的 --contents JSON(按下表映射)|
| `entry submit` | `reportId`、`dingtalkOpenMarkdownLink`、`dingtalkOpenUrl`(CLI 自动反查详情后追加)| final reply 优先直接使用 `dingtalkOpenMarkdownLink`;需要结构化展示时用 `dingtalkOpenLink.title` + `dingtalkOpenLink.url` |
| `inbox list` / `outbox list` | `reportId` | `entry get` / `entry stats` 的 --report-id |
| `entry get` | `result.url`(`dingtalk://...`)| `entry submit` 未返回 `dingtalkOpenUrl` 或查看已有日志时,final reply 中以 markdown link 形式给用户:`[在钉钉中查看日志](result.url)` |
**`template get` → `entry submit --contents` 字段映射**(必须严格对齐,不要自己改写字段名):
| `template get` 返回 | `entry submit --contents` 字段 |
|------------------------|--------------------------|
| `field_name`(string)| `key` |
| `field_sort`(number)| `sort`(string,例如 `"0"`) |
| `field_type`(number)| `type`(string,例如 `"1"`) |
| —(用户填写)| `content` |
| —(推断)| `contentType`(`type=1` 用 `markdown`,其余用 `origin`) |
## 注意事项
- `--start` / `--end` 使用 ISO-8601 格式(如 `2026-03-10T00:00:00+08:00`)
- `template list` 不需要参数,直接返回当前用户可用的所有日志模版
- `entry submit` 前必须先查模版(参见「提交链路硬约束」),勿猜测 `templateId` 或 `contents` 中的 `key`/`sort`;多控件时数组须覆盖模版必填项
- `inbox list` / `outbox list` 默认用于筛选目标日志,不保证返回完整正文;读取正文请继续调用 `entry get`
- `outbox list` 不传时间窗口默认最近 20 天(服务端单次查询跨度上限 20 天,超过会被拒绝),CLI 会在 stderr 输出 informational 提示;查更早数据需多次滚动调用,每次跨度 ≤ 20 天
- `entry submit` 的 contents JSON 大小限制为 **10MB**,**不支持分批次提交**。超出限制时需精简内容或将内容拆分为多个独立日志分别提交
## 常见错误诊断(CLI Code → 真实含义 → 建议动作)
错误码均落到 errors.go 既有 `INPUT_*` / `MCP_*` / `RESOURCE_*` 体系,对应进程退出码见全局错误码文档。
| Code | ExitCode | 真实含义 | 建议动作 |
|------|---------|---------|---------|
| `INPUT_INVALID_JSON` | 3 | `--contents` 或 `--contents-file` 内容非合法 JSON | 检查 JSON 数组结构,每项必须是 object,含 `key`/`sort`/`content`/`contentType`/`type` 五个字段 |
| `INPUT_FILE_NOT_FOUND` | 3 | `--contents-file` 路径不存在 / sandbox OS 风格不匹配(macOS 路径在 Windows 沙箱)| 先确认 sandbox OS 与路径风格;改写到 `os.tmpdir()` 等可移植目录 |
| `INPUT_MISSING_PARAM` | 3 | `--template-id` / `--contents` 必填缺失 | 显式传值;从 `template list` 取合法 templateId |
| `INPUT_TOO_LARGE` | 3 | contents JSON 超过 10MB 限制 | **不支持分批次提交**。需精简内容或拆分为多个独立日志分别提交 |
| `MCP_TOOL_ERROR` | 1 | 服务端业务错(含 `server_error_code: PARAM_ERROR`,覆盖 templateId 错 / 字段名错 / 字段值错 / contents 空等多种形态)| 查看 `server_error_code` / `technical_detail`;服务端不区分具体子错因,按提交链路重新走 `template list → template get → entry submit`;连续 ≥ 2 次仍失败必须停止重试,降级 final_reply |
| `RESOURCE_NOT_FOUND` | 1 | reportId / templateId 在服务端找不到 | 用 `list` 或 `template list` 重新获取 |
## 何时停止重试
`dws report entry submit` 在以下情况下**必须停止重试**,转为降级 final_reply:
1. 同一 templateId 连续 ≥ 3 次返回 PARAM_ERROR / INVALID_CONTENTS 类错误,且每次重试只是改 contents 字段名 / 格式而未重读 schema;
2. 出现服务端不可读的 PARAM_ERROR(technical_detail 仅含 `root.success当前值`)—— 即使只 1 次也应停止;
3. 出现 `INPUT_FILE_NOT_FOUND` 后下一次重试仍未先 `ls` 验证路径存在性。
降级 final_reply 模板:
> 当前 `dws report entry submit` 在该模版下持续返回不可恢复错误(已尝试 N 次,错误码 `<Code>`)。建议您:
>
> 1. 在钉钉客户端打开「日志」应用,选择「<模版名>」模版;
> 2. 复制下面的内容粘贴到对应字段;
> 3. 提交。
>
> 我已记录本次失败 trace,会同步给 dws 团队修复。
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
## 相关产品
- [doc](./doc.md) — 长文本文档创作(钉钉在线文档),不是日志模版
- [todo](./todo.md) — 个人任务管理,不是日志汇报
# 电子表格 (sheet) 命令参考
> **渐进式文档**:本文件为路由层(索引 + 意图判断 + 全局约束),各命令的详细参数、示例和注意事项在 [sheet/](./sheet/) 目录下按需加载。
## 适用范围
`sheet` 仅支持钉钉在线电子表格(`contentType=ALIDOC`、`extension=axls`)。
| 文件类型 | 处理方式 |
|---------|---------|
| 在线电子表格(`axls`) | 走 `sheet` 全部命令 |
| `xlsx` / `xls` / `xlsm` / `csv` | `dws drive download --node <ID> --output <路径>` 下载到本地处理,禁止调用任何 `sheet` 子命令 |
| 本地 xlsx 导入为在线表格 | `dws drive upload --file <路径> --convert`(上传并转换为在线电子表格,转换后可用 `sheet` 命令操作) |
| 在线表格导出为 xlsx | `dws sheet export`(axls → xlsx 格式转换) |
用户贴原始 `alidocs` URL 时必须先 probe:`dws doc info --node <URL> --format json`,按 [链接规范](../url-patterns.md#alidocs-url-类型探测流程) 校验:
- `contentType=ALIDOC` + `extension=axls` → 继续走 `sheet`
- `extension=xlsx` / `xls` / `xlsm` / `csv` → 转 `dws drive download`,告知用户"这是本地表格文件,已为你下载到本地处理"
## URL → NODE_ID
| URL 格式 | 提取方式 |
|----------|---------|
| `.../i/nodes/{id}` 或 `.../i/nodes/{id}?query` | 取路径末段作 NODE_ID(忽略 query) |
| `.../spreadsheetv2/{key}/...` | **完整 URL 原样传 `--node`**,禁止提取 path segment |
参数不确定时先 `dws sheet <命令> --help`。
## Reference 索引
| Reference | 描述 |
|-----------|------|
| [sheet-workbook](./sheet/sheet-workbook.md) | 管理表格文档与工作表。当用户说"创建表格"、"有哪些工作表"、"新建/重命名/隐藏/冻结/复制/删除工作表"时使用。命令:`create`/`list`/`info`/`new`/`update`/`copy`/`delete-sheet` |
| [sheet-read-data](./sheet/sheet-read-data.md) | 读取工作表数据。当用户说"读数据"、"看表格内容"、"查看数据"时使用。推荐 `csv-get`(CSV 格式、token 低、防爆保护);需 value + dataValidation / hyperlink / richText / cellStyles 等 per-cell 元数据时用 `range read`。大范围数据建议分页读取(单次 ≤5000 单元格)。命令:`csv-get`/`range read` |
| [sheet-write-data](./sheet/sheet-write-data.md) | 写入数据到工作表。当用户说"写数据"、"填表"、"更新单元格"、"写公式"、"超链接"、"写值同时设样式/数据验证"、"追加数据"、"导入CSV"时使用。大批量纯值(>5行或>20单元格)必须用 `csv-put` 而非 `range update`。命令:`range update`/`append`/`csv-put` |
| [sheet-search-replace](./sheet/sheet-search-replace.md) | 搜索和替换文本。当用户说"搜索"、"查找"、"替换"、"把A改成B"时使用。禁止用 `range read` 全量读取后客户端过滤代替 `find`,禁止用 `range update` 模拟 `replace`。命令:`find`/`replace` |
| [sheet-range-operations](./sheet/sheet-range-operations.md) | 区域结构性操作。当用户说"清空"、"排序"、"自动填充"、"复制区域到"、"移动数据到"时使用。均为服务端原子操作,禁止 `range read`+`range update` 组合模拟。排序前必须先读前几行判断表头。命令:`range clear`/`range sort`/`range fill`/`range copy-to`/`range move-to` |
| [sheet-dimension-operations](./sheet/sheet-dimension-operations.md) | 行列增删移动与属性设置。当用户说"插入行/列"、"删除行/列"、"隐藏/显示行列"、"设行高/列宽"、"移动行/列"、"追加空行/空列"时使用。命令:`insert-dimension`/`delete-dimension`/`update-dimension`/`move-dimension`/`add-dimension` |
| [sheet-style-format](./sheet/sheet-style-format.md) | 单元格样式与合并。当用户说"设样式"、"改颜色/字体/对齐"、"数字格式(百分比/货币/日期)"、"合并/取消合并"时使用。纯样式/批量样式走 `set-style`;写值同时设置少量 cell 样式可用 `range update` 的 `cellStyles`。命令:`range set-style`/`range batch-set-style`/`merge-cells`/`unmerge-cells` |
| [sheet-dropdown](./sheet/sheet-dropdown.md) | 下拉列表管理。当用户说"设置下拉"、"下拉选项"、"删除下拉"时使用。命令:`set-dropdown`/`get-dropdown`/`delete-dropdown` |
| [sheet-media-image](./sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片需先 `media-upload` 再 `create-float-image`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` |
| [sheet-filter](./sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` |
| [sheet-filter-view](./sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` |
| [sheet-conditional-format](./sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` |
| [sheet-export](./sheet/sheet-export.md) | 导出表格为 xlsx。当用户说"导出"、"下载xlsx"、"存为Excel"时使用。单命令一站式,CLI 内部自动轮询,禁止 Agent 侧重试。命令:`export` |
## 全局硬约束
1. **`--sheet-id` 禁止臆测**:未知时必须 `dws sheet list --node <ID> --format json` 查询,禁止编造 `Sheet1`/`sheet1`/`0`/`default`
2. **合并单元格是结构信息**:`dws sheet info --node <ID> --sheet-id <SHEET_ID> --format json` 返回 `mergedRanges`(如 `["C7:D11"]`);不要在 `range read` / `csv-get` 里寻找合并信息
3. **最后非空坐标只用 A1 语义**:`sheet info` 通过 `nonEmptyRange` 返回 A1/UI 边界;优先使用 `nonEmptyRange.range`,需要追加行/列时使用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn`。不要依赖底层 0-based 的 `lastNonEmptyRow` / `lastNonEmptyColumn`
4. **`range update` 维度校验**:`--values` 行列数必须与 `--range` 完全一致;只接 `--values` 一个数据参数,cell `type` 仅支持 `text` / `richText`;整格超链接通过 cell-level `hyperlink` 表达,富文本片段链接才使用 `richText.texts[].type="link"`
5. **dataValidation 三语义**:不传 `dataValidation` 字段=保留原 DV;`dataValidation:{type:"none"}`=显式清除;`dataValidation:{type:"dropdown"/"checkbox",...}`=覆盖。`{}` 跳过亦保留原 DV
6. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。Agent 调用不要用 `hyperlink:null`,避免网关/Schema 过滤 null 字段
7. **样式写法**:cell-level 样式用 `cellStyles` 或 `range set-style`;richText 片段级样式才用子项 `style`。不要在 `type:"text"` 顶层使用旧 `style` 字段
8. **用专用命令不用组合模拟**:搜索→`find`、替换→`replace`、清空→`range clear`、排序→`range sort`、填充→`range fill`、复制区域→`range copy-to`、移动区域→`range move-to`、移动行列→`move-dimension`
9. **大批量纯值用 `csv-put`**(>5 行或 >20 单元格),不用 `range update`
10. **单元格图片用 `write-image`**(`range update` 不支持图片参数)
11. **`export` 禁止自行轮询**(CLI 内部已完成渐进式退避,最多 30 次约 5 分钟)
12. **单次调用上限**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000)
13. **关键区分**:sheet(电子表格/单元格读写)vs aitable(AI多维表/结构化记录)vs doc(文档)
## URL 粘贴场景
用户直接粘贴表格 URL(无其他指令):
- 先 probe:`dws doc info --node <URL> --format json`
- `extension=axls` → `list` + `range read`(读取第一个工作表数据)
- `extension=xlsx`/`xls`/`xlsm`/`csv` → 转 `dws drive download --node <URL> --output ./`
用户粘贴 URL + 附加指令:
- probe 为 `axls` → 按 Reference 索引路由到对应命令
- probe 为 xlsx/csv → 先 `dws drive download` 下载到本地,严禁调用 sheet 命令
## 导入本地表格
用户说"导入Excel/把xlsx转为在线表格/上传表格并在线编辑"时:
```bash
# 上传并转换为在线电子表格(转换后返回 nodeId,可用 sheet 命令操作)
dws drive upload --file ./data.xlsx --convert
# 指定上传到某个文件夹
dws drive upload --file ./data.xlsx --folder <FOLDER_ID> --convert
# 指定上传到知识库
dws drive upload --file ./data.xlsx --workspace <WS_ID> --convert
```
- `--convert` 是关键参数,不加则仅上传为附件,不会转换为在线电子表格
- 转换后的文档为 `axls` 格式,可用 `sheet` 全部命令操作
- 支持 `.xlsx` / `.xls` / `.csv` 等格式
## nodeId 说明
`--node` 同时支持文档 ID、URL、分享链接。`drive list` 返回中必须用 `fileId`(UUID 格式),禁止用 `dentryId`(纯数字)。
# 条件格式 (conditional format)
## 使用场景
### 条件格式
#### 强制走条件格式的触发词(硬约束)
当用户出现以下口语指令时,**强制**走 `cond-format create/update/delete`,**禁止**用 `range set-style` 写静态背景色/字体色代替:
- **颜色动作**:"标红 / 标黄 / 标绿 / 上色 / 染色 / 涂色"
- **视觉强调**:"高亮 / 突出 / 标记 / 标注 / 区分"
- **条件触发**:"重复的标出来 / 异常的圈出来 / 过期的染红 / 大于 X 的标黄 / 不达标的标红"
- **联动语义**:"颜色随数据变 / 联动 / 自动更新 / 改了数据颜色也跟着变"
- **数值可视化**:"数据条 / 色阶 / 渐变色 / 进度条样式"
**判断标准**:交付后 `cond-format list` 必须能返回该规则;否则视为违规。
> 如果用 `range set-style` 写静态背景色,源数据变化时颜色不会跟着变。典型反例:用户要求"过期单元格标红"时用静态填充——日期变化后颜色不再准确。
**大数据量优势**:当数据量 > 1000 行时,条件格式是首选——它由服务端自身渲染,不需要逐行调用 `range set-style`,性能远优于静态样式写入。
#### 意图判断
用户说"条件格式/条件样式/自动变色/满足条件时高亮/按条件设样式/条件格式规则":
- 查看已有条件格式 → `cond-format list`
- 查看指定规则详情 → `cond-format list --rule-id RULE_ID`
用户说"创建条件格式/设置条件格式/大于某值时标红/包含某文本时高亮/数据条/图标集/色阶/重复值高亮":
- 创建条件格式规则 → `cond-format create`
- `--condition` 为 JSON 对象,key 为条件类型,value 为条件参数
- `--cell-style` 为命中时的样式(适用于数值/文本/空值/错误/重复/公式/排名/平均值/标准差类型)
- `--data-bar-style` 为数据条样式(仅数据条类型时使用)
- 色阶和图标集类型不需要 `--cell-style`(样式内置在条件定义中)
用户说"修改条件格式/更新条件格式规则/改条件/改样式/改范围":
- 更新条件格式规则 → `cond-format update`
- 未传入的字段保持原有值不变
- 传入 `--condition` 会替换原有条件类型
用户说"删除条件格式/移除条件格式/取消条件格式":
- 删除条件格式规则 → `cond-format delete --yes`
- 删除不可恢复,执行前必须向用户确认,同意后才加 `--yes` 执行
- 规则已不存在时操作仍返回成功
#### 常见配置错误(必须注意)
- **创建后必须验证**:条件格式创建后必须调用 `cond-format list` 验证规则是否生效。如果验证发现规则未生效或配置不正确,应立即修复并重试
- **范围要精确**:条件格式的应用范围必须精确覆盖用户指定的列/行,不要遗漏也不要过度扩大
- **`backgroundColor` vs `fontColor` 的中文语义**:用户中文语境下的"标红/高亮/染色/标记"指**单元格背景色**,用 `backgroundColor`;"文字红/字体红/把字变红"才用 `fontColor`。默认无说明时选 `backgroundColor`
- **日期/空值比较必须防空**:用户说"过期的标红"时,公式必须排除空单元格,否则空白格也会被误判为过期而全表标红。正确公式:`=AND(E1<>"", E1<=TODAY())`;错误公式:`=E1<=TODAY()`(空值会被当作 0 判为过期)
- **公式引用方式**:自定义公式条件中的单元格引用需要根据实际场景选择相对/绝对引用(如 `=E1<=TODAY()` 使用相对引用使公式随行变化,而非 `=$E$1<=TODAY()` 只比较一个格)
- **创建前必须确认列对应**:仅读表头不够——如果表头语义含糊,formula 里引用的列字母可能张冠李戴。建议先读 3-5 行数据样本(如 `range read --range "A1:Z5"`)确认列名对应的实际值和数据类型
#### 辅助列+条件格式两步走(高频致命错误防护)
**用户明确要求"辅助列+条件格式"两步走时,禁止用 `formulaCondition` 绕过**:
当用户说以下任意一种表达时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),**禁止**直接用一个 `formulaCondition` 公式一步完成:
- "**增加辅助列**,再/然后标记……"
- "**先计算/判断** XX **是否** YY,**再**标记……"
- "**新建一列**放结果,再用结果染色"
- 明确要求用"辅助列"、"辅助字段"、"判断列"、"标记列"
**正确做法(两步走)**:
```bash
# Step 1: 用 range update 在新列写判断公式(形成"是/否"辅助列)
dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "H2:H100" \
--values '[["=IF(A2>B2,\"是\",\"否\")"],...]'
# Step 2: 基于辅助列值做条件格式(用 formulaCondition 引用辅助列)
dws sheet cond-format create --node NODE_ID --sheet-id SHEET_ID \
--ranges '["A2:H100"]' \
--condition '{"formulaCondition":{"formula":"=$H2=\"是\""}}' \
--cell-style '{"backgroundColor":"#FFECEC"}'
```
**错误做法(一步走绕过辅助列)**:
```bash
# 虽然逻辑等价,但产物里缺辅助列 → 用户打开表格看不到"是/否"列
dws sheet cond-format create --node NODE_ID --sheet-id SHEET_ID \
--ranges '["A2:H100"]' \
--condition '{"formulaCondition":{"formula":"=$A2>$B2"}}' \
--cell-style '{"backgroundColor":"#FFECEC"}'
```
**为什么禁止一步走**:用户明确要求辅助列是有**业务意图**的——让人肉眼能在表里看到判断结果列;条件格式只是视觉辅助。一步 `formulaCondition` 虽然效果对了,但用户打开表格看不到辅助列,被视为"操作不完整"。
> `formulaCondition` 单独使用的场景是:用户**没有**明确要求辅助列、只要"标红符合条件的行"时。
#### 典型工作流
```
1. 先读取现有条件格式了解当前配置
dws sheet cond-format list --node NODE_ID --sheet-id SHEET_ID
2. 创建/更新/删除条件格式规则
dws sheet cond-format create --node NODE_ID --sheet-id SHEET_ID --ranges '["A1:E100"]' \
--condition '{"numberCondition":{"operator":"greater","value1":"80"}}' \
--cell-style '{"backgroundColor":"#FFCDD2","fontColor":"#B71C1C","bold":true}'
3. 再次读取验证结果是否生效
dws sheet cond-format list --node NODE_ID --sheet-id SHEET_ID
```
#### 条件类型参考
条件类型(`--condition` JSON 的 key,每次只能选一种):
| 条件类型 | 说明 | 参数 |
|---------|------|------|
| `numberCondition` | 数值比较 | operator: equal/not-equal/greater/greater-equal/less/less-equal/between/not-between + value1 + value2 |
| `textCondition` | 文本匹配 | operator: contains/not-contains/starts-with/ends-with + value |
| `emptyCondition` | 空值判断 | operator: is-empty/is-not-empty |
| `errorCondition` | 错误值 | operator: error/no-error |
| `duplicateCondition` | 重复/唯一值 | operator: duplicate/unique |
| `formulaCondition` | 自定义公式 | formula: "=A1>100" |
| `rankCondition` | 排名 | value + isPercent + isBottom |
| `averageCondition` | 高于/低于平均值 | isAbove + andEqual |
| `stdevCondition` | 标准差 | value + isAbove + andEqual |
| `dataBarCondition` | 数据条 | minPoint + maxPoint(每个含 type + value) |
| `iconSetCondition` | 图标集 | iconSet(数组)+ showIconOnly |
| `colorScaleCondition` | 色阶 | criterias(数组,每项含 type + value + color) |
## 命令详细参考
### 获取条件格式规则
```
Usage:
dws sheet cond-format list [flags]
Example:
# 获取所有条件格式规则
dws sheet cond-format list --node <NODE_ID> --sheet-id <SHEET_ID>
# 获取单个规则的详情
dws sheet cond-format list --node <NODE_ID> --sheet-id <SHEET_ID> --rule-id <RULE_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--rule-id string 条件格式规则 ID (可选,不传则返回全部)
```
- **用途**:查看指定工作表中已有的条件格式规则,或获取单个规则的详情。
- **场景**:创建/更新/删除条件格式前后验证规则状态;获取 ruleId 供后续 update/delete 使用。
- **返回**:rules 数组,每条规则包含 id、type、ranges、条件参数、cellStyle/dataBarStyle 等。
### 创建条件格式规则
```
Usage:
dws sheet cond-format create [flags]
Example:
# 数值条件:大于 80 时标红加粗
dws sheet cond-format create --node <NODE_ID> --sheet-id <SHEET_ID> \
--ranges '["A1:A100"]' \
--condition '{"numberCondition":{"operator":"greater","value1":"80"}}' \
--cell-style '{"backgroundColor":"#FFCDD2","fontColor":"#B71C1C","bold":true}'
# 文本条件:包含"延期"时加删除线
dws sheet cond-format create --node <NODE_ID> --sheet-id <SHEET_ID> \
--ranges '["B1:B50"]' \
--condition '{"textCondition":{"operator":"contains","value":"延期"}}' \
--cell-style '{"backgroundColor":"#FFF3E0","strikethrough":true}'
# 数据条
dws sheet cond-format create --node <NODE_ID> --sheet-id <SHEET_ID> \
--ranges '["C1:C20"]' \
--condition '{"dataBarCondition":{"minPoint":{"type":"auto"},"maxPoint":{"type":"auto"}}}' \
--data-bar-style '{"fill":["#4CAF50","#F44336"],"isGradient":true}'
# 色阶(三色)
dws sheet cond-format create --node <NODE_ID> --sheet-id <SHEET_ID> \
--ranges '["D1:D50"]' \
--condition '{"colorScaleCondition":{"criterias":[{"type":"maxmin","color":"#F44336"},{"type":"percentile","value":"50","color":"#FFEB3B"},{"type":"maxmin","color":"#4CAF50"}]}}'
# 重复值高亮
dws sheet cond-format create --node <NODE_ID> --sheet-id <SHEET_ID> \
--ranges '["E1:E100"]' \
--condition '{"duplicateCondition":{"operator":"duplicate"}}' \
--cell-style '{"backgroundColor":"#FCE4EC"}'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--ranges string 应用范围 JSON 数组 (必填),如 '["A1:E10"]'
--condition string 条件类型及参数 JSON 对象 (必填)
--cell-style string 单元格样式 JSON 对象 (可选)
--data-bar-style string 数据条样式 JSON 对象 (可选,仅数据条类型)
```
- **用途**:在指定工作表中创建一条条件格式规则。
- **注意事项**:
- 创建后**必须**用 `cond-format list` 验证规则是否生效
- 中文"标红/高亮/染色"默认指 `backgroundColor`,"字体红"才是 `fontColor`
- 日期/空值公式必须防空:`=AND(E1<>"", E1<=TODAY())` 而非 `=E1<=TODAY()`
- 公式中用相对引用使公式随行变化
- 创建前建议先 `range read` 读 3-5 行数据确认列对应关系
- **条件类型**(`--condition` JSON 的 key,每次只能选一种):numberCondition / textCondition / emptyCondition / errorCondition / duplicateCondition / formulaCondition / rankCondition / averageCondition / stdevCondition / dataBarCondition / iconSetCondition / colorScaleCondition
### 更新条件格式规则
```
Usage:
dws sheet cond-format update [flags]
Example:
# 修改条件(改为大于 90)
dws sheet cond-format update --node <NODE_ID> --sheet-id <SHEET_ID> --rule-id <RULE_ID> \
--condition '{"numberCondition":{"operator":"greater","value1":"90"}}'
# 修改样式
dws sheet cond-format update --node <NODE_ID> --sheet-id <SHEET_ID> --rule-id <RULE_ID> \
--cell-style '{"backgroundColor":"#C8E6C9","fontColor":"#1B5E20"}'
# 修改应用范围
dws sheet cond-format update --node <NODE_ID> --sheet-id <SHEET_ID> --rule-id <RULE_ID> \
--ranges '["A1:F200"]'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--rule-id string 条件格式规则 ID (必填)
--ranges string 应用范围 JSON 数组 (可选)
--condition string 条件类型及参数 JSON 对象 (可选,传入后替换原有条件)
--cell-style string 单元格样式 JSON 对象 (可选)
--data-bar-style string 数据条样式 JSON 对象 (可选,仅数据条类型)
```
- **用途**:更新已有条件格式规则的部分或全部配置。
- **场景**:修改阈值、切换条件类型、调整样式、扩大应用范围。
- **注意**:未传入的字段保持原有值不变;`--ranges`/`--condition`/`--cell-style`/`--data-bar-style` 至少传入一个。
- **ruleId 获取**:通过 `cond-format list` 获取。
### 删除条件格式规则
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws sheet cond-format delete [flags]
Example:
# 删除条件格式规则(必须加 --yes 确认)
dws sheet cond-format delete --node <NODE_ID> --sheet-id <SHEET_ID> --rule-id <RULE_ID> --yes
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--rule-id string 条件格式规则 ID (必填)
```
- **用途**:删除指定条件格式规则。
- **幂等性**:规则已不存在时操作仍返回成功。
- **ruleId 获取**:通过 `cond-format list` 获取。
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `cond-format list` | `rules` 数组(含 `id`、`type`、`ranges`、条件参数、`cellStyle`/`dataBarStyle`) | 获取 ruleId 供 update / delete 使用;验证规则是否生效 |
| `cond-format create` | 新创建规则的 `id` | 用于后续 update / delete 的 --rule-id |
| `cond-format update` | 更新后的规则信息 | 确认更新结果 |
| `cond-format delete` | 操作结果 | 确认删除完成 |
| `list` | 工作表的 `sheetId` | cond-format list / create / update / delete 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- ★ **强制走条件格式的触发词**:用户说"标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变"时,强制走 `cond-format create/update/delete`,禁止用 `range set-style` 写静态背景色代替
- **创建后必须验证**:条件格式创建后必须调用 `cond-format list` 验证规则是否生效
- **范围要精确**:条件格式的应用范围必须精确覆盖用户指定的列/行,不要遗漏也不要过度扩大
- **`backgroundColor` vs `fontColor` 的中文语义**:用户中文语境下的"标红/高亮/染色/标记"指**单元格背景色**,用 `backgroundColor`;"文字红/字体红/把字变红"才用 `fontColor`。默认无说明时选 `backgroundColor`
- **日期/空值比较必须防空**:用户说"过期的标红"时,公式必须排除空单元格。正确公式:`=AND(E1<>"", E1<=TODAY())`;错误公式:`=E1<=TODAY()`(空值会被当作 0 判为过期)
- **公式引用方式**:自定义公式条件中的单元格引用需要根据实际场景选择相对/绝对引用(如 `=E1<=TODAY()` 使用相对引用使公式随行变化)
- **创建前必须确认列对应**:仅读表头不够——如果表头语义含糊,formula 里引用的列字母可能张冠李戴。建议先读 3-5 行数据样本(如 `range read --range "A1:Z5"`)确认列名对应的实际值和数据类型
- **辅助列+条件格式两步走**:用户明确要求"辅助列"时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),禁止直接用 `formulaCondition` 一步绕过
- **大数据量优势**:当数据量 > 1000 行时,条件格式是首选——它由服务端自身渲染,不需要逐行调用 `range set-style`,性能远优于静态样式写入
- **判断标准**:交付后 `cond-format list` 必须能返回该规则;否则视为违规
# 行列操作 (dimension operations)
## 使用场景
### 行列操作
用户说"插入行/插入列/在某行前插入/在某列前插入":
- 插入行或列 → `insert-dimension`
- 在末尾追加 → `append`(insert-dimension 不支持末尾追加)
用户说"删除行/删除列/删掉第几行/删掉某列/移除行/移除列":
- 删除行或列 → `delete-dimension`
- 仅清空内容但保留行/列 → `range clear`(默认清除值保留格式)
用户说"隐藏行/隐藏列/显示行/显示列/设置行高/设置列宽/调整行高/调整列宽/行列属性":
- 隐藏/显示行或列 → `update-dimension --hidden` / `--hidden=false`
- 设置行高/列宽 → `update-dimension --pixel-size`
- 同时修改尺寸与显隐 → `update-dimension --pixel-size --hidden`
用户说"移动行/移动列/调整行顺序/调整列顺序/行列拖拽/把第N行移到第M行":
- 移动行或列 → `move-dimension`
- 请勿用 `range read` + `range update` 读取再重写来模拟移动,`move-dimension` 是原子操作,能保留格式和合并状态
用户说"追加空行/追加空列/增加行数/增加列数/扩展表格/在末尾加空行":
- 追加空行/空列 → `add-dimension`
- 注意与 `append`(追加数据行)区分:`add-dimension` 追加的是空行/空列,`append` 追加的是带数据的行
- 请勿用 `range update` 写空数据来模拟追加,`add-dimension` 直接扩展表格维度
**结构预检**:插入、删除、移动行列前,必须先执行 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 查看 `mergedRanges`。合并区域跨过操作位置时,行列变更可能导致表头/分组标题断裂、空白或错位;需要先向用户说明影响,必要时取消合并后操作,再按原模式重新 `merge-cells`。
## 命令详细参考
### 在指定位置插入行或列
```
Usage:
dws sheet insert-dimension [flags]
Example:
# 在第 3 行之前插入 2 行
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --position "3" --length 2
# 在 A 列之前插入 1 列
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --position "A" --length 1
# 使用工作表前缀(忽略 --sheet-id)
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --position "Sheet1!3" --length 5
# 在 AB 列之前插入 3 列
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --position "AB" --length 3
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--dimension string 插入维度: ROWS 或 COLUMNS (必填)
--position string 插入位置,A1 表示法 (必填)。ROWS 时为行号如 "3";COLUMNS 时为列字母如 "A"
--length string 插入数量,正整数 (必填),最大 5000
```
在钉钉表格指定工作表的指定位置之前插入若干空行或空列。
`--dimension ROWS` 时,`--position` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--position` 为列字母。
支持在 `--position` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。
若需要在末尾追加行/列,请使用 `append` 命令。
### 删除指定位置的行或列
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws sheet delete-dimension [flags]
Example:
# 从第 3 行开始删除 2 行
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --position "3" --length 2
# 从 A 列开始删除 1 列
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --position "A" --length 1
# 使用工作表前缀(忽略 --sheet-id)
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --position "Sheet1!3" --length 5
# 从 AB 列开始删除 3 列
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --position "AB" --length 3
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--dimension string 删除维度: ROWS 或 COLUMNS (必填)
--position string 删除起始位置,A1 表示法 (必填)。ROWS 时为行号如 "3";COLUMNS 时为列字母如 "A"
--length string 删除数量,正整数 (必填),最大 5000
```
在钉钉表格指定工作表中,从指定位置起删除若干连续的行或列。
`--dimension ROWS` 时,`--position` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--position` 为列字母。
支持在 `--position` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。
删除后后续的行/列会向前移动填补空位;若需要仅清空内容但保留行/列占位,请使用 `range clear`。
### 更新指定范围行/列属性
```
Usage:
dws sheet update-dimension [flags]
Example:
# 隐藏第 3~4 行
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --start-index "3" --length 2 --hidden
# 显示 A~B 列
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --start-index "A" --length 2 --hidden=false
# 设置第 1~5 行行高为 40px
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --start-index "1" --length 5 --pixel-size 40
# 设置 C 列列宽为 200px 并隐藏
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --start-index "C" --length 1 --pixel-size 200 --hidden
# 使用工作表前缀(忽略 --sheet-id)
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --start-index "Sheet1!3" --length 2 --hidden
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--dimension string 更新维度: ROWS 或 COLUMNS (必填)
--start-index string 起始位置,A1 表示法 (必填)。ROWS 时为行号如 "3";COLUMNS 时为列字母如 "A"
--length string 更新数量,正整数 (必填),最大 5000
--hidden 是否隐藏 (true=隐藏, false=显示),与 --pixel-size 至少填其一
--pixel-size int 行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽,与 --hidden 至少填其一
```
批量更新钉钉表格指定工作表中连续多行/多列的属性,支持设置显隐状态(hidden)与行高/列宽(pixelSize)。
`--dimension ROWS` 时,`--start-index` 为 1-based 行号字符串;`--dimension COLUMNS` 时,`--start-index` 为列字母。
支持在 `--start-index` 中携带工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`。
`--hidden` 与 `--pixel-size` 至少必须提供一个。当同时提供时,将先应用尺寸再应用显隐,任一失败整体失败。
`--pixel-size` 单位为像素,`dimension=ROWS` 时表示行高、`dimension=COLUMNS` 时表示列宽。
### 移动行或列
```
Usage:
dws sheet move-dimension [flags]
Example:
# 将第 2 行移动到第 5 行的位置
dws sheet move-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --start-index "2" --end-index "2" --destination-index "5"
# 将第 2~4 行(共 3 行)移动到第 1 行的位置(最前面)
dws sheet move-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --start-index "2" --end-index "4" --destination-index "1"
# 将 B~C 列(共 2 列)移动到 D 列的位置
dws sheet move-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension COLUMNS --start-index "B" --end-index "C" --destination-index "D"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--dimension string 维度类型: ROWS 或 COLUMNS (必填)
--start-index string 源起始位置,A1 表示法 (必填)
--end-index string 源结束位置,A1 表示法 (必填)
--destination-index string 目标位置,A1 表示法 (必填)
```
startIndex、endIndex 和 destinationIndex 均使用 A1 表示法:`--dimension ROWS` 时为 1-based 行号(如 "2"),`--dimension COLUMNS` 时为列字母(如 "B")。
源行/列将移动到 destinationIndex 所指的位置。destinationIndex 不能落在源范围 [startIndex, endIndex] 内。
**合并单元格注意**:如果源范围或目标位置涉及合并单元格,操作会报错中断。移动前先通过 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 查询 `mergedRanges`,必要时先用 `unmerge-cells` 取消合并再移动,移动后再用 `merge-cells` 恢复需要保留的合并区域。
### 追加空行或空列
```
Usage:
dws sheet add-dimension [flags]
Example:
dws sheet add-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension ROWS --length 5
dws sheet add-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --dimension COLUMNS --length 3
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--dimension string 维度类型: ROWS 或 COLUMNS (必填)
--length int 追加数量,正整数,最多 5000 (必填)
```
在工作表末尾追加指定数量的空行或空列。
## 核心工作流
```bash
# ── 工作流 6: 插入行或列 ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> --format json
# 2. 在第 3 行之前插入 2 行
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --position "3" --length 2 --format json
# 3. 在 A 列之前插入 1 列
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension COLUMNS --position "A" --length 1 --format json
# 4. 使用工作表前缀指定位置
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --position "Sheet1!5" --length 3 --format json
```
```bash
# ── 工作流 6b: 删除行或列 ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> --format json
# 2. 从第 3 行开始删除 2 行
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --position "3" --length 2 --format json
# 3. 从 A 列开始删除 1 列
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension COLUMNS --position "A" --length 1 --format json
# 4. 使用工作表前缀指定位置
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --position "Sheet1!5" --length 3 --format json
```
```bash
# ── 工作流 6c: 更新行/列属性(显隐、行高/列宽) ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> --format json
# 2. 隐藏第 3~4 行
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --start-index "3" --length 2 --hidden --format json
# 3. 显示 A~B 列
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension COLUMNS --start-index "A" --length 2 --hidden=false --format json
# 4. 设置第 1~5 行行高为 40px
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension ROWS --start-index "1" --length 5 --pixel-size 40 --format json
# 5. 设置 C 列列宽为 200px 并隐藏
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension COLUMNS --start-index "C" --length 1 --pixel-size 200 --hidden --format json
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `insert-dimension` | `a1Notation` 新插入区域范围 | 确认插入位置和范围 |
| `delete-dimension` | `a1Notation` 被删除区域范围 | 确认删除位置和范围 |
| `update-dimension` | `a1Notation` 被更新区域范围、`hidden` 生效的显隐状态、`pixelSize` 生效的尺寸 | 确认更新结果 |
| `move-dimension` | `sheetId` 工作表 ID | 确认操作完成 |
| `add-dimension` | `sheetId` 工作表 ID | 确认操作完成 |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `sheet info` 的 `mergedRanges` 是行列结构操作的重要预检信息。插入列时尤其要检查多行表头合并区,原有合并区域通常不会自动扩展到新列,必要时需重新设置合并区域
- `insert-dimension` 在指定位置之前插入空行或空列,不写入数据;如需在末尾追加行/列,使用 `append`
- `insert-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS`
- `insert-dimension` 的 `--position` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`
- `insert-dimension` 的 `--length` 最大为 5000
- `delete-dimension` 从指定位置起删除若干连续的行或列,删除后后续行/列向前移动填补空位
- `delete-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS`
- `delete-dimension` 的 `--position` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`
- `delete-dimension` 的 `--length` 最大为 5000
- `delete-dimension` 若需仅清空内容但保留行/列占位,请使用 `range clear`(默认清除值保留格式,比手动构造空数组更简洁)
- `update-dimension` 批量更新连续行/列的显隐状态与行高/列宽
- `update-dimension` 的 `--dimension` 只接受 `ROWS` 或 `COLUMNS`
- `update-dimension` 的 `--start-index` 支持工作表前缀(如 `Sheet1!3`),此时忽略 `--sheet-id`
- `update-dimension` 的 `--length` 最大为 5000
- `update-dimension` 的 `--hidden` 与 `--pixel-size` 至少必须提供一个
- `update-dimension` 的 `--pixel-size` 单位为像素,`dimension=ROWS` 时表示行高、`dimension=COLUMNS` 时表示列宽
- `update-dimension` 当同时提供 `--hidden` 与 `--pixel-size` 时,将先应用尺寸再应用显隐,任一失败整体失败
- ★ `move-dimension` vs `range update`:需要移动行或列时,必须使用 `move-dimension` 命令,禁止用 `range update` 读取数据后手动重写来模拟移动效果。`move-dimension` 是原子操作,能保留单元格的格式、合并状态等属性
- `move-dimension` 的 `--start-index`、`--end-index` 和 `--destination-index` 均使用 A1 表示法(ROWS 时为 1-based 行号,COLUMNS 时为列字母)
- `move-dimension` 的 `--destination-index` 不能落在源范围 [startIndex, endIndex] 内
- `move-dimension` 的源范围 [startIndex, endIndex] 最大跨度为 5000
- `add-dimension` vs `range update`:需要在末尾追加空行/空列时,必须使用 `add-dimension` 命令,禁止用 `range update` 写空数据来模拟追加效果
- `add-dimension` 追加的是空行/空列,与 `append`(追加带数据的行)不同
- `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000
# 下拉列表 (dropdown)
## 使用场景
### 下拉列表
用户说"设置下拉列表/下拉选项/下拉菜单/添加下拉/配置下拉":
- 设置下拉列表 → `set-dropdown`
- 设置多选下拉 → `set-dropdown --multi-select`
用户说"查看下拉列表/获取下拉配置/下拉列表有哪些选项":
- 获取下拉列表配置 → `get-dropdown`
用户说"删除下拉列表/移除下拉/取消下拉/清除下拉":
- 删除下拉列表 → `delete-dropdown`
## 命令详细参考
### 设置下拉列表
```
Usage:
dws sheet set-dropdown [flags]
Example:
# 设置单选下拉列表
dws sheet set-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A100" \
--options '[{"value":"选项1"},{"value":"选项2"},{"value":"选项3"}]'
# 设置带颜色的多选下拉列表
dws sheet set-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "B2:B50" \
--options '[{"value":"高","color":"#ff0000"},{"value":"中","color":"#ffaa00"},{"value":"低","color":"#00ff00"}]' \
--multi-select
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 目标单元格范围,A1 表示法,如 A2:A100 (必填)
--options string 下拉选项 JSON 数组 (必填),如 '[{"value":"选项1","color":"#ff0000"}]'
--multi-select 是否允许多选(默认单选)
```
在指定单元格范围内设置下拉列表。设置后用户可从预定义选项中选择值。
- **用途**:为单元格配置下拉列表,支持自定义选项颜色和多选。
- **场景**:规范数据输入,如状态选择(完成/进行中/待处理)、优先级(高/中/低)等。
- **注意**:选项值不能包含英文逗号;如果目标范围已存在下拉列表,会被新配置覆盖。
### 获取下拉列表配置
```
Usage:
dws sheet get-dropdown [flags]
Example:
dws sheet get-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A100"
dws sheet get-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 查询范围,A1 表示法,如 A1:A100 (必填)
```
查询指定范围内的下拉列表配置信息,包括选项值、颜色和是否多选。
- **用途**:查看单元格已设置的下拉列表选项和配置。
- **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。
- **返回**:`dataValidations` 数组,相同选项的单元格聚合为一组,每组包含 `conditionValues`(选项值)、`ranges`(覆盖范围)、`options`(含 `enableMultiSelect` 和 `colorValueMap`)。范围内无下拉列表时 `hasDropdown` 为 false。
### 删除下拉列表
```
Usage:
dws sheet delete-dropdown [flags]
Example:
dws sheet delete-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A100"
dws sheet delete-dropdown --node <NODE_ID> --sheet-id <SHEET_ID> --range "B1:D10"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 要删除下拉列表的范围,A1 表示法 (必填)
```
删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。
- **用途**:移除不再需要的下拉列表约束。
- **注意**:已填写的单元格值不会被清除;目标范围不存在下拉列表时操作仍返回成功。
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `set-dropdown` | `range` 实际设置范围、`optionCount` 选项数量、`enableMultiSelect` 是否多选 | 确认下拉列表设置成功 |
| `get-dropdown` | `hasDropdown` 是否存在下拉、`dataValidations` 下拉配置列表(含 `conditionValues`、`ranges`、`options`) | 查看已有下拉配置 |
| `delete-dropdown` | `range` 实际删除范围 | 确认下拉列表删除完成 |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `set-dropdown` 在指定范围内设置下拉列表,`--options` 为 JSON 数组,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB` 格式)。选项值不能包含英文逗号。`--multi-select` 启用多选模式。如果目标范围已存在下拉列表,会被新配置覆盖
- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。无下拉列表时 `hasDropdown` 为 false
- `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功
# 导出 (export)
## 使用场景
### 导出
用户说"导出/下载xlsx/存为Excel/存成表格文件/把表格变成xlsx/导出表格/下载表格/导出为 excel":
- 导出表格 → `export`(单命令一站式,内部自动完成提交、轮询、可选下载)
- 仅需传 `--node`,可选 `--output` 指定本地文件/目录(不传则返回 downloadUrl)
- 需要落盘到本地 → `dws sheet export --node <NODE_ID> --output <path>`,命令自动下载 xlsx
- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性)
- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟)
## 命令详细参考
### 导出表格为 xlsx(异步任务一站式)
```
Usage:
dws sheet export [flags] # 一站式:提交 → 轮询 → 可选下载
Example:
# 仅导出,返回 downloadUrl(链接有时效性,请尽快下载)
dws sheet export --node <NODE_ID>
dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
# 导出并自动下载为本地文件
dws sheet export --node <NODE_ID> --output ./report.xlsx
# --output 为目录时,自动按下载链接中的文件名保存
dws sheet export --node <NODE_ID> --output ./
Flags:
--node string 表格文档 ID 或 URL (必填)
--output string 本地保存路径(可选,支持文件路径或目录)
```
将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。
**内部流程**:
1. 调 `submit_export_job` 获取 `jobId`
2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时
3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件
**内置轮询策略(CLI 内实现,无需关心)**:
- 第 1~5 次:每次间隔 2 秒
- 第 6~10 次:每次间隔 5 秒
- 第 11~20 次:每次间隔 10 秒
- 第 21~30 次:每次间隔 15 秒
- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误
**命令返回**:
- `--output` 未指定:进度日志 + 末尾输出 `jobId` 和 `downloadUrl`(链接有时效性,请尽快下载)
- `--output` 指定为文件路径:下载到该路径并输出 `导出完成: <path>`
- `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录下
**失败处理(命令内部已处理,Agent 仅需转述)**:
- MCP 返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试
- 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试
**限制**:仅支持钉钉在线电子表格(alxs)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。
## 核心工作流
```bash
# ── 工作流 12: 导出表格为 xlsx(单命令一站式)──
# 场景 A:仅获取下载链接(命令内部自动完成提交+轮询,最终返回 downloadUrl)
dws sheet export --node <NODE_ID> --format json
# 传入 URL 也可:
# dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
# 场景 B:导出并自动下载为本地文件
dws sheet export --node <NODE_ID> --output ./report.xlsx
# 场景 C:下载到目录,自动按链接推断文件名
dws sheet export --node <NODE_ID> --output ./
# 禁止在 Agent 侧实现任何轮询或重试,CLI 内部已按 2s/5s/10s/15s 渐进式退避自动完成(最多 30 次)。
# 若命令返回失败或超时,直接告知用户稍后再试,不要自动重调 dws sheet export。
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `export` | `downloadUrl`(未指定 --output)/ `导出完成: <path>`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 |
## 注意事项
- ★ `export` 仅支持钉钉在线电子表格(alxs)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal`
- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令
- `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可
- ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试
- `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载
- `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存
- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性)
# 筛选视图 (filter-view)
## 使用场景
### 筛选视图
用户说"筛选视图/查看筛选视图/有哪些筛选视图/筛选视图列表":
- 获取所有筛选视图 → `filter-view list`
用户说"筛选视图详情/查看某个筛选视图/筛选视图信息/筛选视图配置":
- 获取单个筛选视图详情 → `filter-view info`
用户说"创建筛选视图/新建筛选视图/添加筛选视图":
- 创建筛选视图 → `filter-view create`
用户说"更新筛选视图/修改筛选视图/改筛选视图名称/改筛选视图范围":
- 更新筛选视图属性 → `filter-view update`
用户说"删除筛选视图/移除筛选视图":
- 删除筛选视图 → `filter-view delete`
用户说"设置筛选条件/添加筛选条件/配置筛选视图条件/按值筛选/按条件筛选/按颜色筛选":
- 设置筛选视图列条件 → `filter-view update-criteria`
用户说"查看筛选条件/有哪些筛选条件/筛选视图设了什么条件/列出筛选条件":
- 列出所有列条件 → `filter-view list-criteria`
- 查看某一列的条件 → `filter-view get-criteria --column N`
用户说"清除筛选条件/移除筛选条件/取消筛选条件":
- 清除筛选视图列条件 → `filter-view delete-criteria`
- 注意与 `filter-view delete`(删除整个筛选视图)区分:`delete-criteria` 仅清除指定列的条件,不删除筛选视图本身
## 命令详细参考
### 获取所有筛选视图
```
Usage:
dws sheet filter-view list [flags]
Example:
dws sheet filter-view list --node <NODE_ID> --sheet-id <SHEET_ID>
dws sheet filter-view list --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --sheet-id "Sheet1"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
```
获取指定工作表的所有筛选视图列表,返回每个筛选视图的 ID、名称和范围信息。
- **用途**:查看当前工作表上已创建的所有筛选视图,获取视图 ID、名称和范围。
- **场景**:在对筛选视图进行 update / delete / update-criteria 等操作前,先用 list 获取可用的 filterViewId。
- **区分**:筛选视图(filter-view)是个人化的数据过滤方式,与全局筛选不同。每个用户可以创建自己的筛选视图,互不影响原始数据。如果没有筛选视图,返回空列表。
### 创建筛选视图
```
Usage:
dws sheet filter-view create [flags]
Example:
# 创建不带筛选条件的筛选视图
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> --name "我的视图" --range "A1:E10"
# 创建带按值筛选条件的筛选视图
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> --name "销售筛选" --range "A1:E10" \
--criteria '[{"column":0,"filterType":"values","visibleValues":["销售部"]}]'
# 创建带按条件筛选的筛选视图(大于等于 200000)
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> --name "高预算" --range "A1:C10" \
--criteria '[{"column":1,"filterType":"condition","conditions":[{"operator":"greater-equal","value":"200000"}]}]'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--name string 筛选视图名称 (必填)
--range string 筛选视图范围,A1 表示法,如 A1:E10 (必填)
--criteria string 筛选条件,JSON 数组 (可选)
```
在指定工作表中创建一个筛选视图。
- **用途**:为指定数据区域创建一个可命名的个人化筛选视图,可选同时设置筛选条件。
- **场景**:用户需要针对某个数据区域建立固定的筛选视角(如"高绩效员工""研发部数据"),方便反复查看。
- **区分**:与全局筛选不同,筛选视图是个人化的,不影响其他用户看到的数据。如果只需创建视图不设条件,后续可通过 `update-criteria` 单独设置;如果要一步到位,可通过 `--criteria` 在创建时直接设置。
`--criteria` 为 JSON 数组,每个元素包含 `column`(列偏移量,从 0 开始)和筛选条件字段。支持三种筛选类型:
- `values`:按值筛选,通过 `visibleValues` 指定允许显示的值列表
- `condition`:按条件筛选,通过 `conditions` 指定条件列表(最多 2 个),每个条件包含 `operator` 和 `value`。支持的操作符(kebab-case):`equal`、`not-equal`、`contains`、`not-contains`、`starts-with`、`not-starts-with`、`ends-with`、`not-ends-with`、`greater`、`greater-equal`、`less`、`less-equal`。多条件之间通过 `conditionOperator` 指定逻辑关系:`and`(且,默认)或 `or`(或)
- `color`:按颜色筛选,通过 `backgroundColor` 或 `fontColor` 指定颜色值(十六进制,如 `#FF0000`),二选一
### 更新筛选视图属性
```
Usage:
dws sheet filter-view update [flags]
Example:
# 更新筛选视图名称
dws sheet filter-view update --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --name "新名称"
# 更新筛选视图范围
dws sheet filter-view update --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --range "A1:F20"
# 更新筛选条件
dws sheet filter-view update --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--criteria '[{"column":1,"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}]'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--filter-view-id string 筛选视图 ID (必填)
--name string 筛选视图新名称
--range string 筛选视图新范围,A1 表示法
--criteria string 筛选条件,JSON 数组
```
更新筛选视图的名称、范围和/或筛选条件,`--name`、`--range`、`--criteria` 至少传入一个。
- **用途**:修改已有筛选视图的名称、数据范围或筛选条件。
- **场景**:数据区域扩展后需要扩大筛选视图范围,或重命名视图,或通过 `--criteria` 一次性批量更新多列筛选条件。
- **区分**:`update` 可同时修改名称、范围和条件,适合批量更新;`update-criteria` 只能设置单列条件,适合精确控制某一列的筛选逻辑。`--criteria` 指定列的条件会被替换,未指定的列保持不变。
`--criteria` 为 JSON 数组,格式与 `filter-view create` 的 `--criteria` 相同,支持的筛选类型和操作符参见「创建筛选视图」说明。
### 删除筛选视图
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws sheet filter-view delete [flags]
Example:
dws sheet filter-view delete --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--filter-view-id string 筛选视图 ID (必填)
```
删除指定的筛选视图。
- **用途**:永久删除一个不再需要的筛选视图及其所有筛选条件。
- **场景**:筛选视图已过时或不再需要时,清理无用的视图。
- **区分**:`delete` 删除整个筛选视图(包括所有列的条件),操作不可恢复;`delete-criteria` 只删除某一列的筛选条件,视图本身保留。此操作不影响全局筛选或其他筛选视图,也不影响原始数据。
### 更新筛选视图列条件
```
Usage:
dws sheet filter-view update-criteria [flags]
Example:
# 按值筛选:只显示"销售部"和"市场部"
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 0 --filter-criteria '{"filterType":"values","visibleValues":["销售部","市场部"]}'
# 按条件筛选:大于 100
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 2 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}'
# 按条件筛选:大于等于 200000
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 1 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"greater-equal","value":"200000"}]}'
# 按条件筛选:小于 100
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 1 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"less","value":"100"}]}'
# 多条件筛选:大于等于 60 且 小于等于 90
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 2 --filter-criteria '{"filterType":"condition","conditionOperator":"and","conditions":[{"operator":"greater-equal","value":"60"},{"operator":"less-equal","value":"90"}]}'
# 按颜色筛选:背景色为红色
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 1 --filter-criteria '{"filterType":"color","backgroundColor":"#FF0000"}'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--filter-view-id string 筛选视图 ID (必填)
--column int 列偏移量,从 0 开始 (必填)
--filter-criteria string 筛选条件,JSON 对象 (必填)
```
更新筛选视图中某一列的筛选条件。
- **用途**:为筛选视图的指定列创建或更新筛选条件,控制该列哪些数据行可见。
- **场景**:只显示某些特定值的行(如"只看研发部")→ `filterType: values`;按数值条件筛选(如"绩效 ≥ 85")→ `filterType: condition` + `operator: greater-equal`;按文本条件筛选(如"名称包含关键字")→ `filterType: condition` + `operator: contains`。
- **区分**:`update-criteria` 精确控制单列条件,适合逐列设置不同的筛选逻辑;`filter-view update --criteria` 可以批量更新多列条件;`delete-criteria` 是 `update-criteria` 的逆操作,删除指定列的条件。
`--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。
例如筛选视图范围为 `B1:E10`,则 `--column 0` 代表 B 列,`--column 1` 代表 C 列。
`--filter-criteria` 为 JSON 对象,支持三种筛选类型:
- `values`:按值筛选,通过 `visibleValues` 指定允许显示的值列表
- `condition`:按条件筛选,通过 `conditions` 指定条件列表(最多 2 个),每个条件包含 `operator` 和 `value`。支持的操作符:`equal`、`not-equal`、`contains`、`not-contains`、`starts-with`、`not-starts-with`、`ends-with`、`not-ends-with`、`greater`、`greater-equal`、`less`、`less-equal`。多条件之间通过 `conditionOperator` 指定逻辑关系:`and`(且,默认)或 `or`(或)
- `color`:按颜色筛选,通过 `backgroundColor` 或 `fontColor` 指定颜色值(十六进制,如 `#FF0000`),二选一
### 删除筛选视图列条件
```
Usage:
dws sheet filter-view delete-criteria [flags]
Example:
# 删除第 1 列(A 列)的筛选条件
dws sheet filter-view delete-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --column 0
# 删除第 3 列(C 列)的筛选条件
dws sheet filter-view delete-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --column 2
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--filter-view-id string 筛选视图 ID (必填)
--column int 列偏移量,从 0 开始 (必填)
```
清除筛选视图中指定列的筛选条件。
- **用途**:移除筛选视图中指定列的筛选条件,使该列不再参与过滤。
- **场景**:之前通过 `update-criteria` 设置了某列的筛选条件,现在需要取消该列的筛选以显示全部数据。
- **区分**:`delete-criteria` 只清除指定列的条件,筛选视图本身和其他列的条件保持不变;`delete` 会删除整个筛选视图。如果指定列没有设置筛选条件,调用此命令不会报错(幂等操作)。
### 获取单个筛选视图详情
```
Usage:
dws sheet filter-view info [flags]
Example:
# 查看指定筛选视图的详情
dws sheet filter-view info --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--filter-view-id string 筛选视图 ID (必填)
```
获取指定筛选视图的完整信息,包括 ID、名称、范围和筛选条件。
- **用途**:查看某个筛选视图的当前配置,包括已设置的所有筛选条件详情。
- **场景**:在修改或删除筛选视图前,先确认其当前状态;或在 `update-criteria` 后验证条件是否生效。
- **区分**:`info` 返回单个视图的完整信息(含 criteria);`list` 返回所有视图的列表概要。`info` 需要指定 `--filter-view-id`,ID 可通过 `list` 获取。
- **实现**:内部调用 `get_filter_views` 获取全部列表后按 ID 过滤。
### 列出筛选视图所有列条件
```
Usage:
dws sheet filter-view list-criteria [flags]
Example:
# 列出筛选视图的所有条件
dws sheet filter-view list-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--filter-view-id string 筛选视图 ID (必填)
```
列出指定筛选视图中已设置的所有列筛选条件。
- **用途**:查看某个筛选视图当前设置了哪些列的筛选条件,包括每列的条件类型和具体规则。
- **场景**:在管理筛选条件(修改/删除特定列条件)前,先了解当前视图有哪些条件;或排查筛选结果不符合预期时检查条件配置。
- **区分**:`list-criteria` 返回所有列的条件(按列偏移量为 key 的对象);`get-criteria` 只返回指定列的条件。如果没有设置任何条件,返回空对象 `{}`。
- **实现**:内部调用 `get_filter_views` 获取视图详情后提取 `criteria` 字段。
### 获取单列筛选条件
```
Usage:
dws sheet filter-view get-criteria [flags]
Example:
# 查看第 1 列(偏移量 0)的筛选条件
dws sheet filter-view get-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --column 0
# 查看第 3 列(偏移量 2)的筛选条件
dws sheet filter-view get-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> --column 2
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--filter-view-id string 筛选视图 ID (必填)
--column int 列偏移量,从 0 开始 (必填)
```
获取指定筛选视图中某一列的筛选条件详情。
- **用途**:查看某个筛选视图中指定列当前设置的筛选条件,包括条件类型、运算符和比较值。
- **场景**:在修改某列条件前,先查看其当前配置;或验证 `update-criteria` 后该列条件是否正确。
- **区分**:`get-criteria` 只返回指定列的条件;`list-criteria` 返回所有列的条件。`--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。
- **实现**:内部调用 `get_filter_views` 获取视图详情后按列偏移量过滤 `criteria` 中的对应条件。
## 核心工作流
```bash
# ── 工作流 11: 筛选视图管理 ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> -f json
# 2. 查看已有筛选视图
dws sheet filter-view list --node <NODE_ID> --sheet-id <SHEET_ID> -f json
# 3. 创建筛选视图(不带条件)
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> \
--name "我的筛选" --range "A1:E100" -f json
# 4. 为筛选视图设置列条件(按值筛选)
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 0 --filter-criteria '{"filterType":"values","visibleValues":["销售部","市场部"]}' -f json
# 5. 为筛选视图设置列条件(按条件筛选)
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 2 --filter-criteria '{"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}' -f json
# 6. 更新筛选视图名称和范围
dws sheet filter-view update --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--name "销售数据筛选" --range "A1:F200" -f json
# 7. 清除某列的筛选条件
dws sheet filter-view delete-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> \
--column 0 -f json
# 8. 删除筛选视图
dws sheet filter-view delete --node <NODE_ID> --sheet-id <SHEET_ID> --filter-view-id <FV_ID> -f json
```
```bash
# ── 工作流 11b: 创建带条件的筛选视图(一步完成) ──
# 创建筛选视图时直接指定筛选条件
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> \
--name "高销售额视图" --range "A1:E100" \
--criteria '[{"column":0,"filterType":"values","visibleValues":["销售部"]},{"column":2,"filterType":"condition","conditions":[{"operator":"greater","value":"50000"}]}]' \
-f json
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `filter-view list` | `filterViews` 筛选视图列表(含 `id`、`name`、`range`) | 获取 filterViewId 用于 info / update / delete / update-criteria / delete-criteria / list-criteria / get-criteria |
| `filter-view info` | `id`、`name`、`range`、`criteria` | 查看单个视图完整配置,确认条件是否生效 |
| `filter-view create` | `id` 筛选视图 ID、`name`、`range` | 用于后续 update / delete / update-criteria / delete-criteria 的 --filter-view-id |
| `filter-view update` | `id`、`name`、`range`、`criteria` | 确认更新结果 |
| `filter-view delete` | `id` 被删除的筛选视图 ID | 确认删除完成 |
| `filter-view update-criteria` | `id` 筛选视图 ID | 确认条件设置完成 |
| `filter-view delete-criteria` | `id` 筛选视图 ID | 确认条件清除完成 |
| `filter-view list-criteria` | 所有列条件(按列偏移量为 key 的对象) | 了解当前视图已设置哪些列的条件 |
| `filter-view get-criteria` | 指定列的条件详情(`filterType`、`conditions` 等) | 查看某列的具体筛选规则 |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- ★ **全局筛选(filter)与筛选视图(filter-view)的区别**:全局筛选影响所有协作者看到的数据展示,每个工作表最多一个;筛选视图是个人化的,互不影响。用户只说"筛选"时默认走 `filter` 系列
- `filter-view list` 获取指定工作表的所有筛选视图列表,返回的 `id` 可用于后续 info / update / delete / update-criteria / delete-criteria / list-criteria / get-criteria 的 `--filter-view-id`
- `filter-view info` 获取单个筛选视图的完整信息(含 criteria),内部复用 `get_filter_views` MCP 按 ID 过滤
- `filter-view list-criteria` 列出指定筛选视图已设置的所有列条件,返回按列偏移量为 key 的对象;无条件时返回空对象 `{}`
- `filter-view get-criteria` 获取指定列的条件详情,`--column` 为列偏移量(从 0 开始);该列无条件时返回错误提示
- `filter-view create` 创建筛选视图时 `--range` 应包含表头行。`--criteria` 可选,不传则创建后无筛选条件,后续可通过 `filter-view update-criteria` 设置
- `filter-view update` 的 `--name`、`--range`、`--criteria` 至少需要传入一个,未指定的字段保持不变
- `filter-view update` 的 `--criteria` 中指定列的条件会被替换,未指定的列保持不变
- `filter-view delete` 删除后该视图及其所有筛选条件将被永久移除,不可恢复
- `filter-view delete` 不影响全局筛选或其他筛选视图
- `filter-view update-criteria` 的 `--column` 为列偏移量(从 0 开始),相对于筛选视图范围首列。例如筛选视图范围为 `B1:E10`,则 `--column 0` 代表 B 列
- `filter-view update-criteria` 设置条件后立即在该筛选视图中生效,仅影响当前视图,不影响全局筛选或其他筛选视图
- `filter-view update-criteria` 的 `--filter-criteria` 中 `conditions` 最多 2 个条件,多条件之间通过 `conditionOperator` 指定逻辑关系(`and` 或 `or`)
- `filter-view delete-criteria` 仅清除指定列的条件,不会删除整个筛选视图。如需删除整个筛选视图,请使用 `filter-view delete`
- `filter-view delete-criteria` 如果指定列没有设置筛选条件,调用不会报错
- 筛选视图相关操作需要"可阅读"权限(list / info / list-criteria / get-criteria)或"可编辑"权限(create / update / delete / update-criteria / delete-criteria),不支持跨组织操作
# 全局筛选 (filter)
## 使用场景
### 筛选视图
用户说"筛选/过滤/只看某些值/只显示满足条件的行/筛选数据/创建筛选/删除筛选/设置筛选条件/清除筛选/排序":
- 查看当前筛选 → `filter get`
- 创建筛选 → `filter create`
- 删除筛选 → `filter delete`
- 批量设置多列条件 → `filter update`
- 清除某一列条件 → `filter clear-criteria`
- 按列排序 → `filter sort`
- **区分全局筛选与筛选视图**:如果用户说"筛选视图"则走 `filter-view` 系列;如果只说"筛选/过滤/只看"则默认走全局 `filter` 系列
- **禁止替代方案**:当用户要求"筛选/只看/仅保留某些行"时,必须通过 `filter create` / `filter update` 创建真实的筛选器。禁止用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替——这些做法会让原数据丢失或不可恢复
## 命令详细参考
### 获取筛选信息
```
Usage:
dws sheet filter get [flags]
Example:
dws sheet filter get --node <NODE_ID> --sheet-id <SHEET_ID>
dws sheet filter get --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --sheet-id "Sheet1"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
```
获取指定工作表的全局筛选信息,返回筛选范围和各列的筛选条件详情。
- **用途**:查看当前工作表上是否存在全局筛选及其配置。
- **场景**:在修改或删除筛选前,先读取当前筛选配置;创建筛选前先确认是否已存在(每个工作表只能有一个筛选)。
- **区分**:全局筛选(filter)影响所有协作者看到的数据展示;筛选视图(filter-view)是个人化的。
- **返回**:`range`(筛选范围,A1 表示法)和 `columnFilterCriteria`(各列条件,key 为列偏移量)。如果未设置筛选,返回筛选信息为空。
### 创建筛选
```
Usage:
dws sheet filter create [flags]
Example:
# 创建筛选框架(不设条件)
dws sheet filter create --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:E100"
# 创建筛选并同时设置条件(按值筛选)
dws sheet filter create --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:E100" --criteria '[{"column":1,"filterType":"values","visibleValues":["北京","上海"]}]'
# 创建筛选并设置条件筛选
dws sheet filter create --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:E100" --criteria '[{"column":2,"filterType":"condition","conditions":[{"operator":"greater","value":"100"}]}]'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 筛选范围,A1 表示法,须包含表头行 (必填)
--criteria string 筛选条件 JSON 数组 (可选)
```
在工作表中创建全局筛选。
- **用途**:为工作表建立筛选器,使数据可按条件过滤展示。
- **约束**:每个工作表只能有一个全局筛选,已存在时会报错。应先 `filter get` 确认不存在后再创建。
- **range 规范**:必须包含表头行(如 `A1:E100`),不能只包含数据行。
- **criteria 格式**:JSON 数组,每个元素含 `column`(列偏移量,从 0 开始)和筛选条件字段。不传则仅创建空筛选框架,后续可通过 `filter update` 设置条件。
### 删除筛选
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws sheet filter delete [flags]
Example:
dws sheet filter delete --node <NODE_ID> --sheet-id <SHEET_ID> --yes
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
```
删除工作表的全局筛选。
- **用途**:移除筛选器,所有被隐藏的行将重新显示。
- **不可逆**:删除后所有筛选条件丢失,需重新创建。
- **前置**:工作表没有筛选时调用会报错,应先 `filter get` 确认存在。
### 批量更新筛选条件
```
Usage:
dws sheet filter update [flags]
Example:
# 同时设置多列的筛选条件
dws sheet filter update --node <NODE_ID> --sheet-id <SHEET_ID> --criteria '[{"column":0,"filterType":"values","visibleValues":["已完成","进行中"]},{"column":2,"filterType":"condition","conditions":[{"operator":"greater","value":"50"}]}]'
# 按颜色筛选
dws sheet filter update --node <NODE_ID> --sheet-id <SHEET_ID> --criteria '[{"column":1,"filterType":"color","backgroundColor":"#FF0000"}]'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--criteria string 筛选条件 JSON 数组 (必填)
```
批量更新筛选条件,可同时设置多列的筛选条件。
- **用途**:一次性设置或替换多列的筛选条件。
- **前置**:工作表必须已创建筛选(通过 `filter create`)。
- **覆盖式**:指定列的条件会被替换,未指定的列保持不变。如只想修改某一列,建议先 `filter get` 读取现有配置。
- **criteria 格式**:JSON 数组,支持三种 `filterType`:
- `values`:按值筛选,指定 `visibleValues` 数组
- `condition`:按条件筛选,指定 `conditions` 数组(最多 2 个)和可选的 `conditionOperator`(`and`/`or`)
- `color`:按颜色筛选,指定 `backgroundColor` 或 `fontColor`(二选一)
### 清除单列筛选条件
```
Usage:
dws sheet filter clear-criteria [flags]
Example:
# 清除第 2 列(B 列)的筛选条件
dws sheet filter clear-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --column 1
# 清除第 1 列(A 列)的筛选条件
dws sheet filter clear-criteria --node <NODE_ID> --sheet-id <SHEET_ID> --column 0
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--column number 列偏移量,从 0 开始 (必填)
```
清除筛选中某一列的筛选条件。
- **用途**:移除某列的筛选条件,该列不再参与筛选计算。
- **区分**:仅清除指定列的条件,不删除整个筛选。如需删除整个筛选,使用 `filter delete`。
- **幂等**:指定列没有设置筛选条件时调用不会报错。
### 筛选排序
```
Usage:
dws sheet filter sort [flags]
Example:
# 按第 1 列(A 列)升序排序
dws sheet filter sort --node <NODE_ID> --sheet-id <SHEET_ID> --column 0 --ascending
# 按第 3 列(C 列)降序排序
dws sheet filter sort --node <NODE_ID> --sheet-id <SHEET_ID> --column 2 --ascending=false
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--column number 排序列偏移量,从 0 开始 (必填)
--ascending 是否升序,默认 true (可选)
```
对筛选范围内的数据按指定列排序。
- **用途**:对数据行按某一列的值进行升序或降序排列。
- **前置**:工作表必须已创建筛选(通过 `filter create`)。
- **注意**:排序会实际改变工作表中数据行的物理顺序,不可撤销。
- **column**:列偏移量从 0 开始,相对于筛选范围首列。
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `filter get` | `range`(筛选范围)、`columnFilterCriteria`(各列条件) | 查看当前筛选配置,确认筛选是否存在 |
| `filter create` | 筛选创建成功的确认 | 确认筛选已建立,后续可通过 `filter update` 设置条件 |
| `filter delete` | 删除成功的确认 | 确认筛选已删除 |
| `filter update` | 更新成功的确认 | 确认条件已设置 |
| `filter clear-criteria` | 清除成功的确认 | 确认指定列的条件已清除 |
| `filter sort` | 排序成功的确认 | 确认排序已完成 |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- ★ **全局筛选(filter)与筛选视图(filter-view)的区别**:全局筛选影响所有协作者看到的数据展示,每个工作表最多一个;筛选视图是个人化的,互不影响。用户只说"筛选"时默认走 `filter` 系列
- `filter get` 获取工作表的全局筛选信息,返回 `range`(筛选范围)和 `columnFilterCriteria`(各列条件)。无筛选时返回空
- `filter create` 创建全局筛选时 `--range` 必须包含表头行(如 `A1:E100`),不能只包含数据行。每个工作表只能有一个筛选,已存在时报错
- `filter create` 的 `--criteria` 可选,不传则仅创建空筛选框架,后续通过 `filter update` 设置条件
- `filter delete` 删除后所有筛选条件丢失且所有被隐藏行重新显示,不可恢复
- `filter delete` 工作表没有筛选时调用会报错,应先 `filter get` 确认存在
- `filter update` 是覆盖式:指定列的条件会被替换,未指定的列保持不变。如只想修改某一列,建议先 `filter get` 读取现有配置再 patch
- `filter update` 前置:工作表必须已创建筛选
- `filter clear-criteria` 仅清除指定列的条件,不删除整个筛选。指定列无条件时不报错(幂等)
- `filter sort` 会实际改变数据行的物理顺序,不可撤销。前置:工作表必须已创建筛选
- ★ **筛选操作规范**(参照飞书 core-operations):
- 当用户要求"筛选/只看/仅保留 X"时,**必须**通过 `filter create` / `filter update` 创建真实的筛选器。**禁止**用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替
- 创建/更新筛选后**必须** `filter get` 回读验证配置正确
- 更新已有筛选前先 `filter get` 读取当前配置,确认目标存在且了解现有条件后再操作
- 筛选条件的列索引(`column`)必须与实际数据列精确对应,不要凭猜测填写
- 筛选不支持正则表达式,传入正则会当成普通文本处理
# 媒体上传与图片 (media & image)
## 使用场景
### 媒体上传
用户说"上传附件/传文件到表格/上传文件到表格/上传到表格":
- 上传附件 → `media-upload`(需表格 ID 或 URL + 本地文件路径)
- 用户指定了上传后的名称 → `media-upload --name "自定义名称"`
- `media-upload` 的 `--name` 参数用于指定附件在表格中显示的名称(不改变本地文件名);不传时默认使用本地文件名
用户说"写入图片/插入图片/加图片/放图片到单元格/嵌入图片到表格":
- 写入图片 → `write-image`(需表格 ID + 工作表 ID + 单元格范围 + 本地图片路径)
- 禁止使用 `range update` 写入图片,因为 `update_range` 的 MCP 工具不支持图片类型参数,调用必定失败。必须使用 `write-image` 命令
- 用户指定了图片尺寸 → `write-image --width N --height M`
### 浮动图片
用户说"浮动图片/悬浮图片/在表格上放一张图/加个浮动的图":
- 创建浮动图片 → 先 `media-upload` 上传图片获取 `resourceUrl`,再 `create-float-image`
- 浮动图片悬浮于单元格之上,不占用单元格内容,与 `write-image`(写入单元格内部的图片)不同
用户说"查看浮动图片/有哪些浮动图片/浮动图片列表":
- 列出所有浮动图片 → `list-float-images`
- 查看某个浮动图片详情 → `get-float-image`
用户说"移动浮动图片/调整浮动图片大小/修改浮动图片/更新浮动图片":
- 更新浮动图片属性 → `update-float-image`(可更新锚点位置、尺寸、偏移量、图片资源路径)
用户说"删除浮动图片/移除浮动图片":
- 删除浮动图片 → `delete-float-image`
关键区分:`write-image`(单元格内嵌图片,占据单元格内容)vs `create-float-image`(浮动图片,悬浮于单元格之上,不占内容)
## 命令详细参考
### 上传附件到表格
```
Usage:
dws sheet media-upload [flags]
Example:
dws sheet media-upload --node <NODE_ID> --file ./report.pdf
dws sheet media-upload --node <NODE_ID> --file ./data.bin --name "数据文件.dat" --mime-type application/octet-stream
Flags:
--node string 目标表格文档的标识,支持传入 URL 或 ID (必填)
--file string 本地文件路径 (必填)
--name string 附件显示名称 (默认使用文件名)
--mime-type string 文件 MIME 类型 (默认根据扩展名推断)
```
### 上传图片并写入表格单元格
```
Usage:
dws sheet write-image [flags]
Example:
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range A1:A1 --file ./chart.png
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --file ./logo.png --width 200 --height 100
Flags:
--node string 目标表格文档的标识,支持传入 URL 或 ID (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 目标单元格区域地址,如 A1:A1 (必填)
--file string 本地图片文件路径 (必填)
--name string 图片显示名称 (默认使用文件名)
--mime-type string 文件 MIME 类型 (默认根据扩展名推断)
--width int 图片显示宽度 (可选)
--height int 图片显示高度 (可选)
```
### 创建浮动图片
```
Usage:
dws sheet create-float-image [flags]
Example:
# 先上传图片获取 resourceUrl
dws sheet media-upload --node <NODE_ID> --file ./chart.png
# 输出: resourceUrl: /core/api/resources/img/xxxx...
# 再创建浮动图片
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
--src "/core/api/resources/img/xxxx..." --range A1 --width 400 --height 300
# 带偏移量
dws sheet create-float-image --node <NODE_ID> --sheet-id <SHEET_ID> \
--src "/core/api/resources/img/xxxx..." --range B2 --width 200 --height 150 --offset-x 10 --offset-y 20
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--src string 图片资源路径,通过 media-upload 获取的 resourceUrl (必填)
--range string 锚点单元格,A1 表示法,如 A1、B3 (必填)
--width int 图片宽度,像素,正整数 (必填)
--height int 图片高度,像素,正整数 (必填)
--offset-x int 水平偏移量,像素 (默认 0)
--offset-y int 垂直偏移量,像素 (默认 0)
```
浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。
- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL
- `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
- `--width` / `--height` 为必填,单位像素,必须为正整数
- `--offset-x` / `--offset-y` 表示相对锚点单元格左上角的偏移量(像素),默认 0,不能为负数
### 获取浮动图片详情
```
Usage:
dws sheet get-float-image [flags]
Example:
dws sheet get-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--float-image-id string 浮动图片 ID (必填)
```
获取单个浮动图片的详细信息,包括 ID、图片资源路径、锚点位置、尺寸和偏移量。
`--float-image-id` 可通过 `list-float-images` 获取。
### 列出工作表所有浮动图片
```
Usage:
dws sheet list-float-images [flags]
Example:
dws sheet list-float-images --node <NODE_ID> --sheet-id <SHEET_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
```
列出指定工作表中所有浮动图片,返回 `floatImages` 数组和 `totalCount`。
### 更新浮动图片属性
```
Usage:
dws sheet update-float-image [flags]
Example:
# 移动浮动图片到新位置
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> --range C5
# 调整尺寸
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> --width 600 --height 400
# 替换图片(需先 media-upload 新图片获取 resourceUrl)
dws sheet update-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID> \
--src "/core/api/resources/img/xxxx..."
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--float-image-id string 浮动图片 ID (必填)
--src string 新的图片资源路径,通过 media-upload 获取的 resourceUrl
--range string 新的锚点单元格,A1 表示法
--width int 新的图片宽度,像素
--height int 新的图片高度,像素
--offset-x int 新的水平偏移量,像素
--offset-y int 新的垂直偏移量,像素
```
更新浮动图片的属性,`--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个。
`--float-image-id` 可通过 `list-float-images` 获取。
### 删除浮动图片
```
Usage:
dws sheet delete-float-image [flags]
Example:
dws sheet delete-float-image --node <NODE_ID> --sheet-id <SHEET_ID> --float-image-id <FI_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--float-image-id string 浮动图片 ID (必填)
```
删除指定的浮动图片,操作不可恢复。`--float-image-id` 可通过 `list-float-images` 获取。
## 核心工作流
```bash
# ── 工作流 9: 上传附件到表格 ──
# 1. 基本用法: 上传本地文件到表格
dws sheet media-upload --node <NODE_ID> --file ./report.pdf -f json
# 2. 自定义附件显示名称 (--name 指定上传后在表格中显示的名称)
dws sheet media-upload --node <NODE_ID> --file ./data.csv --name "销售数据.csv" -f json
# 3. 指定 MIME 类型 (文件扩展名无法推断时)
dws sheet media-upload --node <NODE_ID> --file ./data.bin --name "导出数据.dat" --mime-type application/octet-stream -f json
# 4. 完整流程: 创建表格 → 上传附件
dws sheet create --name "项目资料" -f json
# 提取 nodeId 后:
dws sheet media-upload --node <NODE_ID> --file ./design.pdf -f json
dws sheet media-upload --node <NODE_ID> --file ./timeline.xlsx --name "项目时间线.xlsx" -f json
# ── 工作流 10: 写入图片到表格单元格 ──
# 1. 基本用法: 写入图片到指定单元格
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range A1:A1 --file ./chart.png -f json
# 2. 指定显示尺寸
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range B2:B2 --file ./logo.png --width 200 --height 100 -f json
# 3. 自定义图片名称
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> --range C3:C3 --file ./photo.jpg --name "产品图.jpg" -f json
# 4. 完整流程: 创建表格 → 写表头 → 写入图片
dws sheet create --name "产品目录" -f json
# 提取 nodeId 后:
dws sheet range update --node <NODE_ID> --sheet-id Sheet1 --range "A1:B1" --values '[["产品名称","产品图片"]]' -f json
dws sheet range update --node <NODE_ID> --sheet-id Sheet1 --range "A2:A2" --values '[["MacBook Pro"]]' -f json
dws sheet write-image --node <NODE_ID> --sheet-id Sheet1 --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `media-upload` | `resourceId`、`resourceUrl` | 附件已上传到表格;`resourceUrl` 可用于 `create-float-image` 的 `--src` |
| `write-image` | `resourceId` | 图片已写入指定单元格 |
| `create-float-image` | `floatImage`(含 `id`、`src`、`range`、`width`、`height`、`offsetX`、`offsetY`) | `id` 用于后续 get / update / delete 的 `--float-image-id` |
| `get-float-image` | `floatImage`(完整信息) | 查看单个浮动图片详情 |
| `list-float-images` | `floatImages` 数组、`totalCount` | 获取所有浮动图片的 `id`,用于后续操作 |
| `update-float-image` | `floatImage`(更新后的完整信息) | 确认更新结果 |
| `delete-float-image` | `message` | 确认删除完成 |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作
- `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作
- ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 底层调用的 `update_range` MCP 工具不支持图片类型参数,调用会失败
- `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格
- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL
- `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`)
- `create-float-image` 的 `--width` / `--height` 为必填,单位像素,必须为正整数;`--offset-x` / `--offset-y` 可选,默认 0,不能为负数
- `write-image`(单元格内嵌图片)vs `create-float-image`(浮动图片):`write-image` 将图片写入单元格内部,占据单元格内容;`create-float-image` 创建悬浮于单元格之上的浮动图片,不占用单元格内容,可自由调整位置和大小
- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;`--src` 必须来自 `media-upload` 的 `resourceUrl`
- `update-float-image` 的 `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个
- `list-float-images` 返回 `floatImages` 数组和 `totalCount`,每个元素包含 `id`(用于后续 get / update / delete)
- `delete-float-image` 操作不可恢复,删除后图片将从工作表中移除
# 区域操作
## 使用场景
用户说"清空/清除区域/擦除内容/清除格式":
- 清除区域 → `range clear`
- 仅清除值 → `range clear --type content`(默认)
- 仅清除格式 → `range clear --type format`
- 全部清除 → `range clear --type all`
- 请勿用 `range update` 写入空字符串来模拟清空,`range clear` 更简洁且支持按类型清除
用户说"排序/给数据排序/按某列排序/升序/降序":
- 区域排序 → `range sort`
- **排序前必须先 `range read` 前 3-5 行**:读取排序范围的前几行(如范围是 A1:D100 则读 A1:D5),对比首行与后续行的模式来判断是否有表头:
- 首行全文本 + 后续行含数字/日期 → 有表头,加 `--has-header`
- 首行与后续行模式一致(都是数字或都是文本) → 无表头,不加
- 首行值语义像列标题(如"姓名""金额""日期")且与后续行明显不同 → 有表头
禁止不读就排——表头误排入数据是不可撤销的破坏性操作
- 请勿用 `range read` 读取数据后客户端排序再 `range update` 写回,`range sort` 是服务端原子操作
用户说"自动填充/填充序列/向下填充/拖拽填充/序列递增":
- 自动填充 → `range fill`
- 请勿用 `range read` 读取源数据后手动计算规律再 `range update` 写入,`range fill` 支持服务端智能填充
用户说"复制区域/把这块数据复制到/复制到另一个工作表":
- 复制区域 → `range copy-to`
- 跨工作表 → `range copy-to --target-sheet-id Sheet2` 或 `--target-range "Sheet2!A1"`
- 请勿用 `range read` + `range update` 读取再写入来模拟复制,`range copy-to` 是原子操作,保留公式引用调整
用户说"移动区域/把数据移到/剪切粘贴/移到另一个工作表":
- 移动区域 → `range move-to`
- 跨工作表 → `range move-to --target-sheet-id Sheet2` 或 `--target-range "Sheet2!A1"`
- 请勿用 `range read` + `range update` + `range clear` 读取-写入-清空来模拟移动,`range move-to` 是原子操作
## 命令详细参考
### 清除区域
```
Usage:
dws sheet range clear [flags]
Example:
dws sheet range clear --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3"
dws sheet range clear --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3" --type format
dws sheet range clear --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3" --type all
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 清除范围,A1 表示法 (必填)
--type string 清除类型: content(仅值,默认) / format(仅格式) / all(全部)
```
### 区域排序
```
Usage:
dws sheet range sort [flags]
Example:
dws sheet range sort --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" \
--sort-keys '[{"column":"A","ascending":true}]'
dws sheet range sort --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" \
--sort-keys '[{"column":"A","ascending":true},{"column":"C","ascending":false}]' --has-header
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 排序范围,A1 表示法 (必填)
--sort-keys string 排序规则 JSON 数组 (必填)
--has-header 首行是否为表头(不参与排序)
```
`--sort-keys` 格式:`[{"column":"A","ascending":true}]`,`column` 使用字母列名(如 "A"、"B"、"AA")。多级排序按数组顺序优先级递减。
### 区域自动填充
```
Usage:
dws sheet range fill [flags]
Example:
dws sheet range fill --node <NODE_ID> --sheet-id <SHEET_ID> \
--source-range "A1:A5" --target-range "A6:A20"
dws sheet range fill --node <NODE_ID> --sheet-id <SHEET_ID> \
--source-range "A1:A5" --target-range "A6:A20" --fill-type copy
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--source-range string 源数据范围,A1 表示法 (必填)
--target-range string 目标填充范围,A1 表示法 (必填)
--fill-type string 填充类型: series(序列,默认) / copy(复制) / onlystyle(仅格式) / withoutstyle(仅值)
```
目标范围须与源范围在行或列维度对齐(不支持对角填充)。
### 复制区域
```
Usage:
dws sheet range copy-to [flags]
Example:
dws sheet range copy-to --node <NODE_ID> --sheet-id <SHEET_ID> \
--source-range "A1:C5" --target-range "D1"
dws sheet range copy-to --node <NODE_ID> --sheet-id <SHEET_ID> \
--source-range "A1:C5" --target-range "A1" --target-sheet-id "Sheet2"
dws sheet range copy-to --node <NODE_ID> --sheet-id <SHEET_ID> \
--source-range "A1:C5" --target-range "D1" --paste-type values
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 源工作表 ID 或名称 (必填)
--source-range string 源范围,A1 表示法 (必填)
--target-range string 目标位置,A1 表示法 (必填)
--target-sheet-id string 目标工作表 ID 或名称(可选,不传则复制到同一工作表)
--paste-type string 粘贴类型: values(仅值) / formulas(仅公式) / formats(仅格式) / all(全部,默认)
```
支持跨工作表复制,两种方式指定目标工作表:
- `--target-sheet-id "Sheet2"` 显式指定
- `--target-range "Sheet2!A1"` 在目标范围中携带工作表前缀
源和目标范围不能重叠(同表时)。
### 移动区域
```
Usage:
dws sheet range move-to [flags]
Example:
dws sheet range move-to --node <NODE_ID> --sheet-id <SHEET_ID> \
--source-range "A1:C5" --target-range "D1"
dws sheet range move-to --node <NODE_ID> --sheet-id <SHEET_ID> \
--source-range "A1:C5" --target-range "A1" --target-sheet-id "Sheet2"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 源工作表 ID 或名称 (必填)
--source-range string 源范围,A1 表示法 (必填)
--target-range string 目标位置,A1 表示法 (必填)
--target-sheet-id string 目标工作表 ID 或名称(可选,不传则移动到同一工作表)
```
支持跨工作表移动,两种方式指定目标工作表:
- `--target-sheet-id "Sheet2"` 显式指定
- `--target-range "Sheet2!A1"` 在目标范围中携带工作表前缀
源和目标范围不能重叠(同表时)。移动后源区域将被清空。
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list` | 工作表的 `sheetId` | range clear / range sort / range fill / range copy-to / range move-to 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
- ★ **清空区域用 `range clear` 不用 `range update`**:`range clear` 支持按类型(值/格式/全部)清除,比手动构造全空数组更简洁可靠
- ★ **复制区域用 `range copy-to` 不用 `range read` + `range update`**:原子操作,保留公式引用自动调整,支持跨工作表
- ★ **移动区域用 `range move-to` 不用 `range read` + `range update` + `range clear`**:原子操作,源区域自动清空,支持跨工作表
- ★ **排序用 `range sort` 不用 `range read` + 客户端排序 + `range update`**:服务端原子操作,支持多级排序
- ★ **排序前必须 `range read` 前几行判断表头**:读取排序范围前 3-5 行,对比首行与后续行的数据模式(类型、语义)来判断是否有表头。禁止不读就排,表头被排入数据不可撤销
- ★ **填充用 `range fill` 不用 `range read` + 手动计算 + `range update`**:服务端智能填充,支持序列递增、公式扩展等
# 数据读取
## 使用场景
用户说"读数据/看表格内容":
- 快速查看纯值数据、批量处理、大表分批读 → `csv-get`(token 消耗低,防爆保护)
- 需要结构化信息(值+样式+数据验证+富文本+单元格级超链接)、查看公式或原始值 → `range read`
- 需要查看合并单元格 / 表头合并结构 → `sheet info`,读取返回的 `mergedRanges`;不要在 `csv-get` 或 `range read` 里找合并信息
## 命令选择
| 读取目的 | 推荐命令 | 说明 |
|---------|---------|------|
| 快速查看纯值、数据分析、大表分批读取 | `csv-get` | CSV 格式,token 消耗约为 JSON 的 1/3,内置 maxChars 防爆 |
| 查看数据验证配置(下拉/复选框) | `range read` | 返回 per-cell 结构,含 dataValidation |
| 查看单元格样式(背景色/字体/对齐等) | `range read` | 返回 per-cell 结构,含 cellStyles(仅显式设置的样式) |
| 查看单元格级超链接 | `range read` | 返回 per-cell 结构,含 hyperlink;富文本片段链接仍在 richText 内 |
| 查看公式文本 | `range read --value-render-option formula` | value 返回公式 |
| 获取原始值(数字/布尔而非格式化字符串) | `range read --value-render-option raw_value` | value 返回原始类型 |
| 查看合并单元格范围 | `sheet info` | 返回 `mergedRanges`,这是工作表结构信息,不属于单元格值读取 |
## 命令详细参考
### 以 CSV 格式读取工作表数据(推荐)
```
Usage:
dws sheet csv-get [flags]
Example:
dws sheet csv-get --node <NODE_ID>
dws sheet csv-get --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10"
dws sheet csv-get --node <NODE_ID> --range "A1:Z500" --value-render-option raw_value
dws sheet csv-get --node <NODE_ID> --range "A1:D10" --max-chars 50000
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (不传则默认第一个工作表)
--range string 读取范围,A1 表示法 (不传则读取全部非空数据)
--value-render-option string 取值模式: formatted_value(默认) | raw_value | formula
--max-chars int CSV 最大字符数 (默认 200000,超出截断)
```
**返回字段说明**:
- `csv` — CSV 文本,每逻辑行前加 `[row=N]` 前缀标注真实表格行号。行号一律从此前缀读取,禁止手算
- `colIndices` — 列字母映射数组(如 `["A","B","C"]`)。定位列字母用 `colIndices[j]`,禁止手数逗号
- `rowIndices` — 行号映射数组(如 `[1,2,3]`)
- `hasMore` — 是否因 maxChars 截断。为 true 时需要调整 `--range` 继续分页读取
`csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。
**取值模式说明**:
| 模式 | 返回内容 | 适用场景 |
|------|---------|---------|
| `formatted_value` | 格式化展示值(如 ¥1,000.00、2025-06-01) | 只看数据 |
| `raw_value` | 原始值(如 1000、45808) | 数据处理、计算 |
| `formula` | 公式文本(如 =SUM(A1:A10)),无公式时回退原始值 | 查看/复制公式 |
**大表分批读取**:当 `hasMore=true` 或数据量很大时,按行窗口分批:
- 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界
- 分批读取:`--range "A1:J500"`、`--range "A501:J1000"` ……
- 单次建议 ≤5000 单元格
### 读取工作表数据(per-cell 结构化信息)
```
Usage:
dws sheet range read [flags] # 别名: dws sheet range get
Example:
dws sheet range read --node <NODE_ID>
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID>
dws sheet range read --node <NODE_ID> --sheet-id "Sheet1" --range "A1:D10"
dws sheet range read --node <NODE_ID> --range "Sheet1!A1:D10"
dws sheet range read --node <NODE_ID> --value-render-option raw_value
dws sheet range read --node <NODE_ID> --value-render-option formula
# 使用 get 别名,与 read 等价
dws sheet range get --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (不传则默认第一个工作表)
--range string 读取范围,A1 表示法 (如 A1:D10,不传则读取全部数据)
--value-render-option string 取值模式: formatted_value(默认) | raw_value | formula
```
**返回字段说明**:
- `cells` — 二维数组,第一维为行,第二维为列。每个元素为 per-cell 对象,字段如下:
| 字段 | 类型 | 是否必有 | 说明 |
|------|------|---------|------|
| `value` | string / number / boolean / null | 始终存在 | 单元格值。`formatted_value` 模式为 string;`raw_value` 模式为原始类型(number/boolean/string/null);`formula` 模式为公式字符串或回退原始值 |
| `dataValidation` | object | 仅有数据验证时出现,无则省略 | 数据验证配置,见下表 |
| `hyperlink` | object | 仅有单元格级超链接时出现,无则省略 | 整格超链接,结构为 `{type, link, text?}`,见下表 |
| `richText` | object | 仅富文本单元格出现 | 富文本结构(含超链接、附件、图片、样式片段等),普通纯文本不含此字段 |
| `cellStyles` | object | 仅有显式设置的样式时出现;MCP 序列化层也可能返回全 null 空壳 | cell-level 样式,见下表。读取时只看非 null 字段;全 null 等同不存在 |
`range read` / `range get` 不返回合并单元格结构。要看合并单元格,请先或另行调用 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json`,使用其中的 `mergedRanges`。
**dataValidation 结构**:
| type | 字段 | 说明 |
|------|------|------|
| `dropdown` | `options: [{value: string, color?: string}]` | 下拉选项列表 |
| `dropdown` | `enableMultiSelect: boolean` | 是否允许多选 |
| `checkbox` | `checked: boolean` | 当前勾选状态 |
**hyperlink 结构**:
| type | 字段 | 说明 |
|------|------|------|
| `path` | `link` + 可选 `text` | 外部 URL 链接 |
| `sheet` | `link` + 可选 `text` | 工作表链接,`link` 为工作表 ID 或名称 |
| `range` | `link` + 可选 `text` | 单元格范围链接,`link` 为 A1 表示法,如 `Sheet1!A4` |
**richText 结构**:
`richText` 表示单元格内的富文本片段,常见结构为 `{type:"richText", texts:[...]}`。`texts` 数组内每个子项代表一个片段:
| 子项 type | 常见字段 | 说明 |
|-----------|----------|------|
| `text` | `text` / `style` | 普通文本片段;`style` 是片段级样式 |
| `link` | `text` / `link` / `subType` / `style` | 富文本片段链接。`subType` 不存在时按 `path` 理解;`path` 表示外部 URL,`sheet` 表示工作表链接,`range` 表示单元格范围链接 |
| `attachment` | `text` / `resourceId` / `mimeType` / `size` | 附件片段 |
| `image` | `resourceId` / `resourceUrl` / `width` / `height` | 图片片段 |
`richText.texts[].link.subType` 与 cell-level `hyperlink.type` 含义一致,但作用范围不同:`hyperlink` 是整个单元格可点击,richText `link` 只作用于该文本片段。读取到 `subType:"sheet"` 时,`link` 通常是真实工作表名称;读取到 `subType:"range"` 时,`link` 通常是 A1 范围(如 `Sheet2!A1:B20`)。不要把 richText 片段链接误当成整格 `hyperlink`。
**cellStyles 字段说明**(仅关注显式设置过的非 null 属性;未设置属性不存在或为 null,应忽略):
| 字段 | 类型 | 说明 |
|------|------|------|
| `fontWeight` | string | `bold` / `normal` |
| `fontColor` | string | 字体颜色,`#RRGGBB` |
| `fontSize` | number | 字号 |
| `fontStyle` | string | `italic` / `normal` |
| `backgroundColor` | string | 背景色,`#RRGGBB` |
| `horizontalAlignment` | string | `left` / `center` / `right` / `general` |
| `verticalAlignment` | string | `top` / `middle` / `bottom` |
| `wordWrap` | string | `overflow` / `clip` / `autoWrap` |
| `numberFormat` | string | 数字格式代码,如 `@`、`#,##0.00`、`yyyy/m/d`;`@` 表示文本 |
| `textUnderline` | boolean | 下划线 |
| `textLineThrough` | boolean | 删除线 |
**返回示例**:
```json
{
"cells": [
[
{"value": "姓名", "cellStyles": {"fontWeight": "bold", "backgroundColor": "#FFF2CC"}},
{"value": "状态", "cellStyles": {"fontWeight": "bold"}, "dataValidation": {"type": "dropdown", "options": [{"value": "进行中"}, {"value": "已完成", "color": "#52C41A"}], "enableMultiSelect": false}}
],
[
{"value": "张三"},
{"value": "钉钉", "hyperlink": {"type": "path", "link": "https://dingtalk.com", "text": "钉钉"}}
]
],
"message": "Successfully retrieved cell data.",
"success": true
}
```
说明:第一行表头有 `cellStyles`(加粗 + 背景色),第二行第二格有单元格级 `hyperlink`。注意:MCP 平台序列化会将未设置的字段填充为 null(如 `"fontStyle": null`),读取时应忽略值为 null 的字段,仅关注非 null 的属性;如果 `cellStyles` 全字段都是 null,视同不存在。`richText` 字段同理——无富文本的普通单元格可能返回 `{"type": null, "texts": null}`,视同不存在。
**取值模式说明**:
| 模式 | value 返回内容 | 适用场景 |
|------|---------|---------|
| `formatted_value` | 格式化展示值(如 ¥1,000.00、2025-06-01) | 只看数据(默认) |
| `raw_value` | 原始值(如 1000、45808) | 数据处理、计算 |
| `formula` | 公式文本(如 =SUM(A1:A10)),无公式时回退原始值 | 查看/复制公式 |
**超时处理建议**:读取大范围数据时若出现超时或响应过慢,请主动缩小 `--range` 查询范围,**建议单次读取的单元格数量控制在 5000 个以内**(例如 50 行 × 100 列、100 行 × 50 列)。对于大表可采用分页读取策略:
- 先通过 `info` 获取 `nonEmptyRange.range`,或用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` 确定 A1 边界
- 按行分批读取,如 `A1:J500`、`A501:J1000`、`A1001:J1500` ……
- 避免不传 `--range` 直接读取整个大工作表
## 核心工作流
```bash
# ── 工作流: 读取已有表格数据 ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> --format json
# 2. 查看工作表详情(行列数、最后非空位置、mergedRanges 等)
dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json
# 3. 读取全部数据
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --format json
# 4. 读取指定区域
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --format json
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list` | 工作表的 `sheetId` | info / range read 的 --sheet-id |
| `info` | `rowCount` / `nonEmptyRange.range` / `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` / `mergedRanges` | 确定数据范围、分页读取边界、识别合并单元格结构 |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
- `range read` 不传 `--range` 时默认读取整个工作表的全部非空数据
- `range read` 的 `--range` 支持 `Sheet1!A1:D10` 格式直接指定工作表(此时忽略 `--sheet-id`)
- ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges`
- `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 获取边界后分批读取,避免不传 `--range` 直接读取整个大工作表
- ★ 当用户要求搜索/查找表格数据时,使用 `find` 命令,不要用 `range read` 读取全量数据后自行过滤——`find` 支持服务端搜索,效率更高、语义更准确
# 搜索与替换
## 使用场景
用户说"搜索/查找/找单元格/搜内容/精确搜索/精确匹配/完全匹配/全字匹配":
- 搜索单元格 → `find`
- 精确匹配(只匹配完全等于的,不匹配包含的) → `find --match-entire-cell`
- 正则搜索 → `find --use-regexp`
- 搜索公式 → `find --match-formula`
- 不要用 `range read` 读取全量数据后在客户端过滤来替代 `find`,必须使用 `find` 命令的服务端搜索能力
用户说"替换/查找替换/全局替换/批量替换/把A替换成B/把所有的X改成Y":
- 查找替换 → `replace`
- 精确匹配后替换(只替换内容完全等于的单元格) → `replace --match-entire-cell`
- 正则替换 → `replace --use-regexp`
- 删除匹配内容 → `replace --replacement ""`
- 请勿用 `find` + `range update`、`range read` + `range update` 等组合来模拟替换,`replace` 是服务端原子操作,效率更高且返回替换计数
## 命令详细参考
### 在工作表中搜索单元格内容
```
Usage:
dws sheet find [flags]
Example:
# 基本搜索
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "销售额"
# 在指定范围内搜索
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "合计" --range "A1:D100"
# 正则表达式搜索(不区分大小写)
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "^total" --use-regexp --match-case=false
# 精确匹配整个单元格内容
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "完成" --match-entire-cell
# 搜索公式文本
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "SUM" --match-formula
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--find string 搜索文本 (必填)
--range string 搜索范围,A1 表示法 (如 A1:D10)
--match-case 区分大小写 (默认 true)
--match-entire-cell 精确匹配整个单元格内容
--use-regexp 启用正则表达式搜索
--match-formula 搜索公式文本而非显示值
--include-hidden 包含隐藏单元格
```
### 全局查找替换
```
Usage:
dws sheet replace [flags]
Example:
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> --find "旧文本" --replacement "新文本"
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> --find "待处理" --replacement "已完成" --match-entire-cell
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> --find "\\d{4}" --replacement "****" --use-regexp
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> --find "旧" --replacement "新" --range "A1:D100"
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> --find "临时" --replacement ""
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--find string 查找文本 (必填)
--replacement string 替换文本 (必填,可为空字符串表示删除)
--range string 替换范围,A1 表示法 (如 A1:D100)
--match-case 区分大小写 (默认 false)
--match-entire-cell 完整单元格匹配
--use-regexp 启用正则表达式匹配
--include-hidden 包含隐藏行/列
```
返回被替换的单元格数量。`--replacement` 可以为空字符串,表示删除匹配内容。
## 核心工作流
```bash
# ── 工作流: 搜索表格数据 ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> --format json
# 2. 基本搜索 — 在指定工作表中查找文本
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "销售额" --format json
# 3. 在指定范围内搜索
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "合计" --range "A1:D100" --format json
# 4. 正则搜索(不区分大小写)
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "^total" --use-regexp --match-case=false --format json
# 5. 精确匹配整个单元格
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "完成" --match-entire-cell --format json
# 6. 搜索公式文本
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> --find "SUM" --match-formula --format json
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list` | 工作表的 `sheetId` | find / replace 的 --sheet-id |
| `find` | `matchedCells` 中的 `a1Notation` | 定位目标单元格,用于 range read / range update |
| `replace` | `replaceCount` 被替换的单元格数量 | 确认替换结果 |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
- ★ **搜索用 `find` 不用 `range read`**:`find` 是服务端搜索,禁止用 `range read` 全量读取后客户端过滤
- ★ **替换用 `replace` 不用 `range update`**:`replace` 是服务端原子操作,返回替换计数
- `find` 返回匹配单元格的地址(A1 表示法)和值,无匹配时返回空数组
- `find` 的 `--match-entire-cell` 用于精确匹配:只返回单元格内容完全等于搜索文本的结果,不会匹配包含该文本的单元格(例如搜索"苹果"时,只匹配"苹果",不匹配"苹果手机""苹果汁"等)。用户说"精确搜索/完全匹配/只搜等于XX的"时必须使用此参数
- `find` 的 `--match-case` 默认为 true(区分大小写),设为 false 可忽略大小写
- `find` 的 `--use-regexp` 启用后,`--find` 参数作为正则表达式处理
- `replace` 的 `--find` 不能为空字符串,`--replace` 可以为空字符串(表示删除匹配内容)
- `replace` 的 `--match-case` 默认为 false(不区分大小写),与 `find` 的默认行为不同
# 单元格格式与合并 (style & format)
## 三种样式设置方式
钉钉表格支持三种样式设置方式,适用不同场景:
| 方式 | 命令 / 字段 | 适用场景 | 粒度 |
|------|------------|---------|------|
| **`set-style` / `batch-set-style`** | `dws sheet range set-style` | 批量刷整片区域的统一样式(表头加粗居中、数字格式等) | range 级别(2D 数组或全 range 统一值) |
| **`cellStyles`**(`range update` 内) | `--values` 中每个 cell 的 `cellStyles` 字段 | 写值同时附带样式,少量 cell 一步到位 | per-cell 级别 |
| **`style`**(richText 片段样式) | `--values` 中 richText 子项(`text`/`link`)的 `style` | 同一单元格内不同文字有不同字体样式 | 文本片段级别 |
选择建议:
- 只设样式不改值 → `set-style` / `batch-set-style`
- 写值 + 样式一步到位(少量 cell) → `range update` + `cellStyles`
- 文本内部分段样式("重要"红色加粗,其余正常) → `range update` + `type:"richText"` 子项 `style`
- 大面积统一样式 → `set-style`(单值刷 range)或 `batch-set-style`(多 range 批量)
注意:`set-style` / `batch-set-style` 和 `range update` 的 `cellStyles` 最终都作用于 cell-level 样式,效果相同。区别在于调用方式——前者是独立命令,后者嵌在写值调用中。`range read` 返回的 `cellStyles` 字段能读回所有显式设置过的 cell-level 样式,无论是通过哪种方式设置的。
`type:"text"` 顶层旧 `style` 字段不要作为新写法使用;整格样式用 `cellStyles`,分段样式才用 richText 子项 `style`。
## 使用场景
### 单元格格式
用户说"设置样式/改颜色/设背景色/加粗/居中/换行/字体颜色/字号":
- 仅设样式不改值 → `range set-style`
- 批量设置不同 range 的样式 → `range batch-set-style --batch ./styles.json`(内部顺序循环调 `update_range`)
- 写值同时附带样式 → `range update --values` 中使用 `cellStyles` 字段(参见 sheet-write-data.md)
- 请勿用 `range update --values` 写空/重写来模拟纯样式变更
用户说"设置数字格式/改成百分比/用人民币显示/按日期显示/文本格式/保留几位小数":
- 批量设置数字格式 → `range set-style --number-format <格式代码>`(如 `0%` / `"¥"#,##0.00` / `yyyy/m/d` / `@`)
- 写值时顺带设置数字格式 → `range update` 中 `cellStyles.numberFormat`
用户说"合并单元格/合并/合并区域/按行合并/按列合并":
- 合并所有单元格 → `merge-cells`(默认 mergeAll)
- 按行合并 → `merge-cells --merge-type mergeRows`
- 按列合并 → `merge-cells --merge-type mergeColumns`
用户说"取消合并/拆分单元格/还原合并":
- 取消合并单元格 → `unmerge-cells`
## 命令详细参考
### 设置单元格样式
```
Usage:
dws sheet range set-style [flags]
Example:
# 给 A1:B3 打上黄底粗体居中
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3" \
--bg-color "#FFF2CC" --font-weight bold --h-align center
# 给 C1:C5 逐单元格设置不同背景色
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "C1:C5" \
--bg-colors-json '[["#FF0000"],["#00FF00"],["#0000FF"],["#FFFF00"],["#FF00FF"]]'
# 整片 range 启用自动换行
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:E10" --word-wrap autoWrap
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 目标区域,如 A1:B3 (必填)
--bg-color string 背景色(#RRGGBB),一键刷整个 range;与 --bg-colors-json 二选一
--bg-colors-json string 背景色二维 JSON 数组,维度需与 --range 一致
--font-size int 字号,一键刷整个 range;与 --font-sizes-json 二选一
--font-sizes-json string 字号二维 JSON 数组
--h-align string 水平对齐:left/center/right/general
--h-aligns-json string 水平对齐二维 JSON 数组
--v-align string 垂直对齐:top/middle/bottom
--v-aligns-json string 垂直对齐二维 JSON 数组
--font-color string 字体颜色(#RRGGBB)
--font-colors-json string 字体颜色二维 JSON 数组
--font-weight string 字体粗细:bold/normal
--font-weights-json string 字体粗细二维 JSON 数组
--word-wrap string 换行方式:overflow/clip/autoWrap(整个 range 共用)
--number-format string 数字格式代码,如 General/@/#,##0/#,##0.00/0%/0.00%/yyyy/m/d/h:mm:ss
```
**特性说明**:
- 每个样式维度提供两种写法,二选一:`--xxx`(单值刷整个 range,CLI 本地展开为二维数组)vs `--xxx-json`(逐单元格指定,维度需与 `--range` 完全一致)
- 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000
- 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center` 等
### 批量设置单元格样式
```
Usage:
dws sheet range batch-set-style [flags]
Example:
dws sheet range batch-set-style --node <NODE_ID> --batch ./styles.json
dws sheet range batch-set-style --node <NODE_ID> --batch ./styles.json --continue-on-error
Flags:
--node string 表格文档 ID 或 URL (必填)
--batch string 批次配置 JSON 文件路径 (必填)
--continue-on-error 遇到失败时继续执行后续条目(默认遇错即停)
```
配置文件格式(JSON 数组,每个元素一条批次项):
```json
[
{
"sheetId": "Sheet1",
"range": "A1:B3",
"bgColor": "#FFF2CC",
"fontSize": 12,
"hAlign": "center",
"vAlign": "middle",
"fontColor": "#333333",
"fontWeight": "bold",
"wordWrap": "autoWrap",
"numberFormat": "General"
},
{
"sheetId": "Sheet1",
"range": "C1:C5",
"bgColorsJson": "[[\"#FF0000\"],[\"#00FF00\"],[\"#0000FF\"],[\"#FFFF00\"],[\"#FF00FF\"]]"
}
]
```
**特性说明**:
- CLI 侧顺序循环逐条调用 `update_range`(非服务端批量),运行时输出 `[N/M]` 进度
- 每条记录执行与 `set-style` 一致的校验:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法
- 默认遇错即停(返回非 0),`--continue-on-error` 时所有条目跑完再返回首个错误
### 合并单元格
```
Usage:
dws sheet merge-cells [flags]
Example:
# 合并所有单元格(默认)
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3"
# 按行合并
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --merge-type mergeRows
# 按列合并
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --merge-type mergeColumns
# 使用带工作表前缀的范围(忽略 --sheet-id)
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "Sheet1!A1:B3"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 目标单元格区域地址,如 A1:B3 (必填)
--merge-type string 合并方式: mergeAll(默认)/mergeRows/mergeColumns
```
支持三种合并方式:
- `mergeAll`(默认):合并所有单元格,将选定区域内的所有单元格合并成一个
- `mergeRows`:按行合并,在选定区域内将同一行相邻的单元格合并
- `mergeColumns`:按列合并,在选定区域内将同一列相邻的单元格合并
注意:合并时只保留左上角单元格的值,其他单元格的值会被丢弃。
`--range` 支持带工作表前缀的写法(如 `Sheet1!A1:B3`),此时将优先使用前缀解析出的工作表,忽略 `--sheet-id`。
合并完成后,可通过 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 查看 `mergedRanges` 验证合并结构。
### 取消合并单元格
```
Usage:
dws sheet unmerge-cells [flags]
Example:
dws sheet unmerge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D5"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 取消合并的范围,A1 表示法 (必填)
```
取消指定范围内所有合并的单元格,恢复为独立单元格。
## number-format 格式 code
适用范围:`number-format` 在 `range set-style` / `range batch-set-style` 中接受(CLI 对应 `--number-format`,batch 配置文件对应 `numberFormat`)。`range update` 没有 `--number-format` 参数,但可在写入值时通过每个 cell 的 `cellStyles.numberFormat` 设置同样的数字格式。
商品 ID、规格 ID、SKU、订单号、手机号、工号等数字形态标识符,使用文本格式 code:`@`。
常用格式:
| 格式类型 | 推荐 code | 展示示例 | 适用场景 |
| --- | --- | --- | --- |
| 常规 | `General` | `1234` / `普通文本` | 普通文本/数字展示 |
| 文本 | `@` | `528545015680` | 商品 ID、规格 ID、SKU、订单号、手机号、工号 |
| 整数 | `0` | `1235` | 数量、计数 |
| 两位小数 | `0.00` | `1234.50` | 单价、评分 |
| 整数千分位 | `#,##0` | `1,235` | 数量、金额整数 |
| 千分位两位小数 | `#,##0.00` | `1,234.50` | 金额、单价 |
| 百分比 | `0%` / `0.00%` | `85%` / `85.00%` | 转化率、占比 |
| 日期 | `yyyy/m/d` | `2026/3/15` | 日期列 |
| 日期时间 | `yyyy/m/d h:mm` | `2026/3/15 14:30` | 日期时间列 |
| 时间 | `h:mm` / `h:mm:ss` | `14:30` / `14:30:05` | 时间列 |
| 科学计数法 | `0.00E+00` / `##0.0E+0` | `1.23E+05` | 科学数据 |
| 人民币 | `"¥"#,##0_);("¥"#,##0)` / `"¥"#,##0.00_);("¥"#,##0.00)` | `¥1,235` / `¥1,234.50` | 金额列 |
| 美元 | `$#,##0_);($#,##0)` / `$#,##0.00_);($#,##0.00)` | `$1,235` / `$1,234.50` | 金额列 |
选择规则:没有特殊展示要求时,优先使用上面的常用格式。只有用户明确要求负数显示方式、中文日期、12 小时制、累计时长、分数或会计格式时,再选择下面的可选变体。
可选变体:
| 用户要求 | 推荐 code | 推荐展示示例 | 可选 code(差异) |
| --- | --- | --- | --- |
| 负数用括号显示 | `#,##0 ;(#,##0)` | `(1,235)` | `#,##0.00;(#,##0.00)`:保留两位小数,如 `(1,234.50)` |
| 负数标红显示 | `#,##0 ;[red](#,##0)` | 红色 `(1,235)` | `#,##0.00;[red](#,##0.00)`:保留两位小数,如红色 `(1,234.50)` |
| 分数 | `# ?/?` | `1 1/2` | `# ??/??`:分母最多两位,如 `1 23/32` |
| 英文月份日期 | `d-mmm-yy` | `15-Mar-26` | `d-mmm`:省略年份,如 `15-Mar`;`mmm-yy`:只显示月年,如 `Mar-26` |
| 中文日期 | `yyyy"年"m"月"d"日"` | `2026年3月15日` | `yyyy"年"m"月"`:只显示年月,如 `2026年3月`;`m"月"d"日"`:只显示月日,如 `3月15日` |
| 12 小时制时间 | `h:mm AM/PM` | `2:30 PM` | `h:mm:ss AM/PM`:显示秒,如 `2:30:05 PM` |
| 中文上午/下午时间 | `上午/下午 h"时"mm"分"` | `下午 2时30分` | `上午/下午 h"时"mm"分"ss"秒"`:显示秒,如 `下午 2时30分05秒` |
| 分秒/累计时长 | `mm:ss` | `05:30` | `[h]:mm:ss`:累计小时,如 `27:05:30`;`mm:ss.0`:显示十分之一秒,如 `05:30.5` |
| 人民币负数标红 | `"¥"#,##0_);[red]("¥"#,##0)` | 红色 `(¥1,235)` | `"¥"#,##0.00_);[red]("¥"#,##0.00)`:保留两位小数,如红色 `(¥1,234.50)` |
| 美元负数标红 | `$#,##0_);[Red]($#,##0)` | 红色 `($1,235)` | `$#,##0.00_);[Red]($#,##0.00)`:保留两位小数,如红色 `($1,234.50)` |
| 会计数字 | `_(* #,##0_);_(* (#,##0);_(* "-"_);_(@_)` | `1,235`,零值显示 `-` | `_(* #,##0.00_);_(* (#,##0.00);_(* "-"??_);_(@_)`:保留两位小数,如 `1,234.50` |
| 人民币会计格式 | `_("¥"* #,##0_);_("¥"* (#,##0);_("¥"*"-"_);_(@_)` | `¥ 1,235`,零值显示 `¥ -` | `_("¥"* #,##0.00_);_("¥"*(#,##0.00);_("¥"* "-"??_);_(@_)`:保留两位小数,如 `¥ 1,234.50` |
## 核心工作流
```bash
# ── 工作流 4: 写入数据并设置样式 ──
# 1. 写入数据
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" \
--values '[["商品","单价","数量"],["苹果",5.5,100],["香蕉",3.2,200]]' --format json
# 2. 设置数字格式(人民币)
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "B2:B3" \
--number-format '"¥"#,##0.00' --format json
# 3. 商品 ID / 规格 ID 按文本展示,避免科学计数法
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:A3" \
--number-format "@" --format json
# 4. 写入单元格级超链接
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "D1" \
--values '[[{"type":"text","text":"详情","hyperlink":{"type":"path","link":"https://dingtalk.com"}}]]' --format json
```
```bash
# ── 工作流 8: 合并单元格 ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> --format json
# 2. 合并所有单元格(默认 mergeAll)
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B3" --format json
# 3. 按行合并
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --merge-type mergeRows --format json
# 4. 按列合并
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" --merge-type mergeColumns --format json
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `merge-cells` | `a1Notation` 实际被合并的范围、`mergeType` 生效的合并方式 | 确认合并结果 |
| `unmerge-cells` | `sheetId` 工作表 ID | 确认操作完成 |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等)
- ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(服务端硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,服务端超 30000 会直接报错
- `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`;背景色/字体颜色统一使用 `#RRGGBB` 格式
- `range update` 支持通过 `cellStyles` 在写值时附带 per-cell 样式,适合少量单元格写值 + 样式一步到位的场景。批量设置整片区域的统一样式时,仍应使用 `set-style` / `batch-set-style`
- `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃
- `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格)
- `merge-cells` 的 `--range` 支持带工作表前缀的写法(如 `Sheet1!A1:B3`),此时忽略 `--sheet-id`
- `merge-cells` 如果目标区域与其他合并单元格、锁定区域或表格区域存在交集,合并将失败
- `unmerge-cells` 取消指定范围内所有合并单元格,使用 A1 表示法指定范围
- 对已有表格做格式延续、插入列后修复表头、或写入前临时取消合并时,先记录 `sheet info` 返回的 `mergedRanges`,操作后按需用 `merge-cells` 恢复
# 表格与工作表管理
## 使用场景
用户说"创建表格/新建电子表格":
- 创建表格文档 → `create`
用户说"看工作表/有哪些工作表/表格结构":
- 列出工作表 → `list`
- 工作表详情 → `info`
用户说"加工作表/新增Sheet":
- 新建工作表 → `new`
用户说"修改工作表名称/重命名工作表/移动工作表位置/隐藏工作表/显示工作表/冻结行/冻结列/取消冻结/更新工作表属性":
- 更新工作表属性 → `update`
- 重命名工作表 → `update --name "新名称"`
- 移动工作表位置 → `update --index N`
- 隐藏工作表 → `update --hidden`
- 显示工作表 → `update --hidden=false`
- 冻结行列 → `update --frozen-row-count N --frozen-column-count M`
- 取消冻结 → `update --frozen-row-count 0 --frozen-column-count 0`
用户说"复制工作表/拷贝工作表/克隆工作表/工作表副本":
- 复制工作表 → `copy`
- 复制并指定名称 → `copy --name "副本名称"`
- 复制并指定位置 → `copy --index N`
用户说"删除工作表/移除工作表/删掉这个Sheet":
- 删除工作表 → `delete-sheet`(不可逆操作,执行前必须向用户确认)
## 命令详细参考
### 创建钉钉表格文档
```
Usage:
dws sheet create [flags]
Example:
dws sheet create --name "销售数据"
dws sheet create --name "Q1 数据" --folder <FOLDER_ID>
dws sheet create --name "知识库表格" --workspace <WS_ID>
Flags:
--name string 表格名称 (必填)
--folder string 目标文件夹 ID (dentryUuid 格式) 或 URL;禁止传入纯数字 dentryId
--workspace string 目标知识库 ID
```
> **ID 格式约束**:`--folder` 只接受 UUID 格式的 `fileId`(如 `ZgpG2NdyVXYOR2D5UGDok65MJMwvDqPk`)或 alidocs 文件夹 URL。`drive list` 返回中有 `dentryId`(纯数字,如 `218595998810`)和 `fileId`(UUID 格式)两个字段,**必须使用 `fileId`,禁止使用 `dentryId`**,传入纯数字会导致命令失败。
### 获取全部工作表列表
```
Usage:
dws sheet list [flags]
Example:
dws sheet list --node <NODE_ID>
dws sheet list --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
Flags:
--node string 表格文档 ID 或 URL (必填)
```
### 获取指定工作表详情
```
Usage:
dws sheet info [flags]
Example:
dws sheet info --node <NODE_ID>
dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID>
dws sheet info --node <NODE_ID> --sheet-id "Sheet1"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (不传则返回第一个工作表)
```
返回字段中 `mergedRanges` 是当前工作表的合并单元格范围列表(A1 表示法,如 `["C7:D11"]`)。它属于工作表结构/布局元数据:读写单元格内容前,如需判断表头、分组标题、续写位置或避开合并冲突,应先看 `sheet info`,不要在 `range read` / `csv-get` 的单元格值里寻找合并信息。
### 新建工作表
```
Usage:
dws sheet new [flags]
Example:
dws sheet new --node <NODE_ID> --name "Sheet2"
dws sheet new --node <NODE_ID> --name "数据汇总"
Flags:
--node string 表格文档 ID (必填)
--name string 工作表名称 (必填)
```
### 更新工作表属性
```
Usage:
dws sheet update [flags]
Example:
# 改名 + 调整冻结
dws sheet update --node <NODE_ID> --sheet-id <SHEET_ID> --name "汇总表" --frozen-row-count 2 --frozen-column-count 1
# 隐藏工作表
dws sheet update --node <NODE_ID> --sheet-id <SHEET_ID> --hidden=true
# 显示工作表
dws sheet update --node <NODE_ID> --sheet-id <SHEET_ID> --hidden=false
# 移动工作表到第一个位置
dws sheet update --node <NODE_ID> --sheet-id <SHEET_ID> --index 0
# 取消冻结
dws sheet update --node <NODE_ID> --sheet-id <SHEET_ID> --frozen-row-count 0 --frozen-column-count 0
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--name string 新名称,最长 100 字符,不能包含 / \ ? * [ ] :
--index int 新位置(从 0 开始)
--hidden --hidden=true 隐藏,--hidden=false 取消隐藏
--frozen-row-count int 冻结行数,0 表示取消冻结
--frozen-column-count int 冻结列数,0 表示取消冻结
```
更新工作表名称、位置、隐藏状态、冻结行列。
`--name` / `--index` / `--hidden` / `--frozen-row-count` / `--frozen-column-count` 至少提供一个;多个属性可同时传入,将在同一次请求中更新。
注意:
- 至少需要保留一个可见的工作表,不能将所有工作表都隐藏
- 冻结行数/列数不能超过工作表的总行数/列数
### 复制工作表
```
Usage:
dws sheet copy [flags]
Example:
# 按默认位置复制
dws sheet copy --node <NODE_ID> --sheet-id <SHEET_ID>
# 指定副本名称和位置
dws sheet copy --node <NODE_ID> --sheet-id <SHEET_ID> --name "销售副本" --index 2
# 只指定名称
dws sheet copy --node <NODE_ID> --sheet-id <SHEET_ID> --name "备份"
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 源工作表 ID 或名称 (必填)
--name string 副本名称,最长 100 字符,不能包含 / \ ? * [ ] : (不传则系统自动生成)
--index int 副本位置(从 0 开始)(不传则放在源工作表之后)
```
复制指定工作表,在同一表格中创建一个副本。
复制操作会将源工作表的所有内容(包括数据、格式、公式等)完整复制到新工作表中。
传 `--index` 时,CLI 会先复制,再追加一次位置更新,把副本移动到目标索引。
名称与已有工作表重复时系统会自动重命名。
### 删除工作表
```
Usage:
dws sheet delete-sheet [flags]
Example:
dws sheet delete-sheet --node <NODE_ID> --sheet-id <SHEET_ID>
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 要删除的工作表 ID 或名称 (必填)
```
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
删除指定的工作表及其所有数据。约束:
- 不能删除隐藏的工作表(需先通过 `sheet update --hidden false` 取消隐藏再删除)
- 不能删除最后一个可见工作表(至少保留一个可见工作表)
## 核心工作流
```bash
# ── 工作流 1: 创建表格并写入数据 ──
# 1. 创建表格文档 — 提取 nodeId
dws sheet create --name "销售数据" --format json
# 2. 查看工作表列表 — 提取 sheetId
dws sheet list --node <NODE_ID> --format json
# 3. 写入表头和数据
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C1" \
--values '[["姓名","部门","销售额"]]' --format json
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:C4" \
--values '[["张三","销售部",50000],["李四","市场部",38000],["王五","销售部",62000]]' --format json
# ── 工作流 2: 读取已有表格数据 ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> --format json
# 2. 查看工作表详情(行列数、最后非空位置等)
dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json
# 3. 读取全部数据
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --format json
# 4. 读取指定区域
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --format json
# ── 工作流 3: 多工作表管理 ──
# 1. 新建工作表
dws sheet new --node <NODE_ID> --name "汇总" --format json
# 2. 在新工作表中写入汇总公式
dws sheet range update --node <NODE_ID> --sheet-id <NEW_SHEET_ID> --range "A1:B1" \
--values '[["指标","数值"]]' --format json
dws sheet range update --node <NODE_ID> --sheet-id <NEW_SHEET_ID> --range "A2:B2" \
--values '[["总销售额","=SUM(Sheet1!C2:C100)"]]' --format json
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `create` | `nodeId` | list / info / new / range read / range update / find 的 --node |
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
| `new` | 新工作表的 `sheetId` | range read / range update / find 的 --sheet-id |
| `info` | `rowCount` / `nonEmptyRange.range` / `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` / `mergedRanges` | 确定数据范围、追加写入起始行、判断合并单元格结构 |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:所有涉及 `--sheet-id` 参数的命令,除非用户主动提供了工作表 ID 或工作表名称,否则在 `sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
- `mergedRanges` 中的范围表示一个整体语义区域。合并区域内非左上角单元格为空并不代表无内容,通常应以左上角单元格的值作为该合并区域的含义。
- `create` 不传 `--folder` 和 `--workspace` 时,默认创建在"我的文档"根目录
- `list` 返回所有工作表的 ID 和名称,是后续操作的必要前置步骤
- `info` 不传 `--sheet-id` 时默认返回第一个工作表的详情
- `new` 创建工作表时,如名称与已有工作表重复,系统会自动重命名
- `update` 的 `--name`、`--index`、`--hidden`、`--frozen-row-count`、`--frozen-column-count` 至少必须提供一个
- `update` 的 `--name` 最长 100 字符,不能包含 `/ \ ? * [ ] :` 等特殊字符
- `update` 的 `--index` 为 0-based 非负整数,0 表示移动到最前面
- `update` 的 `--hidden` 设为 true 时,至少需要保留一个可见的工作表,不能将所有工作表都隐藏
- `update` 的 `--frozen-row-count` / `--frozen-column-count` 为非负整数,不能超过工作表的总行数/列数,设为 0 表示取消冻结
- `update` 当同时提供多个属性时,所有属性将在同一次请求中更新
- `copy` 复制操作会将源工作表的所有内容(包括数据、格式、公式等)完整复制到新工作表
- `copy` 的 `--name` 可选,不传时系统自动生成名称(通常为"源名称 副本"或类似格式)
- `copy` 的 `--name` 最长 100 字符,不能包含 `/ \ ? * [ ] :` 等特殊字符
- `copy` 当指定名称与已有工作表重复时,系统会自动重命名为合法值
- `copy` 的 `--index` 可选,不传时副本将放置在源工作表之后的默认位置
- `delete-sheet` 为不可逆操作,执行前必须向用户确认
- `delete-sheet` 不能删除隐藏的工作表,需先通过 `update --hidden=false` 取消隐藏再删除
- `delete-sheet` 不能删除最后一个可见工作表,至少保留一个可见工作表
- ★ 关键区分: sheet(电子表格/单元格读写) vs aitable(AI多维表/结构化记录/字段定义) vs doc(文档编辑/阅读)
# 数据写入
## 使用场景
用户说"写数据/填表/更新单元格/写入公式":
- 更新数据 → `range update`
- 【强制】`--sheet-id` 必填:即使是单工作表也不能省略,不要参照 `range read` 的默认行为;未知时先执行 `dws sheet list --node <NODE_ID> --format json` 获取 `sheetId`,禁止凭空臆测为 `Sheet1`、`sheet1`、`0`、`default` 等
- 注意:如果用户的目的是替换文本、移动行列、追加空行空列、清空区域、排序、填充、复制区域或移动区域,请勿使用 `range update`,必须使用对应的专用命令(`replace`/`move-dimension`/`add-dimension`/`range clear`/`range sort`/`range fill`/`range copy-to`/`range move-to`)
- **批量纯值写入优先用 `csv-put`**:当写入场景同时满足以下条件时,必须优先使用 `csv-put` 而非 `range update`:(1) 写入的是纯值(不含公式、超链接、dataValidation、cellStyles、richText);(2) 数据量较大(超过 5 行或超过 20 个单元格);(3) 数据来源为表格/CSV 文本/结构化文本。`csv-put` 无需手动构造二维 JSON 数组,直接传 CSV 文本即可,更简洁高效且支持自动扩容
用户说"追加数据/添加行/在末尾加数据/新增记录":
- 追加数据 → `append`
用户说"批量写入CSV/导入CSV/CSV写入表格/把CSV贴到表格里":
- 写入 CSV → `csv-put`
- 与 `range update` 的区别:`csv-put` 接受 CSV 文本直接写入,无需手动构造二维 JSON 数组;适合大批量纯值写入
- 与 `append` 的区别:`csv-put` 写入指定位置(--start-cell),`append` 在末尾追加
**三种写入命令能力对比**:
| 能力 | `range update` | `append` | `csv-put` |
|------|---------------|----------|-----------|
| 公式(`=` 开头) | 支持 | 不支持 | 不支持(当文本) |
| 单元格级超链接(`hyperlink`) | 支持 | 不支持 | 不支持 |
| 富文本(片段链接/附件/图片) | 支持 | 不支持 | 不支持 |
| richText 片段样式(bold/color) | 支持 | 不支持 | 不支持 |
| `cellStyles`(背景色/字号/对齐等 cell-level 样式) | 支持 | 不支持 | 不支持 |
| `{}` 跳过(保留原值) | 支持 | 不适用 | 不适用 |
| `dataValidation`(下拉/复选框) | 支持 | 不支持 | 不支持 |
| 原始值(纯数字/字符串) | 支持 | 支持 | 支持 |
| 自动定位末尾 | 不支持 | 支持 | 不支持 |
| 自动扩容行列 | 不支持 | 支持 | 支持 |
## 命令详细参考
### 更新工作表指定区域内容
```
Usage:
dws sheet range update [flags]
Example:
# 写入文本
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:B2" \
--values '[[{"type":"text","text":"姓名"},{"type":"text","text":"分数"}],[{"type":"text","text":"张三"},{"type":"text","text":"90"}]]'
# 写入公式
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "C2" \
--values '[[{"type":"text","text":"=A2&B2"}]]'
# 写入单元格级超链接
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1" \
--values '[[{"type":"text","text":"钉钉","hyperlink":{"type":"path","link":"https://dingtalk.com"}}]]'
# 清理单元格级超链接,保留当前值
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1" \
--values '[[{"hyperlink":{"type":"none"}}]]'
# 清空单个单元格(text 为空字符串)
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1" \
--values '[[{"type":"text","text":""}]]'
Flags:
--node string 表格文档 ID (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--range string 目标单元格区域地址,如 A1:B3 (必填)
--values string 单元格内容,二维 JSON 数组 (必填);每个元素必须是 object:{type:text,text:...}、{type:richText,texts:[...]}、{dataValidation:...}、{cellStyles:...}、{hyperlink:...} 或 {}(详见下文 values 参数格式说明)
```
**合并单元格注意(`range update`)**:这里说的是 `range update` 写入单元格对象这一路径,不是所有写入命令的统一行为。目标范围与已有合并区域冲突时,MCP 服务端会拦截并返回 `MERGED_CELLS_CONFLICT` 错误,错误消息中通常会列出具体冲突的合并区域地址。收到此错误时按以下流程处理:
1. 从错误消息中获取冲突的合并区域地址(如 `A1:B2, C3:D4`),或通过 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 查询完整的合并区域列表(`mergedRanges` 数组)
2. 用 `dws sheet unmerge-cells --range <冲突区域>` 取消这些合并
3. 执行 `range update` 写入数据
4. 如需保留原合并效果,用 `dws sheet merge-cells` 重新合并对应区域(注意合并后仅保留左上角单元格的值)
续写或改写已有格式化表格时,先用 `sheet info` 读取 `mergedRanges`。若原数据块存在跨列标题行(如 `A1:G1`),新增同类标题行后也要用 `merge-cells` 复制相同合并模式;仅写入值或样式不会自动创建合并区域。
**单次调用建议**:行数 ≤ 1000,单元格总数(行×列)≤ 5000;超过时请拆分多次调用。
**何时该用 `csv-put` 替代**:如果你准备用 `range update` 写入纯值(不含公式、超链接、富文本对象),且数据量超过 5 行或 20 个单元格,应改用 `csv-put`——它接受 CSV 文本直接写入,无需手动拼装二维 JSON 数组,且支持自动扩容行列。仅在需要写入公式(`=SUM(...)`)、单元格级超链接、富文本对象或修改少量单元格时才使用 `range update`。
**范围职责**:`range update` 负责写入单元格内容(原始值/公式/富文本对象),并支持通过 `cellStyles` 附带 per-cell 样式。如需批量设置整片区域的样式(不写值),请使用 `dws sheet range set-style`。
### 在工作表末尾追加数据
```
Usage:
dws sheet append [flags]
Example:
dws sheet append --node <NODE_ID> --sheet-id <SHEET_ID> --values '[["张三","销售部",50000]]'
dws sheet append --node <NODE_ID> --sheet-id <SHEET_ID> \
--values '[["李四","市场部",38000],["王五","销售部",62000]]'
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--values string 追加数据,二维 JSON 数组 (必填)
```
`--values` 为二维 JSON 数组,外层每个元素代表一行,内层每个元素代表一个单元格值。
追加的数据列数应与工作表已有数据的列数保持一致。
### 将 CSV 数据写入指定位置
```
Usage:
dws sheet csv-put [flags]
Example:
dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 \
--csv 'name,score\nAlice,95\nBob,87'
dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell B2 \
--csv @data.csv --allow-overwrite
cat data.csv | dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> \
--start-cell A1 --csv -
dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 \
--csv @data.csv --dry-run
Flags:
--node string 表格文档 ID 或 URL (必填)
--sheet-id string 工作表 ID 或名称 (必填)
--csv string CSV 文本、@文件路径 或 - 表示 stdin (必填)
--start-cell string 起始单元格,A1 表示法 (必填)
--allow-overwrite 允许覆盖已有数据 (默认 false)
```
将 RFC 4180 格式的 CSV 文本写入指定工作表的指定单元格位置。
- **分隔符必须是英文逗号 `,`**(ASCII 0x2C),禁止使用中文逗号 `,`(U+FF0C)。中文逗号不会被识别为分隔符,会导致整行被写入同一个单元格。生成 CSV 内容时务必检查分隔符
- 只写纯值,不支持公式/样式/批注。`=` 开头的内容当文本处理,不会被解析为公式
- 数字/日期/百分数由表格引擎自动识别类型(如 `95` 存为数字,`2025-03-01` 存为日期)
- 自动扩容行列:CSV 数据超出当前工作表维度时自动追加行/列
- 与 `range update` 不同,目标区域如含合并单元格,`csv-put` 会打散合并并写入纯值
- 若需要保留原有合并结构,写入前先用 `sheet info` 记录 `mergedRanges`,写入后用 `merge-cells` 恢复对应区域
- `--allow-overwrite` 默认 false,目标区域有数据时需显式传 `--allow-overwrite` 才能覆盖
- `--csv` 支持三种输入:直接传文本、`@filepath` 从本地文件读取、`-` 从 stdin 管道读取
- CSV 文本上限 2M 字符,单元格总数上限 30000
- 特殊字符处理:CLI 会自动过滤 `\r`(Windows 换行符)和 BOM(UTF-8 文件头标记),Excel/Windows 导出的 CSV 可直接使用;如 CSV 数据中含零宽字符(U+200B 等)或 Bidi 控制符,CLI 会拒绝并报错
## values 参数格式说明
`range update` 只接受 `--values` 一个数据参数,为二维 JSON 数组,第一维为行,第二维为列。每个 cell 是以下之一:
- `{}` 空对象:**跳过该单元格,保留原值不变**。只更新部分单元格时用 `{}` 占位,避免拆分多次调用
- `{type:"text",...}` 或 `{type:"richText",...}` 对象
- 任何 cell 可附加 `dataValidation` 字段,在写值的同时设置数据校验(下拉列表 / 复选框)
- 任何 cell 可附加 `cellStyles` 字段,在写值的同时设置 cell-level 样式(背景色 / 字体 / 对齐等)
- 任何 cell 可附加 `hyperlink` 字段设置单元格级超链接;`{"hyperlink":{"type":"none"}}` 表示清理单元格级超链接并保留当前值
### {}(跳过,保留原值)
```json
{}
```
只更新范围内部分单元格时,用 `{}` 占位不需要修改的位置。示例:`--range "A1:C1" --values '[[{"type":"text","text":"新值"},{},{}]]'` 只更新 A1,B1 和 C1 保持不变。
### type=text(普通文本)
```json
{ "type": "text", "text": "文本内容" }
{ "type": "text", "text": "重要", "cellStyles": { "fontWeight": "bold", "fontColor": "#FF0000" } }
```
- `text` 必须为字符串;`text=""` 表示**清空该 cell**
- `text` 以 `=` 开头识别为公式(如 `"=SUM(B2:B4)"`)
- 写数字 / 布尔请用字符串形式(如 `{"type":"text","text":"100"}` / `"true"`),服务端按内容自动识别
- 字体样式(加粗/颜色/字号等)统一走 `cellStyles`,不支持 `style` 字段
### hyperlink 子结构(可选,与 type 同级,单元格级超链接)
`hyperlink` 作用于整个单元格,适合“这个单元格整体可点击跳转”的场景。它和 richText 的片段级 `link` 不同。
> **hyperlink 三种语义**:
> - **不传 `hyperlink` 字段** → 保留原超链接(引擎自动 readback 回写)
> - **`hyperlink: {"type":"none"}`** → 显式清除单元格超链接
> - **`hyperlink: {"type":"path"/"sheet"/"range", link, text?}`** → 写新超链接(覆盖)
>
> `{}` 跳过也会保留原超链接。
```json
{ "type": "text", "text": "钉钉", "hyperlink": { "type": "path", "link": "https://dingtalk.com" } }
{ "hyperlink": { "type": "sheet", "link": "Sheet2" } }
{ "hyperlink": { "type": "range", "link": "Sheet1!A4" } }
{ "hyperlink": { "type": "none" } }
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `type` | string | 必填,`path`(外部链接)/ `sheet`(工作表链接)/ `range`(单元格范围链接)/ `none`(显式清除) |
| `link` | string | type=path/sheet/range 时必填。`path` 为 URL;`sheet` 为工作表 ID 或名称;`range` 为 A1 表示法 |
| `text` | string | 可选显示文本。通常只传 cell 的 `text`,不用重复传 `hyperlink.text` |
注意:
- 不传 `hyperlink` 字段同于 “保留原超链接”,无需先 read 再回传
- Agent 调用统一使用 `hyperlink: {type:"none"}` 清除超链接;底层 REST 兼容 `hyperlink:null`,但 MCP schema / 网关可能过滤 null 字段,不要把 null 当默认写法
- `hyperlink` 可以不带 `type/text` cell 单独出现,用于只设置或清理链接并保留原值
- 不要把 `hyperlink` 和 `type:"richText"` 混用;整格链接用 `hyperlink`,片段链接用 richText 子项 `type:"link"`
### type=richText(富文本:片段链接 / 附件 / 图片 / 多片段组合)
```json
{ "type": "richText", "texts": [ ...子项数组... ] }
```
`texts` 子项 `type` 枚举与字段:
| 子项 type | 必填字段 | 可选字段 | 说明 |
|-----------|---------|---------|------|
| `text` | `text`(字符串) | `style` | 普通文本片段 |
| `link` | `text` + `link`(都非空字符串) | `subType` / `style` | 富文本片段链接。`subType` 默认为 `path`;`path` 的 `link` 是 URL,`sheet` 的 `link` 是真实工作表名称,`range` 的 `link` 是 A1 表示法(如 `Sheet1!A1:B2`) |
| `attachment` | `text` + `resourceId` + `mimeType` | `size`(字节数) | 附件。`text` 是显示文件名,`resourceId` 通过 `dws sheet media-upload` 获取 |
| `image` | `resourceId` + `resourceUrl` | `text`(建议传 `""`) / `width` / `height` | 图片。两个 resource 字段都通过 `dws sheet media-upload` 获取;像素 |
### style 子结构(仅 richText 子项的 `text` / `link` 类型支持)
用于 richText 内部片段级样式,实现同一单元格内不同文字有不同样式(如部分文字红色加粗)。
| 字段 | 类型 | 说明 |
|------|------|------|
| `bold` | boolean | 加粗 |
| `italic` | boolean | 斜体 |
| `underline` | boolean | 下划线 |
| `strike` | boolean | 删除线 |
| `color` | string | 字体颜色,16 进制色值(如 `#FF0000`) |
| `size` | number | 字号,正整数 |
**richText link 的 `subType`**:
```json
{ "type": "link", "text": "钉钉", "link": "https://dingtalk.com", "subType": "path" }
{ "type": "link", "text": "工作表", "link": "Sheet2", "subType": "sheet" }
{ "type": "link", "text": "明细区域", "link": "Sheet2!A1:B20", "subType": "range" }
```
- 不传 `subType` 时按 `path` 处理,适合外部 URL
- `subType:"sheet"` / `"range"` 需要使用真实工作表名称或 A1 范围;未知时先 `dws sheet list --node <NODE_ID> --format json`,禁止猜 `Sheet1`
- 这只影响富文本片段链接;整格链接仍使用 cell-level `hyperlink`
- 写入后用 `range read` 读取时,`richText.texts[].subType` 会按同样语义返回;不要把 richText 片段链接和整格 `hyperlink` 混淆
注意:`type:"text"` 的顶层旧 `style` 字段只作为历史兼容存在,新请求不要使用;整个单元格的字体样式请用 `cellStyles`,同一 cell 内分段样式才用 richText 子项 `style`。
### dataValidation 子结构(可选,与 type 同级)
任何 cell 可附加 `dataValidation` 字段,在写值的同时设置数据校验。支持两种类型:
> **dataValidation 三种语义**:
> - **不传 `dataValidation` 字段** → 自动保留原 DV(无需 read 后回写)
> - **`dataValidation: {"type":"none"}`** → 显式清除该单元格 DV
> - **`dataValidation: {"type":"dropdown"/"checkbox", ...}`** → 写新 DV(覆盖原 DV)
>
> `{}` 跳过和不传 dataValidation 字段都会保留原 DV。
**dropdown(下拉列表)**:
```json
{ "type": "text", "text": "High", "dataValidation": { "type": "dropdown", "options": [{"value":"High","color":"#00ff00"},{"value":"Low","color":"#ff0000"}], "enableMultiSelect": false } }
```
- `options`:必填,`[{value, color?}]` 数组
- `enableMultiSelect`:可选,是否多选,默认 false
**checkbox(复选框)**:
```json
{ "dataValidation": { "type": "checkbox", "checked": true } }
```
- `checked`:可选,初始勾选状态,默认 false
- checkbox 通常不需要 type/text(保留原值),也可以和 `type:"text"` 共存
**翻译场景示例**(一次调用更新文本 + 翻译 dropdown 选项 + 跳过 checkbox):
```bash
dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "A1:C1" \
--values '[[{"type":"text","text":"High","dataValidation":{"type":"dropdown","options":[{"value":"High"},{"value":"Medium"},{"value":"Low"}]}},{},{"type":"text","text":"Translated"}]]'
```
### cellStyles 子结构(可选,与 type 同级)
任何 cell 可附加 `cellStyles` 字段,在写值的同时设置 cell-level 样式。与 `style`(内联文本样式)的区别见下方说明。
```json
{ "type": "text", "text": "重要", "cellStyles": { "fontWeight": "bold", "backgroundColor": "#FFF2CC" } }
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `fontWeight` | string | `bold` / `normal` |
| `fontColor` | string | 字体颜色,`#RRGGBB` |
| `fontSize` | number | 字号 |
| `fontStyle` | string | `italic` / `normal` |
| `backgroundColor` | string | 背景色,`#RRGGBB` |
| `horizontalAlignment` | string | `left` / `center` / `right` / `general` |
| `verticalAlignment` | string | `top` / `middle` / `bottom` |
| `wordWrap` | string | `overflow` / `clip` / `autoWrap` |
| `numberFormat` | string | 数字格式 code,如 `@`、`#,##0.00`、`yyyy/m/d`;格式 code 说明见 [「number-format 格式 code」](sheet-style-format.md#number-format-格式-code) |
| `textUnderline` | boolean | 下划线 |
| `textLineThrough` | boolean | 删除线 |
所有字段均可选,只传需要设置的字段。也可以不传 `type`/`text`,仅用 `{cellStyles:{...}}` 对已有单元格追加样式(保留原值)。
选择 `numberFormat` 前,先阅读 [「number-format 格式 code」](sheet-style-format.md#number-format-格式-code),确认目标格式类型对应的 code。
长数字标识符请显式设置文本格式:商品 ID、规格 ID、SKU、订单号、手机号、工号等字段建议写成 `{"type":"text","text":"528545015680","cellStyles":{"numberFormat":"@"}}`。仅把值写成文本不一定能阻止常规格式展示;`@` 可以避免 11 位以上数字形态 ID 被显示成科学计数法。`range append` 不支持随行传 `cellStyles`,追加后请对返回的 `a1Notation` 或目标 ID 列执行 `range set-style --number-format "@"`。
**`cellStyles` vs `style` vs `set-style` 的区别**:
| 方式 | 适用场景 | 写在哪里 | 作用范围 |
|------|---------|---------|---------|
| `style`(richText 片段样式) | 同一 cell 内不同文字有不同字体样式 | richText 子项(`text`/`link` 类型)的 `style` | 文本片段级别 |
| `cellStyles`(cell-level 样式) | 背景色、对齐、换行、数字格式等 | cell 的 `cellStyles` | 整个单元格 |
| `set-style` / `batch-set-style` | 批量设置整片区域的样式 | 单独命令,与 `range update` 分开调用 | 指定 range 内所有单元格 |
典型用法:
- 写入少量单元格 + 样式 → 用 `range update` 的 `cellStyles`,一次调用搞定
- 批量刷整片区域统一样式 → 用 `set-style`(如 "给 A1:Z1 表头加粗居中")
- 文本内部分段样式(如"重要"二字红色加粗,其余正常) → 用 `type:"richText"` + 子项 `style`
### 混合示例(普通文字 + 带样式片段链接)
```json
{
"type": "richText",
"texts": [
{ "type": "text", "text": "请访问 " },
{ "type": "link", "text": "钉钉官网", "link": "https://dingtalk.com", "style": { "color": "#0080FF", "underline": true } }
]
}
```
### 重要约束
- 不再支持 `{type:"number"}` / `{type:"boolean"}` / `{type:"null"}` —— MCP `complexValues` 仅接受 `text` / `richText` 两种 type,或 `{}` 跳过。数字 / 布尔走 `{type:"text","text":"<字符串形式>"}`
- 不支持直接传入原始值(字符串、数字、布尔、null、空字符串);`null` 不等同于 `{}`,`null` 会报错
- 维度必须与 `--range` 范围完全一致,例如 `--range "A1:B3"` 需要 3 行 2 列的数组
- 清理整格超链接使用 `{"hyperlink":{"type":"none"}}`;不要使用 `{"hyperlink":null}` 作为 agent 默认调用形态
- 写图片到单元格建议直接用 `dws sheet write-image`(更简洁)
- 清空整片区域请用 `dws sheet range clear`;只清空单个 cell 可在 `--values` 中传 `{"type":"text","text":""}`
## 核心工作流
```bash
# ── 工作流 1: 创建表格并写入数据 ──
# 1. 创建表格文档 — 提取 nodeId
dws sheet create --name "销售数据" --format json
# 2. 查看工作表列表 — 提取 sheetId
dws sheet list --node <NODE_ID> --format json
# 3. 写入表头和数据
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C1" \
--values '[[{"type":"text","text":"姓名"},{"type":"text","text":"部门"},{"type":"text","text":"销售额"}]]' --format json
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A2:C4" \
--values '[[{"type":"text","text":"张三"},{"type":"text","text":"销售部"},{"type":"text","text":"50000"}],[{"type":"text","text":"李四"},{"type":"text","text":"市场部"},{"type":"text","text":"38000"}],[{"type":"text","text":"王五"},{"type":"text","text":"销售部"},{"type":"text","text":"62000"}]]' --format json
# ── 工作流 4: 写入数据并设置样式 ──
# 1. 写入数据
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:C3" \
--values '[[{"type":"text","text":"商品"},{"type":"text","text":"单价"},{"type":"text","text":"数量"}],[{"type":"text","text":"苹果"},{"type":"text","text":"5.5"},{"type":"text","text":"100"}],[{"type":"text","text":"香蕉"},{"type":"text","text":"3.2"},{"type":"text","text":"200"}]]' --format json
# 2. 设置数字格式(人民币)——两种方式均可:
# 方式 A: 写值时通过 cellStyles 一步到位
# 方式 B: 单独用 set-style 设置(适合只改格式不改值)
dws sheet range set-style --node <NODE_ID> --sheet-id <SHEET_ID> --range "B2:B3" \
--number-format '"¥"#,##0.00' --format json
# 3. 长数字 ID 写值时同步设置文本格式,避免科学计数法
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "D2:D3" \
--values '[[{"type":"text","text":"528545015680","cellStyles":{"numberFormat":"@"}}],[{"type":"text","text":"528545015681","cellStyles":{"numberFormat":"@"}}]]' --format json
# 4. 写入单元格级超链接
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "D1" \
--values '[[{"type":"text","text":"详情","hyperlink":{"type":"path","link":"https://dingtalk.com"}}]]' --format json
# ── 工作流 5: 追加数据 ──
# 1. 获取工作表列表
dws sheet list --node <NODE_ID> --format json
# 2. 查看工作表详情(确认列结构)
dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json
# 3. 追加单行数据
dws sheet append --node <NODE_ID> --sheet-id <SHEET_ID> \
--values '[["张三","销售部",50000]]' --format json
# 4. 追加多行数据
dws sheet append --node <NODE_ID> --sheet-id <SHEET_ID> \
--values '[["李四","市场部",38000],["王五","销售部",62000]]' --format json
```
## 上下文传递
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `create` | `nodeId` | list / info / new / range update / append / csv-put 的 --node |
| `list` | 工作表的 `sheetId` | range update / append / csv-put 的 --sheet-id |
| `new` | 新工作表的 `sheetId` | range update / append / csv-put 的 --sheet-id |
| `info` | `rowCount` / `nonEmptyRange.range` / `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn` / `mergedRanges` | 确定数据范围、追加写入起始行、识别合并单元格结构 |
| `append` | `a1Notation` 追加数据所在范围 | 确认追加位置 |
| `csv-put` | `a1Notation` 实际写入的单元格范围 | 确认写入位置和范围 |
## 注意事项
- ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node <NODE_ID> --format json` 查询真实的 `sheetId` / 工作表名称后再调用,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等);用户仅给出工作表名称时,也应通过 `list` 校验该名称是否存在,避免名称大小写或拼写不一致导致失败
- ★ **`range update` 维度校验(强制)**:调用 `range update` 写入 `--values` 时,必须严格校验二维 JSON 数组的行数与列数与 `--range` 指定的范围完全一致:
- 例如 `--range "A1:C3"` 表示 3 行 × 3 列,`--values` 必须是 `[[v1,v2,v3],[v4,v5,v6],[v7,v8,v9]]` 这样 3×3 的数组
- `--range "A1"` 表示 1 行 × 1 列,`--values` 必须是 `[[v]]`
- 维度不足请按行 / 列补齐为同等大小;不需要修改的位置用 `{}` 跳过(保留原值),需要清空的位置用 `{"type":"text","text":""}`;禁止出现各行列数不一致或与 `--range` 不匹配的情况,否则调用会直接报错
- 如需写整格超链接,把 `{"type":"text","text":"...","hyperlink":{"type":"path","link":"..."}}` 放进 `--values` 二维数组对应的单元格里;富文本片段链接才使用 richText 子项 `type:"link"`
- ★ **清空区域优先用 `range clear`(强制)**:需要清空整片区域时必须使用 `range clear`,禁止用 `range update` 模拟。仅在 `range update` 写入混合数据时个别 cell 需要清空,才在 `--values` 中用 `{"type":"text","text":""}`
- ★ **不再支持 `{type:"number"}` / `{type:"boolean"}` / `{type:"null"}`(强制)**:MCP `complexValues` 仅接受 `type:"text"` 与 `type:"richText"` 两种,CLI 会在本地直接拦截非法 type 并报错。写数字 / 布尔请用 `{"type":"text","text":"<字符串形式>"}`(服务端按内容自动识别),不要再用旧的 `value` 字段
- **dataValidation 三语义**:不传字段=保留;`{type:"none"}`=清除;`{type:"dropdown"/"checkbox",...}`=覆盖。无需先 read 再回传,引擎自动保留原 DV
- **hyperlink 三语义**:不传字段=保留;`{type:"none"}`=清除;`{type:"path"/"sheet"/"range",...}`=覆盖。Agent 调用不要使用 `hyperlink:null`
- ★ **单次调用上限(强制)**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000)
- ★ **大批量纯值写入用 `csv-put` 不用 `range update`**:当写入纯值(无公式、无超链接、无富文本对象)且数据量较大时(>5 行或 >20 单元格),必须使用 `csv-put`。`csv-put` 接受 CSV 文本直接写入,无需构造二维 JSON 数组,支持自动扩容,更简洁高效。仅在需要写入公式、单元格级超链接、富文本对象,或仅更新少量单元格时才使用 `range update`
- `range update` 必填 `--values`;单元格级超链接通过 cell 的 `hyperlink` 字段表达,附件 / 图片 / 带样式片段通过 `--values` 内的 richText 富格式表达,CLI 不再有 `--hyperlinks` 参数
- `range update` 职责边界:`range update` 写入单元格内容(文本 / 公式 / 富文本对象),支持通过 `cellStyles` 附带 per-cell 样式(背景色 / 字号 / 对齐等)。但批量刷整片区域的统一样式时,应使用 `dws sheet range set-style`(如 "给表头加粗居中")或 `dws sheet range batch-set-style --batch <config.json>`。两种方式各有适用场景:少量 cell 写值 + 样式一步到位用 `cellStyles`;大面积统一样式用 `set-style`
- `append` 自动定位到最后一行有数据的位置下方插入,无需手动计算行号
- `append` 的 `--values` 二维数组中每行的列数必须一致,否则会报错。如果用户提供的数据中各行长度不同,必须先将短行用空字符串 `""` 补齐到与最长行相同的列数后再调用。追加的数据列数也应与工作表已有数据列数保持一致
- `append` vs `range update`:追加新行用 `append`,修改已有单元格用 `range update`
- ★ **`append` / `csv-put` 不支持 `{}` skip、`dataValidation`、富文本、公式**:这些能力仅限 `range update`。`append` 和 `csv-put` 只接受原始值(字符串/数字/布尔),走的是不同的 MCP tool(`append_rows` / `set_range_from_csv`)。需要写入公式、超链接、下拉列表或跳过部分单元格时,必须使用 `range update`
# 单命令产品合集
以下产品命令较少,合并参考。
---
## devdoc — 开放平台文档
### 搜索开放平台文档
```
Usage:
dws devdoc article search [flags]
Example:
dws devdoc article search --query "OAuth2 接入" --page 1 --size 10
Flags:
--query string 搜索关键词 (必填)
--page string 页码 (默认 1)
--cursor string 分页游标,翻页传上次返回的 nextCursor;传入后不再使用 --page
--size string 每页数量 (默认 10)
```
### 错误排查
```
Usage:
dws devdoc error diagnose [flags]
Example:
dws devdoc error diagnose --request-id 15r6h45w0muec --format json
dws devdoc error diagnose --error-code 33012 --error-message "missing scope" --format json
Flags:
--query string 原始排查问题
--request-id string 开放平台 requestId
--trace-id string requestId 的兼容别名
--error-code string 错误码
--error-message string 错误描述,会合并进原始问题
--api string API 名称,会合并进原始问题作为补充检索词
--context string 额外排查上下文,会合并进原始问题
--page int 分页页码 (默认 1)
--cursor string 分页游标,翻页传上次返回的 nextCursor;传入后不再使用 --page
--size int 分页大小 (默认 10)
```
---
## oa — 审批
### 查询可见审批流程
```
Usage:
dws oa approval list-forms [flags]
Example:
dws oa approval list-forms --format json
```
### 查询审批实例详情
```
Usage:
dws oa approval detail --instance-id <ID> [flags]
Example:
dws oa approval detail --instance-id <ID> --format json
```
### 查询审批记录
```
Usage:
dws oa approval records --instance-id <ID> [flags]
Example:
dws oa approval records --instance-id <ID> --format json
```
### 查询待我审批的任务
```
Usage:
dws oa approval tasks [flags]
Example:
dws oa approval tasks --format json
```
### 查询待我处理的审批
```
Usage:
dws oa approval list-pending [flags]
Example:
dws oa approval list-pending --format json
```
### 查询我发起的审批
```
Usage:
dws oa approval list-initiated [flags]
Example:
dws oa approval list-initiated --format json
```
### 同意审批
```
Usage:
dws oa approval approve --instance-id <ID> --task-id <TASK_ID> [flags]
Example:
dws oa approval approve --instance-id <ID> --task-id <TASK_ID> --format json
```
### 拒绝审批
```
Usage:
dws oa approval reject --instance-id <ID> --task-id <TASK_ID> [flags]
Example:
dws oa approval reject --instance-id <ID> --task-id <TASK_ID> --remark "不符合要求" --format json
```
### 撤销审批
```
Usage:
dws oa approval revoke --instance-id <ID> [flags]
Example:
dws oa approval revoke --instance-id <ID> --format json
```
---
## 意图判断
- 用户说"开发文档/API 文档/接口文档" → `devdoc article search`
- 用户说"调用报错/requestId/traceId/错误码/错误描述" → `devdoc error diagnose`;若返回 `PARAM_ERROR - 未找到指定工具`,降级 `devdoc article search` 并标记后端工具注册待闭环
- 用户说"审批/请假/报销/出差" → `oa approval`
- 用户说"同意审批/批准" → `oa approval approve`
- 用户说"拒绝审批/驳回" → `oa approval reject`
- 用户说"撤销审批/撤回" → `oa approval revoke`
- 用户说"待我审批/我要审批的" → `oa approval list-pending` 或 `oa approval tasks`
- 用户说"我发起的审批" → `oa approval list-initiated`
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `devdoc article search` | 文档链接、nextCursor | 直接展示给用户;hasMore=true 时用 --cursor 翻页 |
| `devdoc error diagnose` | diagnosticInfo、references、materials、nextCursor | 排查开放平台调用错误;hasMore=true 时用 --cursor 翻页;不可用时退回文档搜索 |
| `oa approval list-forms` | processCode | detail / records 等 |
| `oa approval tasks` | taskId, instanceId | approve / reject |
| `oa approval list-pending` | instanceId | detail / approve / reject |
| `oa approval list-initiated` | instanceId | detail / revoke |
# 待办 (todo) 命令参考
## 命令总览
### 创建待办
```
Usage:
dws todo task create [flags]
Example:
dws todo task create --title "修复线上Bug" --executors <USER_ID_1>,<USER_ID_2> --priority 40
dws todo task create --title "每日站会" --executors <USER_ID> --due "2026-03-20T10:00:00+08:00" --recurrence "DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1"
Flags:
--due string 截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00;这是 deadline,不是 reminder)
--executors string 执行者 userId 列表 (必填)
--priority string 优先级: 10低/20普通/30较高/40紧急
--recurrence string 循环待办 (需先设置 --due); 仅支持按天循环,格式见下方说明
--title string 待办标题 (必填)
```
### 创建子待办
```
Usage:
dws todo task create-sub [flags]
Example:
dws todo task create-sub --parent-id <PARENT_TASK_ID> --title "子任务标题" --executors <USER_ID_1>,<USER_ID_2> --priority 40
dws todo task create-sub --parent-id <PARENT_TASK_ID> --title "子任务标题" --executors <USER_ID> --due "2026-03-20T10:00:00+08:00"
Flags:
--due string 截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00;这是 deadline,不是 reminder)
--executors string 执行者 userId 列表 (必填)
--parent-id string 父待办任务 ID (必填,该信息可以通过创建待办接口或者查询待办列表接口返回)
--priority string 优先级: 10低/20普通/30较高/40紧急
--recurrence string 循环待办 (需先设置 --due); 仅支持按天循环,格式见下方说明
--title string 子待办标题 (必填)
```
### 查询待办列表
```
Usage:
dws todo task list [flags]
Example:
dws todo task list --page 1 --size 20 --status false
Flags:
--page string 页码 (默认 1)
--size string 每页数量 (默认 20)
--status string true=已完成, false=未完成
```
### 修改待办任务
```
Usage:
dws todo task update [flags]
Example:
dws todo task update --task-id <taskId> --title "新标题"
dws todo task update --task-id <taskId> --priority 40 --due "2026-03-10T18:00:00+08:00"
dws todo task update --task-id <taskId> --done true
Flags:
--done string 完成状态: true/false
--due string 截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00;这是 deadline,不是 reminder)
--priority string 优先级: 10低/20普通/30较高/40紧急
--task-id string 待办任务 ID (必填)
--title string 新标题
```
### 修改执行者的待办完成状态
```
Usage:
dws todo task done [flags]
Example:
dws todo task done --task-id <taskId> --status true
dws todo task done --task-id <taskId> --status false
Flags:
--status string 完成状态: true=已完成, false=未完成 (必填)
--task-id string 待办任务 ID (必填)
```
### 待办详情
```
Usage:
dws todo task get [flags]
Example:
dws todo task get --task-id <taskId>
Flags:
--task-id string 待办任务 ID (必填)
```
### 删除待办
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws todo task delete [flags]
Example:
dws todo task delete --task-id <taskId>
dws todo task delete --task-id <taskId> --yes
Flags:
--task-id string 待办任务 ID (必填)
```
### 新增待办评论
```
Usage:
dws todo comment add [flags]
Example:
dws todo comment add --task-id <taskId> --content "评论内容"
Flags:
--task-id string 待办任务 ID (必填)
--content string 评论内容 (必填)
```
### 查询待办评论列表
```
Usage:
dws todo comment list [flags]
Example:
dws todo comment list --task-id <taskId>
dws todo comment list --task-id <taskId> --page 1 --size 20
Flags:
--task-id string 待办任务 ID (必填)
--page string 页码 (默认 1)
--size string 每页数量 (默认 20)
```
### 删除待办评论
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws todo comment delete [flags]
Example:
dws todo comment delete --task-id <taskId> --comment-id <commentId>
dws todo comment delete --task-id <taskId> --comment-id <commentId> --yes
Flags:
--task-id string 待办任务 ID (必填)
--comment-id string 评论 ID (必填)
--yes 跳过二次确认 (慎用)
```
### 添加待办执行人
```
Usage:
dws todo task add-executor [flags]
Example:
dws todo task add-executor --task-id <taskId> --executors <USER_ID_1>,<USER_ID_2>
Flags:
--executors string 执行者 userId 列表 (必填)
--task-id string 待办任务 ID (必填)
```
### 移除待办执行人
```
Usage:
dws todo task remove-executor [flags]
Example:
dws todo task remove-executor --task-id <taskId> --executors <USER_ID_1>,<USER_ID_2>
Flags:
--executors string 执行者 userId 列表 (必填)
--task-id string 待办任务 ID (必填)
```
### 添加待办参与人
```
Usage:
dws todo task add-participant [flags]
Example:
dws todo task add-participant --task-id <taskId> --participants <USER_ID_1>,<USER_ID_2>
Flags:
--participants string 参与人 userId 列表 (必填)
--task-id string 待办任务 ID (必填)
```
### 移除待办参与人
```
Usage:
dws todo task remove-participant [flags]
Example:
dws todo task remove-participant --task-id <taskId> --participants <USER_ID_1>,<USER_ID_2>
Flags:
--participants string 参与人 userId 列表 (必填)
--task-id string 待办任务 ID (必填)
```
### 添加待办提醒
```
Usage:
dws todo task add-reminder [flags]
Example:
dws todo task add-reminder --task-id <taskId> --base-time dueTime --due-date-offset -30
dws todo task add-reminder --task-id <taskId> --base-time customTime --reminder-time-stamp "2026-03-10T18:00:00+08:00"
Flags:
--base-time string 提醒基准时间: dueTime/customTime (必填)
--due-date-offset string 截止时间偏移量 (baseTime=dueTime 时必填)
--reminder-time-stamp string 自定义提醒时间 ISO-8601 (如 2026-03-10T18:00:00+08:00;baseTime=customTime 时必填)
--task-id string 待办任务 ID (必填)
```
参数说明:
| 参数 | 类型 | 说明 |
|------|------|------|
| `--base-time` | string | 提醒基准时间,必填。`dueTime` = 基于截止时间偏移;`customTime` = 自定义时间戳 |
| `--due-date-offset` | number | 截止时间偏移量(分钟),`baseTime=dueTime` 时必填。负数表示提前,如 `-30` 表示截止前 30 分钟 |
| `--reminder-time-stamp` | string | 自定义提醒时间,ISO-8601 格式(如 `2026-03-10T18:00:00+08:00`),`baseTime=customTime` 时必填 |
### 重置待办提醒
```
Usage:
dws todo task reset-reminder [flags]
Example:
dws todo task reset-reminder --task-id <taskId>
dws todo task reset-reminder --task-id <taskId> --reminder-rules '[{"dueDateOffset":-30,"baseTime":"dueTime"},{"reminderTimeStamp":"2026-03-10T18:00:00+08:00","baseTime":"customTime"}]'
Flags:
--reminder-rules string 提醒规则 JSON 数组 (可选,为空则清除提醒)
--task-id string 待办任务 ID (必填)
```
`--reminder-rules` 数据结构说明:
JSON 数组,每个元素为一条提醒规则,支持两种 `baseTime` 模式混合使用:
| 字段 | 类型 | 说明 |
|------|------|------|
| `baseTime` | string | 提醒基准时间,必填。`dueTime` = 基于截止时间偏移;`customTime` = 自定义时间戳 |
| `dueDateOffset` | number | 截止时间偏移量(分钟),`baseTime=dueTime` 时必填。负数表示提前,如 `-30` 表示截止前 30 分钟 |
| `reminderTimeStamp` | string | 自定义提醒时间,ISO-8601 格式(如 `2026-03-10T18:00:00+08:00`),`baseTime=customTime` 时必填 |
示例:
```json
[
{"dueDateOffset": -30, "baseTime": "dueTime"},
{"reminderTimeStamp": "2026-03-10T18:00:00+08:00", "baseTime": "customTime"}
]
```
以上表示两条提醒规则:第一条在截止时间前 30 分钟提醒,第二条在指定时间(ISO-8601)提醒。
## 意图判断
用户说"加个待办/记一下/TODO" → `task create`
用户说"每天重复/循环待办/按天重复" → `task create`(需 `--due` + `--recurrence`)
用户说"加个子任务/创建子待办" → `task create-sub`
用户说"看看待办/我有啥要做" → `task list`
用户说"改个待办/修改待办标题/改优先级" → `task update`
用户说"做完了/完成待办/标记完成" → `task done`
用户说"看看待办详情" → `task get`
用户说"删除待办/取消待办" → `task delete`
用户说"给待办加条评论/留个备注" → `comment add`
用户说"看看这个待办的评论" → `comment list`
用户说"删除这条评论" → `comment delete`
用户说"加个执行人/添加执行者" → `task add-executor`
用户说"移除执行人/删除执行者" → `task remove-executor`
用户说"加个参与人/添加参与者" → `task add-participant`
用户说"移除参与人/删除参与者" → `task remove-participant`
用户说"给待办加个提醒/设置提醒" → `task add-reminder`
用户说"重置提醒/清除提醒/修改提醒规则" → `task reset-reminder`
关键区分: todo(个人待办)
## 核心工作流
```bash
# 1. 创建待办 — 提取 todoTaskId
dws todo task create --title "修复线上Bug" --executors userId1,userId2 \
--priority 40 --due "2026-03-10T18:00:00+08:00" --format json
# 1b. 创建按天循环的待办(必须先有 --due;recurrence 与 MCP create_personal_todo 一致)
dws todo task create --title "每日站会" --executors userId1 \
--due "2026-03-20T10:00:00+08:00" \
--recurrence "DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1" --format json
# 1c. 创建子待办(需先获取父待办 ID)
dws todo task create-sub --parent-id <PARENT_TASK_ID> --title "子任务标题" --executors userId1 \
--priority 40 --due "2026-03-10T18:00:00+08:00" --format json
# 2. 查看未完成待办
dws todo task list --page 1 --size 20 --status false --format json
# 3. 查看待办详情
dws todo task get --task-id <taskId> --format json
# 4. 修改待办信息
dws todo task update --task-id <taskId> --title "新标题" --priority 40 --format json
# 5. 标记待办完成
dws todo task done --task-id <taskId> --status true --format json
# 6. 删除待办
dws todo task delete --task-id <taskId> --yes --format json
# 7. 给待办新增评论
dws todo comment add --task-id <taskId> --content "已开始处理" --format json
# 8. 查看待办评论列表
dws todo comment list --task-id <taskId> --page 1 --size 20 --format json
# 9. 删除待办评论
dws todo comment delete --task-id <taskId> --comment-id <commentId> --yes --format json
# 10. 添加待办执行人
dws todo task add-executor --task-id <taskId> --executors userId1,userId2 --format json
# 11. 移除待办执行人
dws todo task remove-executor --task-id <taskId> --executors userId1 --format json
# 12. 添加待办参与人
dws todo task add-participant --task-id <taskId> --participants userId1,userId2 --format json
# 13. 移除待办参与人
dws todo task remove-participant --task-id <taskId> --participants userId1 --format json
# 14. 添加待办提醒(基于截止时间偏移,待办必须有截止时间)
dws todo task add-reminder --task-id <taskId> --base-time dueTime --due-date-offset <dueDateOffset> --format json
# 15. 添加待办提醒(自定义时间戳)
dws todo task add-reminder --task-id <taskId> --base-time customTime --reminder-time-stamp "2026-03-10T18:00:00+08:00" --format json
# 16. 重置待办提醒
dws todo task reset-reminder --task-id <taskId> --format json
# 17. 重置待办提醒(指定新规则)
dws todo task reset-reminder --task-id <taskId> --reminder-rules '<reminderRules>' --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|---------------------------------------------|
| `task create` | `todoTaskId` | update/done/get/delete 的 --task-id |
| `task list` | `result[].id` | update/done/get/delete 的 --task-id |
| `task create` | `todoTaskId` | update/done/get/delete/comment 的 --task-id |
| `task list` | `result[].id` | update/done/get/delete/comment/add-executor/remove-executor/add-participant/remove-participant 的 --task-id |
| `task get` | `result.todoDetailModel.subTodos[]` | 获取子待办列表,提取子待办的 `taskId` 用于后续操作 |
| `comment list` | `result[].commentId` | `comment delete` 的 --comment-id |
## 注意事项
- 优先级值: 10=低, 20=普通, 30=较高, 40=紧急
- `--due` 是截止时间 dueTime,不是提醒时间;使用 ISO-8601 格式(如 2026-03-10T18:00:00+08:00)
- 当前不支持单独的 `reminder` / `remind-at` 精确提醒能力;不要把 `--due` 解释成“几点提醒”
- `--recurrence`:仅在与 `--due` 同时设置时有效;当前仅支持按天循环。字符串内需含换行,示例:`DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1`(DTSTART 表示首次截止时间,需与业务约定一致)
- 若用户的真实诉求是“到点提醒我”,需要先说明能力边界;当前 CLI 只能表达 deadline / recurrence,不能表达独立 reminder schedule
- `task list` 的 `--status` 对应 MCP `get_user_todos_in_current_org` 的 `todoStatus` 参数
- todo 是个人待办管理产品
- `task update` 可同时修改标题/优先级/截止时间/完成状态
- `task done` 专用于修改执行者的完成状态,与 `task update --done` 作用不同
- `task delete` 为不可逆操作,建议加 `--yes` 并与用户确认
- `comment delete` 同样为不可逆操作,执行前需用户确认;`--comment-id` 可通过 `comment list` 获取
- `task add-executor` / `task remove-executor` 用于管理待办的执行人,`--executors` 支持逗号分隔的多个 userId
- `task add-participant` / `task remove-participant` 用于管理待办的参与人,`--participants` 支持逗号分隔的多个 userId
- 执行人 (executor) 与参与人 (participant) 的区别:执行人负责完成待办,参与人仅关注待办进度
- `task add-reminder` 用于为待办添加提醒,`--base-time` 支持 `dueTime`(基于截止时间偏移,待办必须有截止时间)和 `customTime`(自定义时间戳)两种模式
- `task reset-reminder` 用于重置待办提醒规则,不传 `--reminder-rules` 则清除所有提醒
## 自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [todo_daily_summary.py](../../scripts/todo_daily_summary.py) | 查看今天/明天/本周未完成待办汇总 | `python todo_daily_summary.py today` |
| [todo_batch_create.py](../../scripts/todo_batch_create.py) | 从 JSON 文件批量创建待办 | `python todo_batch_create.py todos.json` |
| [todo_overdue_check.py](../../scripts/todo_overdue_check.py) | 扫描逾期待办输出逾期清单 | `python todo_overdue_check.py` |
# 知识库 (wiki) 命令参考
## 查询命令帮助
当你不确定某个命令的具体参数、格式或可选项时,**优先执行 `--help` 查询**,不要猜测参数名或凭记忆编造。
```bash
# 查看 wiki 下所有子命令
dws wiki --help
# 查看具体命令的完整参数说明
dws wiki space get --help
dws wiki member add --help
# 查看子命令组下的所有命令
dws wiki space --help
dws wiki member --help
```
规则:
- 参数名不确定时 → 先 `--help`,再调用
- 报错 "unknown flag" 时 → `--help` 确认正确的 flag 名称
- 不确定某个功能是否存在时 → `dws wiki --help` 查看命令列表
## 命令总览
### 创建知识库
```
Usage:
dws wiki space create [flags]
Example:
dws wiki space create --name "产品文档库" --format json
dws wiki space create --name "技术方案" --desc "团队技术方案归档" --format json
Flags:
--name string 知识库名称 (必填,不超过 100 字符)
--desc string 知识库描述 (选填,不超过 500 字符)
--icon string 知识库图标标识 (选填)
```
### 删除知识库
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws wiki space delete [flags]
Example:
dws wiki space delete --workspace <workspaceId>
dws wiki space delete --workspace "https://alidocs.dingtalk.com/i/spaces/xxx/overview"
Flags:
--workspace string 知识库 ID 或 URL (必填)
```
将指定知识库移入回收站。删除后知识库会进入回收站,可在回收站中恢复。
> **重要约束**:
> - 操作者必须具备知识库的 OWNER 角色。
> - 删除操作不可逆(从回收站恢复除外),请确认后再执行。
### 查看知识库详情
```
Usage:
dws wiki space get [flags]
Example:
dws wiki space get --workspace <workspaceId> --format json
dws wiki space get --workspace "https://alidocs.dingtalk.com/i/spaces/xxx/overview" --format json
Flags:
--workspace string 知识库 ID 或 URL (必填)
```
支持传入知识库 ID 或知识库 URL,系统自动识别。
知识库 URL 格式:`https://alidocs.dingtalk.com/i/spaces/{workspaceId}/overview`
### 列出知识库
```
Usage:
dws wiki space list [flags]
Example:
dws wiki space list --format json
dws wiki space list --type myWikiSpace --format json
dws wiki space list --type orgWikiSpace --limit 50 --format json
Flags:
--type string 知识库类型: myWikiSpace / orgWikiSpace (默认 orgWikiSpace)
--limit string 每页数量 1-50 (默认 20)
--cursor string 分页游标 (首页留空)
```
- `myWikiSpace`:返回当前用户的「我的文档」个人空间(固定 1 条,不支持分页)
- `orgWikiSpace`(默认):返回组织内有权访问的知识库列表,支持分页
### 搜索知识库
```
Usage:
dws wiki space search [flags]
Example:
dws wiki space search --query "产品文档" --format json
dws wiki space search --query "技术方案" --limit 20 --format json
dws wiki space search --type myWikiSpace --format json
Flags:
--query string 搜索关键词 (--type myWikiSpace 时可省略)
--type string 知识库类型: myWikiSpace 时直接返回「我的文档」,省略则搜索组织知识库
--limit string 返回数量 1-20 (默认 10)
```
当 `--type myWikiSpace` 时,忽略 `--query`,直接返回「我的文档」个人空间。
### 添加知识库成员(容器级授权)
```
Usage:
dws wiki member add [flags]
Example:
dws wiki member add --workspace <WS_ID> --users uid1 --role READER
dws wiki member add --workspace <WS_ID> --users uid1,uid2 --role EDITOR
dws wiki member add --workspace "https://alidocs.dingtalk.com/i/spaces/<WS_ID>/overview" --users uid1 --role MANAGER
Flags:
--workspace string 目标知识库 ID 或 URL (必填)
--users strings 被加入的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
--role string 授予的角色 (必填,大小写敏感,必须全大写): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读)
```
> **重要约束**:
> - 仅支持 USER 类型。
> - 角色枚举严格大写:MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加,知识库创建者默认为所有者)。
> - 操作者需具备知识库的 OWNER 或 MANAGER 权限。
> - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**;后端会直接拒绝。如果你的目标只是把某篇文档分享给别人,请改用 `dws drive permission add` 在节点级别授权。
### 移除知识库成员
```
Usage:
dws wiki member remove [flags]
Example:
dws wiki member remove --workspace <WS_ID> --users uid1
dws wiki member remove --workspace <WS_ID> --users uid1,uid2
Flags:
--workspace string 目标知识库 ID 或 URL (必填)
--users strings 被移除的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
```
> **重要约束**:
> - OWNER 角色不可通过此接口移除。
> - 操作者需具备知识库的 OWNER 或 MANAGER 权限。
> - 移除后相关用户将无法访问该知识库下的内容(除非通过节点级权限另行授权)。
> - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**。
### 修改知识库成员角色
```
Usage:
dws wiki member update [flags]
Example:
dws wiki member update --workspace <WS_ID> --users uid1 --role EDITOR
dws wiki member update --workspace <WS_ID> --users uid1,uid2 --role READER
Flags:
--workspace string 目标知识库 ID 或 URL (必填)
--users strings 目标用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
--role string 新角色 (必填,大小写敏感,必须全大写): MANAGER / EDITOR / DOWNLOADER / READER
```
### 列出知识库成员
```
Usage:
dws wiki member list [flags]
Example:
dws wiki member list --workspace <WS_ID>
dws wiki member list --workspace <WS_ID> --limit 100
dws wiki member list --workspace <WS_ID> --filter-role EDITOR
Flags:
--workspace string 目标知识库 ID 或 URL (必填)
--limit int 返回数量上限,最大 200 (默认 50)
--filter-role string 按角色过滤: MANAGER / EDITOR / DOWNLOADER / READER (选填)
```
> 接口不支持游标分页,使用 `--limit` 一次性拉取。
### 列出知识库节点
```
Usage:
dws wiki node list [flags]
Aliases:
list, ls
Example:
dws wiki node list --workspace <workspaceId> --format json
dws wiki node list --workspace <workspaceId> --folder <parentNodeId> --format json
dws wiki node list --workspace <workspaceId> --limit 20 --cursor <pageToken> --format json
Flags:
--workspace string 知识库 ID (必填)
--folder string 父节点 nodeId (选填,不传则列出根目录)
--limit int 每页数量 (默认 50,最大 50)
--cursor string 分页游标
```
### 在知识库中创建节点
```
Usage:
dws wiki node create [flags]
Example:
dws wiki node create --workspace <workspaceId> --name "新文档" --format json
dws wiki node create --workspace <workspaceId> --name "方案目录" --type folder --format json
dws wiki node create --workspace <workspaceId> --name "数据表" --type asheet --folder <parentNodeId> --format json
Flags:
--workspace string 知识库 ID (必填)
--name string 节点名称 (必填)
--type string 节点类型: adoc / asheet / folder / axls (默认 adoc)
--folder string 父节点 nodeId (选填,不传则在根目录创建)
```
### 复制知识库节点
```
Usage:
dws wiki node copy [flags]
Example:
dws wiki node copy --workspace <workspaceId> --node <nodeId> --format json
dws wiki node copy --workspace <workspaceId> --node <nodeId> --folder <targetFolderId> --format json
Flags:
--workspace string 知识库 ID (必填)
--node string 源节点 ID (必填)
--folder string 目标文件夹 nodeId (选填)
```
### 移动知识库节点
```
Usage:
dws wiki node move [flags]
Example:
dws wiki node move --workspace <workspaceId> --node <nodeId> --folder <targetFolderId> --format json
dws wiki node move --workspace <workspaceId> --node <nodeId> --format json
Flags:
--workspace string 知识库 ID (必填)
--node string 源节点 ID (必填)
--folder string 目标文件夹 nodeId (选填)
```
### 删除知识库节点
> **CAUTION:** 不可逆操作 — 执行前必须向用户确认。
```
Usage:
dws wiki node delete [flags]
Example:
dws wiki node delete --workspace <workspaceId> --node <nodeId>
dws wiki node delete --workspace <workspaceId> --node <nodeId> --yes
Flags:
--workspace string 知识库 ID (必填,用于权限校验)
--node string 节点 ID (必填)
```
将知识库中的节点移入回收站。权限要求: 对节点有"管理"权限。
### 在知识库中搜索节点
```
Usage:
dws wiki node search [flags]
Example:
dws wiki node search --workspace <workspaceId> --query "方案" --format json
dws wiki node search --workspace <workspaceId> --query "周报" --limit 10 --format json
dws wiki node search --workspace <workspaceId> --query "设计" --extensions adoc,asheet --format json
Flags:
--workspace string 知识库 ID (必填)
--query string 搜索关键词 (必填)
--extensions string 按文件类型过滤,逗号分隔: adoc,asheet 等 (选填)
--limit int 每页数量 (选填)
--cursor string 分页游标 (选填)
```
在指定知识库空间内搜索节点。与 `drive search` 的区别:
- `wiki node search` — 限定在某个知识库空间内搜索(需要 `--workspace`)
- `drive search` — 全局搜索,聚合钉盘 + 文档空间结果
### 列出空间(支持钉盘空间类型)
`wiki space list` 除了支持知识库类型(`orgWikiSpace` / `myWikiSpace`),还支持钉盘空间类型:
```
Usage:
dws wiki space list --type orgSpace --format json # 钉盘企业空间
dws wiki space list --type mySpace --format json # 钉盘「我的文件」
dws wiki space list --type orgWikiSpace --format json # 知识库(默认)
dws wiki space list --type myWikiSpace --format json # 我的文档
Flags:
--type string 空间类型:
orgWikiSpace (默认) — 组织知识库
myWikiSpace — 我的文档个人空间
orgSpace — 钉盘企业空间
mySpace — 钉盘「我的文件」
--limit string 每页数量 1-50 (默认 20)
--cursor string 分页游标 (首页留空)
```
> 钉盘空间类型(`orgSpace` / `mySpace`)会自动路由到钉盘 MCP 服务,等同于原 `drive list-spaces`(已 deprecated)。
## 意图判断
- 用户说"创建知识库/新建知识库" → `space create`
- 用户说"查看知识库/知识库详情" → `space get`
- 用户说"我的知识库/知识库列表/有哪些知识库" → `space list`
- 用户说"列出钉盘空间/钉盘团队空间" → `space list --type orgSpace`
- 用户说"搜索知识库/找知识库" → `space search`
- 用户说"我的文档/个人空间" → `space list --type myWikiSpace`
- 用户说"知识库下的文件/知识库里有哪些文档/浏览知识库内容" → `node list`(需 `--workspace`)
- 用户说"在知识库里搜文档/空间内搜索" → `node search`(需 `--workspace` + `--query`)
- 用户说"在知识库里创建文档/新建文件夹" → `node create`(需 `--workspace` + `--name`)
- 用户说"复制知识库里的文档" → `node copy`(需 `--workspace` + `--node`)
- 用户说"移动知识库里的文档" → `node move`(需 `--workspace` + `--node`)
- 用户说"删除知识库里的文档/节点" → `node delete`(需 `--workspace` + `--node`)
- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--workspace` + `--users` + `--role`)
- 用户说"修改某人在知识库的权限/调整成员角色" → `member update`
- 用户说"移除知识库成员/把某人从知识库移除/删除知识库成员" → `member remove`(需 `--workspace` + `--users`)
- 用户说"知识库有哪些成员/查看知识库成员" → `member list`
- 用户说"删除知识库/移除知识库/把知识库删了" → `space delete`(需 `--workspace`)
> **跨产品路由说明**:知识库节点的**内容操作**(读取/编辑/块级操作)仍由 `dws doc` 承担:
>- 用户说"读某个知识库里的某篇文档" → 先 `node list` 拿到 nodeId,再走 **`dws doc read --node <nodeId>`**
>- 用户说"搜文件"(不指定空间) → 走 **`dws drive search`**(全局聚合搜索)
关键区分(两层模型):
- **wiki node**(空间管理层:节点的列出/创建/复制/移动/删除/搜索)vs **doc**(内容层:读写/编辑/块级/评论/导出)vs **drive**(存储层:文件上传/下载/搜索/权限,不关心格式)
- **wiki node search**(空间内搜索,需 `--workspace`)vs **drive search**(全局搜索,聚合钉盘+文档空间)
- **wiki node create**(在空间中创建空文件实体)vs **doc create**(创建文档并写入内容)
- **wiki member**(容器级,授权整个知识库)vs **doc permission / drive permission**(节点级,授权单篇文档)
- 「我的文档」**只能用** `doc permission` / `drive permission`,不能用 `wiki member`
- **wiki space list --type orgSpace/mySpace**(列出钉盘空间)vs **wiki space list**(默认列出知识库)
## 核心工作流
```bash
# 列出我有权访问的组织知识库
dws wiki space list --format json
# 获取「我的文档」个人空间
dws wiki space list --type myWikiSpace --format json
# 搜索知识库
dws wiki space search --query "产品" --format json
# 创建知识库
dws wiki space create --name "新项目文档" --desc "项目相关文档归档" --format json
# 查看知识库详情
dws wiki space get --workspace <workspaceId> --format json
# ── 工作流: 浏览知识库内容 ──
# 1. 获取知识库 ID
dws wiki space list --format json
# 2. 列出根目录节点
dws wiki node list --workspace <workspaceId> --format json
# 3. 进入子目录
dws wiki node list --workspace <workspaceId> --folder <parentNodeId> --format json
# 4. 读取文档内容(跨到 doc)
dws doc read --node <nodeId> --format json
# ── 工作流: 在知识库中创建文档 ──
# 1. 创建文档节点
dws wiki node create --workspace <workspaceId> --name "新方案" --format json
# 2. 创建文件夹
dws wiki node create --workspace <workspaceId> --name "方案归档" --type folder --format json
# 3. 在指定文件夹下创建
dws wiki node create --workspace <workspaceId> --name "子文档" --folder <parentNodeId> --format json
# ── 工作流: 在知识库中搜索 ──
# 在指定知识库内搜索
dws wiki node search --workspace <workspaceId> --query "方案" --format json
# 按文件类型过滤
dws wiki node search --workspace <workspaceId> --query "周报" --extensions adoc --format json
# ── 工作流: 列出钉盘空间 ──
# 列出钉盘企业空间
dws wiki space list --type orgSpace --format json
# 获取钉盘「我的文件」
dws wiki space list --type mySpace --format json
# ── 工作流: 复制/移动节点 ──
# 复制节点到另一个文件夹
dws wiki node copy --workspace <workspaceId> --node <nodeId> --folder <targetFolderId> --format json
# 移动节点到另一个文件夹
dws wiki node move --workspace <workspaceId> --node <nodeId> --folder <targetFolderId> --format json
# ── 工作流: 删除知识库节点 ──
# 删除节点(会要求确认)
dws wiki node delete --workspace <workspaceId> --node <nodeId>
# ── 工作流: 给知识库加成员 ──
# 1. 先确认知识库 ID(避免授权到「我的文档」)
dws wiki space list --format json # 注意:不要 --type myWikiSpace
# 2. 添加成员
dws wiki member add --workspace <WS_ID> --users <UID> --role EDITOR --format json
# 3. 查看当前成员
dws wiki member list --workspace <WS_ID> --format json
# ── 工作流: 移除知识库成员 ──
# 1. 查看当前成员
dws wiki member list --workspace <WS_ID> --format json
# 2. 移除成员
dws wiki member remove --workspace <WS_ID> --users <UID> --format json
# ── 工作流: 删除知识库 ──
# 1. 确认知识库信息
dws wiki space get --workspace <workspaceId> --format json
# 2. 删除知识库
dws wiki space delete --workspace <workspaceId> --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `space create` | `workspaceId` | node list / member add 的 --workspace |
| `space list` | `workspaceId` | node list / member add 的 --workspace |
| `space search` | `workspaceId` | node list / member add 的 --workspace |
| `space get` | `spaceUrl` | 分享给用户 |
| `node list` | `nodeId` | node copy/move/delete 的 --node / `dws doc read` 的 --node |
| `node search` | `nodeId` | node copy/move/delete 的 --node / `dws doc read` 的 --node |
| `node create` | `nodeId` | node copy/move/delete 的 --node / `dws doc read` 的 --node |
| `member list` | `userId` | member update 的 --users / member remove 的 --users |
## 相关产品
- [doc](./doc.md) — 内容层:文档读写/编辑/块级操作/评论/导出(仅对自研文档有意义)
- [drive](./drive.md) — 存储层:文件列出/搜索/上传/下载/复制/移动/重命名/删除/权限(不关心文件格式)
# Recovery Guide
`dws` 的 recovery 闭环以 `dws recovery execute` 为正式入口。主入口仍是 [SKILL.md](../SKILL.md),错误分类与排查细节可结合 [error-codes.md](./error-codes.md) 和 [global-reference.md](./global-reference.md) 一起使用。
## 标准流程
1. 原始 `dws` 命令失败后,从 stderr 提取 `RECOVERY_EVENT_ID=<event_id>`
2. 执行 `dws recovery execute --event-id <event_id> --format json`
3. 读取返回的 `RecoveryBundle`
4. 先查看 `plan.decision_owner`
5. 当 `decision_owner == "agent"` 时,把完整 `RecoveryBundle` 视为事实包,由 Agent 基于事实包、对应产品文档和 `dws` skill 做整体判断,再决定是否组织下一次 grounded 的 `dws` 恢复尝试
6. 恢复尝试结束后,执行 `dws recovery finalize --event-id <event_id> --outcome recovered|failed|handoff --execution-file <file.json> --format json`
CLI 会把 recovery 过程文件保存在 `DWS_CONFIG_DIR/recovery/` 下,并自动清理 30 天前的旧文件与旧事件记录。
## 如何阅读 `RecoveryBundle`
`RecoveryBundle` 重点字段:
- `status`:`needs_agent_action` 或 `analysis_failed`
- `context`:原始失败上下文
- `replay`:可重放的脱敏命令信息和工具参数
- `plan`:规则分类、`decision_owner`、`agent_route`、`doc_search`、`kb_hits`、`doc_actions`、`human_actions`
- `doc_search.request`:CLI 实际发起的开放平台文档检索请求参数
- `doc_search.response`:CLI 收到的原始文档检索返回内容块
- `probe_results`:CLI 已完成的只读探测和上下文审计结果
- `agent_task`:给 Agent 的明确工作单
- `finalize_hint`:最终必须回写的 `finalize` 命令模板和 `execution-file` 要求
当 `status == "analysis_failed"` 时,不要假设 CLI 已经修复问题;应先读取 `analysis_error`、`doc_search`、`probe_results`,再判断是否还能继续 grounded 尝试。
当 `plan.decision_owner == "agent"` 时:
- 必须把 CLI 返回内容视为事实包,而不是已定案的恢复结论
- 优先基于完整 `RecoveryBundle` 做判断;若只能读取 `agent_route.payload`,也必须使用其中的 `context`、`replay`、`doc_search`、`kb_hits`、`doc_actions`、`probe_results`
- unknown 场景里,不要把 `should_retry`、`should_stop`、`human_actions` 当作 CLI 已经替 Agent 作出的最终决定
## Agent 允许做什么
- 读取 [global-reference.md](./global-reference.md)、[error-codes.md](./error-codes.md) 和对应产品文档
- 基于完整 `RecoveryBundle`,尤其是 `doc_actions`、`kb_hits`、`probe_results`、`context`、`replay` 做整体判断
- 仅在依据明确时重新发起新的 `dws` 命令
- 将真实尝试过程记录进 `execution-file`,再调用 `finalize`
## Agent 禁止做什么
- 编造 taskId、recordId、threadId、UUID、token、URL 或其他业务参数
- 绕过 `dws` 改用 curl、HTTP API、浏览器或其他未授权手段
- 未确认前把失败命令替换成另一套业务流程
- 因为 `human_actions` 里提到用户步骤,就跳过 bundle 里的其余分析信息
## `execution-file` 最小要求
`execution-file` 必须至少包含:
- `actions`
- `attempts`
- `result`
- `error_summary`
`attempts` 是数组,每次尝试至少记录:
- `command_summary`
- `result`
- `error_summary`
- `source`
兼容旧格式:
- `action` 会被归一化成单元素 `actions`
- 数值型 `attempts` 会被展开为 `legacy_execution_file` 来源的 attempts 记录
- `error` 会被归一化到 `error_summary`
## unknown 参数错误处理
如果 `execute` 进入 unknown 场景,且 `probe_results` 已给出 CLI 检查过的上下文来源,Agent 应:
- 先检查上一步输出、已有上下文、产品文档或 `probe_results` 里是否存在真实参数来源
- 若没有可靠来源,就回写 `handoff`
- 不要盲猜新的 ID、UUID、URL、token 或其他业务参数
对应标准闭环:
```bash
dws recovery execute --event-id <event_id> --format json
dws recovery finalize --event-id <event_id> --outcome handoff --execution-file execution.json --format json
```
## 必读参考
- [error-codes.md](./error-codes.md)
- [global-reference.md](./global-reference.md)
- [intent-guide.md](./intent-guide.md)
- [products/](./products/)
# URL 格式与处理规范
## alidocs URL 分流决策(必须首先执行)
收到 `alidocs.dingtalk.com` URL 时,**必须按以下顺序判断,禁止跳过**:
1. URL 路径含 `/i/p/` → **分享短链**,禁止调用 `dws doc` 任何子命令 → 按下方 [分享短链处理](#分享短链处理) 执行
2. URL 路径含 `/i/nodes/` → **节点链接**,需探测类型 → 按下方 [alidocs URL 类型探测流程](#alidocs-url-类型探测流程) 执行
3. URL 路径含 `/spreadsheetv2/` → **电子表格直链**,直接路由到 `sheet`,将完整 URL 原样传给 `--node` 参数
4. URL 路径含 `/document/edit` 或 `/document/preview` 且 query 参数包含 `dentryKey` → **文档链接**,直接路由到 `doc`,将完整 URL 原样传给 `--node` 参数(URL 中不一定有 `type=d`,只需匹配路径和 `dentryKey` 参数即可)
5. 其他 alidocs URL 格式 → 告知用户当前暂不支持该链接格式
---
## 已知 URL 格式
需要自行拼接链接时,只能使用以下模板:
| 产品 | 用途 | URL 格式 | ID 来源 |
|------|------|----------|---------|
| `aitable` | AI表格 Base 链接 | `https://alidocs.dingtalk.com/i/nodes/{baseId}` | `base list/search/create/get` 返回的 `baseId` |
| `aitable` | AI表格模板预览 | `https://docs.dingtalk.com/table/template/{templateId}` | `template search` 返回的 `templateId` |
| `doc` | 文档链接 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` | `doc` 命令返回的 `dentryUuid` |
| `sheet` | 电子表格链接 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` | `sheet create` 返回的 `dentryUuid` |
| `sheet` | 电子表格直链 | `https://alidocs.dingtalk.com/spreadsheetv2/{key}/...?dentryKey={key}&type=s` | 用户提供的完整 URL,直接传给 `--node` |
| `doc` | 文档链接(edit/preview) | `https://alidocs.dingtalk.com/document/{edit\|preview}?...&dentryKey={key}` | 用户提供的完整 URL,直接传给 `--node` |
| `minutes` | 听记链接 | `https://shanji.dingtalk.com/app/transcribes/{taskUuid}` | `list mine/shared` 返回的 `taskUuid` |
不在此表中的产品,禁止自行拼接 URL。命令返回中包含完整链接时直接使用,否则告知用户无法提供。
## 分享短链处理
`alidocs.dingtalk.com/i/p/{shortKey}` 是钉钉文档的**对外分享短链**,`dws doc` 命令无法解析此格式。
### 识别规则
URL 路径中包含 `/i/p/` 即为分享短链(无论后面是否还有子路径),例如:
- `https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2`
- `https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7`
- `https://alidocs.dingtalk.com/i/p/AbCdEfGh1234`
- `https://alidocs.dingtalk.com/i/p/AbCdEfGh1234/sheets/XYZ789`
> **关键**:只要 URL 中出现 `/i/p/`,无论后面跟什么子路径(`/docs/...`、`/sheets/...` 等),都属于分享短链,一律禁止调用 `dws doc`。
### 处理方式
**不要调用 `dws doc` 任何子命令**(包括 `doc info`、`doc read` 等),`dws` 无法解析此格式。
- **需要获取文档内容时**:使用 `read_url` 工具直接读取该链接
- **其他操作(如移动、复制、权限管理等)**:告知用户此链接为分享短链,无法直接执行复制、移动、权限管理等操作。如需保存该文档内容,建议用户在钉钉客户端中打开该页面,手动复制文本内容,然后可通过 `dws doc create` 创建一篇新文档并将内容写入
```
# 需要读取文档内容时(无论 /i/p/ 后面有没有子路径,都用 read_url)
read_url("https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2")
read_url("https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7")
# 禁止(以下全部会失败,dws 无法解析任何含 /i/p/ 的 URL)
dws doc info --node "https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2" --format json
dws doc read --node "https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7" --format json
```
### 当 `read_url` 返回内容不完整时
钉钉文档分享页是动态渲染的,`read_url` 可能只能获取到页面标题等有限信息,无法获取文档正文。此时**禁止猜测原因**(如"权限不足""文档为空""文档已删除"等),**禁止建议用户"提供 `/i/nodes/` 格式链接"**(分享短链和节点链接是不同体系,普通用户无法自行转换)。应直接告知用户:
> 这个链接是钉钉文档的分享短链,由于页面是动态渲染的,我无法通过该链接直接获取文档的完整正文内容。
>
> 你可以:
> 1. 在钉钉客户端中打开该文档,将正文内容复制粘贴给我
> 2. 如果文档已保存在你的文档空间中,可以告诉我文档名称,我通过 `dws doc search` 搜索后再读取
---
## alidocs URL 类型探测流程
`alidocs.dingtalk.com/i/nodes/{id}` 是钉钉文档空间的统一 URL,可能指向**文档、电子表格、多维表、文件、文件夹**等不同类型。**禁止仅凭 URL 就假定为文档**,必须先探测类型再路由到正确的产品。
### 探测步骤
```
Step 1 → dws doc info --node "<URL>" --format json
Step 2 → 从返回中提取 contentType、extension、nodeType 字段
Step 3 → 按下方路由规则映射到对应产品
```
### 路由映射表
| 条件 | 路由到产品 | 后续操作 |
|------|-----------|---------|
| `contentType=ALIDOC`, `extension=adoc` | `doc` | 按 [doc.md](./products/doc.md) 操作 |
| `contentType=ALIDOC`, `extension=axls` | `sheet` | 按 [sheet.md](./products/sheet.md) 操作(仅 `axls` 在线电子表格) |
| `contentType=ALIDOC`, `extension=able` | `aitable` | 将 nodeId 作为 baseId,按 [aitable.md](./products/aitable.md) 操作 |
| `contentType=DOCUMENT`, `extension=xlsx` / `xls` / `xlsm` / `csv` | `doc` | 必须用 `dws doc download` 下载到本地处理,禁止走 `sheet`(非在线表格,sheet 命令无法操作) |
| `contentType≠ALIDOC`, `nodeType=file` | `doc` | 调用 `dws doc download` 下载,返回文件下载链接 |
| `nodeType=folder` | `doc` | 调用 `dws doc list --folder <ID>` 列出指定文件夹直接子节点列表 |
| 以上均不匹配 | — | 告知用户当前暂不支持该类型 |
> axls vs xlsx 关键区分:
> - `axls`(钉钉在线电子表格,`contentType=ALIDOC`)→ 走 `sheet` 产品线(读/写/筛选/导出等服务端原子操作)
> - `xlsx` / `xls` / `xlsm` / `csv`(上传到文档空间的本地表格文件,`contentType=DOCUMENT`)→ 必须走 `dws doc download` 下载到本地后再解析处理,严禁错误路由到 `sheet` 产品线(sheet 命令只支持在线表格,调用 xlsx 节点会直接报错)
> - 用户想把在线表格导出为 xlsx 文件 → 开源 dws CLI 暂未暴露在线表格导出能力(envelope schema 里有 `submit_export_job` / `query_export_job`,但当前 cobra 未注册),需要在钉钉客户端手动导出 xlsx
### 示例
```bash
# 用户传入: https://alidocs.dingtalk.com/i/nodes/abc123
dws doc info --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json
# 返回 contentType=ALIDOC, extension=axls → 在线电子表格,路由到 sheet
dws sheet list --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json
# 返回 contentType≠ALIDOC, extension=xlsx/xls/csv → 本地表格文件,必须下载处理(禁止走 sheet)
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/xlsx456" --output ./xlsx456.xlsx
# 返回 contentType≠ALIDOC, nodeType=file → 普通文件,下载
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/def456" --output ./def456.bin
# 返回 nodeType=folder → 文件夹,列出子节点
dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/ghi789" --format json
```
### 何时可跳过探测
当用户指令中已明确指定产品(如"帮我读这个文档"、"看下这个表格的数据"),可结合用户意图**跳过探测**直接路由。仅在以下情况**必须执行探测**:
- 用户只粘贴 URL,无其他上下文
- 用户指令与 URL 实际类型可能不一致(如说"文档"但实际是表格)
- 用户直接粘贴的是原始 `alidocs` URL,且没有上游命令返回来确认类型
---
## alidocs URL probe 后能力矩阵
> 给 Agent 在用户问"那能不能 XXX"时使用——一眼看出该节点类型支持哪些操作。
> 标 ⚠️ 的项是当前 dws-opensource 用 transitional helper 实现(feat/align-yuyuan 分支),mse 端 toolOverride 落地后转为动态生成。
| extension / contentType | 读取 | 写入 | 删除 | 导出 | 权限 | 媒体 |
|-------------------------|------|------|------|------|------|------|
| **adoc**(在线文档) | `doc read` | `doc update` / `doc block update` | ⚠️ `doc delete` | ⚠️ `doc export` (→ docx) | ⚠️ `doc permission *` | ⚠️ `doc media download/insert` |
| **axls**(在线电子表格) | `sheet range read` / `sheet list` | `sheet range write` / `sheet append` | ⚠️ `doc delete`(节点删除) | `sheet submit_export_job` + `sheet query_export_job`(待吴淼 W-01 收敛为单命令 `sheet export`) | ⚠️ `doc permission *`(节点级,跨产品) | 不适用 |
| **able**(在线多维表) | `aitable base get` / `aitable record query` | `aitable record create/update` | ⚠️ `doc delete`(节点删除)或 `aitable base delete --yes` | `aitable export data --base-id <BASE_ID> --scope all --output ./x.xlsx` | ⚠️ `doc permission *`(节点级) | `aitable attachment upload-file` |
| **xlsx / xls / xlsm / csv**(本地表格文件) | `doc download` → 本地用 xlsx skill 解析 | 不支持服务端写(先下载改本地再上传) | ⚠️ `doc delete`(节点删除) | 不需要(本身就是 xlsx) | ⚠️ `doc permission *` | 不适用 |
| **普通文件** (nodeType=file) | `doc download` | 不支持服务端写 | ⚠️ `doc delete` | 不需要 | ⚠️ `doc permission *` | 不适用 |
| **文件夹** (nodeType=folder) | `doc list --folder <URL>` | `doc create --folder <URL> ...` | ⚠️ `doc delete` | 不适用 | ⚠️ `doc permission *` | 不适用 |
| **分享短链** `/i/p/<short>` | `read_url` 兜底(外部工具) | 不适用 | 不适用 | 不适用 | 不适用 | 不适用 |
### 使用方式
```
Agent 流程:
1. 用户给 URL → dws doc info --node <URL> (路由起点)
2. 拿到 extension / contentType / nodeType
3. 在本矩阵查"能做什么 / 不能做什么"
4. 不能做的直接告知用户(参考 capability-limits.md),不要重试
```
### 跨产品授权的关键判断
| 用户说 | 路由 | 不要混淆 |
|--------|------|---------|
| "把这个文档/表格/多维表分享给张三" | **节点级**:`doc permission add --node <URL> --user <UID> --role EDITOR` | 不是 `wiki member add` |
| "把张三加到这个知识库" | **容器级**:`wiki member add --workspace <WS> --users <UID> --role <ROLE>` | 不是 `doc permission add` |
> 区分依据:**doc permission 作用于单个 node(document / file / folder);wiki member 作用于整个 workspace 容器**。同一用户在 workspace 是 EDITOR、在某个 node 上仍可被单独提升为 MANAGER(节点级覆盖容器级)。
#!/usr/bin/env python3
"""
通过 MCP 导出任务(export_data)导出 AI 表格,并可自动下载文件。
与普通命令的区别:
- 自动处理 taskId 轮询(直到拿到 downloadUrl 或达到轮询上限)。
- 自动保存导出文件到本地(可选 --output)。
用法:
python scripts/aitable_export_via_task.py <baseId> --scope all
python scripts/aitable_export_via_task.py <baseId> --scope table --table-id <tableId>
python scripts/aitable_export_via_task.py <baseId> --scope view --table-id <tableId> --view-id <viewId>
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
import time
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.error import HTTPError, URLError
from urllib.parse import urlparse
from urllib.request import Request, urlopen
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
ALLOWED_FORMATS = {"excel", "attachment", "excel_and_attachment", "excel_with_inline_images"}
def validate_resource_id(resource_id: str) -> bool:
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
def run_dws(dws_bin: str, args: list[str], timeout_sec: int = 120) -> Tuple[int, str, str]:
cmd = [dws_bin] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_sec)
return result.returncode, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
return 124, "", f"dws command timeout after {timeout_sec}s"
except FileNotFoundError:
return 127, "", f"dws binary not found: {dws_bin}"
def parse_json_output(raw: str) -> Optional[Dict[str, Any]]:
try:
obj = json.loads(raw)
return obj if isinstance(obj, dict) else None
except json.JSONDecodeError:
return None
def normalize_download_url(url: str) -> str:
if url.startswith("http://") or url.startswith("https://"):
return url
return f"https://{url}"
def download_file(url: str, output_path: Path) -> Tuple[bool, str]:
req = Request(url, method="GET")
try:
with urlopen(req, timeout=180) as resp:
if resp.status != 200:
return False, f"download http status: {resp.status}"
output_path.write_bytes(resp.read())
return True, ""
except HTTPError as e:
body = e.read().decode("utf-8", "ignore")
return False, f"HTTP {e.code}: {body[:300]}"
except URLError as e:
return False, f"URL error: {e.reason}"
def fail(msg: str, code: int = 1) -> None:
print(f"错误:{msg}", file=sys.stderr)
sys.exit(code)
def build_start_args(args: argparse.Namespace) -> list[str]:
cmd = [
"aitable",
"export",
"data",
"--base-id",
args.base_id,
"--scope",
args.scope,
"--format",
args.export_format,
"--timeout-ms",
str(args.timeout_ms),
]
if args.table_id:
cmd.extend(["--table-id", args.table_id])
if args.view_id:
cmd.extend(["--view-id", args.view_id])
return cmd
def main() -> None:
parser = argparse.ArgumentParser(description="通过 MCP 导出任务导出 AI 表格")
parser.add_argument("base_id", help="目标 AI 表格 baseId")
parser.add_argument("--scope", choices=["all", "table", "view"], required=True, help="导出范围")
parser.add_argument("--table-id", help="scope=table/view 时必填")
parser.add_argument("--view-id", help="scope=view 时必填")
parser.add_argument("--export-format", default="excel", choices=sorted(ALLOWED_FORMATS), help="导出格式")
parser.add_argument("--timeout-ms", type=int, default=1000, help="单次等待毫秒数,默认 1000")
parser.add_argument("--poll-timeout-ms", type=int, default=3000, help="轮询等待毫秒数,默认 3000")
parser.add_argument("--max-polls", type=int, default=10, help="最大轮询次数,默认 10")
parser.add_argument("--output", help="本地保存路径(不传则按 fileName 保存到当前目录)")
parser.add_argument("--dws", default="dws", help="dws 可执行文件路径,默认 dws")
parser.add_argument("--no-download", action="store_true", help="仅返回 downloadUrl,不下载文件")
args = parser.parse_args()
if not validate_resource_id(args.base_id):
fail("无效的 baseId 格式")
if args.scope in ("table", "view") and not args.table_id:
fail("scope=table/view 时必须传 --table-id")
if args.scope == "view" and not args.view_id:
fail("scope=view 时必须传 --view-id")
print("[1/2] start export task", file=sys.stderr)
rc, out, err = run_dws(args.dws, build_start_args(args), timeout_sec=120)
if rc != 0:
fail(f"export_data 启动失败: {err or out}", rc)
obj = parse_json_output(out)
if not obj:
fail(f"export_data 返回非 JSON: {out[:300]}")
data = obj.get("data", {}) or {}
status = obj.get("status")
if status == "error":
fail(f"export_data 返回失败: {json.dumps(obj, ensure_ascii=False)}")
download_url = data.get("downloadUrl")
task_id = data.get("taskId")
file_name = data.get("fileName") or "export_result.bin"
polls = 0
while not download_url and task_id and polls < args.max_polls:
polls += 1
print(f"[2/2] polling task ({polls}/{args.max_polls})", file=sys.stderr)
rc2, out2, err2 = run_dws(
args.dws,
[
"aitable",
"export",
"data",
"--base-id",
args.base_id,
"--task-id",
task_id,
"--timeout-ms",
str(args.poll_timeout_ms),
],
timeout_sec=max(120, int(args.poll_timeout_ms / 1000) + 60),
)
if rc2 != 0:
fail(f"export_data 轮询失败: {err2 or out2}", rc2)
obj2 = parse_json_output(out2)
if not obj2:
fail(f"export_data 轮询返回非 JSON: {out2[:300]}")
if obj2.get("status") == "error":
fail(f"export_data 轮询返回失败: {json.dumps(obj2, ensure_ascii=False)}")
d2 = obj2.get("data", {}) or {}
download_url = d2.get("downloadUrl") or download_url
file_name = d2.get("fileName") or file_name
task_id = d2.get("taskId") or task_id
if not download_url:
time.sleep(0.2)
result: Dict[str, Any] = {
"baseId": args.base_id,
"scope": args.scope,
"exportFormat": args.export_format,
"taskId": task_id,
"fileName": file_name,
"downloadUrl": download_url,
"polledTimes": polls,
}
if not download_url:
result["status"] = "pending"
result["summary"] = "导出任务仍在处理中,请继续用 taskId 轮询。"
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(3)
if args.no_download:
result["status"] = "success"
result["summary"] = "导出完成(未下载文件)。"
print(json.dumps(result, ensure_ascii=False, indent=2))
return
norm_url = normalize_download_url(download_url)
output_path = Path(args.output).expanduser().resolve() if args.output else Path.cwd() / file_name
ok, dl_err = download_file(norm_url, output_path)
if not ok:
fail(f"downloadUrl 下载失败: {dl_err}")
result["status"] = "success"
result["summary"] = "导出完成并已下载。"
result["savedPath"] = str(output_path)
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
通过 MCP 文件导入任务(prepare_import_upload -> PUT -> import_data)导入 AI 表格。
与 import_records.py 的区别:
- 本脚本:走“文件导入任务”链路,通常会新建导入数据表。
- import_records.py:走 create_records,写入已有 table。
用法:
python scripts/aitable_import_via_task.py <baseId> <filePath>
python scripts/aitable_import_via_task.py <baseId> <filePath> --timeout 30
python scripts/aitable_import_via_task.py <baseId> <filePath> --dws /tmp/dws
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
RESOURCE_ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
ALLOWED_EXTENSIONS = {".csv", ".xlsx", ".xls"}
def validate_resource_id(resource_id: str) -> bool:
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
def run_dws(dws_bin: str, args: list[str], timeout_sec: int = 120) -> Tuple[int, str, str]:
cmd = [dws_bin] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_sec)
return result.returncode, result.stdout.strip(), result.stderr.strip()
except subprocess.TimeoutExpired:
return 124, "", f"dws command timeout after {timeout_sec}s"
except FileNotFoundError:
return 127, "", f"dws binary not found: {dws_bin}"
def parse_json_output(raw: str) -> Optional[Dict[str, Any]]:
try:
obj = json.loads(raw)
return obj if isinstance(obj, dict) else None
except json.JSONDecodeError:
return None
def put_file(upload_url: str, file_path: Path) -> Tuple[bool, str]:
payload = file_path.read_bytes()
req = Request(upload_url, data=payload, method="PUT")
# 关键:清空 Content-Type,避免 SignatureDoesNotMatch。
req.add_header("Content-Type", "")
try:
with urlopen(req, timeout=180) as resp:
if resp.status == 200:
return True, ""
return False, f"unexpected HTTP status: {resp.status}"
except HTTPError as e:
body = e.read().decode("utf-8", "ignore")
return False, f"HTTP {e.code}: {body[:300]}"
except URLError as e:
return False, f"URL error: {e.reason}"
def fail(msg: str, exit_code: int = 1) -> None:
print(f"错误:{msg}", file=sys.stderr)
sys.exit(exit_code)
def main() -> None:
parser = argparse.ArgumentParser(description="通过文件导入任务导入 AI 表格")
parser.add_argument("base_id", help="目标 AI 表格 baseId")
parser.add_argument("file_path", help="待导入文件路径(.csv/.xlsx/.xls)")
parser.add_argument("--timeout", type=int, default=30, help="import_data 等待秒数,默认 30")
parser.add_argument("--dws", default="dws", help="dws 可执行文件路径,默认 dws")
args = parser.parse_args()
base_id = args.base_id.strip()
file_path = Path(args.file_path).expanduser().resolve()
if not validate_resource_id(base_id):
fail("无效的 baseId 格式")
if not file_path.exists() or not file_path.is_file():
fail(f"文件不存在或不可读: {file_path}")
if file_path.suffix.lower() not in ALLOWED_EXTENSIONS:
fail(f"仅支持 {sorted(ALLOWED_EXTENSIONS)},当前文件: {file_path.name}")
file_size = file_path.stat().st_size
if file_size <= 0:
fail("文件为空")
print(f"[1/3] prepare import upload: {file_path.name} ({file_size} bytes)", file=sys.stderr)
rc, out, err = run_dws(
args.dws,
[
"aitable",
"import",
"upload",
"--base-id",
base_id,
"--file-name",
file_path.name,
"--file-size",
str(file_size),
"--format",
"json",
],
)
if rc != 0:
fail(f"prepare_import_upload 失败: {err or out}", rc)
prepare_obj = parse_json_output(out)
if not prepare_obj:
fail(f"prepare_import_upload 返回非 JSON: {out[:300]}")
if prepare_obj.get("status") != "success":
fail(f"prepare_import_upload 返回失败: {json.dumps(prepare_obj, ensure_ascii=False)}")
pdata = prepare_obj.get("data") or {}
upload_url = pdata.get("uploadUrl")
import_id = pdata.get("importId")
if not upload_url or not import_id:
fail(f"prepare_import_upload 缺少 uploadUrl/importId: {json.dumps(pdata, ensure_ascii=False)}")
print("[2/3] upload file bytes via PUT", file=sys.stderr)
ok, put_err = put_file(upload_url, file_path)
if not ok:
fail(f"PUT 上传失败: {put_err}")
print("[3/3] trigger import_data", file=sys.stderr)
rc2, out2, err2 = run_dws(
args.dws,
[
"aitable",
"import",
"data",
"--import-id",
import_id,
"--timeout",
str(args.timeout),
"--format",
"json",
],
timeout_sec=max(120, args.timeout + 30),
)
if rc2 != 0:
fail(f"import_data 调用失败: {err2 or out2}", rc2)
import_obj = parse_json_output(out2)
if not import_obj:
fail(f"import_data 返回非 JSON: {out2[:300]}")
result = {
"baseId": base_id,
"fileName": file_path.name,
"fileSize": file_size,
"importId": import_id,
"status": import_obj.get("status"),
"summary": import_obj.get("summary"),
"data": import_obj.get("data", {}),
"error": import_obj.get("error", {}),
}
print(json.dumps(result, ensure_ascii=False, indent=2))
if import_obj.get("status") != "success":
sys.exit(2)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
查看我今天/本周/指定日期的考勤记录(自动获取 userId)
用法:
python attendance_my_record.py # 今天
python attendance_my_record.py today # 今天
python attendance_my_record.py 2026-03-10 # 指定日期
python attendance_my_record.py --dry-run # 仅显示命令
"""
import sys
import json
import subprocess
import re
from datetime import datetime
from typing import List, Any, Optional
DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}$')
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
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 get_my_user_id(dry_run: bool = False) -> Optional[str]:
data = run_dws([
'contact', 'user', 'get-self', '--format', 'json',
], dry_run=dry_run)
if dry_run:
return '<MY_USER_ID>'
if not data or not isinstance(data, dict):
return None
return data.get('userId') or data.get('userid')
def main():
dry_run = '--dry-run' in sys.argv
args = [a for a in sys.argv[1:] if a != '--dry-run']
date_str = args[0] if args else 'today'
if date_str == 'today':
date_str = datetime.now().strftime('%Y-%m-%d')
elif not DATE_PATTERN.match(date_str):
print(__doc__)
sys.exit(1)
print('🔍 获取当前用户信息...')
user_id = get_my_user_id(dry_run=dry_run)
if not user_id and not dry_run:
print('错误:无法获取当前用户 ID')
sys.exit(1)
print(f'📊 查询 {date_str} 考勤记录...\n')
data = run_dws([
'attendance', 'record', 'get',
'--user', user_id or '<MY_USER_ID>',
'--date', date_str,
'--format', 'json',
], dry_run=dry_run)
if dry_run:
return
if not data:
print('未查到考勤记录')
return
print(f"📋 考勤记录 ({date_str})")
print('=' * 40)
print(json.dumps(data, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
考勤报表导出脚本 — 公共模块
[AI Agent 强制门禁] 本模块不可单独执行,且任何调用方脚本
(attendance_report_detail/monthly/daily.py)执行前都必须先阅读:
dingtalk-workspace/references/products/attendance-report.md
工作流细节、报表类型判断、人员获取、列选择、错误处理等约束
全部在 attendance-report.md,禁止凭本脚本源码或 --help 自行组装命令。
被 attendance_report_detail.py / attendance_report_monthly.py /
attendance_report_daily.py 三个粒度脚本共享。
职责:
1. dws CLI 调用(run_dws / run_dws_raw)
2. 接口分批 / 切片(chunk_users / slice_date_range)
3. dws 返回值通用解析(unwrap_result / extract_records)
4. 字段(columns)模糊匹配(match_columns_by_keywords)
5. userId → name 映射(resolve_user_names)
6. Excel 写入(write_excel)
7. 错误处理 / stderr 进度日志
约束:
- 不依赖 dws 命令的具体业务字段(除接口顶层 success/result/error)
- 业务字段解析全部由各粒度脚本负责
- 时间字段单位由各粒度脚本自行处理(attendance 接口多为毫秒时间戳)
"""
from __future__ import annotations
import hashlib
import json
import os
import subprocess
import sys
import tempfile
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any, Iterable
# ─────────────────────────────────────────────────────────────────────────────
# 常量:dws 接口限制(来自 attendance.md)
# ─────────────────────────────────────────────────────────────────────────────
MAX_USERS_PER_BATCH = 5 # report query-data: --users 最多 5 人(降低单批人数避免超时)
MAX_DAYS_PER_SLICE = 32 # report query-data: --start 到 --end ≤ 32 天
DWS_TIMEOUT_SECONDS = 120 # 单次 dws 调用超时
DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
DATE_FMT = "%Y-%m-%d"
# ─────────────────────────────────────────────────────────────────────────────
# stderr 日志(脚本所有进度信息打 stderr,stdout 留给最终摘要)
# ─────────────────────────────────────────────────────────────────────────────
def log(msg: str) -> None:
"""打印进度信息到 stderr,stdout 保留给最终摘要。"""
print(msg, file=sys.stderr, flush=True)
def warn(msg: str) -> None:
log(f"[WARN] {msg}")
def error(msg: str) -> None:
log(f"[ERROR] {msg}")
# ─────────────────────────────────────────────────────────────────────────────
# dws 调用
# ─────────────────────────────────────────────────────────────────────────────
class DwsCallError(Exception):
"""dws 调用失败(含进程退出非零、超时、JSON 解析失败、业务 success=false)。"""
def __init__(self, message: str, *, is_permission_error: bool = False) -> None:
super().__init__(message)
self.is_permission_error = is_permission_error
def _looks_like_permission_error(text: str) -> bool:
"""启发式:判断错误文本是否属于权限/管理员问题。"""
if not text:
return False
lower = text.lower()
keywords = ("403", "permission", "denied", "unauthorized",
"forbidden", "无权限", "权限不足", "管理员")
return any(k in lower for k in keywords)
def run_dws(args: list[str]) -> Any:
"""
调用 `dws <args> --format json`,返回解析后的 JSON。
自动追加 `--format json`(如果调用方没传),并解开顶层 `success/result/error`:
- success=True → 返回 result 内容
- success=False → 抛 DwsCallError(含 is_permission_error 标记)
- 进程退出非零或解析失败 → 抛 DwsCallError
注意:本函数仅做"顶层解包",业务字段解析由调用方负责。
"""
if "--format" not in args:
args = args + ["--format", "json"]
cmd = ["dws"] + args
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=DWS_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired as e:
raise DwsCallError(f"dws 调用超时({DWS_TIMEOUT_SECONDS}s):{' '.join(cmd)}") from e
except FileNotFoundError as e:
raise DwsCallError("未找到 dws 命令,请确认 dws CLI 已安装并在 PATH 中") from e
stdout = result.stdout or ""
stderr = result.stderr or ""
if result.returncode != 0:
is_perm = _looks_like_permission_error(stderr) or _looks_like_permission_error(stdout)
raise DwsCallError(
f"dws 调用失败(exit={result.returncode}): {stderr.strip() or stdout.strip()}",
is_permission_error=is_perm,
)
try:
data = json.loads(stdout)
except json.JSONDecodeError as e:
raise DwsCallError(f"dws 返回非 JSON:{stdout[:200]!r}") from e
return unwrap_result(data)
def unwrap_result(data: Any) -> Any:
"""
解开 dws 返回的顶层 `{success, result, error}` 包装。
success=True → 返回 result(可能是 dict / list / None)
success=False → 抛 DwsCallError
其他形态 → 原样返回(兼容部分接口直接返回数据)
"""
if not isinstance(data, dict):
return data
if "success" not in data:
# 不是标准包装,原样返回
return data
if data.get("success") is True:
return data.get("result")
# success = False
err = data.get("error") or {}
if isinstance(err, dict):
msg = err.get("message") or err.get("msg") or json.dumps(err, ensure_ascii=False)
else:
msg = str(err)
raise DwsCallError(
f"dws 业务失败:{msg}",
is_permission_error=_looks_like_permission_error(msg),
)
# ─────────────────────────────────────────────────────────────────────────────
# 通用记录提取(兼容多种数据嵌套形态)
# ─────────────────────────────────────────────────────────────────────────────
def extract_records(payload: Any) -> list[dict]:
"""
从 dws 返回的 result 中提取"记录数组"。
兼容多种常见嵌套:
- 直接是 list[dict] → 原样返回
- {"data": [...]} → 取 data
- {"records": [...]} → 取 records
- {"list": [...]} → 取 list
- {"items": [...]} → 取 items
- {"result": [...]} (双层包装) → 递归一次
- 其他 dict 但只有一个值是 list → 取那个 list
- 其他形态 → 返回 [],并 warn
业务字段不在本函数关心范围内。
"""
if payload is None:
return []
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
for key in ("data", "records", "list", "items", "result"):
if key in payload and isinstance(payload[key], list):
return [item for item in payload[key] if isinstance(item, dict)]
# 兜底:dict 中只有一个 list 值
list_values = [v for v in payload.values() if isinstance(v, list)]
if len(list_values) == 1:
return [item for item in list_values[0] if isinstance(item, dict)]
warn(f"未能从返回中识别记录数组,顶层 keys={list(payload.keys())}")
return []
warn(f"未能识别返回类型:{type(payload).__name__}")
return []
def flatten_query_data_records(
records: list[dict],
column_id_to_name: dict[str, str] | None = None,
) -> list[dict]:
"""
展平 report query-data 返回的嵌套 values 结构。
接口原始格式:
{"userId":"xxx", "values":[{"termId":"173410778","value":"1"}, ...], "workDate":"2026-05-01"}
展平后:
{"userId":"xxx", "workDate":"2026-05-01", "173410778":"1", "节假日+出勤":"1", ...}
Args:
records: extract_records 返回的原始记录列表
column_id_to_name: 可选的 columnId → columnName 映射,展平时同时写入字段名 key
"""
flattened: list[dict] = []
for record in records:
values_list = record.get("values")
if not isinstance(values_list, list):
# 已经是平铺格式或无 values 字段,原样保留
flattened.append(record)
continue
flat: dict[str, Any] = {}
# 保留顶层非 values 字段(userId, workDate, corpId 等)
for k, v in record.items():
if k != "values":
flat[k] = v
# 展平 values 数组
for entry in values_list:
if not isinstance(entry, dict):
continue
term_id = str(entry.get("termId", entry.get("columnId", entry.get("id", ""))))
value = entry.get("value", entry.get("data", ""))
if term_id:
flat[term_id] = value
# 同时写入字段名 key(方便按名称取值)
if column_id_to_name and term_id in column_id_to_name:
flat[column_id_to_name[term_id]] = value
flattened.append(flat)
return flattened
def extract_group_names_from_records(
records: list[dict],
user_ids: list[str],
) -> dict[str, str]:
"""
获取每个用户的考勤组名称。
优先从 report query-data 原始记录中提取(如果接口返回了 groupName 字段),
否则回退到通过 `dws attendance group search` + `filtered-get --member`
获取所有考勤组的成员列表,反向映射 userId → 考勤组名称。
返回 {userId: groupName} 映射,未找到的用户映射为空字符串。
"""
group_map: dict[str, str] = {}
candidate_keys = ("groupName", "group_name", "attendanceGroupName",
"groupId", "group_id")
# 1) 先尝试从原始记录中提取
for record in records:
uid = _first_nonempty(record, ("userId", "userid", "user_id", "targetUserId"))
if uid is None:
continue
uid_str = str(uid)
if uid_str in group_map:
continue
name = _first_nonempty(record, candidate_keys)
if name is not None and str(name).strip():
group_map[uid_str] = str(name).strip()
# 2) 如果还有用户未匹配到考勤组,通过 group API 反向查找
missing_uids = {uid for uid in user_ids if uid not in group_map}
if missing_uids:
api_map = _resolve_group_names_via_api(missing_uids)
group_map.update(api_map)
# 兜底:未找到的用户填空字符串
for uid in user_ids:
if uid not in group_map:
group_map[uid] = ""
return group_map
def _resolve_group_names_via_api(target_uids: set[str]) -> dict[str, str]:
"""
通过 dws attendance group search + filtered-get --member
反向映射 userId → 考勤组名称。
流程:
1. group search 获取所有考勤组(id + name)
2. 对每个考勤组调用 filtered-get --member 获取成员 userId 列表
3. 将 target_uids 中的用户与考勤组成员做交集映射
"""
result_map: dict[str, str] = {}
if not target_uids:
return result_map
# 获取所有考勤组
try:
search_payload = run_dws([
"attendance", "group", "search",
"--limit", "200",
])
except DwsCallError as e:
log(f"[group] 获取考勤组列表失败:{e}")
return result_map
search_result = unwrap_result(search_payload)
groups: list[dict] = []
if isinstance(search_result, dict):
groups = search_result.get("items", [])
elif isinstance(search_result, list):
groups = search_result
if not groups:
log("[group] 未获取到任何考勤组")
return result_map
log(f"[group] 共 {len(groups)} 个考勤组,开始查询成员列表")
remaining = set(target_uids)
for group in groups:
if not remaining:
break
group_id = group.get("id")
group_name = group.get("name", "")
if not group_id:
continue
try:
detail_payload = run_dws([
"attendance", "group", "filtered-get",
"--group-id", str(group_id),
"--member",
])
except DwsCallError as e:
log(f"[group] filtered-get 失败 (group={group_name}): {e}")
continue
detail = unwrap_result(detail_payload)
if not isinstance(detail, dict):
continue
member_users = detail.get("memberUsers", [])
if not isinstance(member_users, list):
continue
for member_uid in member_users:
uid_str = str(member_uid)
if uid_str in remaining:
result_map[uid_str] = group_name
remaining.discard(uid_str)
if remaining:
log(f"[group] {len(remaining)} 个用户未匹配到考勤组")
return result_map
def dump_first_record_for_inspection(records: list[dict], label: str) -> None:
"""
第一次跑脚本时,把第一条记录打到 stderr,方便用户/开发者
看清真实字段结构后回来调优解析逻辑。
"""
if not records:
log(f"[inspect:{label}] 无记录")
return
sample = records[0]
log(f"[inspect:{label}] 首条记录字段示例(用于核对真实结构):")
log(json.dumps(sample, ensure_ascii=False, indent=2))
# ─────────────────────────────────────────────────────────────────────────────
# 时间区间切片
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class DateSlice:
start: datetime # 含
end: datetime # 含
@property
def start_str(self) -> str:
return self.start.strftime(DATETIME_FMT)
@property
def end_str(self) -> str:
return self.end.strftime(DATETIME_FMT)
@property
def label(self) -> str:
return f"{self.start.strftime(DATE_FMT)}~{self.end.strftime(DATE_FMT)}"
def parse_datetime_arg(s: str, *, end_of_day: bool = False) -> datetime:
"""
解析用户输入的日期参数,支持:
- YYYY-MM-DD → 00:00:00 或 23:59:59(取决于 end_of_day)
- YYYY-MM-DD HH:mm:ss
"""
s = s.strip()
try:
return datetime.strptime(s, DATETIME_FMT)
except ValueError:
pass
try:
d = datetime.strptime(s, DATE_FMT)
if end_of_day:
return d.replace(hour=23, minute=59, second=59)
return d
except ValueError as e:
raise ValueError(
f"无法解析日期 {s!r},请使用 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss"
) from e
def slice_date_range(
start: datetime,
end: datetime,
max_days: int = MAX_DAYS_PER_SLICE,
) -> list[DateSlice]:
"""
把 [start, end] 切成多个 ≤ max_days 天的小区间。
每片含起含止;最后一片可能短于 max_days。
"""
if end < start:
raise ValueError(f"结束时间 {end} 早于开始时间 {start}")
slices: list[DateSlice] = []
cur_start = start
while cur_start <= end:
# 这片的最晚结束时间(不超 max_days,且不超 end)
cur_end_limit = cur_start + timedelta(days=max_days - 1)
cur_end_limit = cur_end_limit.replace(hour=23, minute=59, second=59)
cur_end = min(cur_end_limit, end)
slices.append(DateSlice(start=cur_start, end=cur_end))
# 下一片从次日 00:00 开始
next_day = (cur_end + timedelta(seconds=1)).replace(
hour=0, minute=0, second=0, microsecond=0
)
cur_start = next_day
return slices
# ─────────────────────────────────────────────────────────────────────────────
# 用户分批
# ─────────────────────────────────────────────────────────────────────────────
def chunk_users(users: list[str], size: int = MAX_USERS_PER_BATCH) -> list[list[str]]:
"""把 userId 列表切成每片 ≤ size 的小批。"""
if size <= 0:
raise ValueError(f"size 必须 > 0,得到 {size}")
return [users[i: i + size] for i in range(0, len(users), size)]
# ─────────────────────────────────────────────────────────────────────────────
# columns 模糊匹配
# ─────────────────────────────────────────────────────────────────────────────
def match_columns_by_keywords(
all_columns: list[dict],
keywords: list[str],
*,
name_keys: tuple[str, ...] = ("name", "columnName", "title", "label"),
id_keys: tuple[str, ...] = ("id", "columnId", "code", "key"),
) -> list[dict]:
"""
在 report columns 返回的字段列表里,按关键词匹配目标字段。
匹配策略(按优先级):
1. 精确匹配:关键词 == 字段名(优先)
2. 子串匹配:关键词是字段名的子串(仅当精确匹配无结果时回退)
列名严格使用接口返回的原始字段名,不做任何修改。
"""
matched: list[dict] = []
matched_ids: set[str] = set()
hit_keywords: set[str] = set()
# 构建 name → (col_dict, cid_str) 索引
col_index: list[tuple[str, str, dict]] = []
for col in all_columns:
name = _first_nonempty(col, name_keys)
cid = _first_nonempty(col, id_keys)
if not name or cid is None:
continue
col_index.append((str(name), str(cid), col))
for kw in keywords:
kw_stripped = kw.strip()
if not kw_stripped:
continue
# 第一轮:精确匹配
exact_hit = False
for name, cid_str, col in col_index:
if name == kw_stripped and cid_str not in matched_ids:
enriched = dict(col)
enriched["_column_id"] = cid_str
enriched["_column_name"] = name
matched.append(enriched)
matched_ids.add(cid_str)
hit_keywords.add(kw_stripped)
exact_hit = True
break
if exact_hit:
continue
# 第二轮:子串匹配(回退),只取第一个命中
kw_lower = kw_stripped.lower()
for name, cid_str, col in col_index:
if kw_lower in name.lower() and cid_str not in matched_ids:
enriched = dict(col)
enriched["_column_id"] = cid_str
enriched["_column_name"] = name
matched.append(enriched)
matched_ids.add(cid_str)
hit_keywords.add(kw_stripped)
break
missing = set(k.strip() for k in keywords if k.strip()) - hit_keywords
if missing:
warn(f"以下关键词未匹配到任何字段,已跳过:{sorted(missing)}")
return matched
def _first_nonempty(d: dict, keys: Iterable[str]) -> Any:
for k in keys:
if k in d and d[k] not in (None, ""):
return d[k]
return None
# ─────────────────────────────────────────────────────────────────────────────
# userId → name 映射
# ─────────────────────────────────────────────────────────────────────────────
def resolve_users_from_input(raw_ids: list[str]) -> list[str]:
"""
智能解析 --users 输入:自动区分部门ID和员工userId。
Wukong Agent 经常把部门ID当 userId 传入。本函数尝试对每个ID调用
`dws contact dept list-members` 获取成员列表:
- 如果成功且返回了员工,说明该ID是部门ID,展开为员工userId列表
- 如果失败或无结果,说明该ID本身就是userId,原样保留
最终返回去重后的 userId 列表。
"""
if not raw_ids:
return []
resolved: list[str] = []
seen: set[str] = set()
# 先尝试批量查部门成员(可能全是部门ID)
try:
result = run_dws([
"contact", "dept", "list-members",
"--ids", ",".join(raw_ids),
])
members = extract_records(result)
if members:
# 成功获取到成员 → 输入是部门ID
for member in members:
uid = _first_nonempty(member, ("userId", "userid", "id"))
if uid and str(uid) not in seen:
resolved.append(str(uid))
seen.add(str(uid))
if resolved:
log(f"[users] 检测到输入为部门ID,已展开为 {len(resolved)} 个员工userId")
return resolved
except DwsCallError:
pass
# 逐个ID尝试:可能混合了部门ID和userId
for raw_id in raw_ids:
if raw_id in seen:
continue
try:
result = run_dws([
"contact", "dept", "list-members",
"--ids", raw_id,
])
members = extract_records(result)
if members:
for member in members:
uid = _first_nonempty(member, ("userId", "userid", "id"))
if uid and str(uid) not in seen:
resolved.append(str(uid))
seen.add(str(uid))
log(f"[users] 部门ID {raw_id} 展开为 {len(members)} 个员工")
continue
except DwsCallError:
pass
# 不是部门ID,当作userId保留
if raw_id not in seen:
resolved.append(raw_id)
seen.add(raw_id)
return resolved
@dataclass
class UserInfo:
"""用户基础信息,用于报表的姓名/部门/工号/职位列。"""
name: str = ""
dept_name: str = ""
job_number: str = ""
title: str = ""
def _extract_title_from_labels(labels: list) -> str:
"""从 orgEmployeeModel.labels 数组中提取职务名称。"""
if not isinstance(labels, list):
return ""
for item in labels:
if isinstance(item, dict) and item.get("groupName") == "职务":
name = item.get("name", "")
if name:
return str(name)
return ""
def _parse_user_record(record: dict) -> tuple[str, UserInfo] | None:
"""
从 dws contact user get 返回的单条记录中解析用户信息。
接口返回结构为嵌套格式:
{"orgEmployeeModel": {"userId": "xxx", "orgUserName": "吾贤",
"depts": [{"deptName": "技术部"}],
"labels": [{"groupName": "职务", "name": "财务"}],
...}, "isAdmin": true}
也兼容扁平格式(其他接口可能返回):
{"userId": "xxx", "name": "吾贤", "deptName": "技术部", ...}
"""
# 优先从嵌套的 orgEmployeeModel 中提取
model = record.get("orgEmployeeModel")
if isinstance(model, dict):
uid = model.get("userId") or model.get("orgUserId")
if not uid:
return None
name = model.get("orgUserName") or model.get("name") or ""
depts = model.get("depts") or []
dept_name = depts[0].get("deptName", "") if depts and isinstance(depts[0], dict) else ""
# 工号:尝试多个候选字段
job_number = (model.get("jobNumber") or model.get("workNumber")
or model.get("empId") or "")
# 职位:优先 title/position,回退到 labels 中 groupName=="职务" 的条目
title = (model.get("title") or model.get("position")
or _extract_title_from_labels(model.get("labels", [])))
return str(uid), UserInfo(
name=str(name),
dept_name=str(dept_name),
job_number=str(job_number),
title=str(title),
)
# 回退:扁平格式
uid = _first_nonempty(record, ("userId", "userid", "id"))
if not uid:
return None
return str(uid), UserInfo(
name=str(_first_nonempty(record, ("name", "userName", "nick")) or ""),
dept_name=str(_first_nonempty(record, ("deptName", "dept_name", "department")) or ""),
job_number=str(_first_nonempty(record, ("jobNumber", "job_number", "workNumber")) or ""),
title=str(_first_nonempty(record, ("title", "position", "jobTitle")) or ""),
)
def resolve_user_info(user_ids: list[str]) -> dict[str, UserInfo]:
"""
批量获取用户的完整基础信息(姓名、部门、工号、职位)。
适配 dws contact user get 返回的嵌套 orgEmployeeModel 结构。
分批处理(每批20人),失败时不抛错。
"""
if not user_ids:
return {}
info_map: dict[str, UserInfo] = {}
for i in range(0, len(user_ids), 20):
batch = user_ids[i:i + 20]
try:
result = run_dws(["contact", "user", "get", "--ids", ",".join(batch)])
for record in extract_records(result):
parsed = _parse_user_record(record)
if parsed:
uid_str, info = parsed
info_map[uid_str] = info
except DwsCallError as e:
log(f"[user-info] 批量 get 失败(batch {i // 20 + 1}):{e}")
# 兜底:未解析到的用户填充 userId 作为姓名
for uid in user_ids:
if uid not in info_map:
info_map[uid] = UserInfo(name=uid)
return info_map
def resolve_user_names(user_ids: list[str]) -> dict[str, str]:
"""
给一组 userId 解析姓名映射(向后兼容接口)。
内部调用 resolve_user_info,只返回 {userId: name} 映射。
"""
info_map = resolve_user_info(user_ids)
return {uid: info.name or uid for uid, info in info_map.items()}
# ─────────────────────────────────────────────────────────────────────────────
# Excel 写入
# ─────────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# Excel 样式常量与辅助函数
# ─────────────────────────────────────────────────────────────────────────────
# 配色(参考钉钉考勤报表风格:青绿标题 + 浅黄表头 + 白色数据区)
_TITLE_FILL_COLOR = "D5EAEA" # 浅青绿 — 主标题背景
_TITLE_FONT_COLOR = "1F6E6E" # 深青 — 主标题字体
_SUBTITLE_FILL_COLOR = "DAEEF3" # 浅蓝 — 副标题(生成时间)背景
_SUBTITLE_FONT_COLOR = "31708F" # 深蓝 — 副标题字体
_HEADER_FILL_COLOR = "FFF2CC" # 浅黄 — 表头背景
_HEADER_FONT_COLOR = "333333" # 深灰近黑 — 表头字体
_DATA_FONT_COLOR = "333333" # 数据字体颜色
_BORDER_COLOR = "BFBFBF" # 浅灰 — 单元格边框
_FONT_NAME = "微软雅黑"
# 日历表人员交替配色(奇数人白底,偶数人浅灰蓝底,便于区分不同人员)
_CALENDAR_BAND_COLORS = ("FFFFFF", "EDF2F9")
# 考勤结果单元格条件配色(参考钉钉 previewStyleByValue 配置)
# 规则按优先级排列,首个匹配命中即停止;无 color 键表示不填充背景色。
import re as _re
_ATTEND_RESULT_STYLE_RULES: list[tuple["_re.Pattern[str]", str | None]] = [
# 白底(红字加粗由字体控制):周末 — POI index 9 WHITE (255,255,255)
(_re.compile(r".*(星期六|星期日|星期天)[\s\S]*"), "FFFFFF"),
# 浅黄(TAN):补卡审批通过/举证打卡/加班/外出/假/调休 — POI index 47 (255,204,153)
(_re.compile(r".*(补卡审批通过|举证打卡审批通过|加班|外出|假|调休)[\s\S]*"), "FFCC99"),
# 浅青(LIGHT_TURQUOISE):外勤/出差 — POI index 41 (204,255,255)
(_re.compile(r".*(外勤|出差)[\s\S]*"), "CCFFFF"),
# 浅黄(TAN):管理员改为正常 — POI index 47 (255,204,153)
(_re.compile(r"(?=.*管理员)(?=.*改为正常)^[\s\S]*$"), "FFCC99"),
# 水蓝(AQUA):旷工迟到(须在"旷工""迟到"之前匹配)— POI index 49 (51,204,204)
(_re.compile(r".*(旷工迟到)[\s\S]*"), "33CCCC"),
# 玫红(ROSE):旷工 — POI index 45 (255,153,204)
(_re.compile(r".*(旷工)[\s\S]*"), "FF99CC"),
# 淡蓝(PALE_BLUE):严重迟到(须在普通"迟到"之前匹配)— POI index 44 (153,204,255)
(_re.compile(r".*(严重迟到)[\s\S]*"), "99CCFF"),
# 浅绿(LIGHT_GREEN):迟到 — POI index 42 (204,255,204)
(_re.compile(r".*(迟到)[\s\S]*"), "CCFFCC"),
# 柠檬黄(LEMON_CHIFFON):早退 — POI index 26 (255,255,153)
(_re.compile(r".*(早退)[\s\S]*"), "FFFF99"),
# 珊瑚粉(CORAL):缺卡 — POI index 29 (255,128,128)
(_re.compile(r".*(缺卡)[\s\S]*"), "FF8080"),
# 无色:未排班/休息/正常 — 不填充
(_re.compile(r".*(未排班|休息|正常)[\s\S]*"), None),
]
def _match_attend_result_color(text: str) -> str | None:
"""
根据考勤结果文本匹配颜色(6 位 hex,不含 #),无匹配或匹配到"无色"规则时返回 None。
"""
if not text:
return None
for pattern, color in _ATTEND_RESULT_STYLE_RULES:
if pattern.fullmatch(text):
return color
return None
# 行高
_TITLE_ROW_HEIGHT = 30 # 主标题行高
_SUBTITLE_ROW_HEIGHT = 22 # 副标题行高
_HEADER_ROW_HEIGHT = 32 # 表头行高(更高,配合浅黄底色)
_DATA_ROW_HEIGHT = 22 # 数据行高
# 列宽估算:中文字符权重,宽度上下限
_CJK_CHAR_WEIGHT = 2.0
_ASCII_CHAR_WEIGHT = 1.1
_MIN_COL_WIDTH = 10
_MAX_COL_WIDTH = 40
def _is_cjk(ch: str) -> bool:
"""判断一个字符是否为中日韩字符(用于估算 Excel 列宽)。"""
if not ch:
return False
code = ord(ch)
return (
0x4E00 <= code <= 0x9FFF # CJK 统一汉字
or 0x3000 <= code <= 0x303F # CJK 符号和标点
or 0xFF00 <= code <= 0xFFEF # 全角字符
)
def _estimate_text_width(text: str) -> float:
"""估算字符串在 Excel 中显示所占的列宽(中文 ≈ 2,ASCII ≈ 1)。"""
if not text:
return 0.0
width = 0.0
for ch in text:
width += _CJK_CHAR_WEIGHT if _is_cjk(ch) else _ASCII_CHAR_WEIGHT
return width
def _is_numeric_value(value: Any) -> bool:
"""判断一个值是否为可对齐到右侧的数字(或纯数字字符串)。"""
if isinstance(value, bool):
return False
if isinstance(value, (int, float)):
return True
if isinstance(value, str):
s = value.strip()
if not s:
return False
try:
float(s)
return True
except ValueError:
return False
return False
def _build_styles():
"""构建 Excel 样式对象集合,避免每个单元格重复创建。"""
from openpyxl.styles import (
Alignment, Border, Font, PatternFill, Side,
)
thin_side = Side(border_style="thin", color=_BORDER_COLOR)
border = Border(left=thin_side, right=thin_side, top=thin_side, bottom=thin_side)
return {
# 主标题("月度汇总展示 统计日期:xxx 至 xxx")
"title_font": Font(name=_FONT_NAME, bold=True, color=_TITLE_FONT_COLOR, size=14),
"title_fill": PatternFill(fill_type="solid", fgColor=_TITLE_FILL_COLOR),
"title_align": Alignment(horizontal="left", vertical="center", indent=1),
# 副标题("报表生成时间:xxx")
"subtitle_font": Font(name=_FONT_NAME, color=_SUBTITLE_FONT_COLOR, size=10),
"subtitle_fill": PatternFill(fill_type="solid", fgColor=_SUBTITLE_FILL_COLOR),
"subtitle_align": Alignment(horizontal="left", vertical="center", indent=1),
# 表头
"header_font": Font(name=_FONT_NAME, bold=True, color=_HEADER_FONT_COLOR, size=11),
"header_fill": PatternFill(fill_type="solid", fgColor=_HEADER_FILL_COLOR),
"header_align": Alignment(horizontal="center", vertical="center", wrap_text=True),
# 数据
"data_font": Font(name=_FONT_NAME, color=_DATA_FONT_COLOR, size=10),
"align_left": Alignment(horizontal="left", vertical="center", wrap_text=True),
"align_right": Alignment(horizontal="right", vertical="center", wrap_text=False),
"align_center": Alignment(horizontal="center", vertical="center", wrap_text=True),
"border": border,
# 日历表人员交替配色(按 merge_groups 块交替)
"band_fills": tuple(
PatternFill(fill_type="solid", fgColor=c)
for c in _CALENDAR_BAND_COLORS
),
}
def _apply_sheet_styles(
ws,
headers: list[str],
rows: list[list[Any]],
styles: dict,
*,
title: str | None = None,
subtitle: str | None = None,
freeze_first_col: bool = True,
merge_groups: list[tuple[int, int, int]] | None = None,
attend_result_columns: set[int] | None = None,
attend_result_rows: set[int] | None = None,
image_columns: list[str] | None = None,
image_size: tuple[int, int] = (80, 120),
) -> None:
"""
给一个已写入数据的 sheet 应用统一样式(钉钉风格)。
布局(自上而下):
[可选] 第 1 行:主标题(青绿底,跨所有列,写"xxx展示 统计日期:A 至 B")
[可选] 第 2 行:副标题(浅蓝底,跨所有列,写"报表生成时间:xxx")
表头行:浅黄底 + 加粗黑字 + 居中 + 加高
数据行:白底 + 居中 + 灰色细边框
参数:
title: 可选主标题文本(如"月度汇总展示 统计日期:2026-01-01 至 2026-01-31")
subtitle: 可选副标题文本(如"报表生成时间:2026-01-20 15:46")
freeze_first_col: 是否冻结首列(姓名)
attend_result_columns: 考勤结果列的 0-based 列索引集合(月度汇总场景)
attend_result_rows: 考勤结果行的 0-based 行偏移集合(日历表场景)
"""
from openpyxl.utils import get_column_letter
n_cols = len(headers)
n_rows = len(rows)
last_col_letter = get_column_letter(n_cols) if n_cols >= 1 else "A"
# ── 标题区(如果有)── 占据 1~2 行,跨所有列 ───────────────────────
title_row_count = 0
if title:
title_row_count += 1
title_row = title_row_count
ws.cell(row=title_row, column=1, value=title)
if n_cols >= 2:
ws.merge_cells(
start_row=title_row, start_column=1,
end_row=title_row, end_column=n_cols,
)
cell = ws.cell(row=title_row, column=1)
cell.font = styles["title_font"]
cell.fill = styles["title_fill"]
cell.alignment = styles["title_align"]
ws.row_dimensions[title_row].height = _TITLE_ROW_HEIGHT
if subtitle:
title_row_count += 1
sub_row = title_row_count
ws.cell(row=sub_row, column=1, value=subtitle)
if n_cols >= 2:
ws.merge_cells(
start_row=sub_row, start_column=1,
end_row=sub_row, end_column=n_cols,
)
cell = ws.cell(row=sub_row, column=1)
cell.font = styles["subtitle_font"]
cell.fill = styles["subtitle_fill"]
cell.alignment = styles["subtitle_align"]
ws.row_dimensions[sub_row].height = _SUBTITLE_ROW_HEIGHT
header_row = title_row_count + 1
first_data_row = header_row + 1
# ── 表头样式 ─────────────────────────────────────────────────────────
for col_idx in range(1, n_cols + 1):
cell = ws.cell(row=header_row, column=col_idx)
cell.font = styles["header_font"]
cell.fill = styles["header_fill"]
cell.alignment = styles["header_align"]
cell.border = styles["border"]
ws.row_dimensions[header_row].height = _HEADER_ROW_HEIGHT
# ── 人员交替配色映射(仅在有 merge_groups 时生效)─────────────────
# 为每个数据行偏移预计算应使用的背景 fill(按人员块交替)
band_fill_map: dict[int, Any] = {}
if merge_groups:
band_fills = styles.get("band_fills", ())
if band_fills:
for group_idx, (start_off, end_off, _n_base) in enumerate(merge_groups):
fill = band_fills[group_idx % len(band_fills)]
for off in range(start_off, end_off + 1):
band_fill_map[off] = fill
# ── 考勤结果条件配色准备 ────────────────────────────────────────────
# attend_result_columns: 月度汇总场景,指定哪些列(0-based)是考勤结果列
# attend_result_rows: 日历表场景,指定哪些行偏移(0-based)是考勤结果行
# 两者都需要结合日期数据列(跳过基础信息列)来判断是否需要配色
from openpyxl.styles import PatternFill as _PF
_attend_fill_cache: dict[str, _PF] = {}
def _get_attend_fill(color_hex: str) -> _PF:
"""按颜色值缓存 PatternFill,避免重复创建。"""
if color_hex not in _attend_fill_cache:
_attend_fill_cache[color_hex] = _PF(fill_type="solid", fgColor=color_hex)
return _attend_fill_cache[color_hex]
_ar_cols = attend_result_columns or set()
_ar_rows = attend_result_rows or set()
# ── 数据行样式 ───────────────────────────────────────────────────────
for row_offset in range(n_rows):
excel_row = first_data_row + row_offset
row_fill = band_fill_map.get(row_offset)
is_attend_row = row_offset in _ar_rows
for col_idx in range(1, n_cols + 1):
cell = ws.cell(row=excel_row, column=col_idx)
cell.font = styles["data_font"]
cell.border = styles["border"]
# 背景色优先级:考勤结果条件配色 > 人员交替配色 > 默认无色
col_zero = col_idx - 1
value = rows[row_offset][col_zero] if col_zero < len(rows[row_offset]) else None
is_attend_cell = (col_zero in _ar_cols) or (is_attend_row and col_zero >= 4)
attend_color = None
if is_attend_cell and value not in (None, ""):
attend_color = _match_attend_result_color(str(value))
if attend_color:
cell.fill = _get_attend_fill(attend_color)
elif row_fill is not None:
cell.fill = row_fill
# 数据区统一居中(参考图风格),仅对长文本(>10 字符)的非首列左对齐避免拥挤
if _is_numeric_value(value):
cell.alignment = styles["align_right"]
else:
text = str(value) if value not in (None, "") else ""
if _estimate_text_width(text) > 14 and col_idx > 1:
cell.alignment = styles["align_left"]
else:
cell.alignment = styles["align_center"]
ws.row_dimensions[excel_row].height = _DATA_ROW_HEIGHT
# ── 列宽自适应 ───────────────────────────────────────────────────────
for col_idx in range(1, n_cols + 1):
header_text = str(headers[col_idx - 1])
max_width = _estimate_text_width(header_text) + 2
for row in rows:
if col_idx - 1 < len(row):
cell_value = row[col_idx - 1]
if cell_value is None or cell_value == "":
continue
w = _estimate_text_width(str(cell_value))
if w > max_width:
max_width = w
max_width = min(max(max_width + 1, _MIN_COL_WIDTH), _MAX_COL_WIDTH)
ws.column_dimensions[get_column_letter(col_idx)].width = max_width
# ── 冻结窗格 ─────────────────────────────────────────────────────────
# 冻结到首个数据行 + 第二列(保留标题/表头/姓名列常驻)
freeze_col_letter = "B" if (freeze_first_col and n_cols >= 2) else "A"
ws.freeze_panes = f"{freeze_col_letter}{first_data_row}"
# ── 自动筛选器(覆盖表头到最后一行数据)──────────────────────────
# 注意:合并单元格时不应用 auto_filter(会和合并冲突)
if n_cols >= 1 and n_rows >= 1 and not merge_groups:
ws.auto_filter.ref = (
f"A{header_row}:{last_col_letter}{first_data_row + n_rows - 1}"
)
# ── 单元格纵向合并(仅指定的基础列)──────────────────────────
# merge_groups: [(start_row_offset, end_row_offset, n_base_cols), ...]
# 其中 row_offset 是基于数据区第 0 行的偏移
if merge_groups:
for start_offset, end_offset, n_base_cols in merge_groups:
if end_offset <= start_offset:
continue
excel_start = first_data_row + start_offset
excel_end = first_data_row + end_offset
for col_idx in range(1, n_base_cols + 1):
# 取首行的值,合并后只保留首行内容
top_value = ws.cell(row=excel_start, column=col_idx).value
ws.merge_cells(
start_row=excel_start, start_column=col_idx,
end_row=excel_end, end_column=col_idx,
)
top_cell = ws.cell(row=excel_start, column=col_idx)
top_cell.value = top_value
top_cell.alignment = styles["align_center"]
top_cell.border = styles["border"]
# ── 图片嵌入(下载 URL 列指向的图片,转 PNG,嵌入对应单元格)────
if image_columns:
_embed_images_in_columns(
ws, headers, rows,
image_column_names=image_columns,
header_row=header_row,
first_data_row=first_data_row,
image_size=image_size,
)
def write_excel(
out_path: str,
headers: list[str],
rows: list[list[Any]],
*,
sheet_name: str = "考勤报表",
title: str | None = None,
subtitle: str | None = None,
image_columns: list[str] | None = None,
image_size: tuple[int, int] = (80, 120),
) -> None:
"""
用 openpyxl 写一个钉钉风格的美化版 Excel。
布局:
[可选] 第 1 行:主标题(青绿底色,跨所有列)
[可选] 第 2 行:副标题(浅蓝底色,跨所有列,常用于"报表生成时间")
表头行:浅黄底色 + 黑字加粗 + 居中 + 加高
数据行:白底 + 文本居中 / 数字右对齐 + 灰色细边框
交互:
- 冻结首列 + 表头(含标题区域)
- 自动筛选器(覆盖表头到最后一行)
- 列宽自适应(中文字符按 2 宽度估算)
参数:
title: 可选主标题(如"月度汇总展示 统计日期:2026-01-01 至 2026-01-31")
subtitle: 可选副标题(如"报表生成时间:2026-01-20 15:46")
"""
try:
from openpyxl import Workbook
except ImportError as e:
raise RuntimeError(
"缺少 openpyxl 依赖,请执行:pip install openpyxl"
) from e
wb = Workbook()
ws = wb.active
ws.title = sheet_name[:31] # openpyxl sheet 名 ≤ 31 字符
# 计算标题占用行数
title_row_count = (1 if title else 0) + (1 if subtitle else 0)
header_row = title_row_count + 1
first_data_row = header_row + 1
# 写入表头
for col_idx, header in enumerate(headers, start=1):
ws.cell(row=header_row, column=col_idx, value=header)
# 写入数据
for row_offset, row in enumerate(rows):
excel_row = first_data_row + row_offset
for col_idx, value in enumerate(row, start=1):
ws.cell(row=excel_row, column=col_idx, value=_excel_safe(value))
# 应用统一样式(含标题区)
styles = _build_styles()
_apply_sheet_styles(
ws, headers, rows, styles,
title=title, subtitle=subtitle,
image_columns=image_columns, image_size=image_size,
)
out_abs = os.path.abspath(out_path)
wb.save(out_abs)
def _excel_safe(value: Any) -> Any:
"""
把 dict / list 等复杂类型序列化为 JSON 字符串,避免 openpyxl 写入失败。
"""
if value is None:
return ""
if isinstance(value, (str, int, float, bool)):
return value
if isinstance(value, datetime):
return value.strftime(DATETIME_FMT)
try:
return json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError):
return str(value)
def write_excel_multi_sheets(
out_path: str,
sheets: list[dict],
) -> None:
"""
用 openpyxl 写一个多 sheet 的 Excel 文件,每个 sheet 应用与 write_excel 相同的钉钉风格美化样式。
每个 sheet 用一个 dict 描述:
{
"name": str, # sheet 标题
"headers": list[str],
"rows": list[list[Any]],
"title": str | None, # 可选主标题(青绿底)
"subtitle": str | None, # 可选副标题(浅蓝底)
"merge_groups": list[tuple[int,int,int]] | None,
# 可选纵向合并配置(基础列)
# 每项: (start_row_offset, end_row_offset, n_base_cols)
"attend_result_columns": set[int] | None,
# 可选:考勤结果列的 0-based 列索引集合(月度汇总场景)
"attend_result_rows": set[int] | None,
# 可选:考勤结果行的 0-based 行偏移集合(日历表场景)
"image_columns": list[str] | None,
# 可选:哪些列名的 URL 要嵌入为图片
"image_size": tuple[int,int] | None,
# 可选:嵌入图片像素尺寸 (width, height),默认 (80,120)
}
"""
if not sheets:
raise ValueError("sheets 不能为空")
try:
from openpyxl import Workbook
except ImportError as e:
raise RuntimeError(
"缺少 openpyxl 依赖,请执行:pip install openpyxl"
) from e
wb = Workbook()
# 删除默认 sheet,由 sheets 描述完全决定
default_ws = wb.active
wb.remove(default_ws)
styles = _build_styles()
for sheet_def in sheets:
name = sheet_def.get("name") or "Sheet"
headers = sheet_def.get("headers") or []
rows = sheet_def.get("rows") or []
title = sheet_def.get("title")
subtitle = sheet_def.get("subtitle")
merge_groups = sheet_def.get("merge_groups")
attend_result_columns = sheet_def.get("attend_result_columns")
attend_result_rows = sheet_def.get("attend_result_rows")
image_columns = sheet_def.get("image_columns")
image_size = sheet_def.get("image_size") or (80, 120)
ws = wb.create_sheet(title=name[:31])
title_row_count = (1 if title else 0) + (1 if subtitle else 0)
header_row = title_row_count + 1
first_data_row = header_row + 1
# 写入表头
for col_idx, header in enumerate(headers, start=1):
ws.cell(row=header_row, column=col_idx, value=header)
# 写入数据
for row_offset, row in enumerate(rows):
excel_row = first_data_row + row_offset
for col_idx, value in enumerate(row, start=1):
ws.cell(row=excel_row, column=col_idx, value=_excel_safe(value))
# 应用统一样式
if headers:
_apply_sheet_styles(
ws, headers, rows, styles,
title=title, subtitle=subtitle,
merge_groups=merge_groups,
attend_result_columns=attend_result_columns,
attend_result_rows=attend_result_rows,
image_columns=image_columns,
image_size=image_size,
)
out_abs = os.path.abspath(out_path)
wb.save(out_abs)
# ─────────────────────────────────────────────────────────────────────────────
# 输出文件命名
# ─────────────────────────────────────────────────────────────────────────────
def build_output_filename(start: datetime, end: datetime, *, suffix: str = "") -> str:
"""
生成 attendance_report_<startDate>_<endDate>[_suffix].xlsx 形式的文件名,
落在当前工作目录。
"""
base = f"attendance_report_{start.strftime(DATE_FMT)}_{end.strftime(DATE_FMT)}"
if suffix:
base = f"{base}_{suffix}"
return f"{base}.xlsx"
# ─────────────────────────────────────────────────────────────────────────────
# 请假数据查询(query-leave)
# ─────────────────────────────────────────────────────────────────────────────
# 默认关注的 4 类假期
DEFAULT_LEAVE_NAMES: tuple[str, ...] = ("事假", "调休", "病假", "年假")
def _normalize_leave_date(raw: Any) -> str | None:
"""把 query-leave 返回的 date 字段(毫秒时间戳字符串/数字)归一化为 YYYY-MM-DD。"""
if raw is None or raw == "":
return None
try:
ts = int(str(raw).strip())
except (TypeError, ValueError):
# 也可能本来就是 YYYY-MM-DD
s = str(raw).strip()
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
return s[:10]
return None
# 毫秒级
if ts >= 1_000_000_000_000:
ts = ts // 1000
try:
return datetime.fromtimestamp(ts).strftime(DATE_FMT)
except (OSError, ValueError, OverflowError):
return None
def query_leave_data(
user_ids: list[str],
start: datetime,
end: datetime,
leave_names: Iterable[str] = DEFAULT_LEAVE_NAMES,
*,
stats: "CallStats | None" = None,
) -> dict[str, dict[str, dict[str, float]]]:
"""
分批分段调用 `dws attendance report query-leave`,聚合每个用户每天的假期数据。
返回结构:
{
userId: {
"YYYY-MM-DD": {
"事假": 1.0,
"调休": 0.5,
...
},
...
},
...
}
若同一 (userId, date, leaveName) 在多次返回中出现(理论不会),按 sum 累加。
分批规则与 query-data 一致:≤ MAX_USERS_PER_BATCH 人/次、≤ MAX_DAYS_PER_SLICE 天/次。
"""
result: dict[str, dict[str, dict[str, float]]] = {}
if not user_ids:
return result
leave_names_list = [n for n in leave_names if n]
if not leave_names_list:
return result
user_batches = chunk_users(user_ids)
date_slices = slice_date_range(start, end)
leave_arg = ",".join(leave_names_list)
log(
f"[leave] 查询 {len(user_ids)} 人 × {len(leave_names_list)} 类假期 × "
f"{len(date_slices)} 个时间片"
)
for bi, batch in enumerate(user_batches, start=1):
for si, dslice in enumerate(date_slices, start=1):
log(
f"[leave] [batch {bi}/{len(user_batches)}] "
f"[slice {si}/{len(date_slices)}] users={len(batch)} "
f"slice={dslice.label}"
)
try:
payload = run_dws([
"attendance", "report", "query-leave",
"--users", ",".join(batch),
"--leave-names", leave_arg,
"--start", dslice.start_str,
"--end", dslice.end_str,
])
if stats is not None:
stats.total_dws_calls += 1
except DwsCallError as e:
if stats is not None:
stats.total_dws_calls += 1
stats.failed_calls += 1
if e.is_permission_error:
error("权限错误:当前账号无管理员权限,无法查询请假数据。")
raise SystemExit(2) from e
if stats is not None:
stats.add_warning(f"[leave query failed] {dslice.label}: {e}")
else:
warn(f"[leave query failed] {dslice.label}: {e}")
continue
records = extract_records(payload)
for record in records:
uid = _first_nonempty(record, ("userId", "userid", "user_id"))
if uid is None:
continue
uid_str = str(uid)
leave_vals = record.get("leaveVals")
if not isinstance(leave_vals, list):
continue
user_bucket = result.setdefault(uid_str, {})
for entry in leave_vals:
if not isinstance(entry, dict):
continue
date_str = _normalize_leave_date(entry.get("date"))
if not date_str:
continue
leave_name = entry.get("leaveName") or entry.get("name")
if not leave_name:
continue
leave_name = str(leave_name)
raw_value = entry.get("value", entry.get("data", 0))
try:
num = float(str(raw_value).strip())
except (TypeError, ValueError):
continue
day_bucket = user_bucket.setdefault(date_str, {})
day_bucket[leave_name] = day_bucket.get(leave_name, 0.0) + num
return result
def build_vacation_filename(
start: datetime | None,
end: datetime | None,
*,
as_of: datetime | None = None,
) -> str:
"""
生成 vacation_export_<startDate>_<endDate>.xlsx;
无时间区间时退化为 vacation_export_<asOfDate>.xlsx(asOfDate 默认为今天)。
"""
if start is not None and end is not None:
return f"vacation_export_{start.strftime(DATE_FMT)}_{end.strftime(DATE_FMT)}.xlsx"
snapshot_date = (as_of or datetime.now()).strftime(DATE_FMT)
return f"vacation_export_{snapshot_date}.xlsx"
# ─────────────────────────────────────────────────────────────────────────────
# 通用报告骨架
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class CallStats:
"""记录一次脚本运行中的 dws 调用统计,用于最终摘要。"""
user_batches: int = 0
date_slices: int = 0
total_dws_calls: int = 0
failed_calls: int = 0
warnings: list[str] = field(default_factory=list)
def add_warning(self, msg: str) -> None:
self.warnings.append(msg)
warn(msg)
def print_summary(
*,
granularity_label: str,
out_path: str,
user_count: int,
column_names: list[str],
start: datetime,
end: datetime,
rows_count: int,
stats: CallStats,
extra_tail: str = "",
) -> None:
"""
把最终摘要打到 stdout,供调用方(Agent / 终端用户)查看。
格式与 SKILL.md 输出模板对齐。
"""
abs_path = os.path.abspath(out_path)
print("[完成] 考勤报表已导出")
print()
print(f"[文件] {abs_path}")
print(f"[粒度] {granularity_label}")
print(f"[用户] {user_count} 人(共 {stats.user_batches} 批)")
print(f"[时间] {start.strftime(DATE_FMT)} ~ {end.strftime(DATE_FMT)}"
f"(共 {stats.date_slices} 个时间片)")
print(f"[调用] 共调用 dws 接口:{stats.total_dws_calls} 次"
+ (f"(其中 {stats.failed_calls} 次失败)" if stats.failed_calls else ""))
print(f"[字段] {' / '.join(column_names) if column_names else '(默认)'}")
print(f"[行数] {rows_count} 行")
if stats.warnings:
print()
print("[警告]")
for w in stats.warnings[:10]:
print(f" - {w}")
if len(stats.warnings) > 10:
print(f" - ...(共 {len(stats.warnings)} 条警告)")
if extra_tail:
print()
print(extra_tail)
# ─────────────────────────────────────────────────────────────────────────────
# 图片下载 + Excel 嵌入(detail 报表的"打卡图片"列专用)
# ─────────────────────────────────────────────────────────────────────────────
# 全局缓存:URL → 本地 PNG 文件路径,避免同一张图重复下载/转换
_IMAGE_CACHE_DIR = os.path.join(
tempfile.gettempdir(), "dws_attendance_report_images"
)
_image_url_to_local: dict[str, str] = {}
# 下载/转换失败的 URL 黑名单,避免反复重试
_image_failed_urls: set[str] = set()
def _ensure_image_cache_dir() -> str:
"""确保图片缓存目录存在并返回路径。"""
os.makedirs(_IMAGE_CACHE_DIR, exist_ok=True)
return _IMAGE_CACHE_DIR
def _is_likely_url(value: Any) -> bool:
"""简单判断一个值是不是 http(s) URL。"""
if not isinstance(value, str):
return False
s = value.strip()
return s.startswith("http://") or s.startswith("https://")
def download_and_convert_image(
url: str,
*,
timeout: int = 10,
) -> str | None:
"""
下载图片 URL → PIL 转 PNG → 缓存到本地,返回本地 PNG 文件路径。
特性:
- 磁盘缓存(同一 URL 只下载一次)
- 支持 webp/jpg/jpeg/png 等格式(PIL 自动识别)
- 失败的 URL 加黑名单,避免反复重试
- 失败返回 None,调用方应保留原 URL 文本
依赖: requests + Pillow(PIL)
"""
if url in _image_url_to_local:
return _image_url_to_local[url]
if url in _image_failed_urls:
return None
cache_dir = _ensure_image_cache_dir()
url_hash = hashlib.md5(url.encode("utf-8")).hexdigest()
local_path = os.path.join(cache_dir, f"{url_hash}.png")
if os.path.exists(local_path) and os.path.getsize(local_path) > 0:
_image_url_to_local[url] = local_path
return local_path
try:
import requests
except ImportError:
warn("缺少 requests 依赖,无法下载图片,请执行: pip install requests")
_image_failed_urls.add(url)
return None
try:
from PIL import Image as PILImage
except ImportError:
warn("缺少 Pillow 依赖,无法转换图片格式,请执行: pip install Pillow")
_image_failed_urls.add(url)
return None
try:
resp = requests.get(url, timeout=timeout)
resp.raise_for_status()
raw_bytes = resp.content
if not raw_bytes:
raise ValueError("下载结果为空")
except Exception as e:
warn(f"[image] 下载失败: {url[:80]}... 原因: {e}")
_image_failed_urls.add(url)
return None
try:
from io import BytesIO
with PILImage.open(BytesIO(raw_bytes)) as img:
# webp 等可能是 RGBA / P 模式,统一转 RGB 再存 PNG
if img.mode not in ("RGB", "RGBA"):
img = img.convert("RGBA")
img.save(local_path, format="PNG")
except Exception as e:
warn(f"[image] 转换失败: {url[:80]}... 原因: {e}")
_image_failed_urls.add(url)
return None
_image_url_to_local[url] = local_path
return local_path
def _set_image_hyperlink(ws, excel_row: int, col_idx: int, url: str) -> None:
"""把单元格设为可点击的超链接,文案显示"打卡图片",避免直接暴露裸 URL。"""
from openpyxl.styles import Font
cell = ws.cell(row=excel_row, column=col_idx)
cell.value = "打卡图片"
cell.hyperlink = url
cell.font = Font(color="0563C1", underline="single")
def _replace_all_image_urls_with_hyperlinks(
ws, headers: list[str], rows: list[list[Any]],
image_column_names: list[str], first_data_row: int,
) -> None:
"""Pillow 不可用时的兜底:把所有图片列的 URL 替换为"打卡图片"超链接。"""
name_to_col_idx: dict[str, int] = {}
for i, h in enumerate(headers, start=1):
if h in image_column_names and h not in name_to_col_idx:
name_to_col_idx[h] = i
for row_offset, row in enumerate(rows):
excel_row = first_data_row + row_offset
for col_idx in name_to_col_idx.values():
if col_idx - 1 >= len(row):
continue
if _is_likely_url(row[col_idx - 1]):
_set_image_hyperlink(ws, excel_row, col_idx, str(row[col_idx - 1]).strip())
def _embed_images_in_columns(
ws,
headers: list[str],
rows: list[list[Any]],
*,
image_column_names: list[str],
header_row: int,
first_data_row: int,
image_size: tuple[int, int] = (80, 120),
) -> None:
"""
把指定列里的 URL 替换为嵌入的图片:
1. 找到 image_column_names 命中的列索引
2. 遍历每行该列的值,若是 http(s) URL 则下载 + 转 PNG + add_image
3. 同步调整列宽(与图片宽匹配)和行高(与图片高匹配)
4. 下载/转换失败时将 URL 替换为可点击的"打卡图片"超链接,避免暴露裸 URL
image_size: (width_px, height_px),控制嵌入图片尺寸,默认 80×120 像素
"""
if not image_column_names:
return
try:
from openpyxl.drawing.image import Image as OpenpyxlImage
from openpyxl.utils import get_column_letter
except ImportError as e:
warn(f"openpyxl 不完整,无法嵌入图片: {e}")
return
# openpyxl 的 Image 类内部依赖 Pillow(模块加载时检测),
# 如果 Pillow 不可用,OpenpyxlImage() 会抛出:
# ImportError: You must install Pillow to fetch image objects
# 这里提前检测,不可用时尝试自动安装,避免逐张图片重复报错。
from openpyxl.drawing.image import PILImage as _openpyxl_pil_check
if not _openpyxl_pil_check:
warn(
"[image] openpyxl 检测到 Pillow 未安装,尝试自动安装..."
)
import subprocess, sys
try:
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "Pillow"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=60,
)
log("[image] Pillow 安装成功,重新加载 openpyxl.drawing.image...")
# 安装后需要重新加载模块,让 openpyxl 重新检测 Pillow
import importlib
import openpyxl.drawing.image as _img_mod
importlib.reload(_img_mod)
from openpyxl.drawing.image import Image as OpenpyxlImage # noqa: F811
from openpyxl.drawing.image import PILImage as _recheck
if not _recheck:
warn(
"[image] Pillow 安装后 openpyxl 仍无法检测到,"
"图片将显示为可点击链接。请手动执行: pip install Pillow"
)
_replace_all_image_urls_with_hyperlinks(
ws, headers, rows, image_column_names, first_data_row,
)
return
except Exception as install_err:
warn(
f"[image] Pillow 自动安装失败: {install_err}\n"
"图片将显示为可点击链接。请手动执行: pip install Pillow"
)
_replace_all_image_urls_with_hyperlinks(
ws, headers, rows, image_column_names, first_data_row,
)
return
# 找到目标列索引(1-based)
name_to_col_idx: dict[str, int] = {}
for i, h in enumerate(headers, start=1):
if h in image_column_names and h not in name_to_col_idx:
name_to_col_idx[h] = i
if not name_to_col_idx:
return
width_px, height_px = image_size
# Excel 列宽单位 ≈ 字符数,1 字符 ≈ 7px;行高单位为点,1 点 ≈ 1.33px
col_width = max(width_px / 7.0, 12.0)
row_height = max(height_px * 0.78, 60.0)
# 调列宽
for col_idx in name_to_col_idx.values():
ws.column_dimensions[get_column_letter(col_idx)].width = col_width
# 收集所有要嵌入的 (excel_row, col_idx, url),统一处理
embed_tasks: list[tuple[int, int, str]] = []
for row_offset, row in enumerate(rows):
excel_row = first_data_row + row_offset
row_has_image = False
for col_name, col_idx in name_to_col_idx.items():
if col_idx - 1 >= len(row):
continue
value = row[col_idx - 1]
if not _is_likely_url(value):
continue
url = str(value).strip()
embed_tasks.append((excel_row, col_idx, url))
row_has_image = True
if row_has_image:
ws.row_dimensions[excel_row].height = row_height
if not embed_tasks:
return
log(f"[image] 准备嵌入 {len(embed_tasks)} 张图片到 Excel...")
success_count = 0
failed_count = 0
for excel_row, col_idx, url in embed_tasks:
local_path = download_and_convert_image(url)
if not local_path:
_set_image_hyperlink(ws, excel_row, col_idx, url)
failed_count += 1
continue
try:
img = OpenpyxlImage(local_path)
img.width = width_px
img.height = height_px
anchor = f"{get_column_letter(col_idx)}{excel_row}"
# 清空原 URL 单元格内容(图片浮在格子上,但保留文本会重叠)
ws.cell(row=excel_row, column=col_idx, value="")
ws.add_image(img, anchor)
success_count += 1
except Exception as e:
warn(f"[image] 嵌入失败 ({excel_row},{col_idx}): {e}")
_set_image_hyperlink(ws, excel_row, col_idx, url)
failed_count += 1
log(f"[image] 嵌入完成: 成功 {success_count} 张,失败 {failed_count} 张")
#!/usr/bin/env python3
"""
考勤报表导出 — 每日统计粒度
⛔ 【AI Agent 强制门禁】调用本脚本前必须先阅读:
dingtalk-workspace/references/products/attendance-report.md
本脚本仅是"考勤报表导出工作流"的执行末端,工作流完整定义在 attendance-report.md,
包含但不限于:
- 阶段 0:报表类型判断(默认月度汇总)
- 阶段 1:人员列表获取(aisearch person / contact dept list-members)
- 阶段 2:列选择(是否传 --column-keywords)
- 阶段 3:调用本脚本
- 阶段 4:结果回传给用户的标准格式
- 错误处理(403 权限、HSF_ILLEGALPARAMS、空数据等)
❌ 严禁仅凭本脚本 docstring 或 --help 输出就直接拼命令执行,会导致:
- 报表数据不全 / 列错位 / 人员遗漏
- 错误处理缺失,把环境错误当业务错误反馈给用户
- 输出格式不规范,用户体验差
按 (userId, workDate) 分组,每人每天一行。
聚合策略:
- 通过启发式识别每条记录的"工作日期":依次尝试字段名
workDate / work_date / date / userCheckTime / day / 工作日期
- 同一 (userId, workDate) 下的多条记录按字段聚合:
* 数值字段 → sum
* 非数值字段 → 取首个非空值(因为同一天同一字段通常只有一个值)
- 缺少 workDate 的记录会归入 "_no_date",并 warn
用法:
python attendance_report_daily.py \
--users userId1,userId2,... \
--start "2026-03-01 00:00:00" \
--end "2026-03-31 23:59:59" \
[--columns 1001,1002]
[--column-keywords "工作日期,出勤状态,迟到时长"]
[--out attendance_report_2026-03-01_2026-03-31_daily.xlsx]
[--inspect]
"""
from __future__ import annotations
import argparse
import sys
from collections import defaultdict
from datetime import datetime
from typing import Any
import attendance_report_common as cmn
# 默认关注字段 — 与 SKILL.md「每日统计预定义列集合」严格对齐(共 33 个)
# 字段名必须和 `dws attendance report columns` 返回的 name 精确匹配
DEFAULT_KEYWORDS = [
"班次",
"上班1打卡时间",
"上班1打卡结果",
"下班1打卡时间",
"下班1打卡结果",
"上班2打卡时间",
"上班2打卡结果",
"下班2打卡时间",
"下班2打卡结果",
"上班3打卡时间",
"上班3打卡结果",
"下班3打卡时间",
"下班3打卡结果",
"关联的审批单",
"出勤天数",
"休息天数",
"工作时长",
"迟到次数",
"迟到时长",
"严重迟到次数",
"严重迟到时长",
"旷工迟到次数",
"早退次数",
"早退时长",
"上班缺卡次数",
"下班缺卡次数",
"旷工天数",
"出差时长",
"外出时长",
"请假",
"加班-审批单统计",
]
# 工作日期字段的候选 key(按优先级试探)
DATE_KEY_CANDIDATES = (
"workDate", "work_date", "userCheckDate", "checkDate",
"date", "day", "工作日期",
)
# 请假字段 — 触发"按假期类型展开"的字段名
# 不参与 query-data 查询,单独走 query-leave 接口,按 4 类假期展开为多列
# 注意:钉钉接口实际返回的字段名可能是 "请假"、"请假分类"、"请假时长" 等,
# 凡以 "请假" 开头的都视为请假字段,统一替换为 4 列假期类型展开。
LEAVE_FIELD_NAME = "请假"
LEAVE_TYPES: tuple[str, ...] = ("事假", "调休", "病假", "年假")
def _is_leave_field(name: str) -> bool:
"""判断一个字段名是否属于"请假"系列(如 请假 / 请假分类 / 请假时长)。"""
return isinstance(name, str) and name.startswith(LEAVE_FIELD_NAME)
# ─────────────────────────────────────────────────────────────────────────────
# 参数解析
# ─────────────────────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description=(
"导出考勤报表 — 每日统计粒度。"
"⛔ AI Agent 必须先读 references/products/attendance-report.md 再调用本脚本,"
"禁止凭 --help 或脚本路径自行拼命令。"
),
)
p.add_argument("--users", required=True,
help="userId 列表,逗号分隔(必填)")
p.add_argument("--start", required=True,
help='开始时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)')
p.add_argument("--end", required=True,
help='结束时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)')
p.add_argument("--columns", default="",
help="字段 ID 列表,逗号分隔;与 --column-keywords 二选一")
p.add_argument("--column-keywords", default="",
help="字段名关键词,逗号分隔;不传则走默认字段集")
p.add_argument("--out", default="",
help="输出 xlsx 文件名;不传则按规范自动生成")
p.add_argument("--inspect", action="store_true",
help="首次跑时打印首条记录原始结构(用于核对真实字段)")
return p.parse_args()
# ─────────────────────────────────────────────────────────────────────────────
# 字段解析(与 detail / monthly 一致)
# ─────────────────────────────────────────────────────────────────────────────
def resolve_columns(args: argparse.Namespace) -> list[dict]:
if args.columns.strip():
cids = [c.strip() for c in args.columns.split(",") if c.strip()]
all_cols_payload = cmn.run_dws(["attendance", "report", "columns"])
all_cols = cmn.extract_records(all_cols_payload)
id_to_name: dict[str, str] = {}
for col in all_cols:
cid = cmn._first_nonempty(col, ("id", "columnId", "code", "key"))
name = cmn._first_nonempty(col, ("name", "columnName", "title", "label"))
if cid is not None:
id_to_name[str(cid)] = str(name) if name else str(cid)
return [{"_column_id": cid, "_column_name": id_to_name.get(cid, cid)}
for cid in cids]
keywords = (
[k.strip() for k in args.column_keywords.split(",") if k.strip()]
if args.column_keywords.strip()
else DEFAULT_KEYWORDS
)
cmn.log(f"[columns] 使用关键词匹配字段:{keywords}")
all_cols_payload = cmn.run_dws(["attendance", "report", "columns"])
all_cols = cmn.extract_records(all_cols_payload)
cmn.log(f"[columns] dws 返回 {len(all_cols)} 个字段")
matched = cmn.match_columns_by_keywords(all_cols, keywords)
if not matched:
raise RuntimeError(
f"未匹配到任何字段。可用字段示例:"
f"{[cmn._first_nonempty(c, ('name','columnName','title','label')) for c in all_cols[:10]]}"
)
cmn.log(f"[columns] 匹配到 {len(matched)} 个字段:{[c['_column_name'] for c in matched]}")
return matched
# ─────────────────────────────────────────────────────────────────────────────
# 接口调用(与 detail / monthly 一致)
# ─────────────────────────────────────────────────────────────────────────────
def query_one_batch(
user_batch: list[str],
column_ids: list[str],
date_slice: cmn.DateSlice,
stats: cmn.CallStats,
*,
column_id_to_name: dict[str, str] | None = None,
inspect: bool = False,
inspected_flag: list[bool] = None,
) -> list[dict]:
cmn.log(
f"[query] users={len(user_batch)} cols={len(column_ids)} "
f"slice={date_slice.label}"
)
try:
payload = cmn.run_dws([
"attendance", "report", "query-data",
"--users", ",".join(user_batch),
"--columns", ",".join(column_ids),
"--start", date_slice.start_str,
"--end", date_slice.end_str,
])
stats.total_dws_calls += 1
except cmn.DwsCallError as e:
stats.total_dws_calls += 1
stats.failed_calls += 1
if e.is_permission_error:
cmn.error(
"权限错误:当前账号无管理员权限,无法导出考勤报表。"
"请联系考勤管理员或换号重试。"
)
raise SystemExit(2) from e
stats.add_warning(f"[query failed] {date_slice.label}: {e}")
return []
records = cmn.extract_records(payload)
# 展平 report query-data 返回的嵌套 values 结构
records = cmn.flatten_query_data_records(records, column_id_to_name)
if inspect and records and inspected_flag is not None and not inspected_flag[0]:
cmn.dump_first_record_for_inspection(records, "query-data (flattened)")
inspected_flag[0] = True
return records
# ─────────────────────────────────────────────────────────────────────────────
# 每日聚合
# ─────────────────────────────────────────────────────────────────────────────
def _value_for_column(record: dict, col: dict) -> Any:
cname, cid = col["_column_name"], col["_column_id"]
for key in (cname, cid, f"col_{cid}", f"column_{cid}"):
if key in record:
return record[key]
return None
def _try_number(value: Any) -> float | None:
if value is None or value == "":
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value.strip())
except ValueError:
return None
return None
def _user_id_of(record: dict) -> str | None:
uid = cmn._first_nonempty(record, ("userId", "userid", "user_id", "targetUserId"))
return str(uid) if uid is not None else None
def _extract_work_date(record: dict, columns: list[dict]) -> str | None:
"""
从一条记录里提取"工作日期"(YYYY-MM-DD 格式)。
试探顺序:
1. record 里的 DATE_KEY_CANDIDATES
2. columns 里 _column_name 含"日期"的字段
3. 13 位毫秒时间戳 → 转 YYYY-MM-DD
4. ISO 字符串 → 截前 10 位
都没找到返回 None。
"""
candidates: list[Any] = []
# 1) 直接 key
for key in DATE_KEY_CANDIDATES:
if key in record and record[key] not in (None, ""):
candidates.append(record[key])
# 2) 字段名含"日期"
for col in columns:
if "日期" in col["_column_name"] or "date" in col["_column_name"].lower():
v = _value_for_column(record, col)
if v not in (None, ""):
candidates.append(v)
for raw in candidates:
date_str = _normalize_date(raw)
if date_str:
return date_str
return None
def _normalize_date(raw: Any) -> str | None:
"""把任意形态的日期值归一化为 YYYY-MM-DD 字符串。"""
if raw is None:
return None
# 毫秒时间戳
if isinstance(raw, (int, float)) and 1_000_000_000_000 <= raw <= 9_999_999_999_999:
try:
return datetime.fromtimestamp(raw / 1000).strftime(cmn.DATE_FMT)
except (OSError, ValueError, OverflowError):
return None
# 秒级时间戳
if isinstance(raw, (int, float)) and 1_000_000_000 <= raw <= 9_999_999_999:
try:
return datetime.fromtimestamp(raw).strftime(cmn.DATE_FMT)
except (OSError, ValueError, OverflowError):
return None
s = str(raw).strip()
if not s:
return None
# 已经是 YYYY-MM-DD
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
head = s[:10]
try:
datetime.strptime(head, cmn.DATE_FMT)
return head
except ValueError:
return None
return None
def aggregate_daily(
all_records: list[dict],
columns: list[dict],
user_ids: list[str],
user_name_map: dict[str, str],
stats: cmn.CallStats,
) -> list[dict[str, Any]]:
"""
按 (userId, workDate) 聚合:
- 数值字段:sum
- 非数值字段:取首个非空值(同一天同字段通常只有一个值)
返回每人每天一行的 dict 列表,按 userId、workDate 排序。
"""
# bucket: (userId, date) → column_name → {sum: float, count_num: int, first_nonnum: Any}
buckets: dict[tuple[str, str], dict[str, dict]] = defaultdict(
lambda: {col["_column_name"]: {"sum": 0.0, "count_num": 0, "first_nonnum": None}
for col in columns}
)
no_date_count = 0
for record in all_records:
uid = _user_id_of(record)
if uid is None:
continue
date_str = _extract_work_date(record, columns)
if date_str is None:
no_date_count += 1
date_str = "_no_date"
for col in columns:
cname = col["_column_name"]
raw = _value_for_column(record, col)
num = _try_number(raw)
cell = buckets[(uid, date_str)][cname]
if num is not None:
cell["sum"] += num
cell["count_num"] += 1
elif raw not in (None, "") and cell["first_nonnum"] is None:
cell["first_nonnum"] = raw
if no_date_count > 0:
stats.add_warning(
f"{no_date_count} 条记录无法识别工作日期,已归入 '_no_date'。"
"请用 --inspect 查看真实字段名"
)
# 输出:按 (uid, date) 排序
rows: list[dict[str, Any]] = []
for (uid, date_str) in sorted(buckets.keys(), key=lambda x: (x[0], x[1])):
row: dict[str, Any] = {
"userId": uid,
"userName": user_name_map.get(uid, uid),
"workDate": date_str,
}
bucket = buckets[(uid, date_str)]
for col in columns:
cname = col["_column_name"]
cell = bucket[cname]
if cell["count_num"] > 0:
total = cell["sum"]
row[cname] = int(total) if total == int(total) else round(total, 2)
elif cell["first_nonnum"] is not None:
row[cname] = cell["first_nonnum"]
else:
row[cname] = ""
rows.append(row)
return rows
# ─────────────────────────────────────────────────────────────────────────────
# main
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
args = parse_args()
raw_ids = [u.strip() for u in args.users.split(",") if u.strip()]
if not raw_ids:
cmn.error("--users 不能为空")
return 2
# 自动识别部门ID并展开为员工userId
user_ids = cmn.resolve_users_from_input(raw_ids)
if not user_ids:
cmn.error("未能解析出任何有效的员工userId")
return 2
cmn.log(f"[users] 最终用户列表:{len(user_ids)} 人")
try:
start = cmn.parse_datetime_arg(args.start, end_of_day=False)
end = cmn.parse_datetime_arg(args.end, end_of_day=True)
except ValueError as e:
cmn.error(str(e))
return 2
if end < start:
cmn.error(f"--end ({end}) 早于 --start ({start})")
return 2
try:
columns = resolve_columns(args)
except cmn.DwsCallError as e:
if e.is_permission_error:
cmn.error("权限错误:当前账号无管理员权限,无法获取考勤字段列表。")
return 2
cmn.error(f"获取字段列表失败:{e}")
return 1
except RuntimeError as e:
cmn.error(str(e))
return 1
column_ids = [c["_column_id"] for c in columns]
column_names = [c["_column_name"] for c in columns]
column_id_to_name = {c["_column_id"]: c["_column_name"] for c in columns}
cmn.log(f"[users] 获取 {len(user_ids)} 个用户基础信息")
user_info_map = cmn.resolve_user_info(user_ids)
user_name_map = {uid: info.name or uid for uid, info in user_info_map.items()}
user_batches = cmn.chunk_users(user_ids)
date_slices = cmn.slice_date_range(start, end)
stats = cmn.CallStats(
user_batches=len(user_batches),
date_slices=len(date_slices),
)
cmn.log(
f"[plan] 共 {len(user_batches)} 批 × {len(date_slices)} 个时间片 "
f"= {len(user_batches) * len(date_slices)} 次接口调用"
)
inspected_flag = [False]
all_records: list[dict] = []
for bi, batch in enumerate(user_batches, start=1):
for si, dslice in enumerate(date_slices, start=1):
cmn.log(f"[batch {bi}/{len(user_batches)}] [slice {si}/{len(date_slices)}]")
records = query_one_batch(
batch, column_ids, dslice, stats,
column_id_to_name=column_id_to_name,
inspect=args.inspect,
inspected_flag=inspected_flag,
)
all_records.extend(records)
if not all_records:
stats.add_warning("查询完成,但未得到任何记录")
# 从原始记录中提取每个用户的考勤组名称
group_name_map = cmn.extract_group_names_from_records(all_records, user_ids)
rows_dict = aggregate_daily(all_records, columns, user_ids, user_name_map, stats)
# 请假数据特殊处理:通过 query-leave 单独查询,按 4 类假期按天展开
# 凡是 "请假" 开头的字段(请假 / 请假分类 / 请假时长 等)都视为请假列
leave_in_columns = any(_is_leave_field(name) for name in column_names)
leave_data: dict[str, dict[str, dict[str, float]]] = {}
if leave_in_columns:
try:
leave_data = cmn.query_leave_data(
user_ids, start, end,
leave_names=LEAVE_TYPES,
stats=stats,
)
except cmn.DwsCallError as e:
stats.add_warning(f"[leave] 查询请假数据失败:{e}")
# 表头对齐 SKILL.md 每日统计预定义列集合:姓名 | 考勤组 | 部门 | 日期 | 考勤字段...
# 请假按假期类型展开为多列(如 "请假-事假", "请假-调休", ...),其余字段保持顺序
# 多个 "请假*" 字段(如 "请假分类" + "请假时长")只展开 1 次,避免重复
base_headers = ["姓名", "考勤组", "部门", "日期"]
data_headers: list[str] = []
leave_expanded = False
for cname in column_names:
if cname == "工作日期":
continue
if _is_leave_field(cname):
if not leave_expanded:
data_headers.extend(f"{LEAVE_FIELD_NAME}-{lt}" for lt in LEAVE_TYPES)
leave_expanded = True
continue
data_headers.append(cname)
headers = base_headers + data_headers
rows_2d = []
for row in rows_dict:
uid = row.get("userId", "")
info = user_info_map.get(uid, cmn.UserInfo(name=uid))
group_name = group_name_map.get(uid, "")
work_date = row.get("workDate", "")
base = [info.name or uid, group_name, info.dept_name, work_date]
# 当天该用户的请假数据
day_leave = leave_data.get(uid, {}).get(work_date, {}) if leave_in_columns else {}
data: list[Any] = []
leave_filled = False
for cname in column_names:
if cname == "工作日期":
continue
if _is_leave_field(cname):
if not leave_filled:
for lt in LEAVE_TYPES:
val = day_leave.get(lt, 0.0)
if val == 0.0:
data.append("")
elif val == int(val):
data.append(int(val))
else:
data.append(round(val, 2))
leave_filled = True
continue
data.append(row.get(cname, ""))
rows_2d.append(base + data)
out_name = args.out or cmn.build_output_filename(start, end, suffix="daily")
title = (
f"每日统计展示 统计日期:{start.strftime(cmn.DATE_FMT)} "
f"至 {end.strftime(cmn.DATE_FMT)}"
)
subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}"
try:
cmn.write_excel(
out_name, headers, rows_2d,
sheet_name="每日统计",
title=title,
subtitle=subtitle,
)
except RuntimeError as e:
cmn.error(str(e))
return 1
cmn.print_summary(
granularity_label="每日统计",
out_path=out_name,
user_count=len(user_ids),
column_names=column_names,
start=start,
end=end,
rows_count=len(rows_2d),
stats=stats,
extra_tail="ℹ️ 同一 (用户, 日期) 下数值字段已求和、非数值字段取首个值。",
)
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
考勤报表导出 — 明细粒度(打卡记录)
通过 `dws attendance check result` + `dws attendance check record`
查询打卡数据,每条打卡记录输出一行,不做聚合。
[AI Agent 强制门禁] 调用本脚本前必须先阅读:
dingtalk-workspace/references/products/attendance-report.md
本脚本仅是"考勤报表导出工作流"的执行末端,工作流完整定义在 attendance-report.md,
包含但不限于:
- 阶段 0:报表类型判断(默认月度汇总,明细需用户明确说"明细/原始记录/每条打卡")
- 阶段 1:人员列表获取(aisearch person / contact dept list-members)
- 阶段 2:列选择(明细报表列固定,不支持 --column-keywords)
- 阶段 3:调用本脚本
- 阶段 4:结果回传给用户的标准格式
- 错误处理(403 权限、HSF_ILLEGALPARAMS、空数据等)
[严禁] 仅凭本脚本 docstring 或 --help 输出就直接拼命令执行,会导致:
- 用户本来要"汇总"被给成"明细"(粒度错误)
- 报表数据不全 / 人员遗漏
- 错误处理缺失,把环境错误当业务错误反馈给用户
与月度汇总/每日统计不同,明细报表:
- 不使用 report columns / report query-data
- 列固定(基础信息 + 打卡字段),不支持自定义列选择
- 分批限制:≤100 人/次(check result),时间跨度 ≤1 个月
用法:
python attendance_report_detail.py \
--users userId1,userId2,... \
--start "2026-03-01" \
--end "2026-03-31" \
[--out attendance_report_2026-03-01_2026-03-31_detail.xlsx]
[--inspect] # 首次跑时打印首条记录原始结构
约束:
- 仅管理员可用,否则 dws 接口返回 403
- --users 超过 100 人 → 自动按每批 100 人分批
- --start 到 --end 超过 31 天 → 自动按月切片
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime
from typing import Any
import attendance_report_common as cmn
# ─────────────────────────────────────────────────────────────────────────────
# 接口限制(check result / check record)
# ─────────────────────────────────────────────────────────────────────────────
CHECK_MAX_USERS_PER_BATCH = 100 # check result: --users 最多 100 人
CHECK_MAX_DAYS_PER_SLICE = 31 # check result/record: 跨度 ≤ 1 个月
CHECK_RESULT_PAGE_SIZE = 1000 # check result: --limit 最大值
# ─────────────────────────────────────────────────────────────────────────────
# 固定表头(与 SKILL.md 明细预定义列集合对齐)
# ─────────────────────────────────────────────────────────────────────────────
# 基础信息列
BASE_HEADERS = ["姓名", "考勤组", "部门"]
# 打卡字段列(以打卡流水为主,关联 check result 的考勤时间和打卡结果)
# 对应 Diamond 配置中 termId 8-20 的列定义
CHECK_HEADERS = [
"考勤日期", "考勤时间", "打卡时间", "打卡结果",
"打卡地址", "打卡备注", "异常打卡原因",
"打卡图片1", "打卡图片2", "打卡设备", "管理员修改备注",
"管理员修改备注图片1", "管理员修改备注图片2", "管理员修改备注图片3",
]
ALL_HEADERS = BASE_HEADERS + CHECK_HEADERS
# ─────────────────────────────────────────────────────────────────────────────
# 参数解析
# ─────────────────────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description=(
"导出考勤报表 — 明细粒度(打卡记录)。"
"[强制] AI Agent 必须先读 references/products/attendance-report.md 再调用本脚本,"
"禁止凭 --help 或脚本路径自行拼命令。"
),
)
p.add_argument("--users", required=True,
help="userId 列表,逗号分隔(必填)")
p.add_argument("--start", required=True,
help='开始时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)')
p.add_argument("--end", required=True,
help='结束时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)')
p.add_argument("--out", default="",
help="输出 xlsx 文件名;不传则按规范自动生成")
p.add_argument("--inspect", action="store_true",
help="首次跑时打印首条记录原始结构(用于核对真实字段)")
p.add_argument("--no-images", action="store_true",
help="不在 Excel 中嵌入打卡图片(默认会下载 URL 并嵌入为缩略图,"
"图片多时较慢;加此参数仅保留 URL 文本)")
p.add_argument("--image-size", default="80x120",
help="嵌入图片像素尺寸 WxH,默认 80x120")
return p.parse_args()
# 含图片 URL 的列名(与 CHECK_HEADERS 中的中文名严格一致)
IMAGE_COLUMN_NAMES = [
"打卡图片1", "打卡图片2",
"管理员修改备注图片1", "管理员修改备注图片2", "管理员修改备注图片3",
]
def _parse_image_size(spec: str) -> tuple[int, int]:
"""解析 --image-size 参数,格式 WxH。失败时回退到默认 (80, 120)。"""
try:
parts = spec.lower().replace(" ", "").split("x")
w, h = int(parts[0]), int(parts[1])
if w > 0 and h > 0:
return (w, h)
except (ValueError, IndexError):
pass
cmn.warn(f"--image-size 格式无效: {spec!r},使用默认 80x120")
return (80, 120)
# ─────────────────────────────────────────────────────────────────────────────
# check result 查询(打卡结果,含分页)
# ─────────────────────────────────────────────────────────────────────────────
def query_check_results(
user_batch: list[str],
date_slice: cmn.DateSlice,
stats: cmn.CallStats,
*,
inspect: bool = False,
inspected_flag: list[bool] | None = None,
) -> list[dict]:
"""
对一批 users × 一个时间片调用 `dws attendance check result`。
自动分页:每次最多 1000 条,返回满 1000 条时递增 offset 继续拉取。
"""
from_date = date_slice.start.strftime(cmn.DATE_FMT)
to_date = date_slice.end.strftime(cmn.DATE_FMT)
all_records: list[dict] = []
offset = 0
while True:
cmn.log(
f"[check-result] users={len(user_batch)} "
f"slice={date_slice.label} offset={offset}"
)
try:
payload = cmn.run_dws([
"attendance", "check", "result",
"--users", ",".join(user_batch),
"--from", from_date,
"--to", to_date,
"--offset", str(offset),
"--limit", str(CHECK_RESULT_PAGE_SIZE),
])
stats.total_dws_calls += 1
except cmn.DwsCallError as exc:
stats.total_dws_calls += 1
stats.failed_calls += 1
if exc.is_permission_error:
cmn.error(
"权限错误:当前账号无管理员权限,无法查询打卡结果。"
"请联系考勤管理员或换号重试。"
)
raise SystemExit(2) from exc
stats.add_warning(f"[check-result failed] {date_slice.label} offset={offset}: {exc}")
break
records = cmn.extract_records(payload)
if inspect and records and inspected_flag is not None and not inspected_flag[0]:
cmn.dump_first_record_for_inspection(records, "check-result")
inspected_flag[0] = True
all_records.extend(records)
# 未满一页 → 无需翻页
if len(records) < CHECK_RESULT_PAGE_SIZE:
break
offset += CHECK_RESULT_PAGE_SIZE
return all_records
# ─────────────────────────────────────────────────────────────────────────────
# check record 查询(打卡流水)
# ─────────────────────────────────────────────────────────────────────────────
def query_check_records(
user_batch: list[str],
date_slice: cmn.DateSlice,
stats: cmn.CallStats,
*,
inspect: bool = False,
inspected_flag: list[bool] | None = None,
) -> list[dict]:
"""对一批 users × 一个时间片调用 `dws attendance check record`。"""
from_date = date_slice.start.strftime(cmn.DATE_FMT)
to_date = date_slice.end.strftime(cmn.DATE_FMT)
cmn.log(
f"[check-record] users={len(user_batch)} slice={date_slice.label}"
)
try:
payload = cmn.run_dws([
"attendance", "check", "record",
"--users", ",".join(user_batch),
"--from", from_date,
"--to", to_date,
])
stats.total_dws_calls += 1
except cmn.DwsCallError as exc:
stats.total_dws_calls += 1
stats.failed_calls += 1
if exc.is_permission_error:
cmn.error(
"权限错误:当前账号无管理员权限,无法查询打卡流水。"
"请联系考勤管理员或换号重试。"
)
raise SystemExit(2) from exc
stats.add_warning(f"[check-record failed] {date_slice.label}: {exc}")
return []
records = cmn.extract_records(payload)
if inspect and records and inspected_flag is not None and not inspected_flag[0]:
cmn.dump_first_record_for_inspection(records, "check-record")
inspected_flag[0] = True
return records
# ─────────────────────────────────────────────────────────────────────────────
# 值提取工具
# ─────────────────────────────────────────────────────────────────────────────
def _humanize_timestamp(value: Any) -> str:
"""把毫秒/秒级时间戳转成可读字符串;非时间戳原样返回。"""
if value is None:
return ""
if isinstance(value, (int, float)):
# 13 位毫秒时间戳
if 1_000_000_000_000 <= value <= 9_999_999_999_999:
try:
return datetime.fromtimestamp(value / 1000).strftime(cmn.DATETIME_FMT)
except (OSError, ValueError, OverflowError):
return str(value)
# 10 位秒级时间戳
if 1_000_000_000 <= value <= 9_999_999_999:
try:
return datetime.fromtimestamp(value).strftime(cmn.DATETIME_FMT)
except (OSError, ValueError, OverflowError):
return str(value)
return str(value) if value != "" else ""
def _extract_field(record: dict, candidate_keys: tuple[str, ...]) -> Any:
"""从 record 中按候选 key 顺序取第一个非空值。"""
return cmn._first_nonempty(record, candidate_keys)
def _extract_date_str(record: dict) -> str:
"""从 check result 记录中提取考勤日期(YYYY-MM-DD)。"""
raw = _extract_field(record, (
"workDate", "work_date", "checkDate", "userCheckDate", "date", "day",
))
if raw is None:
return ""
# 毫秒时间戳
if isinstance(raw, (int, float)) and raw > 1_000_000_000_000:
try:
return datetime.fromtimestamp(raw / 1000).strftime(cmn.DATE_FMT)
except (OSError, ValueError, OverflowError):
return str(raw)
s = str(raw).strip()
# 已经是 YYYY-MM-DD 或 YYYY-MM-DD HH:mm:ss → 取前 10 位
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
return s[:10]
return s
def _extract_time_str(record: dict, candidate_keys: tuple[str, ...]) -> str:
"""从记录中提取时间字段,毫秒时间戳自动转 HH:mm:ss。"""
raw = _extract_field(record, candidate_keys)
if raw is None:
return ""
if isinstance(raw, (int, float)) and raw > 1_000_000_000_000:
try:
return datetime.fromtimestamp(raw / 1000).strftime("%H:%M:%S")
except (OSError, ValueError, OverflowError):
return str(raw)
if isinstance(raw, (int, float)) and raw > 1_000_000_000:
try:
return datetime.fromtimestamp(raw).strftime("%H:%M:%S")
except (OSError, ValueError, OverflowError):
return str(raw)
return str(raw)
# ─────────────────────────────────────────────────────────────────────────────
# 字段翻译 / 提取工具函数(与 Java DataProvider 实现对齐)
# ─────────────────────────────────────────────────────────────────────────────
# 打卡结果映射(对应 CheckResultUtil.java 的 getCheckResultStr 逻辑)
_CHECK_RESULT_MAP: dict[str, str] = {
"Normal": "正常",
"Late": "迟到",
"Early": "早退",
"NotSigned": "未打卡",
"SeriousLate": "严重迟到",
"Absenteeism": "旷工迟到",
"LeaveEarly": "早退",
}
# 打卡设备 / 来源类型映射(对应 SourceType 枚举 + UserDeviceOriginData.java)
_SOURCE_TYPE_MAP: dict[str, str] = {
"ATM": "考勤机",
"BEACON": "蓝牙",
"DING_ATM": "钉钉考勤机",
"USER": "手机打卡",
"BOSS": "管理员",
"SYSTEM": "系统",
"CARD": "门禁",
"SELF_SERVICE": "自助补卡",
}
# 异常打卡原因中文描述(对应 SecurityConfigureUtil DEFAULT_CHEAT_LIST)
_CHEAT_REASON_MAP: dict[str, str] = {
"LocationNotMatch": "定位异常",
"WifiNotMatch": "WIFI异常",
"MockLocation": "模拟定位",
"FaceNotMatch": "人脸比对失败",
"DeviceNotMatch": "设备异常",
"OutsideRange": "不在打卡范围",
"NoBluetooth": "蓝牙未开启",
"BluetoothNotMatch": "蓝牙不匹配",
}
def _translate_check_result(raw_result: str) -> str:
"""
把接口返回的英文打卡结果翻译成中文,与 CheckResultUtil.getCheckResultStr 对齐。
未命中翻译表时原样返回。
"""
if not raw_result:
return ""
return _CHECK_RESULT_MAP.get(raw_result, raw_result)
def _translate_source_type(raw_source: str) -> str:
"""
把接口返回的 sourceType 枚举值翻译成中文,与 UserDeviceOriginData 对齐。
未命中翻译表时原样返回。
"""
if not raw_source:
return ""
return _SOURCE_TYPE_MAP.get(raw_source, raw_source)
def _extract_location(record: dict) -> str:
"""
拼接打卡地址:地点名称 + 详细地址,与 UserLocationOriginData 对齐。
Java 逻辑:
locationResult.getSpaceName() → 地点名称
locationResult.getDetailAddr() → 详细地址(含省市区+街道)
两者均有时拼接,只有一个时单独返回。
"""
space_name = str(_extract_field(record, (
"spaceName", "space_name", "locationName", "location_name",
)) or "").strip()
detail_addr = str(_extract_field(record, (
"detailAddr", "detail_addr", "detailAddress", "address", "userAddress",
)) or "").strip()
if space_name and detail_addr:
return f"{space_name} {detail_addr}"
return space_name or detail_addr
def _extract_exception_reason(record: dict) -> str:
"""
提取并翻译异常打卡原因,与 CheckExceptionReasonOriginData 对齐。
Java 逻辑:
取 features.getInvalidRecordMsg()(逗号分隔的错误码列表)
逐个从 DEFAULT_CHEAT_LIST 查中文描述后再拼接返回。
"""
raw = str(_extract_field(record, (
"invalidRecordMsg", "invalid_record_msg",
"outsideRemark", "outside_remark",
"exceptionReason",
)) or "").strip()
if not raw:
return ""
# 逗号分隔的多个错误码,逐个翻译后重新拼接
codes = [c.strip() for c in raw.split(",") if c.strip()]
translated = [_CHEAT_REASON_MAP.get(code, code) for code in codes]
return ",".join(translated)
def _extract_photo_url(record: dict, candidate_keys: tuple[str, ...]) -> str:
"""从 record 或其 features 嵌套结构中提取图片 URL。"""
raw = _extract_field(record, candidate_keys)
if raw is None:
return ""
return str(raw).strip()
def _extract_remark_photo(record: dict) -> str:
"""
打卡图片1(备注/外勤打卡照片)。
dws check record 的真实返回字段(实测验证):
- 顶层 photoUrl:外勤/拍照打卡的主图片 URL
- 顶层 outsideAttachment:外勤打卡的附件(可能含多张图片)
- 顶层 remarkPhotos:备注图片数组(旧字段,部分版本)
Java 侧 RemarkPhotoOriginData 对应 features.getRemarkPhotos(),
但 dws CLI 实际把图片字段提到了顶层,需直接读顶层字段。
"""
# 1) 兼容数组形式的 remarkPhotos(早期版本)
remark_photos = record.get("remarkPhotos") or record.get("remark_photos")
if isinstance(remark_photos, list) and remark_photos:
return str(remark_photos[0]).strip()
if isinstance(remark_photos, str) and remark_photos.strip():
parts = [p.strip() for p in remark_photos.split(",") if p.strip()]
return parts[0] if parts else ""
# 2) dws CLI 当前实际返回的字段(顶层)
# photoUrl 优先,其次 outsideAttachment,再次旧候选名
photo = _extract_photo_url(record, (
"photoUrl", "photo_url",
"outsideAttachment", "outside_attachment",
"remarkPhoto", "remark_photo",
"userImage", "user_image", "imageUrl", "image_url",
))
if photo:
# outsideAttachment 可能是逗号分隔多张,取第一张
if "," in photo:
first = photo.split(",")[0].strip()
if first:
return first
return photo
# 3) 兜底:从 features 嵌套 JSON 里翻
return _extract_photo_from_features(record, (
"photoUrl", "remarkPhoto", "remarkPhotos",
"outsideAttachment", "userImage", "imageUrl",
))
def _extract_face_check_photo(record: dict) -> str:
"""
打卡图片2(人脸识别照片)。
Java 侧 FaceCheckPhotoOriginData 对应 features.getFacePhoto()。
dws CLI 中人脸图未稳定暴露在顶层,优先读 features 嵌套字段。
"""
# 1) 顶层候选
face = _extract_photo_url(record, (
"facePhoto", "face_photo",
"faceCheckPhoto", "face_check_photo",
"faceImage", "face_image",
"faceUrl", "face_url",
))
if face:
return face
# 2) features 嵌套兜底
return _extract_photo_from_features(record, (
"facePhoto", "faceCheckPhoto", "faceImage", "faceUrl",
))
def _extract_photo_from_features(
record: dict,
candidate_keys: tuple[str, ...],
) -> str:
"""
从 record['features'](JSON 字符串或 dict)中提取图片 URL。
候选 key 命中 features 中第一个非空值则返回。
"""
feat = record.get("features")
if isinstance(feat, str):
feat_str = feat.strip()
if not feat_str or feat_str[0] not in "{[":
return ""
try:
feat = json.loads(feat_str)
except (ValueError, TypeError):
return ""
if not isinstance(feat, dict):
return ""
for key in candidate_keys:
val = feat.get(key)
if val in (None, "", [], {}):
continue
if isinstance(val, list) and val:
return str(val[0]).strip()
s = str(val).strip()
if "," in s:
return s.split(",")[0].strip()
return s
return ""
def _extract_boss_remark(record: dict) -> str:
"""
管理员修改备注,与 BossCheckRemarkOriginData 对齐。
Java 逻辑:features.getBossRemark()。
"""
return str(_extract_field(record, (
"bossRemark", "boss_remark",
"approveRemark", "approve_remark",
"adminModifyRemark", "admin_modify_remark",
)) or "").strip()
def _extract_boss_photo(record: dict, photo_index: int) -> str:
"""
管理员修改备注图片(1/2/3),与 BossCheckPhoto1/2/3OriginData 对齐。
Java 逻辑:features.getBossPhotos(),按 index 取对应张。
photo_index: 0-based 索引(0=图片1, 1=图片2, 2=图片3)
"""
boss_photos = record.get("bossPhotos") or record.get("boss_photos")
if isinstance(boss_photos, list):
if photo_index < len(boss_photos):
return str(boss_photos[photo_index]).strip()
return ""
if isinstance(boss_photos, str) and boss_photos.strip():
parts = [p.strip() for p in boss_photos.split(",") if p.strip()]
return parts[photo_index] if photo_index < len(parts) else ""
# 降级:尝试独立字段
val = _extract_field(record, (
f"bossPhoto{photo_index + 1}", f"boss_photo_{photo_index + 1}",
))
return str(val).strip() if val else ""
# ─────────────────────────────────────────────────────────────────────────────
# 关联合并 check result + check record → 明细行
# ─────────────────────────────────────────────────────────────────────────────
def _build_result_index(
check_results: list[dict],
) -> dict[tuple[str, str], list[dict]]:
"""
把 check result 按 (userId, 打卡时间 YYYY-MM-DD HH:mm:ss) 建索引,
用于关联打卡流水获取考勤时间和打卡结果。
"""
index: dict[tuple[str, str], list[dict]] = {}
for rec in check_results:
uid = str(_extract_field(rec, ("userId", "userid", "user_id")) or "")
raw_time = _extract_field(rec, (
"userCheckTime", "user_check_time", "checkTime", "baseCheckTime",
))
time_key = _humanize_timestamp(raw_time) if raw_time else "_unknown"
key = (uid, time_key)
index.setdefault(key, []).append(rec)
return index
def build_record_rows(
check_records: list[dict],
check_results: list[dict],
user_info_map: dict[str, cmn.UserInfo],
group_name_map: dict[str, str],
) -> list[dict[str, str]]:
"""
以 check record(打卡流水)为主表构建明细行。
每条打卡流水记录输出一行,只展示有实际打卡的记录。
通过打卡时间关联 check result 获取"考勤时间"和"打卡结果"。
列顺序与 Diamond 配置 termId 8-20 对齐,各字段逻辑与 Java DataProvider 一致。
返回每行一个 dict,key 与 ALL_HEADERS 对齐。
"""
result_index = _build_result_index(check_results)
rows: list[dict[str, str]] = []
for record in check_records:
uid = str(_extract_field(record, ("userId", "userid", "user_id")) or "")
info = user_info_map.get(uid, cmn.UserInfo(name=uid))
# ── 打卡时间(实际打卡时间,OriginUserCheckTimePlug)────────────────
actual_time_raw = _extract_field(record, (
"userCheckTime", "user_check_time", "checkTime",
))
actual_time = _humanize_timestamp(actual_time_raw)
# ── 关联 check result 获取"考勤时间"和"打卡结果" ──────────────────
time_key = actual_time if actual_time else "_unknown"
matched_results = result_index.get((uid, time_key), [])
result_rec = matched_results[0] if matched_results else {}
# 考勤时间 = 班次规定的应打卡时间(OriginPlanCheckTimePlug)
plan_time_raw = _extract_field(result_rec, (
"planCheckTime", "plan_check_time", "baseCheckTime",
)) if result_rec else None
plan_time = _humanize_timestamp(plan_time_raw) if plan_time_raw else ""
# 打卡结果(OriginUserCheckResultPlug):英文枚举 → 中文
raw_check_result = str(_extract_field(result_rec, (
"checkResult", "check_result", "timeResult", "result",
)) or "") if result_rec else ""
check_result_str = _translate_check_result(raw_check_result)
# ── 打卡设备(OriginUserDevicePlug):sourceType 枚举 → 中文 ────────
raw_source_type = str(_extract_field(record, (
"sourceType", "source_type", "deviceType", "device_type",
)) or "")
device_str = _translate_source_type(raw_source_type)
row: dict[str, str] = {
# 基础信息
"姓名": info.name or uid,
"考勤组": group_name_map.get(uid, ""),
"部门": info.dept_name,
# termId=8 考勤时间(OriginPlanCheckTimePlug)
"考勤日期": _extract_date_str(record),
"考勤时间": plan_time,
# termId=9 打卡时间(OriginUserCheckTimePlug)
"打卡时间": actual_time,
# termId=10 打卡结果(OriginUserCheckResultPlug)
"打卡结果": check_result_str,
# termId=11 打卡地址(OriginUserLocationPlug)
# Java 逻辑:spaceName + detailAddr 拼接
"打卡地址": _extract_location(record),
# termId=12 打卡备注(OriginUserRemarkPlug)
# Java 逻辑:features.getRemark()
"打卡备注": str(_extract_field(record, (
"remark", "userRemark", "user_remark",
)) or "").strip(),
# termId=13 异常打卡原因(OriginCheckExceptionReasonPlug)
# Java 逻辑:features.getInvalidRecordMsg() → 翻译错误码
"异常打卡原因": _extract_exception_reason(record),
# termId=14 打卡图片1(OriginRemarkPhotoPlug)
# Java 逻辑:features.getRemarkPhotos()[0]
"打卡图片1": _extract_remark_photo(record),
# termId=15 打卡图片2(OriginFaceCheckPhotoPlug)
# Java 逻辑:features.getFacePhoto()
"打卡图片2": _extract_face_check_photo(record),
# termId=16 打卡设备(OriginUserDevicePlug)
# Java 逻辑:SourceType 枚举 → 中文
"打卡设备": device_str,
# termId=17 管理员修改备注(OriginBossCheckRemarkPlug)
# Java 逻辑:features.getBossRemark()
"管理员修改备注": _extract_boss_remark(record),
# termId=18/19/20 管理员修改备注图片1/2/3(OriginBossCheckPhoto1/2/3Plug)
# Java 逻辑:features.getBossPhotos()[0/1/2]
"管理员修改备注图片1": _extract_boss_photo(record, 0),
"管理员修改备注图片2": _extract_boss_photo(record, 1),
"管理员修改备注图片3": _extract_boss_photo(record, 2),
}
rows.append(row)
return rows
# ─────────────────────────────────────────────────────────────────────────────
# main
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
args = parse_args()
# 1. 解析参数
raw_ids = [u.strip() for u in args.users.split(",") if u.strip()]
if not raw_ids:
cmn.error("--users 不能为空")
return 2
# 自动识别部门ID并展开为员工userId
user_ids = cmn.resolve_users_from_input(raw_ids)
if not user_ids:
cmn.error("未能解析出任何有效的员工userId")
return 2
cmn.log(f"[users] 最终用户列表:{len(user_ids)} 人")
try:
start = cmn.parse_datetime_arg(args.start, end_of_day=False)
end = cmn.parse_datetime_arg(args.end, end_of_day=True)
except ValueError as exc:
cmn.error(str(exc))
return 2
if end < start:
cmn.error(f"--end ({end}) 早于 --start ({start})")
return 2
# 2. 解析 userId → 用户信息(使用 resolve_user_info,已适配 labels 职位提取)
cmn.log(f"[users] 获取 {len(user_ids)} 个用户基础信息")
user_info_map = cmn.resolve_user_info(user_ids)
# 3. 切批 + 切片(明细用 100 人/批、31 天/片)
user_batches = cmn.chunk_users(user_ids, size=CHECK_MAX_USERS_PER_BATCH)
date_slices = cmn.slice_date_range(start, end, max_days=CHECK_MAX_DAYS_PER_SLICE)
stats = cmn.CallStats(
user_batches=len(user_batches),
date_slices=len(date_slices),
)
cmn.log(f"[plan] 共 {len(user_batches)} 批 × {len(date_slices)} 个时间片")
# 4. 拉数据:check record(打卡流水)+ check result(用于关联考勤时间和打卡结果)
inspected_result_flag = [False]
inspected_record_flag = [False]
all_check_results: list[dict] = []
all_check_records: list[dict] = []
for batch_idx, batch in enumerate(user_batches, start=1):
for slice_idx, date_slice in enumerate(date_slices, start=1):
cmn.log(f"[batch {batch_idx}/{len(user_batches)}] "
f"[slice {slice_idx}/{len(date_slices)}]")
results = query_check_results(
batch, date_slice, stats,
inspect=args.inspect, inspected_flag=inspected_result_flag,
)
all_check_results.extend(results)
records = query_check_records(
batch, date_slice, stats,
inspect=args.inspect, inspected_flag=inspected_record_flag,
)
all_check_records.extend(records)
cmn.log(f"[data] check result: {len(all_check_results)} 条, "
f"check record: {len(all_check_records)} 条")
if not all_check_records:
stats.add_warning("查询完成,但未得到任何打卡流水记录")
# 5. 获取考勤组信息(通过 group API 反向映射 userId → 考勤组名称)
group_name_map = cmn.extract_group_names_from_records(all_check_records, user_ids)
# 6. 构建明细行(以 check record 为主表,关联 check result 获取考勤时间和打卡结果)
detail_rows = build_record_rows(
all_check_records, all_check_results, user_info_map, group_name_map,
)
# 7. 写 Excel
rows_2d = [[row.get(h, "") for h in ALL_HEADERS] for row in detail_rows]
out_name = args.out or cmn.build_output_filename(start, end, suffix="detail")
title = (
f"考勤明细展示 统计日期:{start.strftime(cmn.DATE_FMT)} "
f"至 {end.strftime(cmn.DATE_FMT)}"
)
subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}"
# 图片嵌入参数:默认开启,--no-images 关闭
image_columns = None if args.no_images else IMAGE_COLUMN_NAMES
image_size = _parse_image_size(args.image_size)
try:
cmn.write_excel(
out_name, ALL_HEADERS, rows_2d,
sheet_name="考勤明细",
title=title,
subtitle=subtitle,
image_columns=image_columns,
image_size=image_size,
)
except RuntimeError as exc:
cmn.error(str(exc))
return 1
# 8. 摘要
cmn.print_summary(
granularity_label="明细(打卡流水)",
out_path=out_name,
user_count=len(user_ids),
column_names=CHECK_HEADERS,
start=start,
end=end,
rows_count=len(rows_2d),
stats=stats,
)
return 0
if __name__ == "__main__":
sys.exit(main()) #!/usr/bin/env python3
"""
考勤报表导出 — 月度汇总粒度
[AI Agent 强制门禁] 调用本脚本前必须先阅读:
dingtalk-workspace/references/products/attendance-report.md
本脚本仅是"考勤报表导出工作流"的执行末端,工作流完整定义在 attendance-report.md,
包含但不限于:
- 阶段 0:报表类型判断(默认月度汇总)
- 阶段 1:人员列表获取(aisearch person / contact dept list-members)
- 阶段 2:列选择(是否传 --column-keywords)
- 阶段 3:调用本脚本
- 阶段 4:结果回传给用户的标准格式
- 错误处理(403 权限、HSF_ILLEGALPARAMS、空数据等)
[严禁] 仅凭本脚本 docstring 或 --help 输出就直接拼命令执行,会导致:
- 报表数据不全 / 列错位 / 人员遗漏
- 错误处理缺失,把环境错误当业务错误反馈给用户
- 输出格式不规范,用户体验差
按人按字段汇总,每人一行(如:迟到 5 次、加班 32 小时、出勤 21 天)。
聚合策略:
- 数值字段(看起来是 int/float)→ 求和
- 时长字段(字段名含"时长"且值为数字)→ 求和(保留单位语义)
- 字符串/枚举字段(如出勤状态)→ 计数(distinct value → count)
- 日期字段 → 计数(去重日期 → 出勤天数)
- 复杂字段(dict/list)→ 拼接(最多 5 条)
用法:
python attendance_report_monthly.py \
--users userId1,userId2,... \
--start "2026-03-01 00:00:00" \
--end "2026-03-31 23:59:59" \
[--columns 1001,1002]
[--column-keywords "迟到次数,加班时长"]
[--out attendance_report_2026-03-01_2026-03-31_monthly.xlsx]
[--inspect]
"""
from __future__ import annotations
import argparse
import sys
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Any
import attendance_report_common as cmn
# 默认关注字段 — 与 SKILL.md「月度汇总预定义列集合」严格对齐(共 20 个)
# 字段名必须和 `dws attendance report columns` 返回的 name 精确匹配
DEFAULT_KEYWORDS = [
"出勤天数",
"休息天数",
"工作时长",
"迟到次数",
"迟到时长",
"严重迟到次数",
"严重迟到时长",
"旷工迟到次数",
"早退次数",
"早退时长",
"上班缺卡次数",
"下班缺卡次数",
"旷工天数",
"出差时长",
"外出时长",
"请假",
"加班-审批单统计",
"考勤结果",
]
# 每日维度字段 — 这些字段在月度汇总中不做聚合,而是按天展开成多列
DAILY_EXPAND_FIELDS = {"考勤结果"}
# 日历表指标 — sheet2"日历表"展示的 3 行指标
# 这 3 个字段会被 resolve_columns 强制追加到查询字段集中(即使用户的 --column-keywords 没包含),
# 否则日历表会是空的。
# 注意:这 3 个字段名必须和 dws attendance report columns 返回的 name 严格一致。
CALENDAR_METRICS: tuple[str, ...] = ("班次名称", "考勤结果", "工作时长")
# 请假字段 — 触发"按假期类型展开"的字段名
# 不参与 query-data 查询,单独走 query-leave 接口,按 4 类假期展开为多列
# 注意:钉钉接口实际返回的字段名可能是 "请假"、"请假分类"、"请假时长" 等,
# 凡以 "请假" 开头的都视为请假字段,统一替换为 4 列假期类型展开。
LEAVE_FIELD_NAME = "请假"
LEAVE_TYPES: tuple[str, ...] = ("事假", "调休", "病假", "年假")
def _is_leave_field(name: str) -> bool:
"""判断一个字段名是否属于"请假"系列(如 请假 / 请假分类 / 请假时长)。"""
return isinstance(name, str) and name.startswith(LEAVE_FIELD_NAME)
# 工作日期字段的候选 key(按优先级试探)
DATE_KEY_CANDIDATES = (
"workDate", "work_date", "userCheckDate", "checkDate",
"date", "day", "工作日期",
)
# ─────────────────────────────────────────────────────────────────────────────
# 参数解析
# ─────────────────────────────────────────────────────────────────────────────
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description=(
"导出考勤报表 — 月度汇总粒度。"
"[强制] AI Agent 必须先读 references/products/attendance-report.md 再调用本脚本,"
"禁止凭 --help 或脚本路径自行拼命令。"
),
)
p.add_argument("--users", required=True,
help="userId 列表,逗号分隔(必填)")
p.add_argument("--start", required=True,
help='开始时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)')
p.add_argument("--end", required=True,
help='结束时间,YYYY-MM-DD 或 "YYYY-MM-DD HH:mm:ss"(必填)')
p.add_argument("--columns", default="",
help="字段 ID 列表,逗号分隔;与 --column-keywords 二选一")
p.add_argument("--column-keywords", default="",
help="字段名关键词,逗号分隔;不传则走默认字段集")
p.add_argument("--out", default="",
help="输出 xlsx 文件名;不传则按规范自动生成")
p.add_argument("--inspect", action="store_true",
help="首次跑时打印首条记录原始结构(用于核对真实字段)")
return p.parse_args()
# ─────────────────────────────────────────────────────────────────────────────
# 字段解析(与 detail 一致)
# ─────────────────────────────────────────────────────────────────────────────
def _ensure_calendar_metrics(
matched: list[dict],
all_cols: list[dict],
) -> list[dict]:
"""
确保 CALENDAR_METRICS 中的 3 个指标字段(班次名称/考勤结果/工作时长)
出现在最终查询字段集中(即使用户传入的 --column-keywords 没匹配到)。
日历表 sheet2 强依赖这 3 个字段,缺一不可。
"""
existing_names = {c["_column_name"] for c in matched}
name_to_col: dict[str, dict] = {}
for col in all_cols:
cid = cmn._first_nonempty(col, ("id", "columnId", "code", "key"))
name = cmn._first_nonempty(col, ("name", "columnName", "title", "label"))
if cid is not None and name:
name_to_col[str(name)] = {
"_column_id": str(cid),
"_column_name": str(name),
}
appended: list[str] = []
for metric_name in CALENDAR_METRICS:
if metric_name in existing_names:
continue
col = name_to_col.get(metric_name)
if col is None:
cmn.log(
f"[calendar] 警告:月历指标字段「{metric_name}」在"
f" report columns 中未找到,月历对应行可能为空"
)
continue
matched.append(col)
appended.append(metric_name)
if appended:
cmn.log(f"[calendar] 已强制追加月历指标字段:{appended}")
return matched
def resolve_columns(args: argparse.Namespace) -> list[dict]:
all_cols_payload = cmn.run_dws(["attendance", "report", "columns"])
all_cols = cmn.extract_records(all_cols_payload)
if args.columns.strip():
cids = [c.strip() for c in args.columns.split(",") if c.strip()]
id_to_name: dict[str, str] = {}
for col in all_cols:
cid = cmn._first_nonempty(col, ("id", "columnId", "code", "key"))
name = cmn._first_nonempty(col, ("name", "columnName", "title", "label"))
if cid is not None:
id_to_name[str(cid)] = str(name) if name else str(cid)
matched = [{"_column_id": cid, "_column_name": id_to_name.get(cid, cid)}
for cid in cids]
return _ensure_calendar_metrics(matched, all_cols)
keywords = (
[k.strip() for k in args.column_keywords.split(",") if k.strip()]
if args.column_keywords.strip()
else DEFAULT_KEYWORDS
)
cmn.log(f"[columns] 使用关键词匹配字段:{keywords}")
cmn.log(f"[columns] dws 返回 {len(all_cols)} 个字段")
matched = cmn.match_columns_by_keywords(all_cols, keywords)
if not matched:
raise RuntimeError(
f"未匹配到任何字段。可用字段示例:"
f"{[cmn._first_nonempty(c, ('name','columnName','title','label')) for c in all_cols[:10]]}"
)
cmn.log(f"[columns] 匹配到 {len(matched)} 个字段:{[c['_column_name'] for c in matched]}")
return _ensure_calendar_metrics(matched, all_cols)
# ─────────────────────────────────────────────────────────────────────────────
# 接口调用(与 detail 一致)
# ─────────────────────────────────────────────────────────────────────────────
def query_one_batch(
user_batch: list[str],
column_ids: list[str],
date_slice: cmn.DateSlice,
stats: cmn.CallStats,
*,
column_id_to_name: dict[str, str] | None = None,
inspect: bool = False,
inspected_flag: list[bool] = None,
) -> list[dict]:
cmn.log(
f"[query] users={len(user_batch)} cols={len(column_ids)} "
f"slice={date_slice.label}"
)
try:
payload = cmn.run_dws([
"attendance", "report", "query-data",
"--users", ",".join(user_batch),
"--columns", ",".join(column_ids),
"--start", date_slice.start_str,
"--end", date_slice.end_str,
])
stats.total_dws_calls += 1
except cmn.DwsCallError as e:
stats.total_dws_calls += 1
stats.failed_calls += 1
if e.is_permission_error:
cmn.error(
"权限错误:当前账号无管理员权限,无法导出考勤报表。"
"请联系考勤管理员或换号重试。"
)
raise SystemExit(2) from e
stats.add_warning(f"[query failed] {date_slice.label}: {e}")
return []
records = cmn.extract_records(payload)
# 展平 report query-data 返回的嵌套 values 结构
records = cmn.flatten_query_data_records(records, column_id_to_name)
if inspect and records and inspected_flag is not None and not inspected_flag[0]:
cmn.dump_first_record_for_inspection(records, "query-data (flattened)")
inspected_flag[0] = True
return records
# ─────────────────────────────────────────────────────────────────────────────
# 日期提取(复用 daily 脚本的逻辑)
# ─────────────────────────────────────────────────────────────────────────────
def _normalize_date(raw: Any) -> str | None:
"""把任意形态的日期值归一化为 YYYY-MM-DD 字符串。"""
if raw is None:
return None
if isinstance(raw, (int, float)) and 1_000_000_000_000 <= raw <= 9_999_999_999_999:
try:
return datetime.fromtimestamp(raw / 1000).strftime(cmn.DATE_FMT)
except (OSError, ValueError, OverflowError):
return None
if isinstance(raw, (int, float)) and 1_000_000_000 <= raw <= 9_999_999_999:
try:
return datetime.fromtimestamp(raw).strftime(cmn.DATE_FMT)
except (OSError, ValueError, OverflowError):
return None
s = str(raw).strip()
if not s:
return None
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
head = s[:10]
try:
datetime.strptime(head, cmn.DATE_FMT)
return head
except ValueError:
return None
return None
def _extract_work_date(record: dict, columns: list[dict]) -> str | None:
"""从一条记录里提取工作日期(YYYY-MM-DD 格式)。"""
candidates: list[Any] = []
for key in DATE_KEY_CANDIDATES:
if key in record and record[key] not in (None, ""):
candidates.append(record[key])
for col in columns:
if "日期" in col["_column_name"] or "date" in col["_column_name"].lower():
v = _value_for_column(record, col)
if v not in (None, ""):
candidates.append(v)
for raw in candidates:
date_str = _normalize_date(raw)
if date_str:
return date_str
return None
def _generate_date_columns(start: datetime, end: datetime) -> list[str]:
"""根据日期范围生成按天展开的列标签列表,格式为日号(如 '1', '2', ...)。"""
dates: list[str] = []
current = start.replace(hour=0, minute=0, second=0, microsecond=0)
end_date = end.replace(hour=0, minute=0, second=0, microsecond=0)
while current <= end_date:
dates.append(current.strftime(cmn.DATE_FMT))
current += timedelta(days=1)
return dates
# ─────────────────────────────────────────────────────────────────────────────
# 月度聚合
# ─────────────────────────────────────────────────────────────────────────────
def _value_for_column(record: dict, col: dict) -> Any:
"""从一条原始记录里取某个字段的值(命名顺位试探)。"""
cname, cid = col["_column_name"], col["_column_id"]
for key in (cname, cid, f"col_{cid}", f"column_{cid}"):
if key in record:
return record[key]
return None
def _try_number(value: Any) -> float | None:
"""尝试把 value 解析为数字;不能则返回 None。"""
if value is None or value == "":
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
s = value.strip()
try:
return float(s)
except ValueError:
return None
return None
def _user_id_of(record: dict) -> str | None:
uid = cmn._first_nonempty(record, ("userId", "userid", "user_id", "targetUserId"))
return str(uid) if uid is not None else None
def aggregate_monthly(
all_records: list[dict],
columns: list[dict],
user_ids: list[str],
user_name_map: dict[str, str],
) -> tuple[list[dict[str, Any]], dict[str, dict[str, dict[str, str]]]]:
"""
按 userId 分组聚合:
- 普通字段(数值/非数值):按原聚合策略处理
- DAILY_EXPAND_FIELDS 中的字段(如"考勤结果"):按 (userId, date) 存储,不聚合
返回:
- rows: 每人一行的聚合结果(不含按天展开字段)
- daily_data: {field_name: {userId: {date_str: value}}}
"""
# 识别哪些列需要按天展开
expand_col_names = {col["_column_name"] for col in columns
if col["_column_name"] in DAILY_EXPAND_FIELDS}
agg_columns = [col for col in columns if col["_column_name"] not in expand_col_names]
# 聚合累加器(仅普通字段)
agg: dict[str, dict[str, dict]] = defaultdict(
lambda: {col["_column_name"]: {"sum": 0.0, "count": 0, "non_numeric": set()}
for col in agg_columns}
)
# 按天展开数据:field_name → userId → date_str → value
daily_data: dict[str, dict[str, dict[str, str]]] = {
fname: defaultdict(dict) for fname in expand_col_names
}
for record in all_records:
uid = _user_id_of(record)
if uid is None:
continue
work_date = _extract_work_date(record, columns)
# 按天展开字段
for fname in expand_col_names:
matching_col = next((c for c in columns if c["_column_name"] == fname), None)
if matching_col and work_date:
raw = _value_for_column(record, matching_col)
if raw not in (None, ""):
daily_data[fname][uid][work_date] = str(raw)
# 普通字段聚合
for col in agg_columns:
cname = col["_column_name"]
raw = _value_for_column(record, col)
num = _try_number(raw)
if num is not None:
agg[uid][cname]["sum"] += num
agg[uid][cname]["count"] += 1
elif raw not in (None, ""):
agg[uid][cname]["non_numeric"].add(str(raw))
rows: list[dict[str, Any]] = []
for uid in user_ids:
row: dict[str, Any] = {
"userId": uid,
"userName": user_name_map.get(uid, uid),
}
bucket = agg.get(uid, {})
for col in agg_columns:
cname = col["_column_name"]
cell = bucket.get(cname)
if not cell or (cell["count"] == 0 and not cell["non_numeric"]):
row[cname] = ""
elif cell["count"] > 0 and not cell["non_numeric"]:
total = cell["sum"]
row[cname] = int(total) if total == int(total) else round(total, 2)
elif cell["count"] == 0 and cell["non_numeric"]:
vals = sorted(cell["non_numeric"])
preview = "/".join(vals[:5]) + ("…" if len(vals) > 5 else "")
row[cname] = f"{len(vals)} 种:{preview}"
else:
total = cell["sum"]
num_part = int(total) if total == int(total) else round(total, 2)
vals = sorted(cell["non_numeric"])
preview = "/".join(vals[:3])
row[cname] = f"{num_part}(另含非数值:{preview})"
rows.append(row)
return rows, daily_data
# ─────────────────────────────────────────────────────────────────────────────
# 日历表(sheet2)构建
# ─────────────────────────────────────────────────────────────────────────────
def _build_calendar_value_map(
all_records: list[dict],
columns: list[dict],
user_ids: list[str],
) -> dict[str, dict[str, dict[str, str]]]:
"""
从 all_records 中按 (uid, date, metric_name) 提取 CALENDAR_METRICS 的值。
返回: {uid: {date_str: {metric_name: value_str}}}
注:同一 (uid, date, metric) 若有多条记录,取最后一条非空值(query-data 同日同字段
通常只返回一条)。
"""
valid_user_ids = set(user_ids)
metric_cols: dict[str, dict] = {}
for col in columns:
if col["_column_name"] in CALENDAR_METRICS:
metric_cols[col["_column_name"]] = col
result: dict[str, dict[str, dict[str, str]]] = {}
for record in all_records:
uid = _user_id_of(record)
if uid is None or uid not in valid_user_ids:
continue
work_date = _extract_work_date(record, columns)
if not work_date:
continue
for metric_name, col in metric_cols.items():
raw = _value_for_column(record, col)
if raw in (None, ""):
continue
uid_bucket = result.setdefault(uid, {})
date_bucket = uid_bucket.setdefault(work_date, {})
date_bucket[metric_name] = str(raw)
return result
def build_calendar_sheet(
all_records: list[dict],
columns: list[dict],
user_ids: list[str],
user_info_map: dict[str, "cmn.UserInfo"],
group_name_map: dict[str, str],
start: datetime,
end: datetime,
) -> dict:
"""
构建日历表 sheet2 的描述 dict(供 write_excel_multi_sheets 使用)。
布局(参考钉钉考勤月历):
列:姓名 | 考勤组 | 部门 | 指标 | 1日 | 2日 | ... | N日
每个用户占 3 行(班次名称 / 考勤结果 / 工作时长)
基础列(前 3 列)做纵向 3 行合并
返回的 sheet dict 包含 merge_groups 配置,让 write_excel_multi_sheets
自动完成基础列合并。
"""
all_dates = _generate_date_columns(start, end)
# 表头:基础列 + 指标列 + 日期列
headers = ["姓名", "考勤组", "部门", "指标"] + [
f"{datetime.strptime(d, cmn.DATE_FMT).day}日" for d in all_dates
]
# 抽取每个 (uid, date, metric) 的值
value_map = _build_calendar_value_map(all_records, columns, user_ids)
rows: list[list[Any]] = []
merge_groups: list[tuple[int, int, int]] = []
attend_result_row_offsets: set[int] = set()
n_metrics = len(CALENDAR_METRICS)
for uid in user_ids:
info = user_info_map.get(uid, cmn.UserInfo(name=uid))
group_name = group_name_map.get(uid, "")
base_cells = [info.name or uid, group_name, info.dept_name]
block_start = len(rows) # 当前用户首行的 row_offset
for metric_name in CALENDAR_METRICS:
row_cells: list[Any] = list(base_cells) + [metric_name]
for date_str in all_dates:
val = value_map.get(uid, {}).get(date_str, {}).get(metric_name, "")
row_cells.append(val)
if metric_name == "考勤结果":
attend_result_row_offsets.add(len(rows))
rows.append(row_cells)
block_end = len(rows) - 1 # 当前用户末行的 row_offset
if block_end > block_start:
# 基础列 = 前 3 列(姓名/考勤组/部门),需纵向合并
merge_groups.append((block_start, block_end, 3))
title = (
f"日历表 统计日期:{start.strftime(cmn.DATE_FMT)} "
f"至 {end.strftime(cmn.DATE_FMT)}"
)
subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}"
return {
"name": "日历表",
"headers": headers,
"rows": rows,
"title": title,
"subtitle": subtitle,
"merge_groups": merge_groups,
"attend_result_rows": attend_result_row_offsets or None,
}
# ─────────────────────────────────────────────────────────────────────────────
# main
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
args = parse_args()
raw_ids = [u.strip() for u in args.users.split(",") if u.strip()]
if not raw_ids:
cmn.error("--users 不能为空")
return 2
# 自动识别部门ID并展开为员工userId
user_ids = cmn.resolve_users_from_input(raw_ids)
if not user_ids:
cmn.error("未能解析出任何有效的员工userId")
return 2
cmn.log(f"[users] 最终用户列表:{len(user_ids)} 人")
try:
start = cmn.parse_datetime_arg(args.start, end_of_day=False)
end = cmn.parse_datetime_arg(args.end, end_of_day=True)
except ValueError as e:
cmn.error(str(e))
return 2
if end < start:
cmn.error(f"--end ({end}) 早于 --start ({start})")
return 2
try:
columns = resolve_columns(args)
except cmn.DwsCallError as e:
if e.is_permission_error:
cmn.error("权限错误:当前账号无管理员权限,无法获取考勤字段列表。")
return 2
cmn.error(f"获取字段列表失败:{e}")
return 1
except RuntimeError as e:
cmn.error(str(e))
return 1
column_ids = [c["_column_id"] for c in columns]
column_names = [c["_column_name"] for c in columns]
column_id_to_name = {c["_column_id"]: c["_column_name"] for c in columns}
cmn.log(f"[users] 获取 {len(user_ids)} 个用户基础信息")
user_info_map = cmn.resolve_user_info(user_ids)
user_name_map = {uid: info.name or uid for uid, info in user_info_map.items()}
user_batches = cmn.chunk_users(user_ids)
date_slices = cmn.slice_date_range(start, end)
stats = cmn.CallStats(
user_batches=len(user_batches),
date_slices=len(date_slices),
)
cmn.log(
f"[plan] 共 {len(user_batches)} 批 × {len(date_slices)} 个时间片 "
f"= {len(user_batches) * len(date_slices)} 次接口调用"
)
inspected_flag = [False]
all_records: list[dict] = []
for bi, batch in enumerate(user_batches, start=1):
for si, dslice in enumerate(date_slices, start=1):
cmn.log(f"[batch {bi}/{len(user_batches)}] [slice {si}/{len(date_slices)}]")
records = query_one_batch(
batch, column_ids, dslice, stats,
column_id_to_name=column_id_to_name,
inspect=args.inspect,
inspected_flag=inspected_flag,
)
all_records.extend(records)
if not all_records:
stats.add_warning("查询完成,但未得到任何记录")
# 从原始记录中提取每个用户的考勤组名称
group_name_map = cmn.extract_group_names_from_records(all_records, user_ids)
# 月度聚合(普通字段聚合 + 每日维度字段按天存储)
rows_dict, daily_data = aggregate_monthly(all_records, columns, user_ids, user_name_map)
# 请假数据特殊处理:通过 query-leave 单独查询,按 4 类假期月度求和
# 凡是 "请假" 开头的字段(请假 / 请假分类 / 请假时长 等)都视为请假列
leave_in_columns = any(_is_leave_field(name) for name in column_names)
leave_data: dict[str, dict[str, dict[str, float]]] = {}
if leave_in_columns:
try:
leave_data = cmn.query_leave_data(
user_ids, start, end,
leave_names=LEAVE_TYPES,
stats=stats,
)
except cmn.DwsCallError as e:
stats.add_warning(f"[leave] 查询请假数据失败:{e}")
# 生成日期范围内所有日期列表
all_dates = _generate_date_columns(start, end)
# 构建表头:基础列 + 普通聚合字段(剔除"请假*"系列和按天展开字段)+ 请假展开列 + 按天展开字段
base_headers = ["姓名", "考勤组", "部门"]
agg_column_names = [
name for name in column_names
if name not in DAILY_EXPAND_FIELDS and not _is_leave_field(name)
]
# 请假按假期类型展开(如 "请假-事假", "请假-调休", ...)
leave_headers: list[str] = []
if leave_in_columns:
leave_headers = [f"{LEAVE_FIELD_NAME}-{lt}" for lt in LEAVE_TYPES]
# 按天展开的表头:字段名-日号(如 "考勤结果-1日", "考勤结果-2日", ...)
expand_headers: list[str] = []
expand_date_map: list[tuple[str, str]] = [] # [(field_name, date_str), ...]
for fname in column_names:
if fname in DAILY_EXPAND_FIELDS:
for date_str in all_dates:
day_num = datetime.strptime(date_str, cmn.DATE_FMT).day
header_label = f"{fname}-{day_num}日"
expand_headers.append(header_label)
expand_date_map.append((fname, date_str))
headers = base_headers + agg_column_names + leave_headers + expand_headers
# 计算考勤结果列的 0-based 列索引集合(供 Excel 条件配色使用)
_expand_col_start = len(base_headers) + len(agg_column_names) + len(leave_headers)
attend_result_col_indices: set[int] = set()
for i, (fname, _date) in enumerate(expand_date_map):
if fname == "考勤结果":
attend_result_col_indices.add(_expand_col_start + i)
rows_2d = []
for row in rows_dict:
uid = row.get("userId", "")
info = user_info_map.get(uid, cmn.UserInfo(name=uid))
group_name = group_name_map.get(uid, "")
base = [info.name or uid, group_name, info.dept_name]
agg_data = [row.get(h, "") for h in agg_column_names]
# 请假按假期类型聚合(月度求和)
leave_row: list[Any] = []
if leave_in_columns:
user_leave = leave_data.get(uid, {})
for lt in LEAVE_TYPES:
total = 0.0
for day_bucket in user_leave.values():
total += day_bucket.get(lt, 0.0)
if total == 0.0:
leave_row.append("")
elif total == int(total):
leave_row.append(int(total))
else:
leave_row.append(round(total, 2))
# 按天展开字段的数据
expand_data = []
for fname, date_str in expand_date_map:
value = daily_data.get(fname, {}).get(uid, {}).get(date_str, "")
expand_data.append(value)
rows_2d.append(base + agg_data + leave_row + expand_data)
out_name = args.out or cmn.build_output_filename(start, end, suffix="monthly")
title = (
f"月度汇总展示 统计日期:{start.strftime(cmn.DATE_FMT)} "
f"至 {end.strftime(cmn.DATE_FMT)}"
)
subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')}"
# sheet1:月度汇总(每人一行)
summary_sheet = {
"name": "月度汇总",
"headers": headers,
"rows": rows_2d,
"title": title,
"subtitle": subtitle,
"attend_result_columns": attend_result_col_indices or None,
}
# sheet2:日历表(每人 3 行:班次名称 / 考勤结果 / 工作时长,按日期展开)
calendar_sheet = build_calendar_sheet(
all_records, columns, user_ids,
user_info_map, group_name_map,
start, end,
)
try:
cmn.write_excel_multi_sheets(out_name, [summary_sheet, calendar_sheet])
except (RuntimeError, ValueError) as e:
cmn.error(str(e))
return 1
cmn.print_summary(
granularity_label="月度汇总",
out_path=out_name,
user_count=len(user_ids),
column_names=column_names,
start=start,
end=end,
rows_count=len(rows_2d),
stats=stats,
extra_tail=(
"[提示] 数值字段已求和;"
"「考勤结果」按天展开为多列(每天一列显示当天考勤状态)。\n"
"[提示] 已附加第二个 sheet「日历表」:每人 3 行(班次名称/考勤结果/工作时长),"
"按日期横向展开,基础列(姓名/考勤组/部门)已纵向合并。"
),
)
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
考勤排班查询导出脚本
[AI Agent 强制门禁] 本脚本执行前必须先阅读:
dingtalk-workspace/references/products/attendance-schedule.md
职责:
1. 分批查询排班记录(支持大量用户自动分批)
2. 将 classId 转为班次名称
3. 将 userId 转为员工姓名
4. 输出日历表格式的排班表 Excel(行=员工,列=日期,单元格=班次名称)
用法:
python attendance_schedule_export.py \
--users userId1,userId2,userId3 \
--start 2026-05-19 --end 2026-05-23
python attendance_schedule_export.py \
--users userId1,userId2 \
--start 2026-05-01 --end 2026-05-31 \
--output my_schedule.xlsx
"""
from __future__ import annotations
import argparse
import os
import sys
from datetime import datetime, timedelta
from typing import Any
from attendance_report_common import (
DATE_FMT,
DATETIME_FMT,
DwsCallError,
chunk_users,
error,
extract_records,
log,
parse_datetime_arg,
resolve_user_names,
run_dws,
warn,
write_excel,
)
# schedule get 接口每批最多用户数(保守值,避免超时)
SCHEDULE_BATCH_SIZE = 20
WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
# ─────────────────────────────────────────────────────────────────────────────
# 排班数据查询(分批)
# ─────────────────────────────────────────────────────────────────────────────
def fetch_schedule_batch(
user_ids: list[str],
start_date: str,
end_date: str,
) -> list[dict]:
"""调用 dws attendance schedule get 查询一批用户的排班记录。"""
users_str = ",".join(user_ids)
try:
result = run_dws([
"attendance", "schedule", "get",
"--users", users_str,
"--start", start_date,
"--end", end_date,
])
except DwsCallError as exc:
error(f"查询排班失败 (users={len(user_ids)}, {start_date}~{end_date}): {exc}")
return []
return extract_records(result) if result else []
def fetch_all_schedules(
user_ids: list[str],
start_date: str,
end_date: str,
) -> list[dict]:
"""分批查询所有用户的排班记录,自动处理用户数超限。"""
all_records: list[dict] = []
batches = chunk_users(user_ids, SCHEDULE_BATCH_SIZE)
total = len(batches)
log(f"📋 共 {len(user_ids)} 人,分 {total} 批查询排班 ({start_date} ~ {end_date})")
for idx, batch in enumerate(batches, start=1):
if total > 1:
log(f" 批次 {idx}/{total}: {len(batch)} 人")
records = fetch_schedule_batch(batch, start_date, end_date)
all_records.extend(records)
log(f"✅ 查询完成,共 {len(all_records)} 条排班记录")
return all_records
# ─────────────────────────────────────────────────────────────────────────────
# 班次名称映射
# ─────────────────────────────────────────────────────────────────────────────
def build_class_name_map(records: list[dict]) -> dict[int, str]:
"""从排班记录中提取 classId → className 映射。
优先使用记录自带的 className;缺失时回退 class search 补全。
"""
class_map: dict[int, str] = {}
missing_ids: set[int] = set()
for record in records:
raw_id = record.get("classId") or record.get("class_id")
raw_name = record.get("className") or record.get("class_name")
if raw_id is None:
continue
cid = int(raw_id)
if raw_name and str(raw_name).strip():
class_map[cid] = str(raw_name).strip()
elif cid != 0 and cid not in class_map:
missing_ids.add(cid)
if missing_ids:
log(f"🔍 {len(missing_ids)} 个班次缺名称,从 class search 补全 ...")
try:
result = run_dws(["attendance", "class", "search", "--page-size", "200"])
for cls in (extract_records(result) if result else []):
cid_raw = cls.get("id") or cls.get("classId")
cname = cls.get("name") or cls.get("className")
if cid_raw is not None and cname:
class_map[int(cid_raw)] = str(cname).strip()
except DwsCallError as exc:
warn(f"class search 失败,部分班次将显示为 ID: {exc}")
return class_map
# ─────────────────────────────────────────────────────────────────────────────
# 日期工具
# ─────────────────────────────────────────────────────────────────────────────
def normalize_work_date(raw: Any) -> str:
"""将排班记录中的 workDate 标准化为 YYYY-MM-DD。"""
if raw is None:
return ""
if isinstance(raw, (int, float)):
ts = raw / 1000 if raw > 1e12 else raw
try:
return datetime.fromtimestamp(ts).strftime(DATE_FMT)
except (OSError, ValueError, OverflowError):
return ""
s = str(raw).strip()
if len(s) >= 10 and s[4] == "-" and s[7] == "-":
return s[:10]
return s
def generate_date_range(start: datetime, end: datetime) -> list[str]:
"""生成 start 到 end 之间的所有日期字符串列表。"""
dates: list[str] = []
current = start
while current <= end:
dates.append(current.strftime(DATE_FMT))
current += timedelta(days=1)
return dates
# ─────────────────────────────────────────────────────────────────────────────
# 构建排班表(日历表格式)
# ─────────────────────────────────────────────────────────────────────────────
def build_schedule_table(
records: list[dict],
user_ids: list[str],
user_names: dict[str, str],
class_map: dict[int, str],
date_range: list[str],
) -> tuple[list[str], list[list[str]]]:
"""构建日历表格式的排班表。
Returns:
(headers, rows)
headers = ["员工姓名", "05-19\n周一", "05-20\n周二", ...]
rows = [["张三", "早班", "早班", "休息", ...], ...]
"""
# 构建 (userId, date) → 班次显示文本
schedule_lookup: dict[tuple[str, str], str] = {}
for record in records:
uid = str(record.get("userId") or record.get("userid") or "")
work_date = normalize_work_date(record.get("workDate") or record.get("work_date"))
if not uid or not work_date:
continue
is_rest = str(record.get("isRest") or record.get("is_rest") or "N").upper()
raw_cid = record.get("classId") or record.get("class_id") or 0
raw_cname = record.get("className") or record.get("class_name") or ""
if is_rest == "Y":
display = "休息"
elif raw_cname and str(raw_cname).strip():
display = str(raw_cname).strip()
else:
cid = int(raw_cid) if raw_cid else 0
if cid in class_map:
display = class_map[cid]
elif cid == 0:
display = "休息"
else:
display = f"班次{cid}"
schedule_lookup[(uid, work_date)] = display
# 表头
headers = ["员工姓名"]
for date_str in date_range:
dt = datetime.strptime(date_str, DATE_FMT)
weekday = WEEKDAY_NAMES[dt.weekday()]
headers.append(f"{date_str[5:]}\n{weekday}")
# 数据行
rows: list[list[str]] = []
for uid in user_ids:
name = user_names.get(uid, uid)
row = [name]
for date_str in date_range:
row.append(schedule_lookup.get((uid, date_str), ""))
rows.append(row)
return headers, rows
# ─────────────────────────────────────────────────────────────────────────────
# 摘要输出
# ─────────────────────────────────────────────────────────────────────────────
def print_summary(
rows: list[list[str]],
date_range: list[str],
out_path: str,
record_count: int,
) -> None:
"""输出排班查询摘要到 stdout。"""
out_abs = os.path.abspath(out_path)
print(f"\n✅ 排班表导出成功!")
print(f" 文件: {out_abs}")
print(f" 人数: {len(rows)}")
print(f" 日期: {date_range[0]} ~ {date_range[-1]} ({len(date_range)} 天)")
print(f" 记录: {record_count} 条")
# 预览前 10 人 × 前 7 天
preview_rows = min(len(rows), 10)
preview_cols = min(len(date_range), 7)
if preview_rows > 0:
print(f"\n排班预览(前 {preview_rows} 人 × 前 {preview_cols} 天):")
header_line = f"{'姓名':<10}" + "".join(
f"{d[5:]:<8}" for d in date_range[:preview_cols]
)
print(header_line)
print("-" * len(header_line))
for row in rows[:preview_rows]:
line = f"{row[0]:<10}" + "".join(
f"{cell:<8}" for cell in row[1:preview_cols + 1]
)
print(line)
if len(date_range) > preview_cols:
print(f" ... 共 {len(date_range)} 天,完整数据见 Excel")
if len(rows) > preview_rows:
print(f" ... 共 {len(rows)} 人,完整数据见 Excel")
def main() -> None:
parser = argparse.ArgumentParser(
description="考勤排班查询导出(排班表格式)",
epilog="执行前必须阅读 attendance-schedule.md",
)
parser.add_argument("--users", required=True, help="userId 列表,逗号分隔(必填)")
parser.add_argument("--start", required=True, help="开始日期 YYYY-MM-DD(必填)")
parser.add_argument("--end", required=True, help="结束日期 YYYY-MM-DD(必填)")
parser.add_argument("--output", default="", help="输出文件路径(可选)")
args = parser.parse_args()
# ── 解析参数 ──
user_ids = [uid.strip() for uid in args.users.split(",") if uid.strip()]
if not user_ids:
error("--users 不能为空")
raise SystemExit(1)
try:
start_dt = parse_datetime_arg(args.start)
end_dt = parse_datetime_arg(args.end, end_of_day=True)
except ValueError as exc:
error(str(exc))
raise SystemExit(1) from exc
start_date = start_dt.strftime(DATE_FMT)
end_date = end_dt.strftime(DATE_FMT)
if end_dt < start_dt:
error(f"结束日期 {end_date} 早于开始日期 {start_date}")
raise SystemExit(1)
output_path = args.output or f"attendance_schedule_{start_date}_{end_date}.xlsx"
log(f"🗓️ 排班查询: {len(user_ids)} 人, {start_date} ~ {end_date}")
# ── 阶段 1: 查询排班记录(分批) ──
records = fetch_all_schedules(user_ids, start_date, end_date)
if not records:
print(f"⚠️ 未查询到排班记录 ({start_date} ~ {end_date})")
return
# ── 阶段 2: 构建班次名称映射 ──
class_map = build_class_name_map(records)
# ── 阶段 3: 解析员工姓名 ──
user_names = resolve_user_names(user_ids)
# ── 阶段 4: 生成日期范围 & 构建排班表 ──
date_range = generate_date_range(start_dt, end_dt)
headers, rows = build_schedule_table(
records, user_ids, user_names, class_map, date_range,
)
# ── 阶段 5: 输出 Excel ──
title = f"排班表 {start_date} 至 {end_date}"
subtitle = f"生成时间:{datetime.now().strftime(DATETIME_FMT)} 共 {len(rows)} 人"
write_excel(
output_path,
headers,
rows,
sheet_name="排班表",
title=title,
subtitle=subtitle,
)
log(f"📄 Excel 已保存: {os.path.abspath(output_path)}")
# ── 阶段 6: 输出摘要 ──
print_summary(rows, date_range, output_path, len(records))
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
考勤排班导入脚本
[AI Agent 强制门禁] 本脚本执行前必须先阅读:
dingtalk-workspace/references/products/attendance-schedule.md
排班工作流、参数校验、班次校验、回显确认等约束全部在
attendance-schedule.md,禁止凭本脚本源码或 --help 自行组装命令。
职责:
1. 二次校验考勤组类型(必须为 TURN 排班制)
2. 二次校验班次 ID 在可用班次列表中
3. 回显排班内容表格,等待用户确认
4. 调用 dws attendance schedule import 执行排班
5. 输出执行结果摘要
用法:
python attendance_schedule_import.py \
--group-id 123456 \
--schedules '[{"userId":"u001","workDate":"2026-05-19","classId":789,"isRest":"N"}]' \
--confirm
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime
from typing import Any
# 复用公共模块
from attendance_report_common import (
run_dws,
DwsCallError,
extract_records,
resolve_user_names,
log,
warn,
error,
)
DATE_FMT = "%Y-%m-%d"
DATETIME_FMT = "%Y-%m-%d %H:%M:%S"
# ─────────────────────────────────────────────────────────────────────────────
# 考勤组校验
# ─────────────────────────────────────────────────────────────────────────────
def _unwrap_group_vo(result: dict) -> dict:
"""从 group get 返回结构中提取 groupVO(type/name/classIds 等字段所在层)。
group get 返回结构:{groupVO: {type, name, classIds, ...}, ...}
filtered-get 返回结构可能直接是扁平的 {type, name, memberUsers, ...}
"""
if not isinstance(result, dict):
return result
group_vo = result.get("groupVO")
if isinstance(group_vo, dict) and group_vo.get("type"):
return group_vo
# 如果顶层已经有 type 字段,说明是扁平结构,直接返回
if result.get("type"):
return result
# 兜底:尝试从所有 dict 类型的值中找包含 type 字段的
for value in result.values():
if isinstance(value, dict) and value.get("type"):
return value
return result
def validate_group_is_turn(group_id: int) -> dict:
"""校验考勤组存在且类型为 TURN(排班制),返回考勤组信息(groupVO 层级)。"""
log(f"🔍 校验考勤组 {group_id} ...")
# 优先用 group get 获取完整信息(含绑定班次列表)
try:
result = run_dws([
"attendance", "group", "get",
"--group-id", str(group_id),
])
except DwsCallError:
# 降级使用 filtered-get
try:
result = run_dws([
"attendance", "group", "filtered-get",
"--group-id", str(group_id),
])
except DwsCallError as exc:
error(f"查询考勤组失败: {exc}")
raise SystemExit(1) from exc
if not result or not isinstance(result, dict):
error(f"考勤组 {group_id} 不存在或返回数据异常")
raise SystemExit(1)
# 关键:从 groupVO 中提取 type/name 等字段
group_vo = _unwrap_group_vo(result)
group_type = group_vo.get("type", "")
group_name = group_vo.get("name", f"ID:{group_id}")
if not group_type:
# 调试输出,帮助排查结构
log(f"[debug] group get 返回顶层 keys: {list(result.keys())}")
error(f"未能从考勤组 {group_id} 返回数据中识别出类型字段")
raise SystemExit(1)
if group_type != "TURN":
type_label = {"FIXED": "固定班制", "NONE": "自由工时"}.get(group_type, group_type)
error(f"考勤组「{group_name}」类型为 {type_label},不是排班制(TURN),无法执行排班操作")
raise SystemExit(1)
log(f"✅ 考勤组「{group_name}」确认为排班制")
return group_vo
# ─────────────────────────────────────────────────────────────────────────────
# 班次校验
# ─────────────────────────────────────────────────────────────────────────────
def extract_group_bound_classes(group_info: dict) -> set[int]:
"""从考勤组详情中提取绑定的班次 ID 集合。
兼容多种字段结构:
- classIds: [int] — 班次 ID 数组
- classes / selectedClass: [dict] — 班次对象数组 (含 id/classId)
- shiftVOList: [dict] — 排班制特有,含 shiftSetting.shiftId
- classNameIdMap: {name: id} — 名称到 ID 映射
"""
def _extract_from_obj(obj: dict) -> set[int]:
"""从单个 dict 层级中提取班次 ID。"""
ids: set[int] = set()
# 方式1: classIds / shiftIds 数组(最常见)
for key in ("classIds", "shiftIds", "classIdList"):
ids_list = obj.get(key)
if isinstance(ids_list, list):
for item in ids_list:
try:
ids.add(int(item))
except (ValueError, TypeError):
pass
# 方式2: classes / selectedClass 对象数组
for key in ("classes", "selectedClass"):
classes = obj.get(key)
if isinstance(classes, list):
for item in classes:
if isinstance(item, dict):
class_id = item.get("id") or item.get("classId")
if class_id is not None:
ids.add(int(class_id))
elif isinstance(item, (int, str)):
try:
ids.add(int(item))
except (ValueError, TypeError):
pass
# 方式3: shiftVOList — 排班制考勤组特有字段
shift_vo_list = obj.get("shiftVOList")
if isinstance(shift_vo_list, list):
for shift_vo in shift_vo_list:
if not isinstance(shift_vo, dict):
continue
# shiftSetting.shiftId
shift_setting = shift_vo.get("shiftSetting")
if isinstance(shift_setting, dict):
shift_id = shift_setting.get("shiftId") or shift_setting.get("classId")
if shift_id is not None:
ids.add(int(shift_id))
# 直接在 shiftVO 层级的 id/shiftId/classId
for id_key in ("id", "shiftId", "classId"):
val = shift_vo.get(id_key)
if val is not None:
try:
ids.add(int(val))
except (ValueError, TypeError):
pass
# 方式4: classNameIdMap {name: id}
class_map = obj.get("classNameIdMap")
if isinstance(class_map, dict):
for _, class_id in class_map.items():
try:
ids.add(int(class_id))
except (ValueError, TypeError):
pass
return ids
# 优先从 groupVO 提取(group get 返回结构),兼容顶层扁平结构
bound_ids: set[int] = set()
group_vo = group_info.get("groupVO")
if isinstance(group_vo, dict):
bound_ids.update(_extract_from_obj(group_vo))
# 同时从顶层提取(兼容 filtered-get 或已解包的结构)
bound_ids.update(_extract_from_obj(group_info))
return bound_ids
def fetch_all_classes() -> dict[int, str]:
"""获取全局所有班次,返回 {classId: className},用于 ID→名称映射。"""
log("🔍 获取班次名称映射 ...")
all_classes: dict[int, str] = {}
page_index = 1
page_size = 200
while True:
try:
result = run_dws([
"attendance", "class", "search",
"--page-index", str(page_index),
"--page-size", str(page_size),
])
except DwsCallError as exc:
error(f"查询班次列表失败: {exc}")
raise SystemExit(1) from exc
records = extract_records(result) if result else []
if not records:
break
for record in records:
class_id = record.get("id") or record.get("classId")
class_name = record.get("name") or record.get("className") or str(class_id)
if class_id is not None:
all_classes[int(class_id)] = class_name
if len(records) < page_size:
break
page_index += 1
log(f"✅ 获取到 {len(all_classes)} 个班次名称")
return all_classes
def validate_class_ids(
schedules: list[dict],
group_bound_class_ids: set[int],
all_classes: dict[int, str],
group_name: str,
) -> None:
"""校验排班记录中的 classId 都在该考勤组绑定的班次中。
如果考勤组未提取到绑定班次列表(可能是接口字段差异),
则降级为全局班次校验并输出警告。
"""
# 如果两个来源都无法获取到班次信息,跳过校验(排班导入接口本身有服务端校验)
no_bound = len(group_bound_class_ids) == 0
no_global = len(all_classes) == 0
if no_bound and no_global:
warn(f"无法获取考勤组绑定班次和全局班次列表,跳过班次校验(将依赖服务端校验)")
return
use_global_fallback = no_bound
if use_global_fallback:
warn(f"未能从考勤组「{group_name}」详情中提取绑定班次列表,降级为全局班次校验")
check_set = set(all_classes.keys())
else:
check_set = group_bound_class_ids
invalid_class_ids: set[int] = set()
for schedule in schedules:
is_rest = str(schedule.get("isRest", "N")).upper()
if is_rest == "Y":
continue
class_id = int(schedule.get("classId", 0))
if class_id != 0 and class_id not in check_set:
invalid_class_ids.add(class_id)
if invalid_class_ids:
invalid_names = [all_classes.get(cid, f"ID:{cid}") for cid in sorted(invalid_class_ids)]
if use_global_fallback:
error(f"以下班次不在可用班次列表中: {', '.join(invalid_names)}")
else:
error(f"以下班次不属于考勤组「{group_name}」: {', '.join(invalid_names)}")
log(f"「{group_name}」可用班次:")
available_ids = check_set if not use_global_fallback else set(all_classes.keys())
for cid in sorted(available_ids):
cname = all_classes.get(cid, f"ID:{cid}")
log(f" - {cname} (ID: {cid})")
raise SystemExit(1)
# ─────────────────────────────────────────────────────────────────────────────
# 日期格式标准化
# ─────────────────────────────────────────────────────────────────────────────
def normalize_work_date(work_date: Any) -> str:
"""将 workDate 统一转换为 yyyy-MM-dd HH:mm:ss 格式。"""
if isinstance(work_date, (int, float)):
timestamp = work_date / 1000 if work_date > 1e12 else work_date
return datetime.fromtimestamp(timestamp).strftime(DATETIME_FMT)
date_str = str(work_date).strip()
for fmt in (DATETIME_FMT, DATE_FMT):
try:
parsed = datetime.strptime(date_str, fmt)
return parsed.strftime(DATETIME_FMT)
except ValueError:
continue
raise ValueError(f"无法解析日期格式: {work_date!r},请使用 YYYY-MM-DD 格式")
# ─────────────────────────────────────────────────────────────────────────────
# 回显排班内容
# ─────────────────────────────────────────────────────────────────────────────
def print_schedule_preview(
group_name: str,
group_id: int,
schedules: list[dict],
available_classes: dict[int, str],
user_names: dict[str, str],
) -> None:
"""向 stdout 打印排班预览表格供用户确认。"""
print("\n📋 排班确认")
print(f"\n考勤组: {group_name} (ID: {group_id})")
dates = sorted({s.get("workDate", "")[:10] for s in schedules})
if dates:
print(f"排班日期: {dates[0]} ~ {dates[-1]}")
print(f"\n{'员工姓名':<12} {'日期':<14} {'班次':<16} {'是否排休':<8}")
print("-" * 54)
for schedule in sorted(schedules, key=lambda s: (s.get("userId", ""), s.get("workDate", ""))):
user_id = schedule.get("userId", "")
user_name = user_names.get(user_id, user_id)
work_date = str(schedule.get("workDate", ""))[:10]
class_id = int(schedule.get("classId", 0))
is_rest = str(schedule.get("isRest", "N")).upper()
if is_rest == "Y":
class_display = "休息"
rest_display = "是"
else:
class_display = available_classes.get(class_id, f"未知班次(ID:{class_id})")
rest_display = "否"
print(f"{user_name:<12} {work_date:<14} {class_display:<16} {rest_display:<8}")
print(f"\n共 {len(schedules)} 条排班记录")
# ─────────────────────────────────────────────────────────────────────────────
# 执行排班
# ─────────────────────────────────────────────────────────────────────────────
def execute_schedule_import(group_id: int, schedules: list[dict]) -> None:
"""调用 dws attendance schedule import 执行排班。"""
log(f"🚀 正在执行排班导入 ({len(schedules)} 条记录) ...")
schedules_json = json.dumps(schedules, ensure_ascii=False)
try:
result = run_dws([
"attendance", "schedule", "import",
"--groupId", str(group_id),
"--scheduleVOS", schedules_json,
"--yes",
])
except DwsCallError as exc:
error(f"排班导入失败: {exc}")
if exc.is_permission_error:
error("提示: 当前账号可能不是考勤管理员,请确认权限")
raise SystemExit(1) from exc
log("✅ 排班导入完成")
return result
# ─────────────────────────────────────────────────────────────────────────────
# 主流程
# ─────────────────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(
description="考勤排班导入(含校验、回显、执行)",
epilog="执行前必须阅读 attendance-schedule.md",
)
parser.add_argument(
"--group-id", required=True, type=int,
help="考勤组 ID(必填,必须为排班制考勤组)",
)
parser.add_argument(
"--schedules", required=True,
help="排班记录 JSON 数组(必填),每条记录包含 userId/workDate/classId/isRest",
)
parser.add_argument(
"--confirm", action="store_true",
help="用户已确认排班内容(必填,表示用户已在 Agent 回显中确认)",
)
parser.add_argument(
"--dry-run", action="store_true",
help="仅校验和回显,不实际执行排班",
)
args = parser.parse_args()
# ── 解析排班记录 JSON ──
try:
schedules: list[dict] = json.loads(args.schedules)
except json.JSONDecodeError as exc:
error(f"--schedules JSON 格式错误: {exc}")
raise SystemExit(1) from exc
if not isinstance(schedules, list) or len(schedules) == 0:
error("--schedules 必须是非空 JSON 数组")
raise SystemExit(1)
# ── 校验必填字段 ──
required_fields = ("userId", "workDate", "classId", "isRest")
for idx, schedule in enumerate(schedules):
for field_name in required_fields:
if field_name not in schedule:
error(f"schedule[{idx}] 缺少必填字段: {field_name}")
raise SystemExit(1)
# ── 标准化日期格式 ──
for idx, schedule in enumerate(schedules):
try:
schedule["workDate"] = normalize_work_date(schedule["workDate"])
except ValueError as exc:
error(f"schedule[{idx}] 日期格式错误: {exc}")
raise SystemExit(1) from exc
# ── 阶段 1: 校验考勤组(必须为 TURN 排班制) ──
group_info = validate_group_is_turn(args.group_id)
group_name = group_info.get("name", f"ID:{args.group_id}")
# ── 阶段 2: 解析员工姓名 ──
user_ids = list({s["userId"] for s in schedules})
user_names = resolve_user_names(user_ids)
# ── 阶段 3: 校验班次(必须属于该考勤组) ──
group_bound_class_ids = extract_group_bound_classes(group_info)
all_classes = fetch_all_classes()
if group_bound_class_ids:
log(f"📋 考勤组「{group_name}」绑定了 {len(group_bound_class_ids)} 个班次:")
for cid in sorted(group_bound_class_ids):
cname = all_classes.get(cid, f"ID:{cid}")
log(f" - {cname} (ID: {cid})")
validate_class_ids(schedules, group_bound_class_ids, all_classes, group_name)
log("✅ 班次校验通过")
# ── 阶段 4: 回显排班内容 ──
print_schedule_preview(group_name, args.group_id, schedules, all_classes, user_names)
if args.dry_run:
print("\n[dry-run] 仅校验和回显,未实际执行排班")
return
if not args.confirm:
print("\n⚠️ 未传入 --confirm 参数,排班未执行")
print("请在 Agent 回显确认后,添加 --confirm 参数重新执行")
return
# ── 阶段 5: 执行排班 ──
execute_schedule_import(args.group_id, schedules)
# ── 阶段 6: 输出摘要 ──
print(f"\n✅ 排班导入成功!")
print(f" 考勤组: {group_name}")
print(f" 排班人数: {len(user_ids)}")
print(f" 排班记录: {len(schedules)} 条")
dates = sorted({s.get('workDate', '')[:10] for s in schedules})
if dates:
print(f" 日期范围: {dates[0]} ~ {dates[-1]}")
# 展示所有排班明细
print(f"\n{'员工姓名':<12} {'日期':<14} {'班次':<16} {'是否排休':<8}")
print("-" * 54)
for schedule in sorted(schedules, key=lambda s: (s.get("userId", ""), s.get("workDate", ""))):
uid = schedule.get("userId", "")
uname = user_names.get(uid, uid)
wdate = str(schedule.get("workDate", ""))[:10]
cid = int(schedule.get("classId", 0))
is_rest = str(schedule.get("isRest", "N")).upper()
if is_rest == "Y":
class_display = "休息"
rest_display = "是"
else:
class_display = all_classes.get(cid, f"未知班次(ID:{cid})")
rest_display = "否"
print(f"{uname:<12} {wdate:<14} {class_display:<16} {rest_display:<8}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
查询团队成员本周排班和出勤统计
用法:
python attendance_team_shift.py --users userId1,userId2,userId3
python attendance_team_shift.py --users userId1,userId2 \
--from 2026-03-10 --to 2026-03-14
python attendance_team_shift.py --users userId1 --dry-run
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
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 get_week_range():
today = datetime.now()
monday = today - timedelta(days=today.weekday())
friday = monday + timedelta(days=4)
return monday.strftime('%Y-%m-%d'), friday.strftime('%Y-%m-%d')
def main():
parser = argparse.ArgumentParser(
description='查询团队成员排班和出勤统计'
)
parser.add_argument(
'--users', required=True, help='用户 ID 列表,逗号分隔'
)
mon, fri = get_week_range()
parser.add_argument('--from', dest='from_date', default=mon)
parser.add_argument('--to', dest='to_date', default=fri)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
user_count = len(args.users.split(','))
if user_count > 50:
print('错误:最多查询 50 人')
sys.exit(1)
print(f"📊 团队排班查询 ({args.from_date} ~ {args.to_date})")
print(f" 人数: {user_count}")
print('=' * 50)
print('\n🔍 查询排班信息...')
data = run_dws([
'attendance', 'shift', 'list',
'--users', args.users,
'--start', args.from_date,
'--end', args.to_date,
'--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
return
if not data:
print('未查到排班信息')
return
print(json.dumps(data, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
假期余额 Excel 导出脚本。
[AI Agent 强制门禁] 调用本脚本前必须先阅读:
dingtalk-workspace/references/products/attendance-vacation.md
本脚本负责:
1. 通过 dws attendance vacation types 获取假期规则列表,用于确定列顺序
2. 通过 dws attendance vacation balance 查询所有假期规则余额
3. 通过 dws contact user get 解析姓名、部门等基础信息
4. 生成横向宽表 Excel:每人一行,假期规则为动态列
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime
from typing import Any
import attendance_report_common as cmn
MAX_USERS_PER_BALANCE_BATCH = 20
BASE_HEADERS = ["姓名", "部门", "入职时间", "首次工作时间"]
USER_ID_KEYS = (
"userId", "userid", "targetUserId", "targetUserID", "staffId", "staffID",
"employeeId", "empId", "dingUserId",
)
LEAVE_CODE_KEYS = (
"leaveCode", "leaveTypeCode", "quotaCode", "vacationCode", "bizType",
"bizCode", "code", "id",
)
LEAVE_NAME_KEYS = (
"leaveName", "leaveTypeName", "quotaName", "vacationName", "name",
"title", "ruleName",
)
BALANCE_KEYS = (
"balance", "balanceQuota", "remain", "remainQuota", "remainDuration",
"restQuota", "availableBalance", "availableQuota", "quotaNumPerDay",
"quotaNumPerHour", "quotaNum", "quota", "value", "leaveBalance",
"leftQuota", "leftBalance",
)
MESSAGE_KEYS = ("message", "msg", "reason", "errorMessage", "errorMsg")
SOURCE_KEYS = ("source", "leaveSource", "ruleSource", "dataSource")
UNIT_KEYS = (
"leaveViewUnit", "viewUnit", "displayUnit", "unit", "quotaUnit",
"durationUnit", "timeUnit", "balanceUnit", "leaveUnit",
)
UNIT_LABELS = {
"day": "天",
"days": "天",
"percent_day": "天",
"hour": "小时",
"hours": "小时",
"minute": "分钟",
"minutes": "分钟",
}
ENTRY_TIME_KEYS = (
"entryTime", "entryDate", "hireDate", "joinDate", "employmentDate", "入职时间",
)
FIRST_WORK_TIME_KEYS = (
"firstWorkTime", "firstWorkingTime", "firstWorkDate", "首次工作时间",
)
UNLIMITED_KEYS = (
"unlimited", "isUnlimited", "unLimit", "unlimitedBalance", "notLimit",
)
NOT_APPLICABLE_KEYS = (
"notApplicable", "notApply", "isNotApplicable", "invalid", "disable", "disabled",
)
VISIBLE_KEYS = ("visible", "visiable", "visibility", "isVisible", "isVisiable")
NO_BALANCE_MESSAGES = ("假期类型没有余额", "没有余额", "未设置假期余额")
NOT_APPLICABLE_MESSAGES = (
"员工未设置首次参加工作时间",
"未设置首次参加工作时间",
"员工未设置入职时间",
"未设置入职时间",
)
EXTERNAL_SOURCE = "external"
EXTERNAL_BALANCE_UNAVAILABLE_MESSAGE = "外部规则暂无余额,需通过接口初始化更新余额"
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"导出假期余额 Excel。AI Agent 必须先读 "
"references/products/attendance-vacation.md 再调用本脚本。"
),
)
parser.add_argument("--users", required=True, help="userId 或 deptId 列表,逗号分隔")
parser.add_argument("--leave-keywords", default="", help="按假期名称关键词筛选列,逗号分隔;默认导出全部")
parser.add_argument("--out", default="", help="输出 xlsx 文件名;不传则自动生成")
parser.add_argument("--inspect", action="store_true", help="打印首条假期类型和余额原始结构到 stderr")
return parser.parse_args()
def first_nonempty(record: dict[str, Any], keys: tuple[str, ...]) -> Any:
for key in keys:
if key in record and record[key] not in (None, ""):
return record[key]
return None
def recursively_collect_dicts(payload: Any) -> list[dict[str, Any]]:
if isinstance(payload, list):
records: list[dict[str, Any]] = []
for item in payload:
records.extend(recursively_collect_dicts(item))
return records
if isinstance(payload, dict):
if looks_like_business_record(payload):
return [payload]
direct_records = cmn.extract_records(payload)
if direct_records:
return direct_records
records = []
for value in payload.values():
records.extend(recursively_collect_dicts(value))
return records
return []
def looks_like_business_record(record: dict[str, Any]) -> bool:
candidate_key_groups = (
USER_ID_KEYS,
LEAVE_CODE_KEYS,
LEAVE_NAME_KEYS,
BALANCE_KEYS,
ENTRY_TIME_KEYS,
FIRST_WORK_TIME_KEYS,
)
return any(first_nonempty(record, keys) is not None for keys in candidate_key_groups)
def is_truthy_flag(value: Any) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
if isinstance(value, str):
return value.strip().lower() in {"true", "1", "y", "yes", "是", "visible"}
return False
def is_falsey_flag(value: Any) -> bool:
if isinstance(value, bool):
return not value
if isinstance(value, (int, float)):
return value == 0
if isinstance(value, str):
return value.strip().lower() in {"false", "0", "n", "no", "否", "invisible", "not_visible"}
return False
def is_no_balance_message(message: Any) -> bool:
return any(keyword in str(message) for keyword in NO_BALANCE_MESSAGES)
def is_not_applicable_message(message: Any) -> bool:
return any(keyword in str(message) for keyword in NOT_APPLICABLE_MESSAGES)
def is_external_leave_type(leave_type: dict[str, str]) -> bool:
return leave_type.get("source", "").strip().lower() == EXTERNAL_SOURCE
def normalize_leave_unit(value: Any) -> str:
if value in (None, ""):
return ""
unit = str(value).strip()
if not unit:
return ""
return UNIT_LABELS.get(unit.lower(), unit)
def format_date(value: Any) -> str:
if value in (None, ""):
return "未设置"
if isinstance(value, (int, float)):
timestamp = value / 1000 if value > 10_000_000_000 else value
try:
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d")
except (OverflowError, OSError, ValueError):
return str(value)
if isinstance(value, str):
stripped = value.strip()
if not stripped:
return "未设置"
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
try:
return datetime.strptime(stripped[:19], fmt).strftime("%Y-%m-%d")
except ValueError:
continue
return stripped[:10] if len(stripped) >= 10 else stripped
return str(value)
def format_balance_value(record: dict[str, Any]) -> Any:
visible = first_nonempty(record, VISIBLE_KEYS)
if visible is not None and is_falsey_flag(visible):
return "不适用"
message = first_nonempty(record, MESSAGE_KEYS)
if message and is_no_balance_message(message):
return "不限制余额"
if message and is_not_applicable_message(message):
return "不适用"
if "hideQuota" in record and is_truthy_flag(record["hideQuota"]):
return "不适用"
for key in UNLIMITED_KEYS:
if key in record and is_truthy_flag(record[key]):
return "不限制余额"
for key in NOT_APPLICABLE_KEYS:
if key in record and is_truthy_flag(record[key]):
return "不适用"
value = first_nonempty(record, BALANCE_KEYS)
if value in (None, ""):
status = first_nonempty(record, ("status", "state", "balanceStatus", *MESSAGE_KEYS))
return status or "不适用"
if isinstance(value, str):
stripped = value.strip()
if stripped in {"UNLIMITED", "Unlimited", "不限", "不限制"}:
return "不限制余额"
if stripped in {"N/A", "NA", "NOT_APPLICABLE", "不适用"}:
return "不适用"
try:
value = float(stripped)
except ValueError:
return stripped
if isinstance(value, (int, float)):
rounded = round(float(value), 2)
return int(rounded) if rounded == int(rounded) else rounded
return value
def normalize_leave_types(payload: Any) -> list[dict[str, str]]:
raw_records = recursively_collect_dicts(payload)
leave_types: list[dict[str, str]] = []
seen: set[str] = set()
for record in raw_records:
code = first_nonempty(record, LEAVE_CODE_KEYS)
name = first_nonempty(record, LEAVE_NAME_KEYS)
if not code and not name:
continue
stable_key = str(code or name)
if stable_key in seen:
continue
seen.add(stable_key)
unit = normalize_leave_unit(first_nonempty(record, UNIT_KEYS))
source = first_nonempty(record, SOURCE_KEYS)
leave_types.append({
"code": str(code or name),
"name": str(name or code),
"unit": unit,
"source": str(source or ""),
})
return leave_types
def normalize_balance_records(payload: Any) -> list[dict[str, Any]]:
raw_records = recursively_collect_dicts(payload)
return [record for record in raw_records if first_nonempty(record, USER_ID_KEYS) or first_nonempty(record, LEAVE_CODE_KEYS) or first_nonempty(record, LEAVE_NAME_KEYS)]
def query_leave_types(inspect: bool) -> list[dict[str, str]]:
payload = cmn.run_dws(["attendance", "vacation", "types"])
if inspect:
records = recursively_collect_dicts(payload)
cmn.log("[inspect] vacation types first record:\n" + json.dumps(records[:1], ensure_ascii=False, indent=2))
leave_types = normalize_leave_types(payload)
cmn.log(f"[types] 获取到 {len(leave_types)} 个假期规则")
return leave_types
def extract_message(payload: Any) -> str:
if isinstance(payload, dict):
message = first_nonempty(payload, MESSAGE_KEYS)
if message:
return str(message)
for value in payload.values():
nested_message = extract_message(value)
if nested_message:
return nested_message
if isinstance(payload, list):
for item in payload:
nested_message = extract_message(item)
if nested_message:
return nested_message
return ""
def enrich_balance_record(record: dict[str, Any], leave_type: dict[str, str]) -> dict[str, Any]:
enriched = dict(record)
enriched.setdefault("leaveCode", leave_type["code"])
enriched.setdefault("leaveName", leave_type["name"])
if leave_type.get("unit"):
enriched.setdefault("unit", leave_type["unit"])
if leave_type.get("source"):
enriched.setdefault("source", leave_type["source"])
return enriched
def build_message_balance_records(
batch: list[str],
leave_type: dict[str, str],
message: str,
) -> list[dict[str, Any]]:
if not message:
return []
return [
{
"userId": user_id,
"leaveCode": leave_type["code"],
"leaveName": leave_type["name"],
"unit": leave_type.get("unit") or "",
"source": leave_type.get("source") or "",
"message": message,
}
for user_id in batch
]
def query_balance_payload(batch: list[str], leave_code: str) -> Any:
return cmn.run_dws([
"attendance", "vacation", "balance",
"--users", ",".join(batch),
"--leave-code", leave_code,
])
def normalize_query_records(
payload: Any,
batch: list[str],
leave_type: dict[str, str],
) -> list[dict[str, Any]]:
records = [
enrich_balance_record(record, leave_type)
for record in normalize_balance_records(payload)
]
if records:
return records
return build_message_balance_records(batch, leave_type, extract_message(payload))
def query_single_user_after_batch_error(
user_id: str,
leave_type: dict[str, str],
batch_error: cmn.DwsCallError,
) -> list[dict[str, Any]]:
leave_code = leave_type["code"]
try:
payload = query_balance_payload([user_id], leave_code)
except cmn.DwsCallError as error:
if is_external_leave_type(leave_type) and not error.is_permission_error:
return build_message_balance_records(
[user_id],
leave_type,
EXTERNAL_BALANCE_UNAVAILABLE_MESSAGE,
)
if is_no_balance_message(error) or is_not_applicable_message(error):
return build_message_balance_records([user_id], leave_type, str(error))
raise
records = normalize_query_records(payload, [user_id], leave_type)
if records:
return records
return build_message_balance_records([user_id], leave_type, str(batch_error))
def query_balance_records(
user_ids: list[str],
leave_types: list[dict[str, str]],
inspect: bool,
) -> list[dict[str, Any]]:
all_records: list[dict[str, Any]] = []
for leave_index, leave_type in enumerate(leave_types, start=1):
leave_code = leave_type["code"]
cmn.log(f"[balance] 查询假期规则 {leave_index}/{len(leave_types)}:{leave_type['name']}({leave_code})")
for batch_index, batch in enumerate(cmn.chunk_users(user_ids, MAX_USERS_PER_BALANCE_BATCH), start=1):
cmn.log(f"[balance] 查询第 {batch_index} 批,{len(batch)} 人")
try:
payload = query_balance_payload(batch, leave_code)
except cmn.DwsCallError as error:
if is_external_leave_type(leave_type) and not error.is_permission_error:
cmn.warn(
f"[balance] 外部假期规则 {leave_type['name']}({leave_code}) 查询失败,"
"按外部规则暂无余额处理"
)
records = build_message_balance_records(
batch,
leave_type,
EXTERNAL_BALANCE_UNAVAILABLE_MESSAGE,
)
all_records.extend(records)
continue
if is_no_balance_message(error):
cmn.warn(
f"[balance] 假期规则 {leave_type['name']}({leave_code}) 没有余额,"
"按不限制余额处理"
)
records = build_message_balance_records(batch, leave_type, str(error))
all_records.extend(records)
continue
if is_not_applicable_message(error):
cmn.warn(
f"[balance] 假期规则 {leave_type['name']}({leave_code}) 依赖员工时间字段,"
"改为逐个员工查询并将缺失配置的员工标为不适用"
)
for user_id in batch:
all_records.extend(query_single_user_after_batch_error(user_id, leave_type, error))
continue
raise
records = normalize_query_records(payload, batch, leave_type)
if inspect and leave_index == 1 and batch_index == 1:
cmn.log("[inspect] vacation balance first record:\n" + json.dumps(records[:1], ensure_ascii=False, indent=2))
all_records.extend(records)
cmn.log(f"[balance] 获取到 {len(all_records)} 条余额记录")
return all_records
def extract_user_id(record: dict[str, Any], fallback_users: list[str]) -> str:
user_id = first_nonempty(record, USER_ID_KEYS)
if user_id:
return str(user_id)
if len(fallback_users) == 1:
return fallback_users[0]
return ""
def build_leave_columns(
leave_types: list[dict[str, str]],
balance_records: list[dict[str, Any]],
keywords: list[str],
) -> list[dict[str, str]]:
columns: list[dict[str, str]] = []
seen: set[str] = set()
for leave_type in leave_types:
code = leave_type["code"]
name = leave_type["name"]
if keywords and not any(keyword in name for keyword in keywords):
continue
seen.add(code)
columns.append(leave_type)
for record in balance_records:
code = first_nonempty(record, LEAVE_CODE_KEYS)
name = first_nonempty(record, LEAVE_NAME_KEYS)
if not code and not name:
continue
code_str = str(code or name)
name_str = str(name or code)
if code_str in seen:
continue
if keywords and not any(keyword in name_str for keyword in keywords):
continue
seen.add(code_str)
unit = normalize_leave_unit(first_nonempty(record, UNIT_KEYS))
source = first_nonempty(record, SOURCE_KEYS)
columns.append({"code": code_str, "name": name_str, "unit": unit, "source": str(source or "")})
return columns
def build_balance_index(
user_ids: list[str],
balance_records: list[dict[str, Any]],
) -> dict[str, dict[str, Any]]:
balance_index: dict[str, dict[str, Any]] = {user_id: {} for user_id in user_ids}
for record in balance_records:
user_id = extract_user_id(record, user_ids)
code = first_nonempty(record, LEAVE_CODE_KEYS)
name = first_nonempty(record, LEAVE_NAME_KEYS)
if not user_id or (not code and not name):
continue
value = format_balance_value(record)
if code:
balance_index.setdefault(user_id, {})[str(code)] = value
if name:
balance_index.setdefault(user_id, {})[str(name)] = value
return balance_index
def extract_user_extra(record: dict[str, Any]) -> dict[str, str]:
return {
"entry_time": format_date(first_nonempty(record, ENTRY_TIME_KEYS)),
"first_work_time": format_date(first_nonempty(record, FIRST_WORK_TIME_KEYS)),
}
def build_user_extra_index(
user_ids: list[str],
balance_records: list[dict[str, Any]],
) -> dict[str, dict[str, str]]:
result = {
user_id: {"entry_time": "未设置", "first_work_time": "未设置"}
for user_id in user_ids
}
for record in balance_records:
user_id = extract_user_id(record, user_ids)
if not user_id:
continue
extra = extract_user_extra(record)
current = result.setdefault(user_id, {"entry_time": "未设置", "first_work_time": "未设置"})
if current["entry_time"] == "未设置" and extra["entry_time"] != "未设置":
current["entry_time"] = extra["entry_time"]
if current["first_work_time"] == "未设置" and extra["first_work_time"] != "未设置":
current["first_work_time"] = extra["first_work_time"]
return result
def build_headers(leave_columns: list[dict[str, str]]) -> list[str]:
headers = BASE_HEADERS.copy()
for leave_column in leave_columns:
name = leave_column["name"]
unit = leave_column.get("unit") or ""
headers.append(f"{name}({unit})" if unit else name)
return headers
def build_rows(
user_ids: list[str],
leave_columns: list[dict[str, str]],
balance_index: dict[str, dict[str, Any]],
user_extra_index: dict[str, dict[str, str]],
user_info_map: dict[str, cmn.UserInfo],
) -> list[list[Any]]:
rows: list[list[Any]] = []
for user_id in user_ids:
user_info = user_info_map.get(user_id, cmn.UserInfo(name=user_id))
user_extra = user_extra_index.get(user_id, {})
user_balances = balance_index.get(user_id, {})
row: list[Any] = [
user_info.name or user_id,
user_info.dept_name,
user_extra.get("entry_time") or "未设置",
user_extra.get("first_work_time") or "未设置",
]
for leave_column in leave_columns:
row.append(
user_balances.get(leave_column["code"], user_balances.get(leave_column["name"], "不适用"))
)
rows.append(row)
return rows
def main() -> int:
args = parse_args()
raw_ids = [user_id.strip() for user_id in args.users.split(",") if user_id.strip()]
if not raw_ids:
cmn.error("--users 不能为空")
return 2
user_ids = cmn.resolve_users_from_input(raw_ids)
if not user_ids:
cmn.error("未能解析出任何有效员工 userId")
return 2
cmn.log(f"[users] 最终用户列表:{len(user_ids)} 人")
keywords = [keyword.strip() for keyword in args.leave_keywords.split(",") if keyword.strip()]
try:
leave_types = query_leave_types(args.inspect)
balance_records = query_balance_records(user_ids, leave_types, args.inspect)
except cmn.DwsCallError as error:
if error.is_permission_error:
cmn.error("权限错误:当前账号无权查询目标员工假期余额,请确认管理员或管理范围权限。")
return 2
cmn.error(f"查询假期余额失败:{error}")
return 1
leave_columns = build_leave_columns(leave_types, balance_records, keywords)
if not leave_columns:
cmn.error("未匹配到任何假期规则列,请检查假期规则或 --leave-keywords 参数。")
return 1
user_info_map = cmn.resolve_user_info(user_ids)
balance_index = build_balance_index(user_ids, balance_records)
user_extra_index = build_user_extra_index(user_ids, balance_records)
headers = build_headers(leave_columns)
rows = build_rows(user_ids, leave_columns, balance_index, user_extra_index, user_info_map)
out_name = args.out or f"attendance_vacation_balance_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
title = "假期余额列表"
subtitle = f"报表生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M')};员工数:{len(user_ids)};假期规则数:{len(leave_columns)}"
try:
cmn.write_excel(
out_name,
headers,
rows,
sheet_name="假期余额",
title=title,
subtitle=subtitle,
)
except RuntimeError as error:
cmn.error(str(error))
return 1
print("✅ 假期余额 Excel 导出完成")
print(f"- 输出文件:{os.path.abspath(out_name)}")
print(f"- 员工数量:{len(user_ids)}")
print(f"- 假期规则列数:{len(leave_columns)}")
if keywords:
print(f"- 假期筛选关键词:{','.join(keywords)}")
print("- 说明:每名员工一行,假期规则横向展开;未设置假期余额显示“不限制余额”,hideQuota=true 显示“不适用”,余额为 0 时显示 0。")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
"""
批量添加字段到钉钉 AI 表格数据表(新版 schema)
用法:
python bulk_add_fields.py <baseId> <tableId> fields.json
fields.json 格式:
[
{"fieldName": "字段 1", "type": "text"},
{"fieldName": "字段 2", "type": "number", "config": {"formatter": "INT"}},
{"fieldName": "字段 3", "type": "singleSelect", "config": {"options": [{"name": "高"}]}}
]
兼容写法:
- name 会自动映射为 fieldName
- phone 会自动映射为 telephone
"""
import sys
import json
import subprocess
import os
import re
from pathlib import Path
from typing import Union, List, Dict, Any, Optional, Tuple
JsonData = Union[List[Any], Dict[str, Any]]
MAX_FILE_SIZE = 10 * 1024 * 1024
ALLOWED_FILE_EXTENSIONS = ['.json']
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
ALLOWED_FIELD_TYPES = {
'text', 'number', 'singleSelect', 'multipleSelect', 'date', 'currency',
'user', 'department', 'group', 'progress', 'rating', 'checkbox',
'attachment', 'url', 'richText', 'telephone', 'email', 'idCard',
'barcode', 'geolocation', 'primaryDoc', 'formula', 'unidirectionalLink',
'bidirectionalLink', 'creator', 'lastModifier', 'createdTime',
'lastModifiedTime',
}
FIELD_TYPE_ALIASES = {
'phone': 'telephone',
}
def resolve_safe_path(path: str, allowed_root: Optional[str] = None) -> Path:
if allowed_root is None:
allowed_root = os.environ.get('OPENCLAW_WORKSPACE', os.getcwd())
allowed_root = Path(allowed_root).resolve()
target_path = (
Path(path).resolve()
if Path(path).is_absolute()
else (Path.cwd() / path).resolve()
)
try:
target_path.relative_to(allowed_root)
return target_path
except ValueError:
raise ValueError(
f"路径超出允许范围:{path}\n"
f"目标路径:{target_path}\n"
f"允许根目录:{allowed_root}\n"
f"提示:设置 OPENCLAW_WORKSPACE 环境变量或确保文件在工作目录内"
)
def validate_resource_id(resource_id: str) -> bool:
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
def validate_file_extension(filename: str, allowed_extensions: list) -> bool:
return any(filename.lower().endswith(ext) for ext in allowed_extensions)
def safe_json_load(file_path: Path, max_size: int = MAX_FILE_SIZE) -> JsonData:
file_size = file_path.stat().st_size
if file_size > max_size:
raise ValueError(
f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)"
)
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
def normalize_field_config(field: Dict[str, Any]) -> Dict[str, Any]:
normalized = dict(field)
if 'fieldName' not in normalized and 'name' in normalized:
normalized['fieldName'] = normalized.pop('name')
normalized['type'] = FIELD_TYPE_ALIASES.get(
normalized.get('type', 'text'), normalized.get('type', 'text')
)
return normalized
def validate_field_config(field: Dict[str, Any]) -> Tuple[bool, str]:
if not isinstance(field, dict):
return False, '字段配置必须是对象'
field = normalize_field_config(field)
if 'fieldName' not in field:
return False, '缺少必需字段:fieldName'
if not isinstance(field['fieldName'], str) or not field['fieldName'].strip():
return False, 'fieldName 必须是非空字符串'
field_type = field.get('type', 'text')
if field_type not in ALLOWED_FIELD_TYPES:
return False, f"不支持的字段类型:{field_type}"
config = field.get('config')
if config is not None and not isinstance(config, dict):
return False, 'config 必须是对象'
if field_type in {'singleSelect', 'multipleSelect'}:
options = (config or {}).get('options')
if not options or not isinstance(options, list):
return False, (
'singleSelect / multipleSelect 必须提供 config.options 数组'
)
if field_type in {'unidirectionalLink', 'bidirectionalLink'}:
linked_sheet_id = (config or {}).get('linkedSheetId')
if not linked_sheet_id or not validate_resource_id(linked_sheet_id):
return False, '关联字段必须提供合法的 config.linkedSheetId'
return True, ''
def build_fields_json(fields: List[Dict[str, Any]]) -> str:
"""构建 --fields 参数的 JSON 字符串。"""
payload_fields = []
for field in fields:
normalized = normalize_field_config(field)
item: Dict[str, Any] = {
'fieldName': normalized['fieldName'].strip(),
'type': normalized.get('type', 'text'),
}
if 'config' in normalized and normalized['config'] is not None:
item['config'] = normalized['config']
payload_fields.append(item)
return json.dumps(payload_fields, ensure_ascii=False)
def run_dws(args: List[str]) -> Optional[Dict[str, Any]]:
if not args:
print('错误:空命令')
return None
cmd = ['dws'] + args
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}")
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError as e:
print(f"无法解析响应:{result.stdout[:200]}...")
print(f"JSON 解析错误:{e}")
return None
except subprocess.TimeoutExpired:
print('错误:命令执行超时(60 秒)')
return None
except FileNotFoundError:
print('错误:未找到 dws 命令,请确认已安装')
return None
def bulk_add_fields(
base_id: str, table_id: str, fields_file: str
) -> bool:
try:
safe_path = resolve_safe_path(fields_file)
except ValueError as e:
print(f"路径验证失败:{e}")
return False
if not validate_file_extension(fields_file, ALLOWED_FILE_EXTENSIONS):
print(f"错误:只允许 {', '.join(ALLOWED_FILE_EXTENSIONS)} 文件")
return False
if not safe_path.exists():
print(f"错误:文件不存在:{safe_path}")
return False
try:
fields = safe_json_load(safe_path)
except ValueError as e:
print(f"错误:{e}")
return False
except json.JSONDecodeError as e:
print(f"错误:JSON 格式无效:{e}")
return False
if not isinstance(fields, list) or not fields:
print('错误:fields.json 必须是非空 JSON 数组')
return False
if len(fields) > 15:
print('错误:单次最多创建 15 个字段,请拆分后重试')
return False
for i, field in enumerate(fields):
valid, error = validate_field_config(field)
if not valid:
print(f"错误:字段 #{i+1} 配置无效:{error}")
return False
fields_json = build_fields_json(fields)
result = run_dws([
'aitable', 'field', 'create',
'--base-id', base_id,
'--table-id', table_id,
'--fields', fields_json,
'--format', 'json',
])
if not result:
return False
print(json.dumps(result, ensure_ascii=False, indent=2))
return True
def main():
if len(sys.argv) != 4:
print(__doc__)
print('用法示例:')
print(' python bulk_add_fields.py basexxx tablexxx fields.json')
sys.exit(1)
base_id = sys.argv[1]
table_id = sys.argv[2]
fields_file = sys.argv[3]
if not validate_resource_id(base_id):
print('错误:无效的 baseId 格式')
sys.exit(1)
if not validate_resource_id(table_id):
print('错误:无效的 tableId 格式')
sys.exit(1)
success = bulk_add_fields(base_id, table_id, fields_file)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
查询多人共同空闲时段,推荐最佳会议时间
用法:
python calendar_free_slot_finder.py \
--users userId1,userId2,userId3 \
--date 2026-03-15 \
--duration 60
python calendar_free_slot_finder.py \
--users userId1,userId2 \
--date 2026-03-15 \
--start-hour 9 --end-hour 18 \
--duration 30 --dry-run
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta, timezone
from typing import List, Dict, Any, Optional, Tuple
TZ = timezone(timedelta(hours=8))
SLOT_STEP_MIN = 30
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
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 fmt_iso(dt: datetime) -> str:
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
def parse_busy_intervals(
data: Any,
) -> List[Tuple[datetime, datetime]]:
intervals = []
if not data:
return intervals
items = []
if isinstance(data, list):
items = data
elif isinstance(data, dict):
for user_data in data.values():
if isinstance(user_data, list):
items.extend(user_data)
elif isinstance(user_data, dict):
items.extend(
user_data.get('busyTimes', [])
)
for item in items:
start_str = item.get('startTime') or item.get('start', '')
end_str = item.get('endTime') or item.get('end', '')
if not start_str or not end_str:
continue
for fmt in (
'%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%dT%H:%M:%S',
'%Y-%m-%dT%H:%M%z',
):
try:
s = datetime.strptime(start_str, fmt)
e = datetime.strptime(end_str, fmt)
if s.tzinfo is None:
s = s.replace(tzinfo=TZ)
if e.tzinfo is None:
e = e.replace(tzinfo=TZ)
intervals.append((s, e))
break
except ValueError:
continue
return intervals
def find_free_slots(
day_start: datetime, day_end: datetime,
busy: List[Tuple[datetime, datetime]],
duration_min: int,
) -> List[Tuple[datetime, datetime]]:
busy_sorted = sorted(busy, key=lambda x: x[0])
merged: List[Tuple[datetime, datetime]] = []
for s, e in busy_sorted:
if merged and s <= merged[-1][1]:
merged[-1] = (merged[-1][0], max(merged[-1][1], e))
else:
merged.append((s, e))
free: List[Tuple[datetime, datetime]] = []
cursor = day_start
for bs, be in merged:
if cursor < bs:
gap = (bs - cursor).total_seconds() / 60
if gap >= duration_min:
free.append((cursor, bs))
cursor = max(cursor, be)
if cursor < day_end:
gap = (day_end - cursor).total_seconds() / 60
if gap >= duration_min:
free.append((cursor, day_end))
return free
def main():
parser = argparse.ArgumentParser(
description='查询多人共同空闲时段'
)
parser.add_argument(
'--users', required=True, help='用户 ID 列表,逗号分隔'
)
parser.add_argument(
'--date', required=True, help='查询日期 YYYY-MM-DD'
)
parser.add_argument(
'--duration', type=int, default=60,
help='会议时长(分钟),默认 60',
)
parser.add_argument(
'--start-hour', type=int, default=9,
help='工作日开始小时,默认 9',
)
parser.add_argument(
'--end-hour', type=int, default=18,
help='工作日结束小时,默认 18',
)
parser.add_argument(
'--dry-run', action='store_true', help='仅显示命令'
)
args = parser.parse_args()
try:
date = datetime.strptime(args.date, '%Y-%m-%d')
except ValueError:
print('错误:日期格式应为 YYYY-MM-DD')
sys.exit(1)
day_start = date.replace(
hour=args.start_hour, tzinfo=TZ
)
day_end = date.replace(hour=args.end_hour, tzinfo=TZ)
data = run_dws([
'calendar', 'busy', 'search',
'--users', args.users,
'--start', fmt_iso(day_start),
'--end', fmt_iso(day_end),
'--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
return
busy = parse_busy_intervals(data)
free = find_free_slots(day_start, day_end, busy, args.duration)
users_list = args.users.split(',')
print(f"\n🕐 空闲时段查询 ({args.date})")
print(f" 参与人: {len(users_list)} 人")
print(f" 会议时长: {args.duration} 分钟")
print(f" 工作时间: {args.start_hour}:00 ~ "
f"{args.end_hour}:00")
print('=' * 50)
if not free:
print(' ❌ 该日无共同空闲时段')
return
print(f"\n✅ 找到 {len(free)} 个可用时段:\n")
for i, (s, e) in enumerate(free, 1):
gap_min = int((e - s).total_seconds() / 60)
label = '⭐ 推荐' if i == 1 else f' 备选{i-1}'
print(f" {label} {s.strftime('%H:%M')} ~ "
f"{e.strftime('%H:%M')} ({gap_min}分钟)")
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
一键创建日程 + 添加参与者 + 搜索并预定空闲会议室
用法:
python calendar_schedule_meeting.py \
--title "Q1 复盘会" \
--start "2026-03-15T14:00" \
--end "2026-03-15T15:00" \
--users userId1,userId2 \
--book-room
python calendar_schedule_meeting.py --dry-run \
--title "测试" --start "2026-03-15T14:00" --end "2026-03-15T15:00"
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta, timezone
from typing import List, Dict, Any, Optional
TZ = timezone(timedelta(hours=8))
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return {'dry_run': True}
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def normalize_time(time_str: str) -> str:
for fmt in ('%Y-%m-%dT%H:%M', '%Y-%m-%d %H:%M',
'%Y-%m-%dT%H:%M:%S'):
try:
dt = datetime.strptime(time_str, fmt)
dt = dt.replace(tzinfo=TZ)
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
except ValueError:
continue
if '+' in time_str or time_str.endswith('Z'):
return time_str
raise ValueError(f"无法解析时间:{time_str}")
def main():
parser = argparse.ArgumentParser(
description='一键创建日程 + 添加参与者 + 预定会议室'
)
parser.add_argument('--title', required=True, help='日程标题')
parser.add_argument('--start', required=True, help='开始时间')
parser.add_argument('--end', required=True, help='结束时间')
parser.add_argument('--desc', default='', help='日程描述')
parser.add_argument('--users', default='', help='参与者 userId')
parser.add_argument(
'--book-room', action='store_true', help='自动预定会议室'
)
parser.add_argument(
'--dry-run', action='store_true', help='仅显示命令'
)
args = parser.parse_args()
try:
start_iso = normalize_time(args.start)
end_iso = normalize_time(args.end)
except ValueError as e:
print(f"错误:{e}")
sys.exit(1)
print('📅 创建日程...')
create_args = [
'calendar', 'event', 'create',
'--title', args.title,
'--start', start_iso,
'--end', end_iso,
'--format', 'json',
]
if args.desc:
create_args.extend(['--desc', args.desc])
result = run_dws(create_args, dry_run=args.dry_run)
if not result:
sys.exit(1)
event_id = None
if not args.dry_run and isinstance(result, dict):
event_id = result.get('eventId') or result.get('id')
print(f" ✓ 日程已创建" +
(f" (eventId: {event_id})" if event_id else ""))
if args.users and event_id:
print('\n👥 添加参与者...')
r = run_dws([
'calendar', 'participant', 'add',
'--event', event_id,
'--users', args.users,
'--format', 'json',
], dry_run=args.dry_run)
if r:
print(f" ✓ 已添加参与者: {args.users}")
elif args.users and args.dry_run:
run_dws([
'calendar', 'participant', 'add',
'--event', '<EVENT_ID>',
'--users', args.users,
'--format', 'json',
], dry_run=True)
if args.book_room:
print('\n🏢 搜索空闲会议室...')
rooms_data = run_dws([
'calendar', 'room', 'search',
'--start', start_iso,
'--end', end_iso,
'--available',
'--format', 'json',
], dry_run=args.dry_run)
if not args.dry_run and rooms_data:
rooms = (rooms_data if isinstance(rooms_data, list)
else rooms_data.get('rooms', []))
if rooms:
room = rooms[0]
room_id = room.get('roomId') or room.get('id')
room_name = room.get('roomName') or room.get('name')
print(f" 找到空闲会议室: {room_name}")
if event_id and room_id:
r = run_dws([
'calendar', 'room', 'add',
'--event', event_id,
'--rooms', str(room_id),
'--format', 'json',
], dry_run=args.dry_run)
if r:
print(f" ✓ 已预定: {room_name}")
else:
print(' ⚠ 该时段无空闲会议室')
print('\n✅ 完成!')
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
查看今天/明天/本周的日程安排
用法:
python calendar_today_agenda.py # 今天
python calendar_today_agenda.py today # 今天
python calendar_today_agenda.py tomorrow # 明天
python calendar_today_agenda.py week # 本周
python calendar_today_agenda.py --dry-run # 仅显示命令
"""
import sys
import json
import subprocess
from datetime import datetime, timedelta, timezone
from typing import List, Dict, Any, Optional
TZ = timezone(timedelta(hours=8))
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
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 get_range(scope: str):
now = datetime.now(TZ)
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
if scope == 'today':
return today, today + timedelta(days=1)
elif scope == 'tomorrow':
t = today + timedelta(days=1)
return t, t + timedelta(days=1)
elif scope == 'week':
ws = today - timedelta(days=today.weekday())
return ws, ws + timedelta(days=7)
return today, today + timedelta(days=1)
def fmt_iso(dt: datetime) -> str:
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
def fmt_time(iso_str: str) -> str:
if not iso_str:
return '??:??'
try:
for fmt in ('%Y-%m-%dT%H:%M:%S%z', '%Y-%m-%dT%H:%M:%S'):
try:
dt = datetime.strptime(iso_str, fmt)
return dt.strftime('%H:%M')
except ValueError:
continue
return iso_str[:16]
except Exception:
return iso_str[:16]
def main():
dry_run = '--dry-run' in sys.argv
args = [a for a in sys.argv[1:] if a != '--dry-run']
scope = args[0] if args else 'today'
if scope not in ('today', 'tomorrow', 'week'):
print(__doc__)
sys.exit(1)
start, end = get_range(scope)
data = run_dws([
'calendar', 'event', 'list',
'--start', fmt_iso(start),
'--end', fmt_iso(end),
'--format', 'json',
], dry_run=dry_run)
if dry_run:
return
events = []
if isinstance(data, list):
events = data
elif isinstance(data, dict):
events = data.get('events', data.get('result', []))
label = {'today': '今天', 'tomorrow': '明天', 'week': '本周'
}.get(scope, scope)
print(f"\n📅 {label}日程 ({start.strftime('%m-%d')} ~ "
f"{end.strftime('%m-%d')})")
print('=' * 50)
if not events:
print(' ✅ 暂无日程,自由安排!')
return
for e in events:
title = e.get('summary') or e.get('title', '无标题')
s = e.get('start', {})
ed = e.get('end', {})
start_t = fmt_time(
s.get('dateTime', '') if isinstance(s, dict) else str(s)
)
end_t = fmt_time(
ed.get('dateTime', '') if isinstance(ed, dict) else str(ed)
)
loc = e.get('location', {})
loc_str = (loc.get('displayName', '')
if isinstance(loc, dict) else str(loc or ''))
line = f" 🕐 {start_t}-{end_t} {title}"
if loc_str:
line += f" 📍{loc_str}"
print(line)
print(f"\n合计: {len(events)} 场日程")
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
导出群聊消息到 JSON 文件(从指定时间点拉取)
用法:
python chat_export_messages.py \
--group <openconversation_id> \
--time "2026-03-10 00:00:00" \
--output messages.json
python chat_export_messages.py \
--query "项目冲刺" \
--time "2026-03-10 00:00:00" \
--no-forward --limit 100
"""
import sys
import json
import subprocess
import argparse
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
)
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(
query: str, dry_run: bool = False,
) -> Optional[str]:
data = run_dws([
'chat', 'search',
'--query', query, '--format', 'json',
], 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('items', inner.get('groups', []))
elif isinstance(inner, list):
groups = inner
else:
groups = []
else:
groups = []
if not groups:
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')
print(f" 找到群聊: {name} ({conv_id})")
return conv_id
def main():
parser = argparse.ArgumentParser(
description='导出群聊消息到 JSON'
)
parser.add_argument('--group', help='群聊 openconversation_id')
parser.add_argument('--query', help='按群名搜索')
parser.add_argument(
'--time', required=True,
help='起始时间 yyyy-MM-dd HH:mm:ss',
)
parser.add_argument(
'--no-forward', action='store_true',
help='拉给定时间之前的消息 (默认拉给定时间之后)',
)
parser.add_argument(
'--limit', type=int, default=0,
help='返回条数 (不传则不限制)',
)
parser.add_argument('--output', default='', help='输出文件')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
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] = []
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,
'--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 作为 --time')
return
if not data:
break
if isinstance(data, list):
page_msgs = data
has_more = False
else:
page_msgs = data.get('messages', data.get('result', []))
has_more = data.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('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 isinstance(data, list):
messages = data
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
messages = inner.get('messages', inner.get('records', []))
elif isinstance(inner, list):
messages = inner
else:
messages = []
else:
messages = []
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('senderNick') or m.get('sender', '未知')
text = m.get('text') or m.get('content', '')
time_str = m.get('createAt') or m.get('time', '')
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
查询与某人的单聊聊天记录
用法:
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> 拉取单聊消息
3. 输出到终端或导出为 JSON 文件
"""
import sys
import json
import subprocess
import argparse
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
"""执行 dws 命令并解析 JSON 输出"""
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
)
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(
name: str, dry_run: bool = False,
) -> Optional[str]:
"""按关键词搜索用户,返回第一个匹配的 userId"""
data = run_dws([
'contact', 'user', 'search',
'--query', name, '--format', 'json',
], 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('users', [])
or inner.get('list', []))
elif isinstance(inner, list):
users = inner
else:
users = []
if not users or not isinstance(users, list):
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', '')
print(f" 找到用户: {user_name} ({user_id})")
return user_id
def main():
parser = argparse.ArgumentParser(
description='查询与某人的单聊聊天记录'
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument('--name', help='按姓名搜索用户')
group.add_argument('--user', help='直接指定 userId')
parser.add_argument(
'--time', required=True,
help='起始时间 yyyy-MM-dd HH:mm:ss',
)
parser.add_argument(
'--no-forward', action='store_true',
help='拉给定时间之前的消息 (默认拉给定时间之后)',
)
parser.add_argument(
'--limit', type=int, default=0,
help='返回条数 (不传则不限制)',
)
parser.add_argument('--output', default='', help='导出到 JSON 文件')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
# 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)
# 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,
'--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 作为 --time')
return
if not data:
break
if isinstance(data, list):
page_msgs = data
has_more = False
else:
# list-direct 返回 {result: {hasMore, messages: [...]}},先解包 result
result_obj = data.get('result', data)
if not isinstance(result_obj, dict):
result_obj = data
page_msgs = result_obj.get('messages', result_obj.get('records', []))
has_more = result_obj.get('hasMore', data.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 isinstance(data, list):
messages = data
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
messages = inner.get('messages', inner.get('records', []))
elif isinstance(inner, list):
messages = inner
else:
messages = []
else:
messages = []
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:
if not isinstance(m, dict):
continue
sender = m.get('senderNick') or m.get('sender', '未知')
text = m.get('text') or m.get('content', '')
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__':
main()
#!/usr/bin/env python3
"""
按部门名称搜索并列出所有成员(自动 deptId 解析)
用法:
python contact_dept_members.py --query "技术部"
python contact_dept_members.py --query "产品" --dry-run
"""
import sys
import json
import subprocess
import argparse
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
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 main():
parser = argparse.ArgumentParser(
description='按部门名称搜索并列出所有成员'
)
parser.add_argument(
'--query', required=True, help='部门名称关键词'
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
print(f'🔍 搜索部门: {args.query}')
dept_data = run_dws([
'contact', 'dept', 'search',
'--query', args.query, '--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
run_dws([
'contact', 'dept', 'list-members',
'--ids', '<DEPT_ID>', '--format', 'json',
], dry_run=True)
return
if not dept_data:
print('未找到匹配部门')
sys.exit(1)
depts = (dept_data if isinstance(dept_data, list)
else dept_data.get('result', dept_data.get('items', [])))
if not depts:
print('未找到匹配部门')
sys.exit(1)
for dept in depts:
dept_id = dept.get('id') or dept.get('deptId')
dept_name = dept.get('name') or dept.get('deptName', '未知')
if not dept_id:
continue
print(f"\n📂 {dept_name} (ID: {dept_id})")
print('-' * 40)
members_data = run_dws([
'contact', 'dept', 'list-members',
'--ids', str(dept_id), '--format', 'json',
])
if not members_data:
print(' 无法获取成员列表')
continue
members = (members_data if isinstance(members_data, list)
else members_data.get('result',
members_data.get('userlist', [])))
if not members:
print(' (暂无成员)')
continue
for m in members:
name = m.get('name') or m.get('userName', '未知')
title = m.get('title') or m.get('position', '')
uid = m.get('userId') or m.get('userid', '')
line = f" 👤 {name}"
if title:
line += f" ({title})"
if uid:
line += f" [ID: {uid}]"
print(line)
print(f" 共 {len(members)} 人")
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
在指定目录创建文档并写入 Markdown 内容(一键完成)
用法:
python doc_create_and_write.py \
--name "项目周报" \
--content "# 本周总结\n\n## 完成事项\n- 任务A"
python doc_create_and_write.py \
--name "会议纪要" \
--content-file notes.md
python doc_create_and_write.py \
--name "知识库文档" --content "# 内容" --folder FOLDER_ID
python doc_create_and_write.py --name "test" --content "hello" --dry-run
"""
import sys
import json
import time
import subprocess
import argparse
from pathlib import Path
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return {'dry_run': True}
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def run_dws_with_retry(
args: List[str],
dry_run: bool = False,
max_retries: int = 3,
retry_delay: float = 1.0,
) -> Optional[Any]:
"""带重试机制的 dws 命令执行"""
last_error = None
for attempt in range(1, max_retries + 1):
result = run_dws(args, dry_run=dry_run)
if result is not None:
return result
if attempt < max_retries:
print(f" ⚠️ 第 {attempt} 次尝试失败,{retry_delay}秒后重试...")
time.sleep(retry_delay)
retry_delay *= 1.5 # 指数退避
return None
def main():
parser = argparse.ArgumentParser(
description='创建文档并写入内容'
)
parser.add_argument('--name', required=True, help='文档名称')
parser.add_argument('--content', default='', help='Markdown 内容')
parser.add_argument('--content-file', default='', help='内容文件')
parser.add_argument('--folder', default='', help='目标文件夹 ID 或 URL')
parser.add_argument('--workspace', default='', help='目标知识库 ID')
parser.add_argument(
'--mode', default='append', choices=['overwrite', 'append'],
help='写入模式: overwrite=覆盖, append=追加 (默认 append)',
)
parser.add_argument(
'--max-retries', type=int, default=3,
help='每块写入失败时的最大重试次数 (默认 3)',
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
content = args.content
if args.content_file:
p = Path(args.content_file)
if not p.exists():
print(f"错误:文件不存在: {p}")
sys.exit(1)
content = p.read_text(encoding='utf-8')
if not content:
print('错误:需要 --content 或 --content-file')
sys.exit(1)
chunk_size = 10000
create_args = ['doc', 'create', '--name', args.name, '--format', 'json']
if args.folder:
create_args.extend(['--folder', args.folder])
if args.workspace:
create_args.extend(['--workspace', args.workspace])
print(f'\n📝 创建文档: {args.name}')
create_data = run_dws(create_args, dry_run=args.dry_run)
node_id = None
if not args.dry_run:
if not create_data:
sys.exit(1)
node_id = (create_data.get('nodeId')
or create_data.get('dentryUuid')
or create_data.get('id', ''))
print(f" ✓ 文档已创建 (ID: {node_id})")
if len(content) <= chunk_size:
mode_label = '追加' if args.mode == 'append' else '覆盖'
print(f'\n✍️ 写入内容 (模式: {mode_label}, {len(content)} 字符)...')
write_data = run_dws([
'doc', 'update',
'--node', node_id or '<NODE_ID>',
'--content', content,
'--mode', args.mode,
'--format', 'json',
], dry_run=args.dry_run)
if write_data:
print(f" ✓ 内容已写入 ({len(content)} 字符)")
else:
chunks = []
pos = 0
while pos < len(content):
end = min(pos + chunk_size, len(content))
if end < len(content):
newline_pos = content.rfind('\n', pos, end)
if newline_pos > pos:
end = newline_pos + 1
chunks.append(content[pos:end])
pos = end
total_chunks = len(chunks)
print(f'\n✍️ 内容较长 ({len(content)} 字符), 分 {total_chunks} 块写入...')
success_chunks = 0
for idx, chunk in enumerate(chunks):
chunk_mode = args.mode if idx == 0 else 'append'
write_data = run_dws_with_retry(
[
'doc', 'update',
'--node', node_id or '<NODE_ID>',
'--content', chunk,
'--mode', chunk_mode,
'--format', 'json',
],
dry_run=args.dry_run,
max_retries=args.max_retries,
)
if write_data:
print(f" ✓ 块 {idx + 1}/{total_chunks} 已写入 ({len(chunk)} 字符)")
success_chunks += 1
elif not args.dry_run:
# 写入失败,报告部分写入状态
print(f"\n❌ 块 {idx + 1}/{total_chunks} 写入失败(已重试 {args.max_retries} 次)")
print(f"\n⚠️ 文档处于部分写入状态:")
print(f" - 文档 ID: {node_id}")
print(f" - 已写入: {success_chunks}/{total_chunks} 块")
print(f" - 失败位置: 第 {idx + 1} 块")
if args.mode == 'overwrite':
print(f" - 模式: 覆盖模式,文档可能包含不完整内容")
print(f" - 建议: 手动检查文档内容,或删除后重新创建")
else:
print(f" - 模式: 追加模式,已写入内容已保存")
print(f" - 建议: 可手动补充剩余内容,或重新运行脚本")
sys.exit(1)
print('\n✅ 完成!')
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
递归列出钉盘目录树结构(可指定深度)
用法:
python drive_tree_list.py # 列出根目录
python drive_tree_list.py --depth 2 # 递归 2 层
python drive_tree_list.py --parent-id <id> # 指定目录
python drive_tree_list.py --dry-run
"""
import sys
import json
import subprocess
import argparse
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
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 list_dir(
parent_id: str = '', dry_run: bool = False,
) -> list:
cmd_args = [
'drive', 'list', '--max', '50', '--format', 'json',
]
if parent_id:
cmd_args.extend(['--parent-id', parent_id])
data = run_dws(cmd_args, dry_run=dry_run)
if not data:
return []
if isinstance(data, list):
return data
if isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
return inner.get('items', inner.get('dentryList', []))
if isinstance(inner, list):
return inner
return []
def print_tree(
items: list, depth: int, max_depth: int,
prefix: str = '', dry_run: bool = False,
):
for i, item in enumerate(items):
is_last = (i == len(items) - 1)
connector = '└── ' if is_last else '├── '
name = item.get('name') or item.get('fileName', '?')
item_type = item.get('type') or item.get('dentryType', '')
is_dir = str(item_type).lower() in (
'folder', 'directory', '1', 'FOLDER'
)
icon = '📁' if is_dir else '📄'
size_str = ''
size = item.get('size') or item.get('fileSize')
if size and not is_dir:
size = int(size)
if size > 1024 * 1024:
size_str = f" ({size / 1024 / 1024:.1f}MB)"
elif size > 1024:
size_str = f" ({size / 1024:.1f}KB)"
else:
size_str = f" ({size}B)"
print(f"{prefix}{connector}{icon} {name}{size_str}")
if is_dir and depth < max_depth:
child_prefix = prefix + (' ' if is_last else '│ ')
dentry_id = (item.get('dentryUuid')
or item.get('id', ''))
if dentry_id:
children = list_dir(dentry_id, dry_run=dry_run)
print_tree(
children, depth + 1, max_depth,
child_prefix, dry_run,
)
def main():
parser = argparse.ArgumentParser(
description='递归列出钉盘目录树'
)
parser.add_argument(
'--parent-id', default='', help='起始目录 ID'
)
parser.add_argument(
'--depth', type=int, default=1,
help='递归深度 (默认 1, 最大 5)',
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
args.depth = min(args.depth, 5)
root_name = args.parent_id or '我的文件'
print(f"📁 {root_name}")
items = list_dir(args.parent_id, dry_run=args.dry_run)
if args.dry_run:
return
if not items:
print(' (空目录)')
return
print_tree(items, 0, args.depth, '', args.dry_run)
print(f"\n共 {len(items)} 个项目 (根目录)")
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
从 dt_media_upload 返回的 URL 中提取 mediaId。
用法:
python extract_media_id.py <url>
python extract_media_id.py "https://down.dingtalk.com/media/lQLPD4JNnliqBq3NBQDNA8Cw_960_1280.png"
# 输出: @lQLPD4JNnliqBq3NBQDNA8Cw
依赖: 无(纯 Python 标准库)
典型工作流(发送图片/语音/视频消息):
# 1. 用 dt_media_upload 上传文件,获得 URL
# 2. 用本脚本从 URL 提取 mediaId
python extract_media_id.py "<dt_media_upload 返回的 URL>"
# 输出: @lQLPxxx(直接用于 --media-id 或 --pic-url 参数)
"""
import argparse
import re
import sys
from urllib.parse import urlparse
def extract_media_id(url: str) -> str:
"""从 dt_media_upload 返回的 URL 中提取 mediaId。
支持的 URL 格式示例:
https://down.dingtalk.com/media/lQLPD4JNnliqBq3NBQDNA8Cw_960_1280.png
https://down.dingtalk.com/media/lQLPD4JNnliqBq3NBQDNA8Cw.png
https://down.dingtalk.com/media/lQLPD4JNnliqBq3NBQDNA8Cw
提取规则:
1. 取 URL 路径中 /media/ 之后的部分
2. 去除尾部的 _数字_数字.扩展名 后缀(如 _960_1280.png)
3. 如果没有尺寸后缀,去除尾部的 .扩展名
4. 加上 @ 前缀
返回: @lQLPD4JNnliqBq3NBQDNA8Cw
"""
url = url.strip()
# 提取路径部分
parsed = urlparse(url)
path = parsed.path # e.g. /media/lQLPxxx_960_1280.png
# 取 /media/ 之后的部分
media_prefix = "/media/"
idx = path.find(media_prefix)
if idx == -1:
print(f"错误:URL 中未找到 /media/ 路径: {url}", file=sys.stderr)
sys.exit(1)
raw = path[idx + len(media_prefix):] # e.g. lQLPxxx_960_1280.png
if not raw:
print(f"错误:/media/ 后无内容: {url}", file=sys.stderr)
sys.exit(1)
# 尝试去除 _数字_数字.扩展名 后缀(如 _960_1280.png)
match = re.match(r'^(.+?)(_\d+_\d+\.\w+)$', raw)
if match:
media_id = match.group(1)
else:
# 没有尺寸后缀,尝试去除 .扩展名
match2 = re.match(r'^(.+)\.\w+$', raw)
if match2:
media_id = match2.group(1)
else:
# 无后缀,直接使用
media_id = raw
return f"@{media_id}"
def main():
parser = argparse.ArgumentParser(
description="从 dt_media_upload 返回的 URL 中提取 mediaId"
)
parser.add_argument("url", help="dt_media_upload 返回的文件 URL")
args = parser.parse_args()
media_id = extract_media_id(args.url)
print(media_id)
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""
从 CSV / JSON 批量导入记录到钉钉 AI 表格(新版 schema)
用法:
python import_records.py <baseId> <tableId> data.csv [batch_size]
python import_records.py <baseId> <tableId> data.json [batch_size]
说明:
- CSV 表头默认视为 fieldId
- JSON 支持两种格式:
1. [{"cells": {"fldxxx": "value"}}, ...]
2. [{"fldxxx": "value"}, ...] # 会自动包装成 cells
"""
import sys
import csv
import json
import subprocess
import os
import re
from pathlib import Path
from typing import Union, List, Dict, Any, Optional, Tuple
JsonData = Union[List[Any], Dict[str, Any]]
RecordDict = Dict[str, str]
MAX_FILE_SIZE = 50 * 1024 * 1024
ALLOWED_CSV_EXTENSIONS = ['.csv']
ALLOWED_JSON_EXTENSIONS = ['.json']
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
MAX_RECORDS_PER_BATCH = 100
DEFAULT_BATCH_SIZE = 50
def resolve_safe_path(
path: str, allowed_root: Optional[str] = None
) -> Path:
if allowed_root is None:
allowed_root = os.environ.get('OPENCLAW_WORKSPACE', os.getcwd())
allowed_root = Path(allowed_root).resolve()
target_path = (
Path(path).resolve()
if Path(path).is_absolute()
else (Path.cwd() / path).resolve()
)
try:
target_path.relative_to(allowed_root)
return target_path
except ValueError:
raise ValueError(
f"路径超出允许范围:{path}\n"
f"目标路径:{target_path}\n"
f"允许根目录:{allowed_root}\n"
f"提示:设置 OPENCLAW_WORKSPACE 环境变量或确保文件在工作目录内"
)
def validate_resource_id(resource_id: str) -> bool:
return bool(
resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip())
)
def validate_file_extension(
filename: str, allowed_extensions: list
) -> bool:
return any(filename.lower().endswith(ext) for ext in allowed_extensions)
def safe_csv_load(
file_path: Path, max_size: int = MAX_FILE_SIZE
) -> List[RecordDict]:
file_size = file_path.stat().st_size
if file_size > max_size:
raise ValueError(
f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)"
)
with open(file_path, 'r', encoding='utf-8', newline='') as f:
return list(csv.DictReader(f))
def safe_json_load(
file_path: Path, max_size: int = MAX_FILE_SIZE
) -> JsonData:
file_size = file_path.stat().st_size
if file_size > max_size:
raise ValueError(
f"文件过大:{file_size:,} 字节 (限制:{max_size:,} 字节)"
)
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
def sanitize_record_value(
value: Any,
) -> Optional[Union[str, int, float, bool, list, dict]]:
if value is None:
return None
if isinstance(value, (bool, int, float, list, dict)):
return value
if not isinstance(value, str):
return value
if not value.strip():
return None
value = value.strip()
if value.lower() == 'true':
return True
if value.lower() == 'false':
return False
try:
if '.' in value:
return float(value)
return int(value)
except ValueError:
return value
def normalize_record(record: Dict[str, Any]) -> Dict[str, Any]:
if 'cells' in record and isinstance(record['cells'], dict):
cells = record['cells']
else:
cells = record
normalized = {}
for key, value in cells.items():
sanitized = sanitize_record_value(value)
if sanitized is not None:
normalized[key] = sanitized
return {'cells': normalized}
def validate_record(
record: Dict[str, Any], headers: List[str]
) -> Tuple[bool, str]:
if not isinstance(record, dict):
return False, '记录必须是对象'
normalized = normalize_record(record)
cells = normalized.get('cells', {})
if not cells or not isinstance(cells, dict):
return False, '记录必须包含非空 cells 对象'
return True, ''
def run_dws(args: List[str]) -> Optional[Dict[str, Any]]:
if not args:
print('错误:空命令')
return None
cmd = ['dws'] + args
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}")
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError as e:
print(f"无法解析响应:{result.stdout[:200]}...")
print(f"JSON 解析错误:{e}")
return None
except subprocess.TimeoutExpired:
print('错误:命令执行超时(120 秒)')
return None
except FileNotFoundError:
print('错误:未找到 dws 命令,请确认已安装')
return None
def import_from_csv(
base_id: str, table_id: str, csv_file: str,
batch_size: int = DEFAULT_BATCH_SIZE,
) -> bool:
try:
safe_path = resolve_safe_path(csv_file)
except ValueError as e:
print(f"路径验证失败:{e}")
return False
if not validate_file_extension(csv_file, ALLOWED_CSV_EXTENSIONS):
print(f"错误:只允许 {', '.join(ALLOWED_CSV_EXTENSIONS)} 文件")
return False
if not safe_path.exists():
print(f"错误:文件不存在:{safe_path}")
return False
try:
rows = safe_csv_load(safe_path)
except ValueError as e:
print(f"错误:{e}")
return False
except csv.Error as e:
print(f"错误:CSV 格式无效:{e}")
return False
if not rows:
print('错误:CSV 文件为空或没有有效数据行')
return False
records = [
normalize_record(row)
for row in rows
if normalize_record(row)['cells']
]
return import_records(base_id, table_id, records, batch_size)
def import_from_json(
base_id: str, table_id: str, json_file: str,
batch_size: int = DEFAULT_BATCH_SIZE,
) -> bool:
try:
safe_path = resolve_safe_path(json_file)
except ValueError as e:
print(f"路径验证失败:{e}")
return False
if not validate_file_extension(json_file, ALLOWED_JSON_EXTENSIONS):
print(f"错误:只允许 {', '.join(ALLOWED_JSON_EXTENSIONS)} 文件")
return False
if not safe_path.exists():
print(f"错误:文件不存在:{safe_path}")
return False
try:
records = safe_json_load(safe_path)
except ValueError as e:
print(f"错误:{e}")
return False
except json.JSONDecodeError as e:
print(f"错误:JSON 格式无效:{e}")
return False
if not isinstance(records, list) or not records:
print('错误:JSON 文件必须是非空数组')
return False
for i, record in enumerate(records):
valid, error = validate_record(record, [])
if not valid:
print(f"错误:记录 #{i+1} 格式无效:{error}")
return False
return import_records(
base_id, table_id,
[normalize_record(r) for r in records], batch_size,
)
def import_records(
base_id: str, table_id: str,
records: List[Dict[str, Any]], batch_size: int,
) -> bool:
if batch_size <= 0:
print('错误:batch_size 必须大于 0')
return False
if batch_size > MAX_RECORDS_PER_BATCH:
batch_size = MAX_RECORDS_PER_BATCH
total_batches = (len(records) + batch_size - 1) // batch_size
success = True
for i in range(0, len(records), batch_size):
batch = records[i:i + batch_size]
batch_num = (i // batch_size) + 1
records_json = json.dumps(batch, ensure_ascii=False)
result = run_dws([
'aitable', 'record', 'create',
'--base-id', base_id,
'--table-id', table_id,
'--records', records_json,
'--format', 'json',
])
if result:
print(
f"[{batch_num}/{total_batches}] "
f"✓ 已提交 {len(batch)} 条记录"
)
else:
print(f"[{batch_num}/{total_batches}] ✗ 导入失败")
success = False
return success
def main():
if len(sys.argv) < 4 or len(sys.argv) > 5:
print(__doc__)
print('用法示例:')
print(
' python import_records.py basexxx tablexxx data.csv 50'
)
sys.exit(1)
base_id = sys.argv[1]
table_id = sys.argv[2]
input_file = sys.argv[3]
batch_size = (
int(sys.argv[4]) if len(sys.argv) == 5
else DEFAULT_BATCH_SIZE
)
if not validate_resource_id(base_id):
print('错误:无效的 baseId 格式')
sys.exit(1)
if not validate_resource_id(table_id):
print('错误:无效的 tableId 格式')
sys.exit(1)
if input_file.lower().endswith('.csv'):
success = import_from_csv(
base_id, table_id, input_file, batch_size
)
elif input_file.lower().endswith('.json'):
success = import_from_json(
base_id, table_id, input_file, batch_size
)
else:
print('错误:仅支持 .csv 或 .json 文件')
sys.exit(1)
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
从听记中提取所有待办事项并汇总
用法:
python minutes_extract_todos.py # 最近 5 条听记
python minutes_extract_todos.py --max 10 # 最近 10 条
python minutes_extract_todos.py --id <uuid> # 指定听记
python minutes_extract_todos.py --dry-run
"""
import sys
import json
import subprocess
import argparse
from pathlib import Path
from typing import List, Any, Optional
_scripts_dir = Path(__file__).resolve().parent
if str(_scripts_dir) not in sys.path:
sys.path.insert(0, str(_scripts_dir))
from minutes_list_parse import uuid_title_pairs_from_payload
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
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 main():
parser = argparse.ArgumentParser(
description='从听记中提取待办事项'
)
parser.add_argument('--max', type=int, default=5)
parser.add_argument('--id', default='', help='指定听记 UUID')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
uuids_with_titles = []
if args.id:
uuids_with_titles = [(args.id, args.id)]
else:
print('🎙️ 获取听记列表...')
data = run_dws([
'minutes', 'list', 'mine',
'--max', str(args.max),
'--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
run_dws([
'minutes', 'get', 'todos',
'--id', '<TASK_UUID>', '--format', 'json',
], dry_run=True)
return
if not data:
return
uuids_with_titles = uuid_title_pairs_from_payload(data)
all_todos = []
for uuid, title in uuids_with_titles:
print(f" 提取待办: {title}")
todos_data = run_dws([
'minutes', 'get', 'todos',
'--id', uuid, '--format', 'json',
])
if not todos_data:
continue
if isinstance(todos_data, list):
items = todos_data
elif isinstance(todos_data, dict):
inner = todos_data.get('result', todos_data)
if isinstance(inner, dict):
items = inner.get('todos', [])
elif isinstance(inner, list):
items = inner
else:
items = []
else:
items = []
for t in items:
if isinstance(t, dict):
t['_source'] = title
all_todos.extend(items)
print(f"\n📋 听记待办汇总")
print('=' * 50)
if not all_todos:
print(' ✅ 暂无待办事项')
return
for t in all_todos:
if not isinstance(t, dict):
print(f" • {t!r}")
continue
content = (t.get('content') or t.get('text')
or t.get('title', ''))
source = t.get('_source', '')
print(f" • {content}")
if source:
print(f" 来自: {source}")
print(f"\n合计: {len(all_todos)} 条待办")
if __name__ == '__main__':
main()
"""将 `dws minutes list mine|shared|all` 的 JSON 规范为 (taskUuid, title) 列表。"""
from __future__ import annotations
import json
from typing import Any, List, Tuple
def _unwrap_rows(payload: Any) -> List[Any]:
if isinstance(payload, list):
return payload
if not isinstance(payload, dict):
return []
for key in ('result', 'data', 'list'):
value = payload.get(key)
if isinstance(value, list):
return value
if isinstance(value, dict):
for inner_key in (
'items', 'list', 'records', 'minutes',
):
inner = value.get(inner_key)
if isinstance(inner, list):
return inner
return []
def uuid_title_pairs_from_payload(payload: Any) -> List[Tuple[str, str]]:
"""列表项可为对象、JSON 字符串、或纯 taskUuid 字符串。"""
out: List[Tuple[str, str]] = []
for item in _unwrap_rows(payload):
if isinstance(item, dict):
uuid = item.get('taskUuid') or item.get('id') or item.get('task_uuid')
if not uuid:
continue
title = item.get('title') or item.get('name') or '无标题'
# 确保值是基本类型再转换
if not isinstance(uuid, (str, int, float, bool)):
continue
if not isinstance(title, (str, int, float, bool)):
title = str(title) if isinstance(title, dict) else '无标题'
out.append((str(uuid), str(title)))
elif isinstance(item, str):
text = item.strip()
if not text:
continue
if text.startswith('{'):
try:
parsed = json.loads(text)
except json.JSONDecodeError:
continue
if not isinstance(parsed, dict):
continue
uuid = (
parsed.get('taskUuid')
or parsed.get('id')
or parsed.get('task_uuid')
)
if not uuid:
continue
title = parsed.get('title') or parsed.get('name') or '无标题'
out.append((str(uuid), str(title)))
else:
out.append((text, text))
return out
#!/usr/bin/env python3
"""
获取最近 N 条听记的 AI 摘要并合并输出
用法:
python minutes_recent_summary.py # 最近 5 条
python minutes_recent_summary.py --max 10 # 最近 10 条
python minutes_recent_summary.py --output summary.md
python minutes_recent_summary.py --dry-run
"""
import sys
import json
import subprocess
import argparse
from pathlib import Path
from typing import List, Any, Optional
_scripts_dir = Path(__file__).resolve().parent
if str(_scripts_dir) not in sys.path:
sys.path.insert(0, str(_scripts_dir))
from minutes_list_parse import uuid_title_pairs_from_payload
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
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 main():
parser = argparse.ArgumentParser(
description='获取最近听记的 AI 摘要'
)
parser.add_argument(
'--max', type=int, default=5, help='获取条数 (默认 5)'
)
parser.add_argument(
'--output', default='', help='输出到 Markdown 文件'
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
print('🎙️ 获取听记列表...')
list_data = run_dws([
'minutes', 'list', 'mine',
'--max', str(args.max),
'--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
run_dws([
'minutes', 'get', 'summary',
'--id', '<TASK_UUID>', '--format', 'json',
], dry_run=True)
return
if not list_data:
print('未找到听记')
return
pairs = uuid_title_pairs_from_payload(list_data)
if not pairs:
print('暂无听记')
return
output_lines = [f"# 最近 {len(pairs)} 条听记摘要\n"]
for i, (uuid, title) in enumerate(pairs, 1):
print(f" [{i}/{len(pairs)}] 获取摘要: {title}")
summary_data = run_dws([
'minutes', 'get', 'summary',
'--id', uuid, '--format', 'json',
])
summary_text = ''
if summary_data:
if isinstance(summary_data, str):
summary_text = summary_data
elif isinstance(summary_data, dict):
summary_text = (summary_data.get('summary')
or summary_data.get('content')
or json.dumps(summary_data,
ensure_ascii=False))
output_lines.append(f"## {i}. {title}\n")
if summary_text:
output_lines.append(f"{summary_text}\n")
else:
output_lines.append("(暂无摘要)\n")
full_output = '\n'.join(output_lines)
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(full_output)
print(f"\n✓ 已输出到 {args.output}")
else:
print('\n' + full_output)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
批量同意/拒绝待审批项(含安全确认)
用法:
python oa_batch_approve.py --action approve --days 7
python oa_batch_approve.py --action reject --remark "不符合要求"
python oa_batch_approve.py --action approve --instance-ids id1,id2
python oa_batch_approve.py --dry-run --action approve
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return {'dry_run': True}
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def to_iso(dt: datetime) -> str:
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
def main():
parser = argparse.ArgumentParser(
description='批量同意/拒绝审批'
)
parser.add_argument(
'--action', required=True,
choices=['approve', 'reject'], help='审批动作',
)
parser.add_argument(
'--remark', default='', help='审批意见'
)
parser.add_argument('--days', type=int, default=7)
parser.add_argument('--instance-ids', default='')
parser.add_argument(
'--yes', action='store_true', help='跳过确认'
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
instance_ids: List[str] = []
if args.instance_ids:
instance_ids = [x.strip() for x in
args.instance_ids.split(',') if x.strip()]
else:
now = datetime.now()
start = now - timedelta(days=args.days)
data = run_dws([
'oa', 'approval', 'list-pending',
'--start', to_iso(start),
'--end', to_iso(now),
'--format', 'json',
], dry_run=args.dry_run)
if not args.dry_run and data:
if isinstance(data, list):
items = data
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
items = inner.get('processInstanceList',
inner.get('items', []))
elif isinstance(inner, list):
items = inner
else:
items = []
else:
items = []
instance_ids = [
item.get('processInstanceId') or item.get('id')
for item in items
if isinstance(item, dict)
and (item.get('processInstanceId') or item.get('id'))
]
if not instance_ids and not args.dry_run:
print('✅ 没有待处理的审批')
return
action_label = '同意' if args.action == 'approve' else '拒绝'
count = len(instance_ids) if instance_ids else '?'
print(f"\n⚠️ 即将 {action_label} {count} 条审批")
if not args.yes and not args.dry_run:
confirm = input('确认执行?(y/N): ').strip().lower()
if confirm != 'y':
print('已取消')
return
success, fail = 0, 0
for i, inst_id in enumerate(instance_ids or ['<INST_ID>'], 1):
tasks_data = run_dws([
'oa', 'approval', 'tasks',
'--instance-id', inst_id,
'--format', 'json',
], dry_run=args.dry_run)
task_id = None
if not args.dry_run and tasks_data:
if isinstance(tasks_data, list):
task_ids = tasks_data
elif isinstance(tasks_data, dict):
inner = tasks_data.get('result', tasks_data)
if isinstance(inner, dict):
task_ids = inner.get('tasks', inner.get('items', []))
elif isinstance(inner, list):
task_ids = inner
else:
task_ids = []
else:
task_ids = []
if task_ids:
task_id = (task_ids[0] if isinstance(task_ids[0], str)
else task_ids[0].get('taskId', ''))
cmd_args = [
'oa', 'approval', args.action,
'--instance-id', inst_id,
'--task-id', task_id or '<TASK_ID>',
'--format', 'json',
]
if args.remark:
cmd_args.extend(['--remark', args.remark])
result = run_dws(cmd_args, dry_run=args.dry_run)
if result:
print(f" ✓ [{i}/{count}] {inst_id} → {action_label}")
success += 1
else:
print(f" ✗ [{i}/{count}] {inst_id}")
fail += 1
print(f"\n完成: 成功 {success}, 失败 {fail}")
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
查看待我审批列表 + 逐条显示详情(自动时间戳计算)
用法:
python oa_pending_review.py # 最近 7 天
python oa_pending_review.py --days 30 # 最近 30 天
python oa_pending_review.py --dry-run
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
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 to_iso(dt: datetime) -> str:
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
def main():
parser = argparse.ArgumentParser(
description='查看待我审批列表'
)
parser.add_argument(
'--days', type=int, default=7, help='查询天数 (默认 7)'
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
now = datetime.now()
start = now - timedelta(days=args.days)
print(f"📋 查询待审批 (最近 {args.days} 天)...")
data = run_dws([
'oa', 'approval', 'list-pending',
'--start', to_iso(start),
'--end', to_iso(now),
'--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
run_dws([
'oa', 'approval', 'detail',
'--instance-id', '<INSTANCE_ID>',
'--format', 'json',
], dry_run=True)
return
if not data:
print('未查到待审批')
return
if isinstance(data, list):
instances = data
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
instances = inner.get('processInstanceList',
inner.get('items', []))
elif isinstance(inner, list):
instances = inner
else:
instances = []
else:
instances = []
if not instances:
print('✅ 暂无待审批事项')
return
print(f"\n🔔 待审批列表 ({len(instances)} 条)")
print('=' * 50)
for i, inst in enumerate(instances, 1):
if not isinstance(inst, dict):
print(f"\n [{i}] {inst}")
continue
inst_id = (inst.get('processInstanceId')
or inst.get('id', ''))
title = inst.get('title') or inst.get('name', '无标题')
status = inst.get('status') or inst.get('result', '')
create_time = inst.get('createTime', '')
if isinstance(create_time, (int, float)):
create_time = datetime.fromtimestamp(
create_time / 1000
).strftime('%Y-%m-%d %H:%M')
print(f"\n [{i}] {title}")
print(f" 状态: {status} 创建: {create_time}")
print(f" ID: {inst_id}")
detail = run_dws([
'oa', 'approval', 'detail',
'--instance-id', inst_id,
'--format', 'json',
])
if detail and isinstance(detail, dict):
forms = detail.get('formComponentValues', [])
if forms:
print(f" --- 表单内容 ---")
for f in forms[:5]:
name = f.get('name', '')
value = f.get('value', '')
if value:
print(f" {name}: {value[:60]}")
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
查看今天收到的日志列表及详情
用法:
python report_inbox_today.py
python report_inbox_today.py --days 3 # 最近 3 天
python report_inbox_today.py --dry-run
"""
import sys
import json
import subprocess
import argparse
from datetime import datetime, timedelta
from typing import List, Any, Optional
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
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 to_iso(dt: datetime) -> str:
return dt.strftime('%Y-%m-%dT%H:%M:%S+08:00')
def main():
parser = argparse.ArgumentParser(
description='查看收到的日志'
)
parser.add_argument(
'--days', type=int, default=1, help='查询天数 (默认 1)'
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
now = datetime.now()
start = now - timedelta(days=args.days)
start = start.replace(hour=0, minute=0, second=0)
label = '今天' if args.days == 1 else f'最近 {args.days} 天'
print(f'📓 查看{label}收到的日志...\n')
data = run_dws([
'report', 'list',
'--start', to_iso(start),
'--end', to_iso(now),
'--cursor', '0',
'--size', '20',
'--format', 'json',
], dry_run=args.dry_run)
if args.dry_run:
return
if not data:
print('未查到日志')
return
reports = (data if isinstance(data, list)
else data.get('result', data.get('reports', [])))
if not reports:
print(' ✅ 暂无收到的日志')
return
print(f"📓 {label}日志 ({len(reports)} 条)")
print('=' * 50)
for r in reports:
rid = r.get('reportId') or r.get('id', '')
creator = r.get('creatorName') or r.get('creator', '未知')
template = r.get('templateName') or r.get('template', '')
create_time = r.get('createTime', '')
if isinstance(create_time, (int, float)):
create_time = datetime.fromtimestamp(
create_time / 1000
).strftime('%Y-%m-%d %H:%M')
print(f"\n 📝 {template or '日志'} - {creator}")
print(f" 时间: {create_time}")
print(f" ID: {rid}")
if rid:
detail = run_dws([
'report', 'detail',
'--report-id', rid, '--format', 'json',
])
if detail and isinstance(detail, dict):
contents = detail.get('contents', [])
for c in contents[:3]:
key = c.get('key') or c.get('title', '')
val = c.get('value') or c.get('content', '')
if key and val:
print(f" {key}: {str(val)[:60]}")
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
从 JSON 文件批量创建待办(含优先级、截止时间、执行者)
用法:
python todo_batch_create.py todos.json
python todo_batch_create.py todos.json --dry-run
todos.json 格式:
[
{"title": "修复线上Bug", "executors": "userId1,userId2", "priority": 40},
{"title": "写周报", "executors": "userId1", "due": "2026-03-15"},
{"title": "代码评审", "executors": "userId1"},
{"title": "每日站会", "executors": "userId1", "due": "2026-03-20",
"recurrence": "DTSTART:20260320T020000Z\\nRRULE:FREQ=DAILY;INTERVAL=1"}
]
字段说明:
- title: 待办标题 (必填)
- executors: 执行者 userId,多人逗号分隔 (必填)
- priority: 优先级 10=低/20=普通/30=较高/40=紧急 (可选)
- due: 截止日期 YYYY-MM-DD 或毫秒时间戳 (可选)
- recurrence: 循环待办规则 (可选,需同时有 due);字符串内需含换行,与 dws --recurrence 一致
"""
import sys
import json
import subprocess
import re
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Any, Optional
ALLOWED_PRIORITIES = {10, 20, 30, 40}
DATE_PATTERN = re.compile(r'^\d{4}-\d{2}-\d{2}$')
MAX_FILE_SIZE = 10 * 1024 * 1024
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Dict[str, Any]]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return {'dry_run': True}
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
except subprocess.TimeoutExpired:
print(' ✗ 命令执行超时', file=sys.stderr)
return None
except (json.JSONDecodeError, FileNotFoundError) as e:
print(f" ✗ 错误:{e}", file=sys.stderr)
return None
def parse_due(due_value) -> Optional[str]:
if not due_value:
return None
due_str = str(due_value)
if due_str.isdigit() and len(due_str) >= 10:
return due_str
if DATE_PATTERN.match(due_str):
dt = datetime.strptime(due_str, '%Y-%m-%d')
dt = dt.replace(hour=23, minute=59, second=59)
return str(int(dt.timestamp() * 1000))
print(f" ⚠ 无法解析截止时间:{due_value},跳过")
return None
def validate_todo(item: Dict[str, Any], idx: int) -> bool:
if not isinstance(item, dict):
print(f" ✗ #{idx+1} 不是有效对象")
return False
if not item.get('title', '').strip():
print(f" ✗ #{idx+1} 缺少 title")
return False
if not item.get('executors', '').strip():
print(f" ✗ #{idx+1} 缺少 executors")
return False
priority = item.get('priority')
if priority is not None and int(priority) not in ALLOWED_PRIORITIES:
print(f" ✗ #{idx+1} 无效优先级:{priority}")
return False
recurrence = item.get('recurrence')
if recurrence and not str(recurrence).strip():
print(f" ✗ #{idx+1} recurrence 不能为空字符串")
return False
if recurrence and not item.get('due'):
print(f" ✗ #{idx+1} 设置 recurrence 时必须提供 due")
return False
return True
def main():
dry_run = '--dry-run' in sys.argv
args = [a for a in sys.argv[1:] if a != '--dry-run']
if not args:
print(__doc__)
sys.exit(1)
file_path = Path(args[0])
if not file_path.exists():
print(f"错误:文件不存在:{file_path}")
sys.exit(1)
if file_path.stat().st_size > MAX_FILE_SIZE:
print(f"错误:文件过大 (限制 {MAX_FILE_SIZE // 1024}KB)")
sys.exit(1)
with open(file_path, 'r', encoding='utf-8') as f:
todos = json.load(f)
if not isinstance(todos, list) or not todos:
print('错误:JSON 文件必须是非空数组')
sys.exit(1)
for i, item in enumerate(todos):
if not validate_todo(item, i):
sys.exit(1)
print(f"📋 准备创建 {len(todos)} 条待办\n")
success, fail = 0, 0
for i, item in enumerate(todos):
title = item['title'].strip()
cmd_args = [
'todo', 'task', 'create',
'--title', title,
'--executors', item['executors'].strip(),
'--format', 'json',
]
priority = item.get('priority')
if priority is not None:
cmd_args.extend(['--priority', str(int(priority))])
due = parse_due(item.get('due'))
if due:
cmd_args.extend(['--due', due])
recurrence = item.get('recurrence')
if recurrence:
rr = str(recurrence).replace('\\n', '\n')
cmd_args.extend(['--recurrence', rr])
result = run_dws(cmd_args, dry_run=dry_run)
if result:
print(f" ✓ [{i+1}/{len(todos)}] {title}")
success += 1
else:
print(f" ✗ [{i+1}/{len(todos)}] {title}")
fail += 1
print(f"\n完成: 成功 {success}, 失败 {fail}")
sys.exit(0 if fail == 0 else 1)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
查询今天/明天/本周未完成的待办并汇总输出
用法:
python todo_daily_summary.py # 默认查今天
python todo_daily_summary.py today # 今天的待办
python todo_daily_summary.py tomorrow # 明天的待办
python todo_daily_summary.py week # 本周的待办
python todo_daily_summary.py --dry-run # 仅显示将执行的命令
"""
import sys
import json
import subprocess
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
PRIORITY_MAP = {10: '低', 20: '普通', 30: '较高', 40: '紧急'}
PAGE_SIZE = 50
MAX_PAGES = 10
def run_dws(args: List[str], dry_run: bool = False) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode != 0:
print(f"错误:{result.stderr.strip()}", file=sys.stderr)
return None
return json.loads(result.stdout)
except subprocess.TimeoutExpired:
print('错误:命令执行超时', file=sys.stderr)
return None
except (json.JSONDecodeError, FileNotFoundError) as e:
print(f"错误:{e}", file=sys.stderr)
return None
def get_date_range(scope: str):
now = datetime.now()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
if scope == 'today':
return today_start, today_start + timedelta(days=1)
elif scope == 'tomorrow':
tmr = today_start + timedelta(days=1)
return tmr, tmr + timedelta(days=1)
elif scope == 'week':
week_start = today_start - timedelta(days=today_start.weekday())
return week_start, week_start + timedelta(days=7)
return today_start, today_start + timedelta(days=1)
def fetch_all_todos(
dry_run: bool = False,
) -> List[Dict[str, Any]]:
all_todos: List[Dict[str, Any]] = []
for page in range(1, MAX_PAGES + 1):
data = run_dws([
'todo', 'task', 'list',
'--page', str(page),
'--size', str(PAGE_SIZE),
'--status', 'false',
'--format', 'json',
], dry_run=dry_run)
if dry_run:
return []
if not data:
break
items = data if isinstance(data, list) else data.get(
'result', data.get('todoCards', [])
)
if not items or not isinstance(items, list):
break
all_todos.extend(items)
if len(items) < PAGE_SIZE:
break
return all_todos
def format_priority(p) -> str:
try:
return PRIORITY_MAP.get(int(p), str(p))
except (ValueError, TypeError):
return '普通'
def format_due(due_ms) -> str:
if not due_ms:
return '无截止时间'
try:
dt = datetime.fromtimestamp(int(due_ms) / 1000)
return dt.strftime('%Y-%m-%d %H:%M')
except (ValueError, TypeError, OSError):
return str(due_ms)
def filter_by_due(
todos: List[Dict[str, Any]], start: datetime, end: datetime,
) -> List[Dict[str, Any]]:
start_ms = int(start.timestamp() * 1000)
end_ms = int(end.timestamp() * 1000)
result = []
for t in todos:
due = t.get('dueTime') or t.get('due')
if not due:
result.append(t)
continue
try:
due_val = int(due)
if start_ms <= due_val < end_ms:
result.append(t)
except (ValueError, TypeError):
result.append(t)
return result
def print_summary(
todos: List[Dict[str, Any]], scope: str,
start: datetime, end: datetime,
):
scope_label = {
'today': '今天', 'tomorrow': '明天', 'week': '本周',
}.get(scope, scope)
print(f"\n📋 {scope_label}未完成待办 "
f"({start.strftime('%m-%d')} ~ {end.strftime('%m-%d')})")
print('=' * 50)
if not todos:
print(' ✅ 暂无待办,轻松一下!')
return
urgent = [t for t in todos if format_priority(
t.get('priority')) == '紧急']
if urgent:
print(f"\n🔴 紧急 ({len(urgent)} 条)")
for t in urgent:
title = t.get('subject') or t.get('title', '无标题')
print(f" • {title} ⏰ {format_due(t.get('dueTime'))}")
normal = [t for t in todos if t not in urgent]
if normal:
print(f"\n📌 其他 ({len(normal)} 条)")
for t in normal:
title = t.get('subject') or t.get('title', '无标题')
pri = format_priority(t.get('priority'))
print(f" • [{pri}] {title} ⏰ {format_due(t.get('dueTime'))}")
print(f"\n合计: {len(todos)} 条待办")
def main():
dry_run = '--dry-run' in sys.argv
args = [a for a in sys.argv[1:] if a != '--dry-run']
scope = args[0] if args else 'today'
if scope not in ('today', 'tomorrow', 'week'):
print(__doc__)
sys.exit(1)
start, end = get_date_range(scope)
todos = fetch_all_todos(dry_run=dry_run)
if dry_run:
return
filtered = filter_by_due(todos, start, end)
print_summary(filtered, scope, start, end)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
扫描已过截止时间但未完成的待办,输出逾期清单
用法:
python todo_overdue_check.py
python todo_overdue_check.py --dry-run
"""
import sys
import json
import subprocess
from datetime import datetime
from typing import List, Dict, Any, Optional
PAGE_SIZE = 50
MAX_PAGES = 10
PRIORITY_MAP = {10: '低', 20: '普通', 30: '较高', 40: '紧急'}
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
cmd = ['dws'] + args
if dry_run:
print(f"[dry-run] {' '.join(cmd)}")
return None
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
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 fetch_all_undone(dry_run: bool = False) -> List[Dict[str, Any]]:
all_todos: List[Dict[str, Any]] = []
for page in range(1, MAX_PAGES + 1):
data = run_dws([
'todo', 'task', 'list',
'--page', str(page), '--size', str(PAGE_SIZE),
'--status', 'false', '--format', 'json',
], dry_run=dry_run)
if dry_run or not data:
break
items = (data if isinstance(data, list)
else data.get('result', data.get('todoCards', [])))
if not items or not isinstance(items, list):
break
all_todos.extend(items)
if len(items) < PAGE_SIZE:
break
return all_todos
def find_overdue(todos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
now_ms = int(datetime.now().timestamp() * 1000)
overdue = []
for t in todos:
due = t.get('dueTime') or t.get('due')
if not due:
continue
try:
if int(due) < now_ms:
overdue.append(t)
except (ValueError, TypeError):
continue
return overdue
def days_overdue(due_ms) -> int:
now = datetime.now()
try:
due_dt = datetime.fromtimestamp(int(due_ms) / 1000)
return max(0, (now - due_dt).days)
except (ValueError, TypeError, OSError):
return 0
def main():
dry_run = '--dry-run' in sys.argv
todos = fetch_all_undone(dry_run=dry_run)
if dry_run:
return
overdue = find_overdue(todos)
overdue.sort(
key=lambda t: int(t.get('dueTime') or t.get('due', 0))
)
print(f"\n⏰ 逾期待办检查 ({datetime.now().strftime('%Y-%m-%d %H:%M')})")
print('=' * 50)
if not overdue:
print(' ✅ 没有逾期待办,继续保持!')
return
for t in overdue:
title = t.get('subject') or t.get('title', '无标题')
due = t.get('dueTime') or t.get('due')
days = days_overdue(due)
pri = PRIORITY_MAP.get(
int(t.get('priority', 20)), '普通'
)
due_str = datetime.fromtimestamp(
int(due) / 1000
).strftime('%Y-%m-%d')
print(f" 🔴 [{pri}] {title}")
print(f" 截止: {due_str} 逾期: {days} 天")
print(f"\n合计: {len(overdue)} 条逾期待办")
sys.exit(1 if overdue else 0)
if __name__ == '__main__':
main()
#!/usr/bin/env python3
"""
上传附件到钉钉 AI 表格 attachment 字段
完整流程(内部自动执行 3 步):
1. dws aitable attachment upload → 获取 uploadUrl + fileToken
2. HTTP PUT 上传文件到 OSS
3. 返回 fileToken,可直接用于 record create/update
用法:
python upload_attachment.py <baseId> <filePath>
输出 (JSON):
{ "fileToken": "ft_xxx", "fileName": "report.pdf", "size": 204800 }
然后在 record create/update 中使用:
dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \
--records '[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]' --format json
"""
import sys
import json
import subprocess
import os
import mimetypes
import re
from pathlib import Path
from typing import Optional, Dict, Any
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
RESOURCE_ID_PATTERN = re.compile(r'^[A-Za-z0-9_-]{8,128}$')
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
def validate_resource_id(resource_id: str) -> bool:
return bool(resource_id and RESOURCE_ID_PATTERN.match(resource_id.strip()))
def detect_mime_type(file_path: Path) -> str:
"""根据文件扩展名推断 MIME type。"""
mime_type, _ = mimetypes.guess_type(str(file_path))
return mime_type or 'application/octet-stream'
def run_dws(args: list) -> Optional[Dict[str, Any]]:
"""调用 dws 命令并返回解析后的 JSON 结果。"""
cmd = ['dws'] + args
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode != 0:
print(f"错误:dws 命令失败: {result.stderr.strip()}", file=sys.stderr)
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError:
print(f"错误:无法解析 dws 响应: {result.stdout[:300]}", file=sys.stderr)
return None
except subprocess.TimeoutExpired:
print('错误:dws 命令超时(60 秒)', file=sys.stderr)
return None
except FileNotFoundError:
print('错误:未找到 dws 命令,请确认已安装并在 PATH 中', file=sys.stderr)
return None
def upload_to_oss(upload_url: str, file_path: Path, mime_type: str) -> bool:
"""通过 HTTP PUT 上传文件到 OSS。"""
file_data = file_path.read_bytes()
req = Request(upload_url, data=file_data, method='PUT')
req.add_header('Content-Type', mime_type)
try:
with urlopen(req, timeout=120) as resp:
if resp.status == 200:
return True
print(f"错误:OSS 上传失败,HTTP {resp.status}", file=sys.stderr)
return False
except HTTPError as e:
print(f"错误:OSS 上传 HTTP 错误 {e.code}: {e.reason}", file=sys.stderr)
return False
except URLError as e:
print(f"错误:OSS 上传网络错误: {e.reason}", file=sys.stderr)
return False
def upload_attachment(base_id: str, file_path_str: str) -> Optional[Dict[str, Any]]:
"""
执行完整的附件上传流程:
1. prepare_attachment_upload → uploadUrl + fileToken
2. PUT 文件到 OSS
3. 返回 fileToken 信息
"""
# 验证文件
file_path = Path(file_path_str).resolve()
if not file_path.exists():
print(f"错误:文件不存在: {file_path}", file=sys.stderr)
return None
if not file_path.is_file():
print(f"错误:不是文件: {file_path}", file=sys.stderr)
return None
file_size = file_path.stat().st_size
if file_size <= 0:
print("错误:文件为空", file=sys.stderr)
return None
if file_size > MAX_FILE_SIZE:
print(f"错误:文件过大 ({file_size:,} 字节,限制 {MAX_FILE_SIZE:,} 字节)", file=sys.stderr)
return None
file_name = file_path.name
mime_type = detect_mime_type(file_path)
# 步骤 1: prepare_attachment_upload
print(f"步骤 1/3: 准备上传 {file_name} ({file_size:,} 字节, {mime_type})...", file=sys.stderr)
dws_args = [
'aitable', 'attachment', 'upload',
'--base-id', base_id,
'--file-name', file_name,
'--size', str(file_size),
'--mime-type', mime_type,
'--format', 'json',
]
result = run_dws(dws_args)
if not result:
return None
status = result.get('status', '')
if status != 'success':
error = result.get('error', {})
print(f"错误:准备上传失败: {error.get('message', json.dumps(error, ensure_ascii=False))}", file=sys.stderr)
return None
data = result.get('data', {})
upload_url = data.get('uploadUrl', '')
file_token = data.get('fileToken', '')
if not upload_url or not file_token:
print(f"错误:返回数据缺少 uploadUrl 或 fileToken: {json.dumps(data, ensure_ascii=False)}", file=sys.stderr)
return None
# 步骤 2: PUT 文件到 OSS
print(f"步骤 2/3: 上传文件到 OSS...", file=sys.stderr)
if not upload_to_oss(upload_url, file_path, mime_type):
return None
# 步骤 3: 返回 fileToken
print(f"步骤 3/3: 上传完成!", file=sys.stderr)
output = {
"fileToken": file_token,
"fileName": file_name,
"size": file_size,
"mimeType": mime_type,
}
return output
def main():
if len(sys.argv) != 3:
print(__doc__)
print('用法:')
print(' python upload_attachment.py <baseId> <filePath>')
print()
print('示例:')
print(' python upload_attachment.py G1DKw2zgV2bEk6PMSBooNxlEVB5r9YAn ./report.pdf')
print()
print('然后在 record create 中使用返回的 fileToken:')
print(' dws aitable record create --base-id <BASE_ID> --table-id <TABLE_ID> \\')
print(' --records \'[{"cells":{"fldAttachId":[{"fileToken":"ft_xxx"}]}}]\' --format json')
sys.exit(1)
base_id = sys.argv[1]
file_path = sys.argv[2]
if not validate_resource_id(base_id):
print('错误:无效的 baseId 格式', file=sys.stderr)
sys.exit(1)
result = upload_attachment(base_id, file_path)
if result is None:
sys.exit(1)
# 正常输出到 stdout(JSON 格式,方便解析)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0)
if __name__ == '__main__':
main()