Compare commits

...
25 Commits
Author SHA1 Message Date
瑞达 7ef88d696b fix(chat): prevent message search from resolving groups 2026-08-03 17:44:19 +08:00
瑞达 891cf87c7f docs(skill): streamline chat progressive routing 2026-08-03 17:37:34 +08:00
瑞达 6182169b3e fix(chat): reconcile remote guidance contracts 2026-08-03 12:03:27 +08:00
瑞达 ab2fba868b fix(chat): improve aligned shortcut reliability
Add hidden compatibility flags and exact high-frequency routes to avoid help-driven recovery loops. Restore granular PAT tests and correct the Runtime Catalog versus curated Schema contract.
2026-08-03 11:53:45 +08:00
瑞达 796cee3d95 fix(skills): gate script priority on runtime availability 2026-08-03 11:53:45 +08:00
瑞达 32c82e1246 fix(chat): keep help guidance on stdout 2026-08-03 11:53:45 +08:00
瑞达 fe2f64dc43 chore(schema): refresh skill source hashes 2026-08-03 11:53:16 +08:00
瑞达 32c9109c71 fix(skills): avoid requiring python for chat workflows 2026-08-03 11:53:16 +08:00
瑞达 7a42c83d3a perf(skills): restore progressive chat shortcut discovery 2026-08-03 11:53:16 +08:00
瑞达 8b1564eff4 test: restore shortcut and confirmation contracts 2026-08-03 11:53:16 +08:00
南润 b8b5583440 PR修复 2026-07-31 17:59:09 +08:00
南润 8f8ba3f290 init 2026-07-31 14:36:19 +08:00
瑞达 fe856a8f38 Merge origin/main into codex/im-chat-skill-hint-align 2026-07-31 13:57:15 +08:00
瑞达 583b453abf feat(skill): embed chat shortcuts in multi skill 2026-07-31 13:42:41 +08:00
瑞达 eef94425e2 test(pat): restore coverage baseline 2026-07-30 16:42:07 +08:00
瑞达 e17ffe5bff test(chat): fix CI script runner and coverage 2026-07-30 16:29:46 +08:00
瑞达 e83e3a3e2c feat(chat): align skill with shortcut runtime 2026-07-30 15:56:16 +08:00
南润 5323129e5e Merge branch 'main' into codex/im-chat-skill-hint-align
# Conflicts:
#	internal/app/schema_shortcut_contract_test.go
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
#	internal/cli/schema_command_registry/products/chat.json
#	internal/cli/schema_hints/runtime-surface-completeness.json
#	skills/multi/dingtalk-chat/SKILL.md
2026-07-30 13:41:02 +08:00
南润 014dea52f0 refactor(chat): remove media selection guard and related tests 2026-07-30 12:41:21 +08:00
南润 20c2ff98c2 Merge branch 'main' into codex/im-chat-skill-hint-align 2026-07-30 10:53:44 +08:00
南润 c7ea642aef fix(chat): enforce destructive guards and media validation 2026-07-30 10:34:42 +08:00
瑞达 b5af90c089 test: satisfy coverage for chat hints 2026-07-29 20:04:05 +08:00
瑞达 a5ee9dffe2 Merge remote-tracking branch 'origin/main' into codex/im-chat-skill-hint-align 2026-07-29 19:43:59 +08:00
瑞达 7179928c75 Merge remote-tracking branch 'origin/main' into codex/im-chat-skill-hint-align
# Conflicts:
#	internal/app/schema_shortcut_contract_test.go
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog/catalog.json
#	internal/cli/schema_catalog/tools/chat.json
#	internal/cli/schema_command_registry/products/chat.json
#	internal/cli/schema_hints/runtime-surface-completeness.json
#	skills/multi/dingtalk-chat/SKILL.md
#	skills/multi/dingtalk-chat/references/chat.md
2026-07-29 19:20:08 +08:00
瑞达 8e48ede81a feat(chat): align skill hints and schema 2026-07-29 15:45:32 +08:00
62 changed files with 32299 additions and 2679 deletions
@@ -0,0 +1,301 @@
# IM Chat Skill 精简与渐进加载优化方案
## 1. 背景
当前 `im-chat-skill-hint-align` 分支已经增强了 Chat Skill 的 Shortcut 路由、执行骨架、身份边界、查询与资源处理、低频原子回退和错误导航。与 Lark IM Skill 对比后,DWS 在 Agent 路由和执行约束上更直接,但根 Skill 仍存在以下问题:
- 高频执行骨架与核心意图表重复。
- Runtime Shortcut Catalog、leaf Schema 和 leaf Help 的读取规则在多处重复。
- Shortcut 错误处理与 Workflow 错误导航重复。
- 身份、ID 和三种置顶对象的边界分散在不同章节。
- 缺少简短、集中且可复用的核心对象与查询结果语义。
- Frontmatter 能力召回仍可扩展,但不能削弱 DING、邮件和班级群等产品边界。
本方案只调整根文件 `skills/multi/dingtalk-chat/SKILL.md` 的组织和必要语义,不把 API 手册、完整 Shortcut 清单或权限表重新放回根 Skill。
## 2. 修改目标
1. 提高高频 Chat 意图的直接命中率,减少不必要的 Catalog、Schema 和 `--help` 调用。
2. 提前建立渐进加载顺序,避免模型在高频任务中优先进入原子命令树。
3. 补齐身份、核心对象、ID 和查询结果的必要语义,降低错误重试和 ID 混用。
4. 合并重复 SOP,确保新增内容不会增加根 Skill 的总体 token。
5. 保持参数、安全和完整 API 事实由 leaf Schema、Help 和 references 按需提供。
## 3. 设计原则
### 3.1 根 Skill 只保留决策必需信息
根 Skill 应负责:
- Skill 触发范围和跨产品排除边界。
- 渐进加载与能力选择顺序。
- 高频意图到精确 Shortcut 的映射。
- 身份、ID、幂等、分页、部分失败等跨命令不变量。
- 低频能力的导航入口。
- 错误恢复和停止条件。
以下内容继续留在 Schema 或 references:
- 完整 Shortcut Catalog。
- API Resources 全量列表。
- 权限 scope 表。
- 叶级参数全集和接口字段格式。
- 只服务单个命令的实现细节。
### 3.2 渐进加载规则必须早于命令骨架
模型应在看到具体命令前,先知道何时直接执行、何时才加载额外上下文。但完整一级命令树不应提前,以免低频原子命令干扰高频 Shortcut 选择。
### 3.3 同一事实只保留一个权威位置
- 高频 Shortcut 只出现在一张核心意图表中。
- Catalog、Schema 和 Help 的读取顺序只定义一次。
- 错误恢复与 reference 导航只定义一次。
- 身份、对象和 ID 的公共边界集中定义,后续章节只引用,不重复解释。
## 4. 目标章节结构
```text
1. Frontmatter
2. Preconditions
3. 加载与路由顺序
4. 核心对象与 ID
5. 核心意图与执行骨架
6. 统一发送
7. 查询、资源与卡片
8. 低频原子路由
├── 一级命令树
├── branch references
└── 低频操作回退表
9. 错误恢复与按需 Reference
10. 跨产品协作
```
## 5. 具体修改
### 5.1 扩展 Frontmatter 产品能力
扩展 `description` 的正向能力召回,覆盖:
- 单聊、群聊、建群、群搜索和群成员管理。
- 消息发送、回复、转发、撤回、查询和聊天记录搜索。
- 图片、文件和消息资源下载。
- 表情回应、收藏、Pin、消息置顶和会话置顶。
- 应用机器人、Webhook 和互动卡片。
- 未读、红点、消息已读状态和会话分类。
同时保留明确排除:
- DING、短信和电话转到 `dingtalk-ding`。
- 邮件转到 `dingtalk-mail`。
- 班级群转到对应的低频产品 Skill。
- 找人本身由 `dingtalk-contact` 或 `dingtalk-aisearch` 负责,Chat 只消费真实人员 ID。
Frontmatter 只描述真实能力和路由边界,不加入参数、SOP 或 token 实现细节。
### 5.2 前移并合并渐进加载规则
将现有“Shortcut 发现”“Shortcut 执行契约”和“渐进加载与一级路由”的加载决策部分合并为紧随 Preconditions 的唯一章节:
```markdown
## 加载与路由顺序
1. 已知高频意图:直接使用“核心意图与执行骨架”,不查 Help。
2. 已有匹配 Shortcut:直接执行;参数、约束或安全不确定时才查 leaf Schema。
3. 仅 Cobra flags 不确定时查 leaf `--help`。
4. 现有路由无法定位低频能力时,才查 Runtime Shortcut Catalog。
5. 没有 Shortcut 时,按需读取对应 branch reference,进入原子命令。
```
同一章节保留以下公共规则:
- 路由优先级为 `exact recipe/runnable script > public Shortcut > atomic command`。
- 不猜测 `cli_path` 或参数名称。
- `confirmation=user_required` 时先确认,再添加 `--yes`。
- 来源冲突时采用更安全的解释并报告契约漂移。
- 命令已确定且参数清楚时直接执行,不为验证已知路径重复发现。
删除其他章节重复出现的 Catalog、Schema、Help 选择说明。
### 5.3 新增“核心对象与 ID”小表
增加不超过 8 行的表格,集中表达:
| 对象 | 核心标识与边界 |
|---|---|
| 人员 | 姓名必须先解析成唯一真实的 `userId` 或 `openDingTalkId`,名称不能作为 ID 传递 |
| 会话 | 使用真实 `openConversationId` / cid;群名只能用于 Shortcut 的目标解析 |
| 消息 | 使用真实 `openMessageId` / msgId,并保持与身份及会话一致 |
| 发送任务 | `openTaskId` 只用于查询发送状态,不能替代消息 ID |
| Thread | thread/topic ID 必须绑定真实会话,不跨会话复用 |
| 身份 | current-user、app-bot 和 Webhook 是不同操作者,不能自动互换 |
| 状态 | 收藏、消息置顶、消息 Pin 和会话置顶作用于不同对象 |
新增后删除后文对这些边界的重复说明。
### 5.4 合并高频骨架与核心意图表
删除独立的“高频直接执行骨架”,将其全部合入唯一的“核心意图与执行骨架”表。表格固定为三列:
| 用户意图 | 精确 Shortcut 骨架 | 必须保留的执行边界 |
|---|---|---|
至少覆盖以下高频场景:
- 姓名发单聊、群名发群消息。
- user、bot、webhook 三种身份发送。
- 建群、改群名、拉人和成员查询。
- 拉取会话消息、查询详情、撤回和发送状态。
- 关键词搜索、组合搜索和查询 @ 我的消息。
- 群邀请链接和群机器人。
- 会话置顶和收藏列表。
- 查和某人的聊天记录。
- 群消息翻页导出。
- 机器人多群广播。
表中直接给出正确参数骨架;命中后照抄参数名,不先调用 `--help`。同一个 Shortcut 不再在其他表中重复列出。
### 5.5 补充统一身份规则
在“统一发送”开头加入统一规则:
> 身份决定真实操作者、可见范围和可用能力;同一目标使用 user、bot 或 webhook 时,结果和权限可能不同,禁止自动切换身份重试。
继续保留:
- 发送前检查身份、目标、正文、标题、@、消息类型和附件路径。
- 重试复用相同 `--idempotency-key`。
- user、bot、webhook 的精确发送模板。
- @ 占位符、新行和文件能力边界。
不加入 Lark 的 access token 类型说明。
### 5.6 补充查询结果与增强失败语义
在“查询、资源与卡片”中增加:
- 发送者名称缺失时保留真实 ID,不猜姓名,也不自动扩大通讯录查询。
- 可选增强字段缺失不代表主查询失败;增强请求失败时保留主结果并写入 per-item ledger。
继续保留:
- `--page-all` 只在确需完整分页时使用。
- 部分失败保留已有结果,禁止把不完整结果声明为完整。
- 资源下载默认关闭,显式请求后才增加请求和本地输出。
- 子消息资源优先使用子 `messageId`。
- 输出路径、覆盖、HTTPS 和重定向安全限制。
### 5.7 拆分“渐进加载”与“一级命令树”
前移的只有加载决策。完整一级命令树及 branch references 改名为“低频原子路由”,保留在高频意图、统一发送和查询规则之后。
这样可以:
- 防止高频任务优先进入 atomic branch。
- 降低不必要的 Schema 和 Help 查询。
- 继续为没有 Shortcut 的能力提供确定导航。
低频原子回退表继续保留收藏、编辑、外部群升级、群昵称、分类、共同群、群公告、群身份、置顶、未读、已读、授权、退群和解散群等差异化入口。
### 5.8 合并错误恢复与 Workflow 导航
将现有“Shortcut 错误处理”和“Workflow 与错误导航”合并为:
```markdown
## 错误恢复与按需 Reference
```
只保留以下规则:
- 路径或参数错误时,按 Catalog、Schema、Help 的既定顺序校正一次。
- 始终从实际输出重新提取下游 ID。
- 复杂消息任务按需读取 `01-messaging.md`。
- Onboarding 按需读取对应 workflow。
- 命令错误按需读取 `chat-error-recovery.md`。
- 权限不足、歧义未消除、无结果或契约冲突时停止并报告。
删除其他位置重复的 `01-messaging.md` 和错误恢复入口。
### 5.9 保留跨产品协作边界
继续保留根 Skill 中不可由 Chat 自己完成的路由:
- 人名解析到 Contact / AISearch。
- DING、短信和电话到 Ding Skill。
- 邮件到 Mail Skill。
- 本地文件与已有 mediaId 的发送差异。
如果某项边界已经在 Frontmatter 或核心对象表中完整表达,正文只保留执行阶段真正需要的补充,不重复整段说明。
## 6. 删除与合并清单
| 当前内容 | 处理方式 |
|---|---|
| “Shortcut 发现(按需)” | 合入前置“加载与路由顺序” |
| “Shortcut 执行契约” | 公共规则合入前置章节 |
| “高频直接执行骨架” | 删除,内容合入核心意图表 |
| “渐进加载与一级路由”中的加载说明 | 前移并去重 |
| 完整一级命令树 | 保留,改放“低频原子路由” |
| “Shortcut 错误处理” | 合入统一错误章节 |
| “Workflow 与错误导航” | 合入统一错误章节 |
| 分散的身份、ID、置顶说明 | 合入核心对象表或统一身份规则 |
| 重复的 `01-messaging.md` 入口 | 只保留一处 |
## 7. 不纳入本次修改
- 不展开 97 个公开 Shortcut。
- 不复制 Lark 的完整 API Resources 和权限 scope 表。
- 不在根 Skill 中维护 leaf 参数全集。
- 不引入与当前 CLI 不一致的新命令或参数。
- 不改变 Schema、Help、Runtime Catalog 和 reference 的事实优先级。
- 不通过增加默认查询、自动通讯录查询或默认资源增强来换取结果丰富度。
## 8. 实施顺序
1. 更新 Frontmatter description,确认能力召回和排除边界。
2. 合并并前移“加载与路由顺序”。
3. 新增“核心对象与 ID”表,删除相应重复边界。
4. 合并高频骨架和核心意图表。
5. 补充统一身份规则。
6. 补充查询结果和增强失败语义。
7. 将一级命令树调整为后置的“低频原子路由”。
8. 合并错误恢复与 reference 导航。
9. 全文检查重复命令、重复 reference 和冲突参数。
10. 运行 Skill 格式及相关策略测试,并用高频场景做静态路由验证。
## 9. 验收标准
### 9.1 内容与结构
- 根 Skill 中只有一份 Catalog、Schema、Help 读取顺序。
- 根 Skill 中只有一张高频意图与 Shortcut 骨架表。
- 根 Skill 中只有一个错误恢复章节。
- `01-messaging.md` 的同类导航不重复。
- 核心对象与 ID 表不超过 8 行数据。
- 一级原子命令树位于高频路由之后。
- Frontmatter 同时覆盖主要产品能力和明确排除边界。
### 9.2 执行行为
- “发给某人”“发到某群”“查 @ 我”“改群名”等高频意图可直接选中已评审 Shortcut,不先查 Help。
- 低频未知意图才触发 Runtime Shortcut Catalog。
- 参数或安全不确定时读取 leaf Schema;只有 Cobra flags 不确定时读取 leaf Help。
- user、bot、webhook 不被自动互换。
- `openTaskId`、消息 ID、会话 ID 不混用。
- 发送者名称或 reaction 等增强缺失时,不把主查询误判为失败。
- 部分失败保留已有结果并明确报告 ledger/completeness。
### 9.3 Token 与维护成本
- 修改后的根 Skill 不超过当前 211 行,并以不丢失必要路由和边界为前提尽量低于 195 行。
- 文件单词数和字符数不高于修改前基线。
- 新增内容通过删除重复 SOP 抵消。
- 不新增完整 Shortcut、API 或权限清单。
## 10. 预期收益
- 减少高频任务中的 `--help` 和重复 Schema 查询。
- 降低因身份切换、ID 混用和发送者名称缺失导致的错误重试。
- 让 Shortcut、Schema、Help、Catalog 和 references 各自保持单一职责。
- 在不增加默认 token 和耗时的前提下,提高 Chat Skill 的选择准确率和执行成功率。
- 降低后续新增 Shortcut 时同时维护多张表和多处规则的漂移风险。
+162
View File
@@ -0,0 +1,162 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
stderrors "errors"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func TestValidateChatWorkbookRawArgs(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
args []string
want string
}{
{
name: "members group flag",
args: []string{"chat", "group", "members", "list", "--group", "cid-demo", "--format", "json"},
want: "群成员列表命令路径或群参数不正确",
},
{
name: "rename group flag",
args: []string{"chat", "group", "rename", "--group=cid-demo", "--name", "新群名"},
want: "群重命名命令不支持 --group",
},
{
name: "image local path",
args: []string{"chat", "message", "send", "--group", "cid-demo", "--msg-type", "image", "--file-path", "/tmp/x.png"},
want: "image 消息不能直接使用 --file-path",
},
{
name: "unsupported message type",
args: []string{"chat", "message", "send", "--group", "cid-demo", "--msg-type=sticker"},
want: "不支持指定的 --msg-type:sticker",
},
{
name: "numeric group id required",
args: []string{"chat", "group", "get-by-group-id", "--group-id", "cid-demo"},
want: "--group-id 必须是数字群号",
},
{
name: "file media id conflict",
args: []string{"chat", "message", "send", "--group", "cid-demo", "--msg-type", "file", "--media-id", "media"},
want: "文件消息不能使用 --media-id",
},
{
name: "silent text media conflict",
args: []string{"chat", "message", "send", "--group", "cid-demo", "--media-id", "media", "--text", "file.pdf"},
want: "检测到 --media-id,但没有指定媒体消息类型",
},
{
name: "dismiss numeric group id",
args: []string{"chat", "group", "dismiss", "--group", "12345678"},
want: "解散群命令需要 openConversationId,不是数字群号",
},
} {
t.Run(tc.name, func(t *testing.T) {
err := validateChatWorkbookRawArgs(tc.args)
var typed *apperrors.Error
if !stderrors.As(err, &typed) {
t.Fatalf("error = %T, want *errors.Error", err)
}
if typed.Message != tc.want || len(typed.Actions) == 0 || len(typed.Examples) == 0 {
t.Fatalf("guidance = %#v", typed)
}
})
}
if err := validateChatWorkbookRawArgs([]string{"chat", "group", "rename", "--id", "cid-demo"}); err != nil {
t.Fatalf("canonical rename args rejected: %v", err)
}
}
func TestChatWorkbookHelpGuidanceCoverage(t *testing.T) {
t.Parallel()
for _, path := range []string{
"chat group members",
"chat group members add",
"chat group members remove",
"chat group members add-bot",
"chat group members remove-bot",
"chat group members list-by-ids",
"chat group create",
"chat group rename",
"chat message list",
"chat message search",
"chat message search-advanced",
"chat message list-all",
"chat message list-by-sender",
} {
guide, ok := chatWorkbookHelpGuidance[path]
if !ok || guide.reason == "" || guide.action == "" || guide.example == "" {
t.Fatalf("incomplete help guidance for %q: %#v", path, guide)
}
}
}
func TestRawArgsFlagValue(t *testing.T) {
t.Parallel()
if got := rawArgsFlagValue([]string{"--msg-type", "image"}, "msg-type"); got != "image" {
t.Fatalf("separate value = %q", got)
}
if got := rawArgsFlagValue([]string{"--msg-type=file"}, "msg-type"); got != "file" {
t.Fatalf("equals value = %q", got)
}
if got := rawArgsFlagValue([]string{"--text", "hello"}, "msg-type"); got != "" {
t.Fatalf("missing value = %q", got)
}
}
func TestRawArgsRequestJSON(t *testing.T) {
t.Parallel()
for _, args := range [][]string{
{"chat", "search", "--format", "json"},
{"chat", "search", "--format=json"},
{"chat", "search", "-f", "JSON"},
{"chat", "search", "-f=json"},
} {
if !rawArgsRequestJSON(args) {
t.Fatalf("rawArgsRequestJSON(%v) = false", args)
}
}
}
func TestSuppressJSONDeprecationPreamble(t *testing.T) {
t.Parallel()
root := NewRootCommand()
cmd := mustFindCommand(t, root, "chat", "media", "upload")
if cmd.Deprecated == "" {
t.Fatal("fixture command is not deprecated")
}
suppressJSONDeprecationPreamble(root, []string{"chat", "media", "upload", "--format", "json"})
if cmd.Deprecated != "" {
t.Fatalf("JSON execution kept deprecation preamble: %q", cmd.Deprecated)
}
plainRoot := NewRootCommand()
plain := mustFindCommand(t, plainRoot, "chat", "media", "upload")
suppressJSONDeprecationPreamble(plainRoot, []string{"chat", "media", "upload"})
if plain.Deprecated == "" {
t.Fatal("human execution unexpectedly removed deprecation metadata")
}
}
+462
View File
@@ -24,6 +24,7 @@ import (
"os/signal"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"syscall"
@@ -112,6 +113,16 @@ func Execute() (exitCode int) {
root := rootNewRootCommandWithEngine(ctx, engine)
timing.Record("cmd_init", time.Since(initStart))
if err := validateChatWorkbookRawArgs(os.Args[1:]); err != nil {
if rawArgsRequestJSON(os.Args[1:]) {
_ = apperrors.PrintJSON(os.Stderr, err)
} else {
_ = apperrors.PrintHumanAt(os.Stderr, err, resolveVerbosity(root))
}
return apperrors.ExitCode(err)
}
suppressJSONDeprecationPreamble(root, os.Args[1:])
// Run PreParse handlers on raw argv before Cobra parses flags.
// This corrects model-generated errors like --userId → --user-id
// and --limit100 → --limit 100.
@@ -127,6 +138,7 @@ func Execute() (exitCode int) {
executed = root
}
err = rewordRequiredFlagError(err)
err = enrichChatWorkbookError(executed, err)
if isUnknownCommandError(err) {
executed.SetOut(os.Stderr)
_ = executed.Help()
@@ -141,6 +153,453 @@ func Execute() (exitCode int) {
return 0
}
func suppressJSONDeprecationPreamble(root *cobra.Command, args []string) {
if root == nil || !rawArgsRequestJSON(args) || len(args) < 3 {
return
}
if args[0] != "chat" || args[1] != "media" || args[2] != "upload" {
return
}
if cmd, _, err := root.Find([]string{"chat", "media", "upload"}); err == nil && cmd != nil {
cmd.Deprecated = ""
}
}
func validateChatWorkbookRawArgs(args []string) error {
path := strings.Join(args, " ")
switch {
case strings.HasPrefix(path, "chat message send ") && rawArgsFlagValue(args, "msg-type") == "file" &&
rawArgsContainFlag(args, "media-id"):
return apperrors.NewValidation(
"文件消息不能使用 --media-id",
apperrors.WithReason("PDF、DOCX、XLSX 和本地图片等文件通过 --file-path 上传发送;mediaId 仅用于已有媒体标识的 image 消息"),
apperrors.WithActions("移除 --media-id", "补充 --file-path 并保留 --msg-type file"),
apperrors.WithExamples(`dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json`),
)
case strings.HasPrefix(path, "chat message send ") && rawArgsContainFlag(args, "media-id") &&
rawArgsFlagValue(args, "msg-type") == "":
return apperrors.NewValidation(
"检测到 --media-id,但没有指定媒体消息类型",
apperrors.WithReason("未指定 --msg-type 时命令会进入文本分支,可能把文件名当成普通文字发送"),
apperrors.WithActions("已有图片 mediaId 时补充 --msg-type image", "发送 PDF/DOCX/XLSX 时移除 --media-id,改用 --msg-type file --file-path"),
apperrors.WithExamples(`dws chat message send --group <openConversationId> --msg-type image --media-id <mediaId> --format json`, `dws chat message send --group <openConversationId> --msg-type file --file-path ./thesis.pdf --format json`),
)
case strings.HasPrefix(path, "chat message send ") && rawArgsFlagValue(args, "msg-type") == "image" &&
rawArgsContainFlag(args, "file-path") && !rawArgsContainFlag(args, "media-id"):
filePath := rawArgsFlagValue(args, "file-path")
return apperrors.NewValidation(
"image 消息不能直接使用 --file-path",
apperrors.WithReason("msg-type=image 只接受已有 mediaId;本地图片路径不能自动转换为 mediaId"),
apperrors.WithActions("发送本地图片时改用 --msg-type file", "保留原路径并通过 --file-path 发送为文件附件"),
apperrors.WithExamples(fmt.Sprintf(`dws chat message send --group <openConversationId> --msg-type file --file-path %q --format json`, filePath)),
)
case strings.HasPrefix(path, "chat message send ") && rawArgsFlagValue(args, "msg-type") == "image" &&
!rawArgsContainFlag(args, "media-id"):
return apperrors.NewValidation(
"图片消息缺少 --media-id",
apperrors.WithReason("msg-type=image 只接受上游已经获得的有效 mediaId,不能把本地文件名当作 mediaId"),
apperrors.WithActions("已有 mediaId 时补充 --media-id", "发送本地图片时改用 --msg-type file --file-path"),
apperrors.WithExamples(`dws chat message send --group <openConversationId> --msg-type image --media-id <mediaId> --format json`, `dws chat message send --group <openConversationId> --msg-type file --file-path ./image.png --format json`),
)
case strings.HasPrefix(path, "chat message send "):
msgType := rawArgsFlagValue(args, "msg-type")
switch msgType {
case "", "text", "markdown", "image", "file", "audio", "video", "location", "profile":
default:
return apperrors.NewValidation(
"不支持指定的 --msg-type:"+msgType,
apperrors.WithReason("当前命令不支持 sticker/card 等消息类型;文本或 Markdown 消息无需传 --msg-type"),
apperrors.WithActions("文本消息移除 --msg-type 并使用 --text", "媒体消息使用 image、file、audio、video、location 或 profile"),
apperrors.WithExamples(`dws chat message send --group <openConversationId> --text "hi" --format json`),
)
}
case strings.HasPrefix(path, "chat group get-by-group-id "):
value := rawArgsFlagValue(args, "group-id")
if value != "" {
if _, err := strconv.ParseInt(value, 10, 64); err != nil {
return apperrors.NewValidation(
"--group-id 必须是数字群号",
apperrors.WithReason("cid 开头的值是 openConversationId,不是 get-by-group-id 所需的数字群号"),
apperrors.WithActions("如果已有 openConversationId,请改用接受 --group 的群查询命令", "只有拿到数字群号时才调用 get-by-group-id"),
apperrors.WithExamples(`dws chat group get-by-group-id --group-id 12345678 --format json`),
)
}
}
case strings.HasPrefix(path, "chat group dismiss ") && rawArgsContainFlag(args, "group"):
value := rawArgsFlagValue(args, "group")
if _, err := strconv.ParseInt(value, 10, 64); err == nil {
return apperrors.NewValidation(
"解散群命令需要 openConversationId,不是数字群号",
apperrors.WithReason("--group 应传 cid 开头或服务端返回的 openConversationId;数字群号只用于 get-by-group-id"),
apperrors.WithActions("先通过 chat search 获取 openConversationId", "确认目标群及不可逆影响后再执行解散"),
apperrors.WithExamples(`dws chat group dismiss --group <openConversationId> --format json`),
)
}
case strings.HasPrefix(path, "chat group members ") && rawArgsContainFlag(args, "group"):
return apperrors.NewValidation(
"群成员列表命令路径或群参数不正确",
apperrors.WithReason("群成员列表的可执行命令是 chat group members,群 ID 参数名为 --id;不存在 members list --group 这一组合"),
apperrors.WithActions("移除多余的 list 子命令", "将 --group 改为 --id"),
apperrors.WithExamples(`dws chat group members --id <openConversationId> --format json`),
)
case strings.HasPrefix(path, "chat group rename ") && rawArgsContainFlag(args, "group"):
return apperrors.NewValidation(
"群重命名命令不支持 --group",
apperrors.WithReason("chat group rename 使用 --id 接收群 openConversationId,而不是 --group"),
apperrors.WithActions("将 --group 改为 --id", "群 ID 不确定时先用 chat search 查询"),
apperrors.WithExamples(`dws chat group rename --id <openConversationId> --name "新群名" --format json`),
)
}
return nil
}
func rawArgsContainFlag(args []string, name string) bool {
prefix := "--" + name
for _, arg := range args {
if arg == prefix || strings.HasPrefix(arg, prefix+"=") {
return true
}
}
return false
}
func rawArgsFlagValue(args []string, name string) string {
prefix := "--" + name
for i, arg := range args {
if strings.HasPrefix(arg, prefix+"=") {
return strings.TrimPrefix(arg, prefix+"=")
}
if arg == prefix && i+1 < len(args) {
return args[i+1]
}
}
return ""
}
func rawArgsRequestJSON(args []string) bool {
for i, arg := range args {
if arg == "--format=json" || arg == "-f=json" {
return true
}
if (arg == "--format" || arg == "-f") && i+1 < len(args) && strings.EqualFold(args[i+1], "json") {
return true
}
}
return false
}
type chatWorkbookGuidance struct {
message string
reason string
actions []string
examples []string
}
var chatRequiredGuidance = map[string]chatWorkbookGuidance{
"chat message send-by-webhook": {
"Webhook 发送参数不完整",
"Webhook 消息必须同时提供机器人地址中的 access_token、标题和正文;不能降级为普通群消息",
[]string{"从自定义机器人 Webhook 地址提取 token", "同时补齐 --title 和 --text,并确保包含机器人安全关键词"},
[]string{`dws chat message send-by-webhook --token <access_token> --title "dws测试通知" --text "dws测试:评测结果已出" --format json`},
},
"chat group rename": {
"群重命名缺少群 ID 或新名称", "--id 必须是群 openConversationId,--name 是新的群名称",
[]string{"先用 chat search 获取群 openConversationId", "同时提供 --id 和 --name"},
[]string{`dws chat group rename --id <openConversationId> --name "新群名" --format json`},
},
"chat group dismiss": {
"解散群缺少目标群 ID", "解散群不可逆且需要群主权限,--group 必须是 openConversationId",
[]string{"先确认目标群和影响范围", "获取 openConversationId 后再执行,并按运行时要求确认"},
[]string{`dws chat group dismiss --group <openConversationId> --format json`},
},
"chat group quit": {
"退出群缺少目标群 ID", "quit 表示当前用户退出群聊,不会解散整个群;--group 必须是 openConversationId",
[]string{"确认你要退出而不是解散群", "先获取目标群 openConversationId"},
[]string{`dws chat group quit --group <openConversationId> --format json`},
},
"chat group set-admin": {
"设置群管理员参数不完整", "需要目标群以及一个或多个成员;默认设为管理员,--off 表示取消管理员",
[]string{"补充 --group", "通过 --user 或 --users 指定成员,取消管理员时增加 --off"},
[]string{`dws chat group set-admin --group <openConversationId> --users <userId1>,<userId2> --format json`},
},
"chat group transfer-owner": {
"转让群主参数不完整", "--group 指定群,--new-owner 使用 openDingTalkId,--user 使用 userId",
[]string{"补充群 openConversationId", "在 --new-owner 和 --user 中选择一个新群主标识"},
[]string{`dws chat group transfer-owner --group <openConversationId> --new-owner <openDingTalkId> --format json`},
},
"chat group update-nick": {
"修改本人群昵称参数不完整", "update-nick 只修改当前登录用户在指定群里的昵称,需要群 ID 和新昵称",
[]string{"补充 --group openConversationId", "补充新的昵称参数"},
[]string{`dws chat group update-nick --group <openConversationId> --nick "新昵称" --format json`},
},
"chat group update-icon": {
"更新群头像参数不完整", "需要群 openConversationId 和上游已经获得的有效图片 mediaId",
[]string{"补充 --group", "从上游媒体能力获取 mediaId 后传入 --icon-media-id"},
[]string{`dws chat group update-icon --group <openConversationId> --icon-media-id <mediaId> --format json`},
},
"chat group share-invite": {
"分享群邀请参数不完整", "--source 是被分享群,--target 是接收分享的会话,--receiver 是接收分享的单聊用户",
[]string{"补充 --source", "在 --target 和 --receiver 中选择一个接收目标"},
[]string{`dws chat group share-invite --source <源群ID> --target <目标会话ID> --format json`},
},
"chat message reply": {
"引用回复参数不完整", "会话 ID、原消息 ID、原发送者和回复正文必须来自或对应同一条原消息",
[]string{"先拉取目标消息", "补齐 conversation-id、ref-msg-id、ref-sender 和 text"},
[]string{`dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到" --format json`},
},
"chat message forward": {
"转发消息参数不完整", "消息 ID 必须属于源会话,并需要明确源会话和目标会话",
[]string{"先从源会话拉取真实消息 ID", "确认 src 和 dest 没有写反"},
[]string{`dws chat message forward --src-conversation-id <源会话ID> --msg-id <openMessageId> --dest-conversation-id <目标会话ID> --format json`},
},
"chat message recall": {
"撤回消息参数不完整", "用户消息撤回需要会话 ID 和本人发送的消息 ID;机器人消息应使用 recall-by-bot",
[]string{"确认消息由当前用户发送", "补齐 conversation-id 和 msg-id"},
[]string{`dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId> --format json`},
},
"chat message read-status": {
"查询消息已读状态参数不完整", "只能查询当前用户发出消息的已读状态,需要会话和消息标识",
[]string{"补齐会话和消息 ID", "人员筛选时区分 userId 与 openDingTalkId"},
[]string{`dws chat message read-status --conversation-id <openConversationId> --message-id <openMessageId> --format json`},
},
"chat message list-by-ids": {
"缺少消息 ID 列表", "--msg-ids 使用逗号分隔的真实 openMessageId,单次最多 50 条",
[]string{"先拉取真实消息 ID", "将不超过 50 条 ID 用逗号连接"},
[]string{`dws chat message list-by-ids --msg-ids <id1>,<id2> --format json`},
},
"chat message download-media": {
"媒体下载参数不完整", "type、resource-id、message-id、open-conversation-id 和 output 必须完整,且资源与消息来自同一条消息",
[]string{"先拉取目标媒体消息", "从同一条消息取得资源、消息和会话标识"},
[]string{`dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/ --format json`},
},
"chat message add-emoji": {
"添加表情回应参数不完整", "需要真实会话 ID、消息 ID 和 emoji 名称",
[]string{"先拉取目标消息", "补齐 conversation-id、msg-id 和 emoji"},
[]string{`dws chat message add-emoji --conversation-id <openConversationId> --msg-id <openMessageId> --emoji "赞" --format json`},
},
"chat message remove-emoji": {
"移除表情回应参数不完整", "只能移除当前用户已添加的同名回应,需要会话、消息和 emoji 名称完全匹配",
[]string{"确认当前用户添加过该回应", "补齐 conversation-id、msg-id 和 emoji"},
[]string{`dws chat message remove-emoji --conversation-id <openConversationId> --msg-id <openMessageId> --emoji "赞" --format json`},
},
"chat message list-by-sender": {
"按发送者查询参数不完整", "必须提供开始时间以及发送者 userId/openDingTalkId 二选一,可选 end 和 cursor",
[]string{"补充 --start", "在 sender-user-id 和 sender-open-dingtalk-id 中选择一个"},
[]string{`dws chat message list-by-sender --sender-user-id <userId> --start "2026-07-14T00:00:00+08:00" --format json`},
},
"chat message query-send-status": {
"缺少发送任务 ID", "--open-task-id 来自 message send 返回的 openTaskId,不是消息 ID",
[]string{"先执行 message send", "从发送结果读取 openTaskId"},
[]string{`dws chat message query-send-status --open-task-id <openTaskId> --format json`},
},
}
func enrichChatWorkbookError(cmd *cobra.Command, err error) error {
if cmd == nil || err == nil {
return err
}
path := cmd.CommandPath()
if fields := strings.Fields(path); len(fields) > 1 {
path = strings.Join(fields[1:], " ")
}
message := err.Error()
var guide chatWorkbookGuidance
switch {
case path == "chat message send" &&
(strings.Contains(message, "unknown flag: --at-user-ids") ||
strings.Contains(message, "unknown flag: --at-users") ||
strings.Contains(message, "unknown flag: --mention")):
guide = chatWorkbookGuidance{
"群消息 @成员参数不正确",
"当前用户身份发送群消息时使用 --at-open-dingtalk-ids,参数值必须是成员的 openDingTalkId;--at-user-ids、--at-users、--mention 均不是有效参数",
[]string{"先查询目标成员的 openDingTalkId", "改用 --at-open-dingtalk-ids,并在正文中写入 <@openDingTalkId>"},
[]string{`dws chat message send --group <openConversationId> --at-open-dingtalk-ids <openDingTalkId> --text "<@openDingTalkId> 请关注" --format json`},
}
case path == "chat media upload":
guide = chatWorkbookGuidance{
"chat media upload 已下线",
"当前 CLI 不再通过该命令把本地文件转换为 mediaId,本地图片和文件统一由 message send 的 file 路径上传并发送",
[]string{"发送本地图片或文件时使用 --msg-type file --file-path", "只有上游已提供 mediaId 时才使用 --msg-type image --media-id"},
[]string{`dws chat message send --group <openConversationId> --msg-type file --file-path ./image.png --format json`},
}
case path == "chat group members" && strings.Contains(message, "unknown flag: --group"):
guide = chatWorkbookGuidance{
"群成员列表命令路径或群参数不正确",
"群成员列表的可执行命令是 chat group members,群 ID 参数名为 --id;不存在 members list --group 这一组合",
[]string{"移除多余的 list 子命令", "将 --group 改为 --id"},
[]string{`dws chat group members --id <openConversationId> --format json`},
}
case path == "chat group rename" && strings.Contains(message, "unknown flag: --group"):
guide = chatWorkbookGuidance{
"群重命名命令不支持 --group",
"chat group rename 使用 --id 接收群 openConversationId,而不是 --group",
[]string{"将 --group 改为 --id", "群 ID 不确定时先用 chat search 查询"},
[]string{`dws chat group rename --id <openConversationId> --name "新群名" --format json`},
}
case path == "chat group create" && strings.Contains(message, "unknown flag: --members"):
guide = chatWorkbookGuidance{
"建群命令不支持 --members",
"chat group create 使用 --users 接收逗号分隔的成员 userId;--members 是其他命令的参数名",
[]string{"将 --members 改为 --users", "成员标识不确定时先查询 userId"},
[]string{`dws chat group create --name "V2评审小组" --users 489149,550582 --format json`},
}
case path == "chat group bots" && strings.Contains(message, "unknown flag: --id"):
guide = chatWorkbookGuidance{
"群机器人列表命令不支持 --id",
"chat group bots 使用 --group 接收群 openConversationId;该参数名与 members、rename 命令不同",
[]string{"将 --id 改为 --group", "群 ID 不确定时先用 chat search 查询"},
[]string{`dws chat group bots --group <openConversationId> --format json`},
}
case path == "chat message list-mentions" && strings.Contains(message, "required flag"):
guide = chatWorkbookGuidance{
"缺少必填参数:--start、--end",
"查询 @我 消息必须同时提供 ISO-8601 格式的开始和结束时间;只提供分页参数不能确定查询范围",
[]string{"同时补充 --start 和 --end,不要逐个参数反复试错", "按本地时区设置明确的查询时间窗"},
[]string{`dws chat message list-mentions --start "2026-07-23T00:00:00+08:00" --end "2026-07-30T23:59:59+08:00" --limit 50 --format json`},
}
case path == "chat message send" && strings.Contains(message, "--group, --user or --open-dingtalk-id is required"):
guide = chatWorkbookGuidance{
"缺少消息接收目标",
"发送消息必须在 --group、--user、--open-dingtalk-id 中选择且只选择一个接收目标",
[]string{"发群消息时先查询并传入群 openConversationId", "发单聊时先查询并传入 userId 或 openDingTalkId"},
[]string{`dws chat message send --group <openConversationId> --text "评测消息" --format json`, `dws chat message send --open-dingtalk-id <openDingTalkId> --text "评测消息" --format json`},
}
case path == "chat search" && strings.Contains(message, "query"):
guide = chatWorkbookGuidance{
"缺少群聊搜索关键词:--query",
"群聊搜索需要关键词才能定位候选群,不能使用空查询",
[]string{"使用 --query 传入群名称或名称片段", "从结果中读取 openConversationId 供后续群命令使用"},
[]string{`dws chat search --query "项目群" --format json`},
}
case path == "chat message search-advanced":
guide = chatWorkbookGuidance{
"高级消息搜索至少需要一个搜索条件",
"空条件搜索无法限定目标消息,必须提供关键词、人员、@我状态或会话范围中的至少一种",
[]string{"按内容搜索时传入 --query", "也可通过 --user、--at-me 或 --conversation-ids 缩小范围"},
[]string{`dws chat message search-advanced --query "评审" --format json`},
}
case path == "chat message search" && strings.Contains(message, "required flag"):
guide = chatWorkbookGuidance{
"关键词消息搜索缺少完整查询条件",
"关键词消息搜索需要 --query、--start 和 --end;当前命令没有提供完整的关键词和时间范围",
[]string{"补充搜索关键词", "同时提供 ISO-8601 格式的开始和结束时间"},
[]string{`dws chat message search --query "评审" --start "2026-07-23T00:00:00+08:00" --end "2026-07-30T23:59:59+08:00" --format json`},
}
case path == "chat message list-all" && strings.Contains(message, "required flag"):
guide = chatWorkbookGuidance{
"跨会话消息查询缺少时间范围",
"拉取全部会话消息必须使用 --start 和 --end 限定范围,避免无边界查询历史消息",
[]string{"同时补充 --start 和 --end", "结果存在 hasMore 时使用 nextCursor 继续翻页"},
[]string{`dws chat message list-all --start "2026-07-23T00:00:00+08:00" --end "2026-07-30T23:59:59+08:00" --limit 50 --format json`},
}
case path == "chat message list-topic-replies" && strings.Contains(message, "topic-id"):
guide = chatWorkbookGuidance{
"缺少话题定位参数:--topic-id",
"topic-id 不能臆造,必须来自同一群聊消息列表中目标话题消息的 openConvThreadId",
[]string{"先执行 chat message list 拉取目标群消息", "从目标话题消息读取 openConvThreadId 并作为 --topic-id"},
[]string{`dws chat message list --group <openConversationId> --time "2026-07-30 23:59:59" --direction older --format json`, `dws chat message list-topic-replies --group <openConversationId> --topic-id <openConvThreadId> --limit 50 --format json`},
}
case path == "chat message send-by-bot" && strings.Contains(message, "required flag"):
guide = chatWorkbookGuidance{
"机器人发送消息缺少必填参数",
"机器人发送需要 robotCode、标题、正文以及群聊或单聊目标,当前参数不完整",
[]string{"补充 --robot-code 和 --title", "通过 --group 或用户参数指定接收目标"},
[]string{`dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "通知" --text "hello" --format json`},
}
case path == "chat message recall-by-bot" && strings.Contains(message, "required flag"):
guide = chatWorkbookGuidance{
"机器人撤回消息缺少 robotCode 或 processQueryKey",
"--keys 的 processQueryKey 来自机器人发送消息的返回结果,不能凭空构造",
[]string{"补充发送该消息的 --robot-code", "从发送结果读取 processQueryKey 并传给 --keys"},
[]string{`dws chat message recall-by-bot --robot-code <robotCode> --group <openConversationId> --keys <processQueryKey> --format json`},
}
case path == "chat message list" && strings.Contains(message, "required flag") && strings.Contains(message, "--time"):
guide = chatWorkbookGuidance{
"拉取会话消息缺少时间锚点:--time",
"消息列表按时间向前或向后拉取,必须提供一个明确的时间锚点",
[]string{"补充格式为 YYYY-MM-DD HH:mm:ss 的 --time", "使用 --direction older 或 newer 明确查询方向"},
[]string{`dws chat message list --group <openConversationId> --time "2026-07-30 10:00:00" --direction older --format json`},
}
case path == "chat message list" && strings.Contains(message, "--group, --user or --open-dingtalk-id is required"):
guide = chatWorkbookGuidance{
"拉取消息时缺少会话目标",
"必须在群聊 openConversationId、单聊 userId、单聊 openDingTalkId 中选择且只选择一个目标",
[]string{"群聊先用 chat search 获取 openConversationId", "单聊先查询人员标识,再传 --user 或 --open-dingtalk-id"},
[]string{`dws chat message list --group <openConversationId> --time "2026-07-15 10:00:00" --format json`},
}
case path == "chat message send" && strings.Contains(message, "media-id is required"):
guide = chatWorkbookGuidance{
"图片消息缺少 --media-id",
"msg-type=image 只接受上游已经获得的有效 mediaId,不能把本地文件名当作 mediaId",
[]string{"已有 mediaId 时补充 --media-id", "发送本地图片时改用 --msg-type file --file-path"},
[]string{`dws chat message send --group <openConversationId> --msg-type image --media-id <mediaId> --format json`, `dws chat message send --group <openConversationId> --msg-type file --file-path ./image.png --format json`},
}
case path == "chat message send" && strings.Contains(message, "readable local --file-path is required"):
guide = chatWorkbookGuidance{
"文件消息缺少可读的本地文件",
"file、audio、video 消息需要可读的 --file-path;旧版 dentry 参数则必须成组提供",
[]string{"优先传入当前机器上可读的 --file-path", "使用旧参数时同时提供 dentry-id、space-id 和 file-name"},
[]string{`dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json`},
}
case path == "chat message send" && strings.Contains(message, "--file-path must be a readable local file"):
guide = chatWorkbookGuidance{
"--file-path 指向的文件不可读",
"指定路径不存在、不是普通文件或当前进程没有读取权限,因此无法上传并发送",
[]string{"检查路径拼写并确认文件存在", "改用当前用户可读取的绝对路径或工作目录相对路径"},
[]string{`dws chat message send --group <openConversationId> --msg-type file --file-path ./report.pdf --format json`},
}
case path == "chat message send" && strings.Contains(message, "unsupported --msg-type"):
guide = chatWorkbookGuidance{
"不支持指定的 --msg-type",
"card 不是当前命令支持的消息类型;文本或 Markdown 消息无需传 --msg-type",
[]string{"文本消息移除 --msg-type 并使用 --text", "媒体消息仅使用 image、file、audio、video、location 或 profile"},
[]string{`dws chat message send --group <openConversationId> --text "消息正文" --format json`},
}
case path == "chat message send" && strings.Contains(message, "message content required"):
guide = chatWorkbookGuidance{
"群消息缺少正文内容",
"未提供 --text 或位置参数,同时也没有选择需要专用参数的媒体消息类型",
[]string{"发送文字时补充 --text", "发送文件时使用 --msg-type file --file-path"},
[]string{`dws chat message send --group <openConversationId> --text "消息正文" --format json`},
}
}
if guide.message == "" {
if required, ok := chatRequiredGuidance[path]; ok &&
(strings.Contains(message, "required") || strings.Contains(message, "缺少")) {
guide = required
} else if strings.HasPrefix(path, "chat ") &&
(strings.Contains(message, "required") ||
strings.Contains(message, "invalid") ||
strings.Contains(message, "unsupported") ||
strings.Contains(message, "unknown flag") ||
strings.Contains(message, "must be")) {
example := fmt.Sprintf("dws %s --help", path)
if meta, ok := cli.ResolveMeta(path); ok && len(meta.Selection.Examples) > 0 {
example = meta.Selection.Examples[0]
if !strings.Contains(example, "--format") {
example += " --format json"
}
}
guide = chatWorkbookGuidance{
"Chat 命令参数校验失败",
message,
[]string{"根据错误补齐或修正参数", fmt.Sprintf("运行 dws %s --help 核对当前命令参数", path)},
[]string{example},
}
} else {
return err
}
}
return apperrors.NewValidation(
guide.message,
apperrors.WithReason(guide.reason),
apperrors.WithHint(guide.actions[0]),
apperrors.WithActions(guide.actions...),
apperrors.WithExamples(guide.examples...),
apperrors.WithCause(err),
)
}
// newPreParseValidationError keeps pipeline handler identity in internal logs
// while exposing only the underlying parameter-domain error to CLI users.
func newPreParseValidationError(err error) error {
@@ -220,6 +679,9 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
)
}
if enriched := enrichChatWorkbookError(cmd, err); enriched != err {
return enriched
}
// Common flag aliases and suggestions
suggestions := map[string]string{
+141
View File
@@ -43,12 +43,153 @@ func configureRootHelp(root *cobra.Command) {
if cmd != root {
defaultHelpFunc(cmd, args)
cli.RenderSafetyAnnotation(cmd)
renderChatAgentSelectionHint(cmd)
return
}
renderRootHelp(root)
})
}
type chatHelpGuidance struct {
reason string
action string
example string
}
var chatWorkbookHelpGuidance = map[string]chatHelpGuidance{
"chat group members": {
"群成员列表固定使用 --id 传群 openConversationId,不使用消息命令的 --group。",
"先查群 ID,再直接执行 members;不要追加多余的 list 子命令。",
`dws chat group members --id <openConversationId> --format json`,
},
"chat group members add": {
"添加群成员固定使用 --id 指定群、--users 指定成员。",
"先查询群 ID 和成员 userId/openDingTalkId,再执行添加。",
`dws chat group members add --id <openConversationId> --users <userId1>,<userId2> --format json`,
},
"chat group members remove": {
"移除群成员使用 --id 和 --users,且不能移除群主。",
"先确认成员和不可逆影响,检查群主身份后再执行。",
`dws chat group members remove --id <openConversationId> --users <userId> --format json`,
},
"chat group members add-bot": {
"添加机器人属于群成员管理,群参数沿用 --id,并需要 robot-code。",
"确认机器人编码和目标群后执行。",
`dws chat group members add-bot --id <openConversationId> --robot-code <robotCode> --format json`,
},
"chat group members remove-bot": {
"移除机器人固定使用 --id 指定群、--bot-id 指定群内机器人。",
"先列出群机器人取得 openBotId,再执行移除。",
`dws chat group members remove-bot --id <openConversationId> --bot-id <openBotId> --format json`,
},
"chat group members list-by-ids": {
"批量查询成员详情使用 --id + --users,users 为成员标识列表。",
"确认目标群和成员 ID 后再查询。",
`dws chat group members list-by-ids --id <openConversationId> --users <openDingTalkId1>,<openDingTalkId2> --format json`,
},
"chat group create": {
"建群使用 --users;创建结果中的群 ID 可继续传给 members add 和 rename。",
"先准备成员 userId,创建后保存返回的 openConversationId。",
`dws chat group create --name "项目群" --users <userId1>,<userId2> --format json`,
},
"chat group rename": {
"群改名只使用 --id + --name,不能使用 --group。",
"先通过 chat search 获取 openConversationId。",
`dws chat group rename --id <openConversationId> --name "新群名" --format json`,
},
"chat message list": {
"message list 按会话和时间拉取消息,不执行服务端关键词搜索。",
"按关键词查找时改用 message search;拉历史时提供会话和 time。",
`dws chat message list --group <openConversationId> --time "2026-07-30 23:59:59" --direction older --format json`,
},
"chat message search": {
"关键词审计应使用服务端搜索,并同时提供 query、start、end。",
"不要用 message list 拉全量后人工筛选。",
`dws chat message search --query "评审" --start "2026-07-01T00:00:00+08:00" --end "2026-07-31T23:59:59+08:00" --format json`,
},
"chat message search-advanced": {
"简单关键词优先 message search;只有组合人员、@、会话等条件时才使用 search-advanced。",
"至少提供一个真实搜索条件,分页参数不算搜索条件。",
`dws chat message search-advanced --query "评审" --conversation-ids <openConversationId> --format json`,
},
"chat message list-all": {
"list-all 按时间跨会话拉取消息,不执行关键词匹配。",
"需要关键词时改用 message search,并始终限制时间范围。",
`dws chat message list-all --start "2026-07-01T00:00:00+08:00" --end "2026-07-31T23:59:59+08:00" --format json`,
},
"chat message list-by-sender": {
"list-by-sender 的核心条件是发送者;核心条件是关键词时应使用 message search。",
"提供发送者 ID 和开始时间,按 nextCursor 翻页。",
`dws chat message list-by-sender --sender-user-id <userId> --start "2026-07-01T00:00:00+08:00" --format json`,
},
}
func renderChatWorkbookHelpGuidance(cmd *cobra.Command) {
if cmd == nil {
return
}
path := strings.TrimSpace(strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" "))
guide, ok := chatWorkbookHelpGuidance[path]
if !ok {
meta, metaOK := cli.ResolveMeta(path)
if !metaOK || meta.Identity.ProductID != "chat" {
return
}
reason := meta.Selection.AgentSummary
if reason == "" {
reason = "执行前需要确认该 Chat 命令的适用场景、必填参数和安全边界。"
}
action := "根据帮助正文补齐必填参数,并在实际执行时增加 --format json。"
if len(meta.Selection.UseWhen) > 0 {
action = meta.Selection.UseWhen[0]
}
example := "dws " + path + " --format json"
if len(meta.Selection.Examples) > 0 {
example = meta.Selection.Examples[0]
if !strings.Contains(example, "--format") {
example += " --format json"
}
}
guide = chatHelpGuidance{reason: reason, action: action, example: example}
}
w := cmd.ErrOrStderr()
_, _ = fmt.Fprintln(w, "错误信息:当前为执行前 guidance,不是运行失败")
_, _ = fmt.Fprintln(w, "原因:"+guide.reason)
_, _ = fmt.Fprintln(w, "建议操作:")
_, _ = fmt.Fprintln(w, "1. "+guide.action)
_, _ = fmt.Fprintln(w, "示例:")
_, _ = fmt.Fprintln(w, "1. "+guide.example)
}
// renderChatAgentSelectionHint exposes the reviewed Chat selection contract in
// command help without reintroducing a second product-local guidance map.
// Selection prose remains authored in schema_hints/selection/chat.json and is
// consumed through the repository-wide ResolveMeta API.
func renderChatAgentSelectionHint(cmd *cobra.Command) {
cliPath := strings.TrimSpace(strings.TrimPrefix(cmd.CommandPath(), cmd.Root().Name()+" "))
meta, ok := cli.ResolveMeta(cliPath)
if !ok || meta.Identity.ProductID != "chat" {
return
}
selection := meta.Selection
w := cmd.OutOrStdout()
_, _ = fmt.Fprintln(w, "Agent guidance:")
if selection.AgentSummary != "" {
_, _ = fmt.Fprintf(w, " Outcome: %s\n", selection.AgentSummary)
}
for _, scenario := range selection.UseWhen {
_, _ = fmt.Fprintf(w, " Use when: %s\n", scenario)
}
for _, scenario := range selection.AvoidWhen {
_, _ = fmt.Fprintf(w, " Avoid when: %s\n", scenario)
}
for _, example := range selection.Examples {
_, _ = fmt.Fprintf(w, " Example: %s\n", example)
}
_, _ = fmt.Fprintln(w, " Output: Agent execution should add --format json.")
}
func renderRootHelp(root *cobra.Command) {
services := visibleMCPRootCommands(root)
utilities := visibleUtilityRootCommands(root)
+21
View File
@@ -69,6 +69,27 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
}
}
func TestChatAgentGuidanceRendersOnlyOnStdout(t *testing.T) {
cmd := NewRootCommand()
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"chat", "clear-messages", "--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("chat clear-messages --help: %v\nstdout:\n%s\nstderr:\n%s", err, stdout.String(), stderr.String())
}
for _, want := range []string{"Agent guidance:", "Outcome:", "Use when:", "Avoid when:", "Example:", "Output:"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("chat help stdout missing %q:\n%s", want, stdout.String())
}
}
if got := strings.TrimSpace(stderr.String()); got != "" {
t.Fatalf("chat help wrote guidance or warnings to stderr:\n%s", got)
}
}
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
root := NewRootCommand()
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
+2 -2
View File
@@ -742,7 +742,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("mcp_tool_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("MCP tool returned a business error; check tool parameters and refer to skill documentation."),
apperrors.WithHint(apperrors.SuggestBusinessHint(callResult.Content)),
apperrors.WithServerDiag(diag),
)
// PAT scope error in business response: offer human-readable output and retry
@@ -767,7 +767,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
apperrors.WithOperation("tools/call"),
apperrors.WithReason("business_error"),
apperrors.WithServerKey(invocation.CanonicalProduct),
apperrors.WithHint("The API returned a business-level error. Check required parameters and values."),
apperrors.WithHint(apperrors.SuggestBusinessHint(callResult.Content)),
apperrors.WithServerDiag(diag),
)
}
@@ -106,7 +106,7 @@ func TestEmbeddedShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
product := executeShortcutSchemaQuery(t, "chat")
productPayload, _ := product["product"].(map[string]any)
if got, want := int(product["count"].(float64)), 129; got != want {
if got, want := int(product["count"].(float64)), 159; got != want {
t.Fatalf("schema chat count = %d, want %d", got, want)
}
summaries := schemaContractObjectSlice(productPayload["tools"])
+5 -15
View File
@@ -131,12 +131,9 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat +chat-bots",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat +chat-dismiss",
@@ -151,12 +148,9 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat +chat-invite-url",
Aliases: map[string]string{
"chat": "group",
"chat-id": "group",
"conversation-id": "group",
"open-conversation-id": "group",
"chat": "group",
},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target"},
},
{
CLIPath: "chat +chat-mute",
@@ -313,11 +307,7 @@ var generatedParamAliases = []ParamAliasEntry{
},
{
CLIPath: "chat +messages-mget",
Aliases: map[string]string{
"message-ids": "msg-ids",
"open-message-ids": "msg-ids",
},
Blocked: []string{"message-id", "msg-id", "open-message-id", "ref-msg-id", "src-msg-id"},
Blocked: []string{"msg-id", "open-message-id", "ref-msg-id", "src-msg-id"},
},
{
CLIPath: "chat +messages-read-status",
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -1,18 +1,18 @@
{
"version": 1,
"source_hash": "sha256:670ca810a83bf2aa6f387a18c3af746393d5ea9de24570cbcebbcf994eb7b613",
"surface_hash": "sha256:60eee8e2f37d6d9d60689efce85082798eb9ad38b7ba7c0b471c3de676a85a16",
"source_hash": "sha256:7111ced2e1cd0764338aacce792d95603734177a82deea4a4642626691940485",
"surface_hash": "sha256:4897d582a472d9d3596f88fb4546e7795d6b9bc334d020712f5d895ce894f7a7",
"coverage": {
"surface_products": 26,
"products_with_metadata": 26,
"surface_tools": 845,
"tools_with_metadata": 845,
"tools_with_agent_summary": 845,
"tools_with_use_when": 845,
"tools_with_avoid_when": 845,
"tools_with_examples": 845,
"tools_with_interface_mode": 845,
"unmatched_skill_tools": 122,
"surface_tools": 875,
"tools_with_metadata": 875,
"tools_with_agent_summary": 875,
"tools_with_use_when": 875,
"tools_with_avoid_when": 875,
"tools_with_examples": 875,
"tools_with_interface_mode": 875,
"unmatched_skill_tools": 96,
"unreviewed_skill_tools": 11
},
"products": {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -79,36 +79,6 @@
"calendar acl add",
"calendar acl delete",
"calendar book update",
"chat category add-conv",
"chat category create",
"chat category delete",
"chat category remove-conv",
"chat category rename",
"chat chmod",
"chat clear-all-red-point",
"chat clear-messages",
"chat clear-red-point",
"chat data-auth cross-org",
"chat group audit-join-validation",
"chat group list-all",
"chat group list-join-validations",
"chat group members list-by-ids",
"chat group notice create",
"chat group notice edit",
"chat group notice get",
"chat group notice list",
"chat group share-invite",
"chat group update-alias",
"chat hide",
"chat list-all-conversations",
"chat mark-read",
"chat mark-unread",
"chat message list-emotion-replies",
"chat message set-top-msg",
"chat message unset-top-msg",
"chat mute-at-all",
"chat mute-red-envelope",
"chat text translate",
"contact label get",
"contact label list",
"contact label list-members",
@@ -1,6 +1,378 @@
{
"id": "chat",
"tools": [
{
"canonical_path": "chat.add_conv_to_categories",
"cli_path": "chat category add-conv"
},
{
"canonical_path": "chat.add_custom_group_role",
"cli_path": "chat group-role add"
},
{
"canonical_path": "chat.add_emoji_reaction",
"cli_path": "chat message add-emoji"
},
{
"canonical_path": "chat.add_group_member",
"cli_path": "chat group members add"
},
{
"canonical_path": "chat.add_message_favorite",
"cli_path": "chat message add-favorite"
},
{
"canonical_path": "chat.add_robot_to_group",
"cli_path": "chat group members add-bot"
},
{
"canonical_path": "chat.add_text_emotion",
"cli_path": "chat message add-text-emotion"
},
{
"canonical_path": "chat.audit_join_group",
"cli_path": "chat group audit-join-validation"
},
{
"canonical_path": "chat.batch_query_group_chat_settings",
"cli_path": "chat group user-settings query"
},
{
"canonical_path": "chat.batch_update_group_chat_settings",
"cli_path": "chat group user-settings set"
},
{
"canonical_path": "chat.clear_all_red_point",
"cli_path": "chat clear-all-red-point"
},
{
"canonical_path": "chat.clear_conversation_messages",
"cli_path": "chat clear-messages"
},
{
"canonical_path": "chat.clear_conversation_red_point",
"cli_path": "chat clear-red-point"
},
{
"canonical_path": "chat.combine_forward_messages",
"cli_path": "chat message combine-forward"
},
{
"canonical_path": "chat.create_and_send_card",
"cli_path": "chat message send-card"
},
{
"canonical_path": "chat.create_conv_category",
"cli_path": "chat category create"
},
{
"canonical_path": "chat.create_group_conversation",
"cli_path": "chat group create"
},
{
"canonical_path": "chat.create_group_notice",
"cli_path": "chat group notice create"
},
{
"canonical_path": "chat.create_smart_conv_category",
"cli_path": "chat category create-smart"
},
{
"canonical_path": "chat.create_text_emotion",
"cli_path": "chat message create-text-emotion"
},
{
"canonical_path": "chat.delete_conv_category",
"cli_path": "chat category delete"
},
{
"canonical_path": "chat.dismiss_group",
"cli_path": "chat group dismiss"
},
{
"canonical_path": "chat.download_media",
"cli_path": "chat message download-media"
},
{
"canonical_path": "chat.edit_group_notice",
"cli_path": "chat group notice edit"
},
{
"canonical_path": "chat.edit_message",
"cli_path": "chat message edit"
},
{
"canonical_path": "chat.forward_message",
"cli_path": "chat message forward"
},
{
"canonical_path": "chat.forward_topic",
"cli_path": "chat message forward-topic"
},
{
"canonical_path": "chat.get_conv_categories_info",
"cli_path": "chat category batch-info"
},
{
"canonical_path": "chat.get_conv_info_by_group_id",
"cli_path": "chat group get-by-group-id"
},
{
"canonical_path": "chat.get_conversation_info",
"cli_path": "chat conversation-info"
},
{
"canonical_path": "chat.get_group_invite_url",
"cli_path": "chat group invite-url"
},
{
"canonical_path": "chat.get_group_mute_config",
"cli_path": "chat group get-mute-config"
},
{
"canonical_path": "chat.get_group_notice",
"cli_path": "chat group notice get"
},
{
"canonical_path": "chat.grant_cross_org_data_access",
"cli_path": "chat data-auth cross-org"
},
{
"canonical_path": "chat.grant_permission",
"cli_path": "chat chmod"
},
{
"canonical_path": "chat.hide_conversation",
"cli_path": "chat hide"
},
{
"canonical_path": "chat.list_all_conversations",
"cli_path": "chat list-all-conversations"
},
{
"canonical_path": "chat.list_apply_join_group_records",
"cli_path": "chat group list-join-validations"
},
{
"canonical_path": "chat.list_conv_categories_by_conv",
"cli_path": "chat category list-by-conv"
},
{
"canonical_path": "chat.list_conversation_message_v2",
"cli_path": "chat message list"
},
{
"canonical_path": "chat.list_conversations_by_category",
"cli_path": "chat category list-conversations"
},
{
"canonical_path": "chat.list_custom_group_roles",
"cli_path": "chat group-role list"
},
{
"canonical_path": "chat.list_group_bots",
"cli_path": "chat group bots"
},
{
"canonical_path": "chat.list_group_member_by_ids",
"cli_path": "chat group members list-by-ids"
},
{
"canonical_path": "chat.list_group_notices",
"cli_path": "chat group notice list"
},
{
"canonical_path": "chat.list_individual_chat_message",
"cli_path": "chat message list-direct"
},
{
"canonical_path": "chat.list_message_emotion_replies",
"cli_path": "chat message list-emotion-replies"
},
{
"canonical_path": "chat.list_message_favorites",
"cli_path": "chat message list-favorites"
},
{
"canonical_path": "chat.list_messages_by_ids",
"cli_path": "chat message list-by-ids"
},
{
"canonical_path": "chat.list_my_groups_pagination",
"cli_path": "chat group list-all"
},
{
"canonical_path": "chat.list_owned_or_admin_groups",
"cli_path": "chat group list-my-groups"
},
{
"canonical_path": "chat.list_pin_messages",
"cli_path": "chat message list-pin-msg"
},
{
"canonical_path": "chat.list_special_focus_messages",
"cli_path": "chat message list-focused"
},
{
"canonical_path": "chat.list_top_conversations",
"cli_path": "chat list-top-conversations"
},
{
"canonical_path": "chat.list_topic_replies",
"cli_path": "chat message list-topic-replies"
},
{
"canonical_path": "chat.list_user_define_conv_categories",
"cli_path": "chat category list"
},
{
"canonical_path": "chat.mark_conversation_unread",
"cli_path": "chat mark-unread"
},
{
"canonical_path": "chat.mark_message_read",
"cli_path": "chat mark-read"
},
{
"canonical_path": "chat.query_custom_user_roles",
"cli_path": "chat group-role query-user"
},
{
"canonical_path": "chat.query_message_send_status",
"cli_path": "chat message query-send-status"
},
{
"canonical_path": "chat.query_msg_read_status",
"cli_path": "chat message read-status"
},
{
"canonical_path": "chat.quit_group",
"cli_path": "chat group quit"
},
{
"canonical_path": "chat.recall_message",
"cli_path": "chat message recall"
},
{
"canonical_path": "chat.recall_robot_message",
"cli_path": "chat message recall-by-bot"
},
{
"canonical_path": "chat.remove_conv_from_categories",
"cli_path": "chat category remove-conv"
},
{
"canonical_path": "chat.remove_custom_group_role",
"cli_path": "chat group-role remove"
},
{
"canonical_path": "chat.remove_custom_user_roles",
"cli_path": "chat group-role remove-user"
},
{
"canonical_path": "chat.remove_emoji_reaction",
"cli_path": "chat message remove-emoji"
},
{
"canonical_path": "chat.remove_group_member",
"cli_path": "chat group members remove"
},
{
"canonical_path": "chat.remove_message_favorite",
"cli_path": "chat message remove-favorite"
},
{
"canonical_path": "chat.remove_robot_in_group",
"cli_path": "chat group members remove-bot"
},
{
"canonical_path": "chat.remove_text_emotion",
"cli_path": "chat message remove-text-emotion"
},
{
"canonical_path": "chat.rename_conv_category",
"cli_path": "chat category rename"
},
{
"canonical_path": "chat.reply_personal_message",
"cli_path": "chat message reply"
},
{
"canonical_path": "chat.search_at_me_message",
"cli_path": "chat message list-mentions"
},
{
"canonical_path": "chat.search_bots",
"cli_path": "chat bot find"
},
{
"canonical_path": "chat.search_common_groups",
"cli_path": "chat search-common"
},
{
"canonical_path": "chat.search_groups",
"cli_path": "chat search"
},
{
"canonical_path": "chat.search_messages",
"cli_path": "chat message search-advanced"
},
{
"canonical_path": "chat.search_messages_by_keyword",
"cli_path": "chat message search"
},
{
"canonical_path": "chat.search_messages_by_sender",
"cli_path": "chat message list-by-sender"
},
{
"canonical_path": "chat.search_messages_by_time_range",
"cli_path": "chat message list-all"
},
{
"canonical_path": "chat.search_my_robots",
"cli_path": "chat bot search"
},
{
"canonical_path": "chat.send_message_by_custom_robot",
"cli_path": "chat message send-by-webhook"
},
{
"canonical_path": "chat.send_personal_message",
"cli_path": "chat message send"
},
{
"canonical_path": "chat.send_robot_message",
"cli_path": "chat message send-by-bot"
},
{
"canonical_path": "chat.set_custom_user_roles",
"cli_path": "chat group-role set-user"
},
{
"canonical_path": "chat.set_group_member_mute_list",
"cli_path": "chat group-mute-member"
},
{
"canonical_path": "chat.set_group_mute",
"cli_path": "chat group-mute"
},
{
"canonical_path": "chat.set_pin_message",
"cli_path": "chat message set-pin-msg"
},
{
"canonical_path": "chat.set_top_conversation",
"cli_path": "chat set-top"
},
{
"canonical_path": "chat.set_top_message",
"cli_path": "chat message set-top-msg"
},
{
"canonical_path": "chat.share_group_invite_url",
"cli_path": "chat group share-invite"
},
{
"canonical_path": "chat.shortcut_at_me",
"cli_path": "chat +at-me"
@@ -57,6 +429,10 @@
"canonical_path": "chat.shortcut_chat_list_mine",
"cli_path": "chat +chat-list-mine"
},
{
"canonical_path": "chat.shortcut_chat_messages",
"cli_path": "chat +chat-messages"
},
{
"canonical_path": "chat.shortcut_chat_mute",
"cli_path": "chat +chat-mute"
@@ -149,10 +525,18 @@
"canonical_path": "chat.shortcut_messages_read_status",
"cli_path": "chat +messages-read-status"
},
{
"canonical_path": "chat.shortcut_messages_send",
"cli_path": "chat +messages-send"
},
{
"canonical_path": "chat.shortcut_messages_send_by_webhook",
"cli_path": "chat +messages-send-by-webhook"
},
{
"canonical_path": "chat.shortcut_messages_send_card",
"cli_path": "chat +messages-send-card"
},
{
"canonical_path": "chat.shortcut_messages_update_card",
"cli_path": "chat +messages-update-card"
@@ -161,114 +545,62 @@
"canonical_path": "chat.shortcut_my_groups",
"cli_path": "chat +my-groups"
},
{
"canonical_path": "chat.shortcut_search_msg",
"cli_path": "chat +search-msg"
},
{
"canonical_path": "chat.shortcut_send_to_group",
"cli_path": "chat +send-to-group"
},
{
"canonical_path": "chat.shortcut_thread_replies",
"cli_path": "chat +thread-replies"
},
{
"canonical_path": "chat.shortcut_unread_chats",
"cli_path": "chat +unread-chats"
},
{
"canonical_path": "chat.search_bots",
"cli_path": "chat bot find"
"canonical_path": "chat.transfer_group_owner",
"cli_path": "chat group transfer-owner"
},
{
"canonical_path": "chat.search_my_robots",
"cli_path": "chat bot search"
"canonical_path": "chat.translate",
"cli_path": "chat text translate"
},
{
"canonical_path": "chat.get_conv_categories_info",
"cli_path": "chat category batch-info"
"canonical_path": "chat.unread_message_conversation_list",
"cli_path": "chat message list-unread-conversations"
},
{
"canonical_path": "chat.create_smart_conv_category",
"cli_path": "chat category create-smart"
"canonical_path": "chat.unset_pin_message",
"cli_path": "chat message unset-pin-msg"
},
{
"canonical_path": "chat.list_user_define_conv_categories",
"cli_path": "chat category list"
"canonical_path": "chat.unset_top_message",
"cli_path": "chat message unset-top-msg"
},
{
"canonical_path": "chat.list_conv_categories_by_conv",
"cli_path": "chat category list-by-conv"
},
{
"canonical_path": "chat.list_conversations_by_category",
"cli_path": "chat category list-conversations"
},
{
"canonical_path": "chat.get_conversation_info",
"cli_path": "chat conversation-info"
},
{
"canonical_path": "chat.list_group_bots",
"cli_path": "chat group bots"
},
{
"canonical_path": "chat.create_group_conversation",
"cli_path": "chat group create"
},
{
"canonical_path": "chat.dismiss_group",
"cli_path": "chat group dismiss"
},
{
"canonical_path": "chat.get_conv_info_by_group_id",
"cli_path": "chat group get-by-group-id"
},
{
"canonical_path": "chat.get_group_mute_config",
"cli_path": "chat group get-mute-config"
},
{
"canonical_path": "chat.get_group_invite_url",
"cli_path": "chat group invite-url"
},
{
"canonical_path": "chat.list_owned_or_admin_groups",
"cli_path": "chat group list-my-groups"
},
{
"canonical_path": "chat.add_group_member",
"cli_path": "chat group members add"
},
{
"canonical_path": "chat.add_robot_to_group",
"cli_path": "chat group members add-bot"
},
{
"canonical_path": "chat.remove_group_member",
"cli_path": "chat group members remove"
},
{
"canonical_path": "chat.remove_robot_in_group",
"cli_path": "chat group members remove-bot"
},
{
"canonical_path": "chat.quit_group",
"cli_path": "chat group quit"
},
{
"canonical_path": "chat.update_group_name",
"cli_path": "chat group rename"
"canonical_path": "chat.update_at_all_notification_off",
"cli_path": "chat mute-at-all"
},
{
"canonical_path": "chat.update_conv_member_roles",
"cli_path": "chat group set-admin"
},
{
"canonical_path": "chat.update_show_history_msg_option",
"cli_path": "chat group set-history"
},
{
"canonical_path": "chat.transfer_group_owner",
"cli_path": "chat group transfer-owner"
"canonical_path": "chat.update_custom_group_role",
"cli_path": "chat group-role update"
},
{
"canonical_path": "chat.update_group_icon",
"cli_path": "chat group update-icon"
},
{
"canonical_path": "chat.update_group_name",
"cli_path": "chat group rename"
},
{
"canonical_path": "chat.update_group_nick",
"cli_path": "chat group update-nick"
@@ -278,200 +610,16 @@
"cli_path": "chat group update-settings"
},
{
"canonical_path": "chat.upgrade_group_to_external",
"cli_path": "chat group upgrade-to-external"
"canonical_path": "chat.update_notification_off",
"cli_path": "chat mute"
},
{
"canonical_path": "chat.batch_query_group_chat_settings",
"cli_path": "chat group user-settings query"
"canonical_path": "chat.update_red_env_notification_off",
"cli_path": "chat mute-red-envelope"
},
{
"canonical_path": "chat.batch_update_group_chat_settings",
"cli_path": "chat group user-settings set"
},
{
"canonical_path": "chat.set_group_mute",
"cli_path": "chat group-mute"
},
{
"canonical_path": "chat.set_group_member_mute_list",
"cli_path": "chat group-mute-member"
},
{
"canonical_path": "chat.add_custom_group_role",
"cli_path": "chat group-role add"
},
{
"canonical_path": "chat.list_custom_group_roles",
"cli_path": "chat group-role list"
},
{
"canonical_path": "chat.query_custom_user_roles",
"cli_path": "chat group-role query-user"
},
{
"canonical_path": "chat.remove_custom_group_role",
"cli_path": "chat group-role remove"
},
{
"canonical_path": "chat.remove_custom_user_roles",
"cli_path": "chat group-role remove-user"
},
{
"canonical_path": "chat.set_custom_user_roles",
"cli_path": "chat group-role set-user"
},
{
"canonical_path": "chat.update_custom_group_role",
"cli_path": "chat group-role update"
},
{
"canonical_path": "chat.list_top_conversations",
"cli_path": "chat list-top-conversations"
},
{
"canonical_path": "chat.add_emoji_reaction",
"cli_path": "chat message add-emoji"
},
{
"canonical_path": "chat.add_message_favorite",
"cli_path": "chat message add-favorite"
},
{
"canonical_path": "chat.add_text_emotion",
"cli_path": "chat message add-text-emotion"
},
{
"canonical_path": "chat.combine_forward_messages",
"cli_path": "chat message combine-forward"
},
{
"canonical_path": "chat.create_text_emotion",
"cli_path": "chat message create-text-emotion"
},
{
"canonical_path": "chat.download_media",
"cli_path": "chat message download-media"
},
{
"canonical_path": "chat.edit_message",
"cli_path": "chat message edit"
},
{
"canonical_path": "chat.forward_message",
"cli_path": "chat message forward"
},
{
"canonical_path": "chat.forward_topic",
"cli_path": "chat message forward-topic"
},
{
"canonical_path": "chat.list_conversation_message_v2",
"cli_path": "chat message list"
},
{
"canonical_path": "chat.search_messages_by_time_range",
"cli_path": "chat message list-all"
},
{
"canonical_path": "chat.list_messages_by_ids",
"cli_path": "chat message list-by-ids"
},
{
"canonical_path": "chat.search_messages_by_sender",
"cli_path": "chat message list-by-sender"
},
{
"canonical_path": "chat.list_individual_chat_message",
"cli_path": "chat message list-direct"
},
{
"canonical_path": "chat.list_message_favorites",
"cli_path": "chat message list-favorites"
},
{
"canonical_path": "chat.list_special_focus_messages",
"cli_path": "chat message list-focused"
},
{
"canonical_path": "chat.search_at_me_message",
"cli_path": "chat message list-mentions"
},
{
"canonical_path": "chat.list_pin_messages",
"cli_path": "chat message list-pin-msg"
},
{
"canonical_path": "chat.list_topic_replies",
"cli_path": "chat message list-topic-replies"
},
{
"canonical_path": "chat.unread_message_conversation_list",
"cli_path": "chat message list-unread-conversations"
},
{
"canonical_path": "chat.query_message_send_status",
"cli_path": "chat message query-send-status"
},
{
"canonical_path": "chat.query_msg_read_status",
"cli_path": "chat message read-status"
},
{
"canonical_path": "chat.recall_message",
"cli_path": "chat message recall"
},
{
"canonical_path": "chat.recall_robot_message",
"cli_path": "chat message recall-by-bot"
},
{
"canonical_path": "chat.remove_emoji_reaction",
"cli_path": "chat message remove-emoji"
},
{
"canonical_path": "chat.remove_message_favorite",
"cli_path": "chat message remove-favorite"
},
{
"canonical_path": "chat.remove_text_emotion",
"cli_path": "chat message remove-text-emotion"
},
{
"canonical_path": "chat.reply_personal_message",
"cli_path": "chat message reply"
},
{
"canonical_path": "chat.search_messages_by_keyword",
"cli_path": "chat message search"
},
{
"canonical_path": "chat.search_messages",
"cli_path": "chat message search-advanced"
},
{
"canonical_path": "chat.send_personal_message",
"cli_path": "chat message send"
},
{
"canonical_path": "chat.send_robot_message",
"cli_path": "chat message send-by-bot"
},
{
"canonical_path": "chat.send_message_by_custom_robot",
"cli_path": "chat message send-by-webhook"
},
{
"canonical_path": "chat.create_and_send_card",
"cli_path": "chat message send-card"
},
{
"canonical_path": "chat.set_pin_message",
"cli_path": "chat message set-pin-msg"
},
{
"canonical_path": "chat.unset_pin_message",
"cli_path": "chat message unset-pin-msg"
"canonical_path": "chat.update_show_history_msg_option",
"cli_path": "chat group set-history"
},
{
"canonical_path": "chat.update_streaming_card",
@@ -482,40 +630,12 @@
"cli_path": "chat message update-text-emotion"
},
{
"canonical_path": "chat.update_notification_off",
"cli_path": "chat mute"
"canonical_path": "chat.update_user_group_alias",
"cli_path": "chat group update-alias"
},
{
"canonical_path": "chat.search_groups",
"cli_path": "chat search"
},
{
"canonical_path": "chat.search_common_groups",
"cli_path": "chat search-common"
},
{
"canonical_path": "chat.set_top_conversation",
"cli_path": "chat set-top"
},
{
"canonical_path": "chat.shortcut_chat_messages",
"cli_path": "chat +chat-messages"
},
{
"canonical_path": "chat.shortcut_messages_send",
"cli_path": "chat +messages-send"
},
{
"canonical_path": "chat.shortcut_messages_send_card",
"cli_path": "chat +messages-send-card"
},
{
"canonical_path": "chat.shortcut_search_msg",
"cli_path": "chat +search-msg"
},
{
"canonical_path": "chat.shortcut_thread_replies",
"cli_path": "chat +thread-replies"
"canonical_path": "chat.upgrade_group_to_external",
"cli_path": "chat group upgrade-to-external"
}
]
}
@@ -801,6 +801,396 @@
"cli_path": "chat group upgrade-to-external",
"runtime_gate": "confirm_dangerous"
},
"chat.add_conv_to_categories": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns validation and calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native category command with its real Cobra contract; runtime has no confirmation gate.",
"cli_path": "chat category add-conv",
"runtime_gate": "none"
},
"chat.create_conv_category": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns title validation and calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native category command and preserve the runtime 15-character title validation; runtime has no confirmation gate.",
"cli_path": "chat category create",
"runtime_gate": "none"
},
"chat.delete_conv_category": {
"effect": "destructive",
"risk": "high",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote category-delete helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native category deletion command with its explicit typed --yes confirmation gate.",
"cli_path": "chat category delete",
"runtime_gate": "typed_yes"
},
"chat.remove_conv_from_categories": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns validation and calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native category membership command with its real Cobra contract; runtime has no confirmation gate.",
"cli_path": "chat category remove-conv",
"runtime_gate": "none"
},
"chat.rename_conv_category": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI owns title validation and calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native category rename command and preserve the runtime 15-character title validation; runtime has no confirmation gate.",
"cli_path": "chat category rename",
"runtime_gate": "none"
},
"chat.grant_permission": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed permission adapter: the executable CLI normalizes grant scope and parameters before calling chat_permission_grant.",
"reviewed": true,
"review_reason": "Publish the documented chat permission grant surface without inventing a runtime confirmation gate.",
"cli_path": "chat chmod",
"runtime_gate": "none"
},
"chat.clear_all_red_point": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native red-point command; runtime has no confirmation gate.",
"cli_path": "chat clear-all-red-point",
"runtime_gate": "none"
},
"chat.clear_conversation_messages": {
"effect": "destructive",
"risk": "high",
"confirmation": "user_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote conversation-clear helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native clear-messages command with its explicit typed --yes confirmation gate.",
"cli_path": "chat clear-messages",
"runtime_gate": "typed_yes"
},
"chat.clear_conversation_red_point": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native red-point command; runtime has no confirmation gate.",
"cli_path": "chat clear-red-point",
"runtime_gate": "none"
},
"chat.grant_cross_org_data_access": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed cross-organization permission adapter: the executable CLI assembles scoped chat_permission_grant arguments.",
"reviewed": true,
"review_reason": "Publish the documented cross-organization data authorization command without inventing a runtime confirmation gate.",
"cli_path": "chat data-auth cross-org",
"runtime_gate": "none"
},
"chat.audit_join_group": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI validates supported audit statuses before calling a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented join-audit command and preserve the runtime AuditApprove/AuditDelete restriction.",
"cli_path": "chat group audit-join-validation",
"runtime_gate": "none"
},
"chat.list_my_groups_pagination": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native paginated group-list command.",
"cli_path": "chat group list-all",
"runtime_gate": "none"
},
"chat.list_apply_join_group_records": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native join-validation list command.",
"cli_path": "chat group list-join-validations",
"runtime_gate": "none"
},
"chat.list_group_member_by_ids": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote member lookup helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native batch group-member lookup command.",
"cli_path": "chat group members list-by-ids",
"runtime_gate": "none"
},
"chat.create_group_notice": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI assembles notice scheduling and notification arguments for a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native group notice creation command; runtime has no confirmation gate.",
"cli_path": "chat group notice create",
"runtime_gate": "none"
},
"chat.edit_group_notice": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI assembles notice update arguments for a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native group notice edit command; runtime has no confirmation gate.",
"cli_path": "chat group notice edit",
"runtime_gate": "none"
},
"chat.get_group_notice": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI validates notice IDs before calling a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native group notice detail command.",
"cli_path": "chat group notice get",
"runtime_gate": "none"
},
"chat.list_group_notices": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native group notice list command.",
"cli_path": "chat group notice list",
"runtime_gate": "none"
},
"chat.share_group_invite_url": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI enforces target/receiver exclusivity before calling a remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native invite-sharing command; runtime has no confirmation gate.",
"cli_path": "chat group share-invite",
"runtime_gate": "none"
},
"chat.update_user_group_alias": {
"effect": "write",
"risk": "low",
"confirmation": "not_required",
"idempotency": "unknown",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote personal-alias helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native personal group-alias command.",
"cli_path": "chat group update-alias",
"runtime_gate": "none"
},
"chat.hide_conversation": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote conversation visibility helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native hide-conversation command.",
"cli_path": "chat hide",
"runtime_gate": "none"
},
"chat.list_all_conversations": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a paginated remote helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native all-conversations list command.",
"cli_path": "chat list-all-conversations",
"runtime_gate": "none"
},
"chat.mark_message_read": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote read-state helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native mark-read command.",
"cli_path": "chat mark-read",
"runtime_gate": "none"
},
"chat.mark_conversation_unread": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote read-state helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native mark-unread command.",
"cli_path": "chat mark-unread",
"runtime_gate": "none"
},
"chat.list_message_emotion_replies": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote emotion-reply helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native batch emotion reply list command.",
"cli_path": "chat message list-emotion-replies",
"runtime_gate": "none"
},
"chat.set_top_message": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote message-top helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native set-top-message command.",
"cli_path": "chat message set-top-msg",
"runtime_gate": "none"
},
"chat.unset_top_message": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote message-top helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native unset-top-message command.",
"cli_path": "chat message unset-top-msg",
"runtime_gate": "none"
},
"chat.update_at_all_notification_off": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote notification preference helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native @all notification preference command.",
"cli_path": "chat mute-at-all",
"runtime_gate": "none"
},
"chat.update_red_env_notification_off": {
"effect": "write",
"risk": "medium",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote notification preference helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native red-envelope notification preference command.",
"cli_path": "chat mute-red-envelope",
"runtime_gate": "none"
},
"chat.translate": {
"effect": "read",
"risk": "low",
"confirmation": "not_required",
"idempotency": "idempotent",
"interface_mode": "composite",
"availability": "available",
"interface_reason": "Reviewed unpinned IM adapter: the executable CLI calls a remote translation helper absent from the pinned MCP snapshot.",
"reviewed": true,
"review_reason": "Publish the documented native chat text translation command.",
"cli_path": "chat text translate",
"runtime_gate": "none"
},
"chat.shortcut_bot_search": {
"effect": "read",
"risk": "low",
@@ -7,7 +7,7 @@
"channel": "open-source"
},
"coverage": {
"source_tools": 845,
"source_tools": 875,
"matched_tools": 71
},
"tools": {
+615 -16
View File
@@ -150,12 +150,12 @@
]
},
"chat.create_and_send_card": {
"agent_summary": "创建并向群聊或单聊发送互动卡片",
"agent_summary": "创建并向群聊或单聊发送通用流式卡片",
"use_when": [
"需要卡片式交互且已准备接收会话或用户时"
"需要通用流式卡片交互且已准备接收会话或用户时"
],
"avoid_when": [
"只发送普通文本时使用 send 或 send-by-bot"
"只发送普通文本时使用 send 或 send-by-bot;转发现有审批、日历、待办等原生产品卡片时应先取得真实 openMessageId,再使用 message forward"
],
"examples": [
"dws chat message send-card --group <openConversationId>"
@@ -263,7 +263,7 @@
"已知消息、会话和资源 ID,需要保存媒体文件时"
],
"avoid_when": [
"只查看文本消息内容时使用对应消息查询命令"
"只查看文本消息内容时使用对应消息查询命令;本命令不是发送附件的前置步骤,本地文件发送直接使用 message send --msg-type file --file-path"
],
"examples": [
"dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ."
@@ -280,10 +280,10 @@
"chat.forward_message": {
"agent_summary": "把一条已有消息转发到另一个会话",
"use_when": [
"已知源消息与源、目标会话 ID 时"
"已知同一源会话中的真实 openMessageId 与源、目标会话 ID,需要保留原消息或原生产品卡片时"
],
"avoid_when": [
"合并转发多条消息时使用 chat message combine-forward"
"合并转发多条消息时使用 chat message combine-forward;OA 实例 ID、日历事件 ID、待办任务 ID 等产品对象 ID 不能代替消息 ID"
],
"examples": [
"dws chat message forward --src-conversation-id <srcConversationId> --msg-id <openMessageId> --dest-conversation-id <destConversationId>"
@@ -340,7 +340,7 @@
"chat.get_conversation_info": {
"agent_summary": "获取群聊或单聊会话的详细信息",
"use_when": [
"已知群 ID 或用户标识并需要解析会话详情时"
"已知群 ID 或单聊用户标识并需要解析会话详情时;--group、--user、--open-dingtalk-id 只能选择一个"
],
"avoid_when": [
"按群名查找会话时使用 chat search"
@@ -383,7 +383,7 @@
"用户明确指定某个会话,并要读取消息或追溯引用回复中的原消息上下文时"
],
"avoid_when": [
"跨全部会话按时间查询时使用 chat message list-all"
"跨全部会话按时间查询时使用 chat message list-all;关键词搜索或审计应使用 message search,不要拉最近消息后在本地筛选"
],
"examples": [
"dws chat message list --group <openConversationId> --time \"2026-07-01 00:00:00\" --limit 50"
@@ -663,7 +663,7 @@
"发送命令返回 openTaskId 后需要确认投递结果时"
],
"avoid_when": [
"没有 openTaskId 或只需查消息内容时不要使用"
"没有 message send 真实返回的 openTaskId 时不要使用;openMessageId 和会话 ID 都不能代替 openTaskId"
],
"examples": [
"dws chat message query-send-status --open-task-id <openTaskId>"
@@ -905,7 +905,7 @@
"chat.reply_personal_message": {
"agent_summary": "引用指定消息发送个人回复",
"use_when": [
"用户要针对某条已有消息进行引用回复时"
"用户要针对某条已有消息进行引用回复,且 conversationId、openMessageId、senderOpenDingTalkId 来自同一条消息时"
],
"avoid_when": [
"无需引用上下文的普通消息使用 chat message send"
@@ -1138,10 +1138,10 @@
"chat.send_personal_message": {
"agent_summary": "以当前用户身份发送群聊或单聊消息",
"use_when": [
"用户明确要以个人身份发送文本或媒体消息时"
"用户明确要以个人身份发送消息时;文本用 --text,图片仅在已有有效 mediaId 时用 --msg-type image --media-id,本地 PDF/DOCX/XLSX 等文件用 --msg-type file --file-path"
],
"avoid_when": [
"机器人身份或 Webhook 发送应使用对应命令"
"机器人身份或 Webhook 发送应使用对应命令;发送审批、日历或待办摘要不会创建对应产品对象"
],
"examples": [
"dws chat message send --group <openConversationId> \"项目已更新\""
@@ -1398,7 +1398,7 @@
"chat.update_group_name": {
"agent_summary": "修改指定群聊的名称",
"use_when": [
"需要给已有群聊重命名时"
"需要把已有群聊改成用户指定的准确名称时;名称不得擅自加前缀、截断或改写,复合任务中重命名后继续完成后续步骤"
],
"avoid_when": [
"只修改个人可见备注时不要使用群名称更新"
@@ -1478,10 +1478,10 @@
"chat.update_streaming_card": {
"agent_summary": "更新已发送流式卡片的内容和状态",
"use_when": [
"已有 bizId 并需要追加内容或结束流式输出时"
"已有 message send-card 真实返回的 bizId,并需要追加内容或结束流式输出时;最后一次更新使用完成状态"
],
"avoid_when": [
"创建新卡片时使用 chat message send-card"
"创建新卡片时使用 chat message send-card;openMessageId、OA 实例 ID、日历事件 ID或待办任务 ID 不能代替 bizId"
],
"examples": [
"dws chat message update-card --biz-id <bizId> --content \"处理完成\" --flow-status 2"
@@ -1596,6 +1596,604 @@
"live-dws-schema:chat.upgrade_group_to_external#FAILED"
]
},
"chat.add_conv_to_categories": {
"agent_summary": "把一个会话加入一个或多个现有自定义分组",
"use_when": [
"已从 category list 取得真实 categoryId,并要把当前账号可访问的会话归入这些分组时"
],
"avoid_when": [
"创建新分组使用 category create;移出分组使用 category remove-conv"
],
"examples": [
"dws chat category add-conv --group <openConversationId> --category-ids 123,456"
],
"reviewed": true,
"review_reason": "依据 nanrun 树形路由与当前 Cobra/多维表参考复核会话分组写入路径;要求使用真实 ID 并在复合任务中继续完成后续步骤。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.add_conv_to_categories",
"cobra-help:dws chat category add-conv",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.create_conv_category": {
"agent_summary": "创建用户自定义会话分组",
"use_when": [
"用户明确要新建手工管理的会话分组,且名称不超过 15 个字符时"
],
"avoid_when": [
"按关键词或成员自动归类应使用 category create-smart;不得静默截断、缩写或改写用户名称"
],
"examples": [
"dws chat category create --title \"项目群\""
],
"reviewed": true,
"review_reason": "迁移 nanrun 的分组标题和复合任务 guidance 到 reviewed selection;保持当前 runtime 的 15 字符校验。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.create_conv_category",
"cobra-help:dws chat category create",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.delete_conv_category": {
"agent_summary": "删除指定的用户自定义会话分组",
"use_when": [
"用户明确要删除已知 categoryId 的自定义分组时"
],
"avoid_when": [
"只想把会话移出分组时使用 category remove-conv;目标不明确时不要删除"
],
"examples": [
"dws chat category delete --category-id 123"
],
"reviewed": true,
"review_reason": "依据当前原生命令与 nanrun 树形 category 分支补齐删除能力的选择边界。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.delete_conv_category",
"cobra-help:dws chat category delete",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.remove_conv_from_categories": {
"agent_summary": "把一个会话从一个或多个自定义分组移出",
"use_when": [
"已知会话和真实 categoryId,需要解除现有分组归属时"
],
"avoid_when": [
"删除整个分组使用 category delete;加入分组使用 category add-conv"
],
"examples": [
"dws chat category remove-conv --group <openConversationId> --category-ids 123,456"
],
"reviewed": true,
"review_reason": "依据当前原生命令与 nanrun 树形 category 分支补齐会话移出分组的选择边界。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.remove_conv_from_categories",
"cobra-help:dws chat category remove-conv",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.rename_conv_category": {
"agent_summary": "重命名用户自定义会话分组",
"use_when": [
"已知 categoryId,且用户给出了不超过 15 个字符的新名称时"
],
"avoid_when": [
"不得为绕过长度限制而截断、缩写或改写;创建新分组使用 category create"
],
"examples": [
"dws chat category rename --category-id 123 --title \"新名称\""
],
"reviewed": true,
"review_reason": "迁移 nanrun 的分组标题和复合任务 guidance 到 reviewed selection;保持当前 runtime 的 15 字符校验。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.rename_conv_category",
"cobra-help:dws chat category rename",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.grant_permission": {
"agent_summary": "为指定 chat scope 和业务参数发起高风险操作授权",
"use_when": [
"目标 chat 操作因行为授权缺失而需要按 scope、目标和时效发起授权时"
],
"avoid_when": [
"不要把它当作实际发送、撤回或群管理命令;跨组织数据读取授权使用 data-auth cross-org"
],
"examples": [
"dws chat chmod chat.message:send --grant-type timed --ttl 24h --permParam openCid=<openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前授权 Cobra、dws-shared 安全规则和树形 data-auth/chmod 分支补齐 Agent 选路。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.grant_permission",
"cobra-help:dws chat chmod",
"skills/multi/dingtalk-chat/SKILL.md"
]
},
"chat.clear_all_red_point": {
"agent_summary": "清除当前用户所有会话的未读红点",
"use_when": [
"用户明确要求全部会话一键已读或清除所有红点时"
],
"avoid_when": [
"只处理一个会话时使用 clear-red-point;本命令不会删除聊天记录"
],
"examples": [
"dws chat clear-all-red-point"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 和 nanrun 会话状态树补齐全局红点清零能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.clear_all_red_point",
"cobra-help:dws chat clear-all-red-point",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.clear_conversation_messages": {
"agent_summary": "清空当前用户在指定会话中的聊天记录",
"use_when": [
"用户明确要求清空某个已知会话的本地聊天记录时"
],
"avoid_when": [
"仅清除未读红点使用 clear-red-point;未确认真实 openConversationId 时不要执行"
],
"examples": [
"dws chat clear-messages --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 和 nanrun 会话状态树补齐清空会话消息的严格目标边界。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.clear_conversation_messages",
"cobra-help:dws chat clear-messages",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.clear_conversation_red_point": {
"agent_summary": "清除指定会话的未读红点",
"use_when": [
"用户只要求把某个已知会话标为已读或清除红点时"
],
"avoid_when": [
"全部会话一键已读使用 clear-all-red-point;本命令不会删除消息"
],
"examples": [
"dws chat clear-red-point --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 和 nanrun 会话状态树补齐单会话红点清理能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.clear_conversation_red_point",
"cobra-help:dws chat clear-red-point",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.grant_cross_org_data_access": {
"agent_summary": "发起 chat 跨组织数据读取授权",
"use_when": [
"跨组织拉取聊天数据因 data scope 缺失,需要为目标组织或全部组织授权时"
],
"avoid_when": [
"发送、撤回和群管理授权使用 chat chmod;普通同组织查询不要预先授权"
],
"examples": [
"dws chat data-auth cross-org --target-org-id 439446171"
],
"reviewed": true,
"review_reason": "依据当前授权 Cobra、dws-shared 跨组织规则和 nanrun data-auth 分支补齐 Agent 选路。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.grant_cross_org_data_access",
"cobra-help:dws chat data-auth cross-org",
"skills/multi/dingtalk-chat/SKILL.md"
]
},
"chat.audit_join_group": {
"agent_summary": "审批一条群聊入群验证记录",
"use_when": [
"已从 list-join-validations 取得真实记录、申请人和邀请人 ID,需要执行 AuditApprove 或 AuditDelete 时"
],
"avoid_when": [
"列出申请使用 list-join-validations;服务端不支持 AuditIgnore、AuditRefuse 或 AuditBlock"
],
"examples": [
"dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <userId> --inviter <userId> --status AuditApprove"
],
"reviewed": true,
"review_reason": "依据 nanrun 的状态 hint 和当前 runtime 支持范围补齐入群审核选择语义。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.audit_join_group",
"cobra-help:dws chat group audit-join-validation",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_my_groups_pagination": {
"agent_summary": "分页拉取当前用户加入的全部群聊",
"use_when": [
"需要完整分页列出我加入的所有群,并沿用 nextCursor 继续读取时"
],
"avoid_when": [
"只查我创建或管理的群使用 group list-my-groups;按关键词找群使用 chat search"
],
"examples": [
"dws chat group list-all --limit 100"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun group 树补齐分页群列表能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_my_groups_pagination",
"cobra-help:dws chat group list-all",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_apply_join_group_records": {
"agent_summary": "分页拉取当前用户相关的入群验证记录",
"use_when": [
"需要查看待处理或历史入群申请,并取得后续审核所需记录 ID 时"
],
"avoid_when": [
"执行审批使用 audit-join-validation;普通群成员列表使用 group members"
],
"examples": [
"dws chat group list-join-validations --limit 20"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun group 树补齐入群验证列表能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_apply_join_group_records",
"cobra-help:dws chat group list-join-validations",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_group_member_by_ids": {
"agent_summary": "按成员 openDingTalkId 批量查询群成员详情",
"use_when": [
"已知群 openConversationId 和一组成员 openDingTalkId,需要批量取成员详情时"
],
"avoid_when": [
"列出整个群成员直接使用 group members --id;不要臆造 members list 子命令"
],
"examples": [
"dws chat group members list-by-ids --id <openConversationId> --users openDingTalkId1,openDingTalkId2"
],
"reviewed": true,
"review_reason": "迁移 nanrun 的群成员 flag guidance 到 reviewed selection,并固定 --id/--users 参数边界。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_group_member_by_ids",
"cobra-help:dws chat group members list-by-ids",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.create_group_notice": {
"agent_summary": "在指定群聊发布即时或定时群公告",
"use_when": [
"用户明确要发布群公告,并已给出群和 Markdown 正文时"
],
"avoid_when": [
"普通聊天消息使用 message send;修改已有公告使用 group notice edit"
],
"examples": [
"dws chat group notice create --group <openConversationId> --content \"今晚 22 点系统维护\""
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun notice 子树补齐群公告创建能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.create_group_notice",
"cobra-help:dws chat group notice create",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.edit_group_notice": {
"agent_summary": "整体替换指定群公告的正文与可选状态",
"use_when": [
"已从公告列表取得真实 dataId,需要修改现有群公告时"
],
"avoid_when": [
"发布新公告使用 group notice create;只查看内容使用 group notice get"
],
"examples": [
"dws chat group notice edit --group <openConversationId> --notice-id <dataId> --content \"更新后的公告\""
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun notice 子树补齐群公告编辑能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.edit_group_notice",
"cobra-help:dws chat group notice edit",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.get_group_notice": {
"agent_summary": "读取指定群公告的详情",
"use_when": [
"已知群和真实公告 dataId,需要查看正文、发布者或统计信息时"
],
"avoid_when": [
"不知道 dataId 时先用 group notice list;不要使用占位符公告 ID"
],
"examples": [
"dws chat group notice get --group <openConversationId> --notice-id <dataId>"
],
"reviewed": true,
"review_reason": "依据 nanrun 的真实 noticeId hint 和当前 Cobra 补齐公告详情选择语义。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.get_group_notice",
"cobra-help:dws chat group notice get",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_group_notices": {
"agent_summary": "分页读取指定群的已发布或定时公告",
"use_when": [
"需要列出群公告并取得后续详情或编辑使用的 dataId 时"
],
"avoid_when": [
"读取单条已知公告使用 group notice get;普通群消息使用 message list"
],
"examples": [
"dws chat group notice list --group <openConversationId> --limit 20"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun notice 子树补齐群公告列表能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_group_notices",
"cobra-help:dws chat group notice list",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.share_group_invite_url": {
"agent_summary": "把一个群的邀请链接分享到目标会话或单聊用户",
"use_when": [
"当前用户已加入源群,且要把邀请链接发给一个目标会话或一个接收人时"
],
"avoid_when": [
"只获取邀请链接使用 group invite-url;--target 与 --receiver 只能选择一个"
],
"examples": [
"dws chat group share-invite --source <sourceConversationId> --target <targetConversationId>"
],
"reviewed": true,
"review_reason": "迁移 nanrun 的 target/receiver 互斥 hint 与源群边界到 reviewed selection。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.share_group_invite_url",
"cobra-help:dws chat group share-invite",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.update_user_group_alias": {
"agent_summary": "设置仅当前用户可见的群备注",
"use_when": [
"用户要修改自己看到的群备注,而不是修改群的公开名称时"
],
"avoid_when": [
"修改所有成员看到的群名使用 group rename;修改自己群昵称使用 group update-nick"
],
"examples": [
"dws chat group update-alias --group <openConversationId> --alias-title \"客户项目群\""
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun group 树补齐群备注和群名/群昵称消歧。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.update_user_group_alias",
"cobra-help:dws chat group update-alias",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.hide_conversation": {
"agent_summary": "从当前用户的会话列表隐藏指定会话",
"use_when": [
"用户明确要隐藏一个已知会话,接受收到新消息后可能再次出现时"
],
"avoid_when": [
"免打扰使用 chat mute;退出群聊使用 group quit;本命令不会删除消息"
],
"examples": [
"dws chat hide --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话状态树补齐隐藏会话能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.hide_conversation",
"cobra-help:dws chat hide",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_all_conversations": {
"agent_summary": "分页获取当前用户的全部单聊和群聊会话",
"use_when": [
"需要完整枚举会话并沿用 nextCursor 翻页时"
],
"avoid_when": [
"只看置顶会话使用 list-top-conversations;只看群聊使用 group list-all"
],
"examples": [
"dws chat list-all-conversations --limit 50"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话树补齐全部会话列表能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_all_conversations",
"cobra-help:dws chat list-all-conversations",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.mark_message_read": {
"agent_summary": "把指定消息及之前的消息标记为已读",
"use_when": [
"已知同一会话中的真实 openMessageId,需要推进已读位置时"
],
"avoid_when": [
"只清除会话红点使用 clear-red-point;查询他人是否已读使用 message read-status"
],
"examples": [
"dws chat mark-read --conversation-id <openConversationId> --message-id <openMessageId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话状态树补齐消息已读写入能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.mark_message_read",
"cobra-help:dws chat mark-read",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.mark_conversation_unread": {
"agent_summary": "把指定会话标记为未读",
"use_when": [
"用户要稍后处理某个已知会话并将其重新标为未读时"
],
"avoid_when": [
"清除红点使用 clear-red-point;查询未读会话使用 message list-unread-conversations"
],
"examples": [
"dws chat mark-unread --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话状态树补齐标记未读能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.mark_conversation_unread",
"cobra-help:dws chat mark-unread",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.list_message_emotion_replies": {
"agent_summary": "批量读取多条消息的 emoji 与文字表情回应",
"use_when": [
"已从消息查询取得一组真实 openMessageId,需要汇总回应信息时"
],
"avoid_when": [
"添加或移除回应使用 add/remove-emoji 或 add/remove-text-emotion;不要传占位符 ID"
],
"examples": [
"dws chat message list-emotion-replies --msg-ids <openMessageId1>,<openMessageId2>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun message 树补齐批量消息回应读取能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.list_message_emotion_replies",
"cobra-help:dws chat message list-emotion-replies",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.set_top_message": {
"agent_summary": "把指定消息置顶到会话顶部",
"use_when": [
"已从目标会话取得真实且同源的 openMessageId,需要设置消息置顶时"
],
"avoid_when": [
"置顶整个会话使用 chat set-top;钉住消息使用 message set-pin-msg"
],
"examples": [
"dws chat message set-top-msg --open-conversation-id <openConversationId> --msg-id <openMessageId>"
],
"reviewed": true,
"review_reason": "迁移 nanrun 的真实消息 ID guidance 到 reviewed selection,并与会话置顶和 Pin 消歧。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.set_top_message",
"cobra-help:dws chat message set-top-msg",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.unset_top_message": {
"agent_summary": "取消指定会话中的消息置顶",
"use_when": [
"已知同源的会话和真实 openMessageId,需要取消消息置顶时"
],
"avoid_when": [
"取消会话置顶使用 chat set-top --off;取消 Pin 使用 message unset-pin-msg"
],
"examples": [
"dws chat message unset-top-msg --open-conversation-id <openConversationId> --msg-id <openMessageId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun message 树补齐取消消息置顶的选择边界。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.unset_top_message",
"cobra-help:dws chat message unset-top-msg",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.update_at_all_notification_off": {
"agent_summary": "关闭或恢复指定会话的 @所有人通知",
"use_when": [
"用户只想调整某个会话的 @所有人提醒偏好时"
],
"avoid_when": [
"关闭全部会话通知使用 chat mute;红包通知偏好使用 mute-red-envelope"
],
"examples": [
"dws chat mute-at-all --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话通知树补齐 @all 通知偏好能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.update_at_all_notification_off",
"cobra-help:dws chat mute-at-all",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.update_red_env_notification_off": {
"agent_summary": "关闭或恢复指定会话的红包通知",
"use_when": [
"用户只想调整某个会话的红包消息提醒偏好时"
],
"avoid_when": [
"关闭全部会话通知使用 chat mute;@所有人通知偏好使用 mute-at-all"
],
"examples": [
"dws chat mute-red-envelope --conversation-id <openConversationId>"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun 会话通知树补齐红包通知偏好能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.update_red_env_notification_off",
"cobra-help:dws chat mute-red-envelope",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.translate": {
"agent_summary": "把指定聊天文本翻译成目标语言",
"use_when": [
"用户给出文本并明确要求翻译为支持的语言代码时"
],
"avoid_when": [
"翻译文档或文件内容应使用对应产品能力;本命令只处理传入文本"
],
"examples": [
"dws chat text translate --query \"你好世界\" --to en_US"
],
"reviewed": true,
"review_reason": "依据当前 Cobra 与 nanrun text 分支补齐聊天文本翻译能力。",
"source_refs": [
"internal/cli/schema_command_registry/products/chat.json#chat.translate",
"cobra-help:dws chat text translate",
"skills/multi/dingtalk-chat/SKILL.md",
"skills/multi/dingtalk-chat/references/chat.md"
]
},
"chat.shortcut_bot_search": {
"agent_summary": "搜索当前用户自己创建的机器人",
"use_when": [
@@ -2425,7 +3023,8 @@
"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"
],
"avoid_when": [
"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"
"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget",
"按群名找群或解析群 openConversationId 时不要使用;改用 +chat-search"
],
"examples": [
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
+264
View File
@@ -207,6 +207,270 @@ func TestCrossPlatformCoveragePATURLAndMutationCoverageEdges(t *testing.T) {
}
}
func TestCrossPlatformCoveragePATClassification(t *testing.T) {
patErr := &PATError{RawJSON: `{"code":"PAT_NO_PERMISSION"}`}
if patErr.Error() != patErr.RawJSON || patErr.RawStderr() != patErr.RawJSON || patErr.ExitCode() != ExitCodePermission {
t.Fatalf("PATError contract changed: %#v", patErr)
}
if !IsPATError(patErr) || IsPATError(stderrors.New("plain")) {
t.Fatal("PAT error classification changed")
}
if !IsPATNoPermissionCode("PAT_NO_PERMISSION") || IsPATNoPermissionCode("UNKNOWN") {
t.Fatal("PAT permission code classification changed")
}
if code, ok := lookupCodeIn(map[string]any{
"code": 1,
"errorCode": "PAT_NO_PERMISSION",
}, patNoPermissionCodes); !ok || code != "PAT_NO_PERMISSION" {
t.Fatalf("lookupCodeIn fallback = %q, %v", code, ok)
}
if code, ok := lookupCodeIn(map[string]any{"code": "UNKNOWN"}, patNoPermissionCodes); ok || code != "" {
t.Fatalf("lookupCodeIn unknown = %q, %v", code, ok)
}
for _, tc := range []struct {
body map[string]any
want string
ok bool
}{
{body: map[string]any{"code": "PAT_NO_PERMISSION"}, want: "PAT_NO_PERMISSION", ok: true},
{body: map[string]any{"error_code": "PAT_SCOPE_AUTH_REQUIRED"}, want: "PAT_SCOPE_AUTH_REQUIRED", ok: true},
{body: map[string]any{"code": "UNKNOWN"}},
} {
code, ok := getPATErrorCode(tc.body)
if code != tc.want || ok != tc.ok {
t.Fatalf("getPATErrorCode(%v) = %q, %v", tc.body, code, ok)
}
}
if code, ok := getDWSGatewayErrorCode(map[string]any{"errorCode": "DWS_AUTH_SERVICE_FAILED"}); !ok || code != "DWS_AUTH_SERVICE_FAILED" {
t.Fatalf("gateway code = %q, %v", code, ok)
}
if !isNotLoggedInError(map[string]any{
"error": 1,
"message": "Missing service_id or access_key",
}) {
t.Fatal("missing-login response was not recognized")
}
if isNotLoggedInError(map[string]any{"message": "other"}) {
t.Fatal("ordinary response was recognized as missing login")
}
for _, body := range []map[string]any{
{"error": "failure"},
{"success": false},
{"success": "FALSE"},
} {
if !isBusinessError(body) {
t.Fatalf("business error was not recognized: %#v", body)
}
}
for _, body := range []map[string]any{
{},
{"success": true},
{"success": "true"},
} {
if isBusinessError(body) {
t.Fatalf("successful response was classified as a business error: %#v", body)
}
}
if err := ClassifyToolResultContent(map[string]any{"code": "DWS_SERVICE_UNAUTHORIZED"}); err == nil {
t.Fatal("gateway tool result was not classified")
}
if err := ClassifyToolResultContent(map[string]any{"code": "PAT_BATCH_AUTH_PENDING"}); !IsPATError(err) {
t.Fatalf("PAT tool result = %T %v", err, err)
}
if err := ClassifyToolResultContent(map[string]any{"success": true}); err != nil {
t.Fatalf("successful tool result = %v", err)
}
responseCases := []struct {
name string
text string
kind string
}{
{name: "invalid json", text: "not-json", kind: "nil"},
{name: "gateway", text: `{"code":"DWS_AUTH_SERVICE_FAILED"}`, kind: "error"},
{name: "not logged in", text: `{"message":"Missing service_id or access_key"}`, kind: "error"},
{name: "pat", text: `{"code":"PAT_NO_PERMISSION"}`, kind: "pat"},
{name: "business", text: `{"success":false,"message":"参数错误"}`, kind: "error"},
{name: "success", text: `{"success":true}`, kind: "nil"},
}
for _, tc := range responseCases {
t.Run(tc.name, func(t *testing.T) {
err := ClassifyMCPResponseText(tc.text)
switch tc.kind {
case "nil":
if err != nil {
t.Fatalf("ClassifyMCPResponseText() = %v", err)
}
case "pat":
if !IsPATError(err) {
t.Fatalf("ClassifyMCPResponseText() = %T %v", err, err)
}
default:
if err == nil {
t.Fatal("ClassifyMCPResponseText() returned nil")
}
}
})
}
if !strings.Contains(authExpiredHint(), "auth login") || !strings.Contains(notLoggedInHint(), "auth login") {
t.Fatal("authentication recovery hints lost the login command")
}
if got := ClassifyPatAuthCheck(map[string]any{"code": "PAT_LOW_RISK_NO_PERMISSION"}); got == nil {
t.Fatal("ClassifyPatAuthCheck() returned nil")
}
if got := ClassifyPatAuthCheck(map[string]any{"code": "UNKNOWN"}); got != nil {
t.Fatalf("ClassifyPatAuthCheck() = %#v", got)
}
wrapped := stderrors.Join(stderrors.New("outer"), patErr)
if got := AsPatAuthCheckError(wrapped); got != patErr {
t.Fatalf("AsPatAuthCheckError() = %#v", got)
}
if got := AsPatAuthCheckError(stderrors.New("plain")); got != nil {
t.Fatalf("AsPatAuthCheckError() = %#v", got)
}
}
func TestSuggestBusinessHintChatRecovery(t *testing.T) {
tests := []struct {
name string
body map[string]any
want string
}{
{
name: "missing group role context",
body: map[string]any{
"error": map[string]any{"code": "IM_ERROR", "message": "listRoles null"},
"summary": "context",
"code": "TOP_LEVEL",
},
want: "list-my-groups",
},
{name: "legacy open id spelling", body: map[string]any{"message": "OpendId is not in conversation"}, want: "实际加入"},
{name: "open id outside conversation", body: map[string]any{"message": "OpenId is not in conversation"}, want: "实际加入"},
{name: "operator outside source group", body: map[string]any{"message": "The operator is not in this group chat"}, want: "源群"},
{name: "missing invitation receiver", body: map[string]any{"message": "targetOpenConversationId和receiverUid不能同时为空"}, want: "--receiver"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if got := SuggestBusinessHint(tc.body); !strings.Contains(got, tc.want) {
t.Errorf("SuggestBusinessHint(%v) = %q, want containing %q", tc.body, got, tc.want)
}
})
}
}
func TestCrossPlatformCoveragePATSerializationAndPolicyEdges(t *testing.T) {
oldHost := hostControlProvider
oldBrowser := patBrowserProvider
t.Cleanup(func() {
SetHostControlProvider(oldHost)
SetPATOpenBrowserProvider(oldBrowser)
})
SetHostControlProvider(func() string { return "" })
if block := HostControlBlock(); block != nil {
t.Fatalf("empty host provider returned %#v", block)
}
SetHostControlProvider(func() string { return "codex" })
SetPATOpenBrowserProvider(func() bool { return false })
out := map[string]any{
"data": map[string]any{
"authUrl": " https://example.test/fe/old#%2FpersonalAuthorization%3FflowId%3Df%26userCode%3Du ",
"callbacks": map[string]any{"owner": "cli"},
},
}
ApplyHostMutations(out)
data := out["data"].(map[string]any)
if data["openBrowser"] != false || data["hostControl"] == nil || data["uri"] == "" {
t.Fatalf("host mutations = %#v", data)
}
if _, ok := data["authUrl"]; ok {
t.Fatalf("authUrl alias was not removed: %#v", data)
}
if _, ok := data["callbacks"]; ok {
t.Fatalf("legacy callbacks were not removed: %#v", data)
}
rawPolicy := cleanPATJSON(map[string]any{
"message": "organization denied",
"scope": "chat.read",
}, "PAT_ORG_POLICY_DENIED")
var policyPayload map[string]any
if err := json.Unmarshal([]byte(rawPolicy), &policyPayload); err != nil {
t.Fatalf("decode policy PAT JSON: %v", err)
}
policyData := policyPayload["data"].(map[string]any)
for key, want := range map[string]any{
"policy": "OPEN_SOURCE_ORG_SCOPE_FORBIDDEN",
"message": "organization denied",
"action": "contact_org_admin",
"openBrowser": false,
"retryable": false,
} {
if got := policyData[key]; got != want {
t.Fatalf("policy data %s = %#v, want %#v", key, got, want)
}
}
if !strings.Contains(policyData["hint"].(string), "organization denied") {
t.Fatalf("policy hint = %#v", policyData["hint"])
}
rawDefault := cleanPATJSON(map[string]any{}, "PAT_ORG_POLICY_DENIED")
var defaultPayload map[string]any
if err := json.Unmarshal([]byte(rawDefault), &defaultPayload); err != nil {
t.Fatalf("decode default policy PAT JSON: %v", err)
}
defaultData := defaultPayload["data"].(map[string]any)
if !strings.Contains(defaultData["hint"].(string), "组织策略") {
t.Fatalf("default policy hint = %#v", defaultData["hint"])
}
prepopulated := map[string]any{"data": map[string]any{
"policy": "CUSTOM",
"message": "existing",
"hint": "existing hint",
}}
applyOrgPolicyDeniedHint(prepopulated, map[string]any{"message": "ignored"})
prepopulatedData := prepopulated["data"].(map[string]any)
if prepopulatedData["policy"] != "CUSTOM" ||
prepopulatedData["message"] != "existing" ||
prepopulatedData["hint"] != "existing hint" {
t.Fatalf("prepopulated policy fields changed: %#v", prepopulatedData)
}
if got := stringValue(map[string]any{
"number": 1,
"blank": " ",
"value": " kept ",
}, "number", "blank", "value"); got != "kept" {
t.Fatalf("stringValue fallback = %q", got)
}
if got := stringValue(map[string]any{"blank": " "}, "blank", "missing"); got != "" {
t.Fatalf("stringValue empty = %q", got)
}
cleaned := stripClassFields(map[string]any{
"class": "top",
"items": []any{
map[string]any{"class": "nested", "keep": "yes"},
"scalar",
},
}).(map[string]any)
if _, ok := cleaned["class"]; ok {
t.Fatalf("top-level class was retained: %#v", cleaned)
}
items := cleaned["items"].([]any)
nested := items[0].(map[string]any)
if _, ok := nested["class"]; ok || nested["keep"] != "yes" || items[1] != "scalar" {
t.Fatalf("nested class cleanup = %#v", cleaned)
}
}
func mustParseURLForTest(t *testing.T, raw string) *url.URL {
t.Helper()
parsed, err := url.Parse(raw)
+53 -14
View File
@@ -42,23 +42,24 @@ const (
// Error is the structured repository-local error model for the Go rewrite.
type Error struct {
Category Category
Message string
Operation string
ServerKey string
Retryable bool
Category Category
Message string
Operation string
ServerKey string
Retryable bool
RetryableSet bool
RetryAfterSeconds *int64
NextRetryAt *time.Time
Reason string
Hint string
Actions []string
AvailableFlags []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
Reason string
Hint string
Actions []string
Examples []string
AvailableFlags []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
}
func (e *Error) Error() string {
@@ -169,6 +170,22 @@ func WithActions(actions ...string) Option {
}
}
// WithExamples records copyable command examples for recovery.
func WithExamples(examples ...string) Option {
return func(err *Error) {
out := make([]string, 0, len(examples))
for _, example := range examples {
if strings.TrimSpace(example) == "" {
continue
}
out = append(out, example)
}
if len(out) > 0 {
err.Examples = out
}
}
}
// WithAvailableFlags records visible local flag names for agent recovery.
func WithAvailableFlags(names ...string) Option {
return func(err *Error) {
@@ -309,7 +326,12 @@ func PrintJSON(w io.Writer, err error) error {
}
if len(typed.Actions) > 0 {
errorPayload["actions"] = typed.Actions
errorPayload["suggested_actions"] = typed.Actions
}
if len(typed.Examples) > 0 {
errorPayload["examples"] = typed.Examples
}
errorPayload["error_message"] = typed.Message
if len(typed.AvailableFlags) > 0 {
errorPayload["available_flags"] = typed.AvailableFlags
}
@@ -388,6 +410,23 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
return writeErr
}
if len(typed.Examples) > 0 {
lines := []string{
"错误信息:" + typed.Message,
"原因:" + typed.Reason,
"建议操作:",
}
for i, action := range typed.Actions {
lines = append(lines, fmt.Sprintf("%d. %s", i+1, action))
}
lines = append(lines, "示例:")
for i, example := range typed.Examples {
lines = append(lines, fmt.Sprintf("%d. %s", i+1, example))
}
_, writeErr := fmt.Fprintln(w, strings.Join(lines, "\n"))
return writeErr
}
// Line 1: Error summary
lines := []string{
fmt.Sprintf("%s %s", tui.StateMark("error"), tui.Danger(fmt.Sprintf("Error: [%s] %s", strings.ToUpper(string(typed.Category)), typed.Message))),
+40
View File
@@ -192,6 +192,46 @@ func TestPrintJSON_AvailableFlags(t *testing.T) {
}
}
func TestPrintJSON_FourPartGuidance(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewValidation(
"缺少参数",
WithReason("必须提供时间范围"),
WithActions("补充开始时间", "补充结束时间"),
WithExamples(`dws chat message list-all --start "..." --end "..." --format json`),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
for _, want := range []string{`"error_message"`, `"reason"`, `"suggested_actions"`, `"examples"`} {
if !strings.Contains(got, want) {
t.Fatalf("four-part JSON output missing %s: %q", want, got)
}
}
}
func TestPrintHuman_FourPartGuidance(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintHuman(&b, NewValidation(
"缺少参数",
WithReason("必须提供时间范围"),
WithActions("补充开始和结束时间"),
WithExamples(`dws chat message list-all --start "..." --end "..."`),
)); err != nil {
t.Fatalf("PrintHuman() error = %v", err)
}
got := b.String()
for _, want := range []string{"错误信息:", "原因:", "建议操作:", "示例:"} {
if !strings.Contains(got, want) {
t.Fatalf("four-part human output missing %q: %q", want, got)
}
}
}
func TestPrintHuman(t *testing.T) {
t.Parallel()
+28
View File
@@ -308,6 +308,13 @@ func notLoggedInHint() string {
return "请先登录:dws auth login"
}
// SuggestBusinessHint returns an actionable recovery hint for a parsed MCP
// business-error payload. Runtime callers share this entry point so product
// helpers and the generic runner do not drift.
func SuggestBusinessHint(body map[string]any) string {
return suggestForBusinessErrorText(body)
}
func suggestForBusinessErrorText(body map[string]any) string {
msg := ""
if v, ok := body["errorMsg"].(string); ok {
@@ -316,6 +323,19 @@ func suggestForBusinessErrorText(body map[string]any) string {
msg = v
} else if v, ok := body["error"].(string); ok {
msg = v
} else if nested, ok := body["error"].(map[string]any); ok {
if code, ok := nested["code"].(string); ok {
msg = code
}
if message, ok := nested["message"].(string); ok {
msg = strings.TrimSpace(msg + " " + message)
}
}
if summary, ok := body["summary"].(string); ok {
msg = strings.TrimSpace(msg + " " + summary)
}
if code, ok := body["code"].(string); ok {
msg = strings.TrimSpace(msg + " " + code)
}
switch {
case strings.Contains(msg, "搜索内容不能为空"):
@@ -326,6 +346,14 @@ func suggestForBusinessErrorText(body map[string]any) string {
return "API rate limit exceeded, wait a moment and retry"
case strings.Contains(msg, "参数错误") || strings.Contains(msg, "param error"):
return "Check input parameters. Use --help for available flags"
case strings.Contains(msg, "listRoles null"):
return "当前群的群身份或权限上下文不可用。请先用 dws chat group list-my-groups --format json 选择当前账号实际加入或管理的群,再核对群成员与权限。"
case strings.Contains(msg, "OpendId is not in conversation") || strings.Contains(msg, "OpenId is not in conversation"):
return "当前账号不在该会话中。请先用 dws chat group list-my-groups --format json 选择实际加入的群,并重新获取该会话中的真实 OpendId。"
case strings.Contains(msg, "The operator is not in this group chat"):
return "当前操作者不在源群中。请重新选择当前账号已加入的群,或先完成入群;不要只替换接收方后重复原命令。"
case strings.Contains(msg, "targetOpenConversationId和receiverUid不能同时为空"):
return "分享群邀请链接必须提供接收目标:群到群使用 --target,群到人使用 --receiver;同时确认 --source 是当前操作者已加入的源群。"
default:
return "MCP tool returned a business error; check parameters and refer to skill documentation."
}
+249 -13
View File
@@ -56,11 +56,20 @@ const maxConversationCategoryTitleRunes = 15
func validatedConversationCategoryTitle(raw string) (string, error) {
title := strings.TrimSpace(raw)
if title == "" {
return "", apperrors.NewValidation("--title 不能为空")
return "", apperrors.NewValidation(
"--title 不能为空",
apperrors.WithReason("invalid_category_title"),
apperrors.WithHint("请提供 1 到 15 个字符的分组标题,并保持用户指定原文。"),
apperrors.WithActions("补充非空 --title", "运行当前命令 --help 查看示例"),
)
}
if utf8.RuneCountInString(title) > maxConversationCategoryTitleRunes {
return "", apperrors.NewValidation(fmt.Sprintf(
"--title 最多 %d 个字符", maxConversationCategoryTitleRunes))
if count := utf8.RuneCountInString(title); count > maxConversationCategoryTitleRunes {
return "", apperrors.NewValidation(
fmt.Sprintf("--title 当前 %d 个字符,最多 %d 个字符", count, maxConversationCategoryTitleRunes),
apperrors.WithReason("category_title_too_long"),
apperrors.WithHint("不得静默截断、缩写或改写用户指定名称;请让用户提供合法标题后重试。"),
apperrors.WithActions("请用户将标题缩短到 15 个字符以内", "使用用户确认后的标题原文重试"),
)
}
return title, nil
}
@@ -331,6 +340,25 @@ func containsMessageMention(text, placeholder string) bool {
}
}
func chatGuidanceError(message, reason string, actions, examples []string) error {
return apperrors.NewValidation(
message,
apperrors.WithReason(reason),
apperrors.WithActions(actions...),
apperrors.WithExamples(examples...),
)
}
func isLikelyPlaceholderID(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized == "" ||
normalized == "0" ||
strings.Contains(normalized, "placeholder") ||
strings.HasPrefix(normalized, "test_") ||
strings.HasPrefix(normalized, "test-") ||
strings.HasPrefix(normalized, "<")
}
func resolveOpenDingTalkID(ctx context.Context, value string) (string, error) {
ids, err := resolveOpenDingTalkIDs(ctx, []string{value})
if err != nil {
@@ -1681,7 +1709,27 @@ func newChatCommand() *cobra.Command {
if atAll && !strings.Contains(text, "<@all>") {
text = "<@all> " + text
}
// 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 不变
var addedMentions []string
for _, rawID := range atOpenIds {
id := strings.TrimSpace(rawID)
if id == "" {
continue
}
wrapped := "<@" + id + ">"
if !containsMessageMention(text, wrapped) && !containsMessageMention(text, "@"+id) {
addedMentions = append(addedMentions, wrapped)
}
}
if len(addedMentions) > 0 {
text = strings.Join(addedMentions, " ") + " " + text
fmt.Fprintf(
os.Stderr,
"错误信息:检测到 --at-open-dingtalk-ids,但正文缺少对应 @ 占位符;CLI 已自动补齐\n原因:钉钉群消息只有正文包含 <@openDingTalkId> 时才会真正展示 @ 提醒\n建议操作:\n1. 后续命令请在 --text 中显式写入对应占位符\n示例:\n1. dws chat message send --group <openConversationId> --at-open-dingtalk-ids %s --text %q --format json\n",
atOpenIdsStr,
text,
)
}
// 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 保持不变
text = normalizeAtPlaceholders(text, atOpenIds, true)
// 群聊统一走 openDingTalkId @ 人接口。
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
@@ -1991,7 +2039,12 @@ func newChatCommand() *cobra.Command {
return fmt.Errorf("--sender-user-id and --sender-open-dingtalk-id are mutually exclusive, specify exactly one")
}
if senderUserID == "" && senderOpenDingTalkID == "" {
return fmt.Errorf("--sender-user-id or --sender-open-dingtalk-id is required")
return chatGuidanceError(
"缺少消息发送者标识",
"list-by-sender 查询的是指定对方发送的消息,必须提供对方的 userId 或 openDingTalkId",
[]string{"使用 --sender-user-id 传入对方 userId", "或使用 --sender-open-dingtalk-id 传入对方 openDingTalkId"},
[]string{`dws chat message list-by-sender --sender-user-id <对方userId> --start "2026-07-14T00:00:00+08:00" --format json`},
)
}
startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start"))
if err != nil {
@@ -2182,6 +2235,25 @@ func newChatCommand() *cobra.Command {
# 查询单聊会话 ID: dws chat conversation-info --user <userId>
# 查询人员: dws contact user search --keyword "姓名" --format json`,
RunE: func(cmd *cobra.Command, args []string) error {
hasCondition := false
for _, name := range []string{
"query", "keyword", "user", "users", "userId", "sender-ids", "senders", "sender",
"at-ids", "conversation-ids", "groups", "group", "message-type",
"conversation-type", "search-conv-type", "start", "end",
} {
if value, _ := cmd.Flags().GetString(name); strings.TrimSpace(value) != "" {
hasCondition = true
break
}
}
if !hasCondition {
atMe, _ := cmd.Flags().GetBool("at-me")
hasCondition = atMe || cmd.Flags().Changed("only-robot") || cmd.Flags().Changed("only-robot-messages")
}
if !hasCondition {
return apperrors.NewValidation("at least one search condition is required")
}
toolArgs := map[string]any{}
// The CLI primary is --query; the IM MCP field is still named "keyword".
@@ -2976,6 +3048,7 @@ func newChatCommand() *cobra.Command {
chatCategoryDeleteCmd := &cobra.Command{
Use: "delete",
Short: "删除用户自定义会话分组",
Long: "删除用户自定义会话分组。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。",
Example: ` dws chat category delete --category-id <分组ID>
# 分组ID 可通过 dws chat category list 获取`,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -2983,6 +3056,14 @@ func newChatCommand() *cobra.Command {
if categoryId == 0 {
return fmt.Errorf("flag --category-id is required")
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"删除会话分组不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标分组及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标会话分组", "获得用户确认后使用 --yes 执行"),
)
}
return callMCPToolOnServer("im", "delete_conv_category", map[string]any{
"categoryId": categoryId,
})
@@ -3058,6 +3139,16 @@ func newChatCommand() *cobra.Command {
if err != nil {
return fmt.Errorf("--category-ids: %w", err)
}
for _, categoryID := range categoryIds {
if categoryID > 0 && categoryID < 1000 {
return chatGuidanceError(
"会话分组 ID 看起来仍是示例占位值",
"--category-ids 必须来自 chat category list 返回的真实分组 ID;123、456 等短示例值不能直接用于移出操作",
[]string{"先查询当前用户的会话分组", "从结果读取真实 categoryId 后再执行移出"},
[]string{`dws chat category list --format json`, `dws chat category remove-conv --group <openConversationId> --category-ids <categoryId> --format json`},
)
}
}
return callMCPToolOnServer("im", "remove_conv_from_categories", map[string]any{
"openConversationId": groupID,
"categoryIds": categoryIds,
@@ -3129,6 +3220,16 @@ func newChatCommand() *cobra.Command {
return err
}
msgIds := parseCSVValues(mustGetFlag(cmd, "msg-ids"))
for _, msgID := range msgIds {
if isLikelyPlaceholderID(msgID) {
return chatGuidanceError(
"消息 ID 仍是占位符,无法查询真实消息",
"--msg-ids 必须来自消息列表返回的真实 openMsgId,test_msg_id_placeholder 等示例值不会命中消息",
[]string{"先执行 chat message list 获取目标消息", "从结果读取 openMsgId 后替换占位符"},
[]string{`dws chat message list-by-ids --msg-ids <openMsgId> --format json`},
)
}
}
if len(msgIds) > 50 {
return fmt.Errorf("--msg-ids 最多支持 50 条,当前 %d 条", len(msgIds))
}
@@ -3152,6 +3253,14 @@ func newChatCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "msg-id", "emoji"); err != nil {
return err
}
if msgID := mustGetFlag(cmd, "msg-id"); isLikelyPlaceholderID(msgID) {
return chatGuidanceError(
"消息 ID 仍是占位符,无法添加表情回应",
"--msg-id 必须是目标消息真实的 openMsgId,不能使用测试占位符",
[]string{"先拉取目标会话消息", "读取目标消息的 openMsgId 后重试"},
[]string{`dws chat message add-emoji --conversation-id <openConversationId> --msg-id <openMsgId> --emoji "赞" --format json`},
)
}
return callMCPToolOnServer("im", "add_emoji_reaction", map[string]any{
"openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"),
"openMsgId": mustGetFlag(cmd, "msg-id"),
@@ -3434,6 +3543,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
conversationID := mustGetFlag(cmd, "open-conversation-id")
messageID := mustGetFlag(cmd, "message-id")
outputPath := mustGetFlag(cmd, "output")
if isLikelyPlaceholderID(resourceID) || isLikelyPlaceholderID(messageID) {
return chatGuidanceError(
"媒体资源参数仍包含占位符",
"--resource-id 和 --message-id 必须来自同一条真实消息,不能使用 test-media 或 test_msg_id_placeholder",
[]string{"先拉取包含媒体的目标消息", "从同一条消息读取 mediaId、openMessageId 和 openConversationId"},
[]string{`dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/ --format json`},
)
}
switch resourceType {
case "mediaId":
@@ -3546,7 +3663,12 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
newOwner = newOwnerUserID
}
if newOwner == "" {
return fmt.Errorf("flag --new-owner or --user is required")
return chatGuidanceError(
"缺少新群主标识",
"--new-owner 接收新群主 openDingTalkId,--user 接收新群主 userId,二者必须选择一个",
[]string{"先查询新群主的人员标识", "使用 --new-owner 或 --user 之一"},
[]string{`dws chat group transfer-owner --group <openConversationId> --new-owner <openDingTalkId> --format json`},
)
}
if !isOpenDingTalkID(newOwner) {
return callMCPToolOnServer("im", "transfer_group_owner", map[string]any{
@@ -3696,9 +3818,36 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
return fmt.Errorf("flag --status is required (0=关闭, 1=开启)")
}
status, _ := cmd.Flags().GetInt("status")
settingKey := mustGetFlag(cmd, "setting-key")
validSettingKeys := map[string]bool{
"authority": true, "joinValidation": true, "onlyAdminCanAtAll": true,
"searchable": true, "addFriendForbidden": true, "toolbarStatus": true,
"pluginCustomizeVerify": true, "onlyAdminCanDING": true,
"allMembersCanCreateMcsConf": true, "onlyAdminCanSetMsgTop": true,
"onlyAdminCanPinMsg": true, "onlyAdminCanSendFile": true,
"allMembersCanCreateCalendar": true, "groupEmailDisabled": true,
"groupRedEnvelopeSwitch": true, "groupLiveAuthority": true,
"groupBillAuthority": true,
}
if !validSettingKeys[settingKey] {
return chatGuidanceError(
"不支持的群设置项:"+settingKey,
"--setting-key 必须使用当前接口支持的精确枚举值,on 不是设置项名称",
[]string{"从 --help 列表选择合法 setting-key", "开启或关闭通过 --status 1/0 表达"},
[]string{`dws chat group update-settings --group <openConversationId> --setting-key searchable --status 1 --format json`},
)
}
if status != 0 && status != 1 {
return chatGuidanceError(
"群设置值只能是 0 或 1",
"--status 表示开关状态:0=关闭,1=开启;其他整数不会被服务端接受",
[]string{"关闭设置时传 --status 0", "开启设置时传 --status 1"},
[]string{`dws chat group update-settings --group <openConversationId> --setting-key searchable --status 1 --format json`},
)
}
return callMCPToolOnServer("im", "update_group_settings", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"settingKey": mustGetFlag(cmd, "setting-key"),
"settingKey": settingKey,
"status": status,
})
},
@@ -3787,6 +3936,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "src-conversation-id", "msg-id", "dest-conversation-id"); err != nil {
return err
}
if msgID := mustGetFlag(cmd, "msg-id"); isLikelyPlaceholderID(msgID) {
return chatGuidanceError(
"转发消息 ID 仍是占位符",
"--msg-id 必须是源会话中真实消息的 openMessageId",
[]string{"先拉取源会话消息", "确认消息属于 --src-conversation-id 后读取 openMessageId"},
[]string{`dws chat message forward --src-conversation-id <源会话ID> --msg-id <openMessageId> --dest-conversation-id <目标会话ID> --format json`},
)
}
toolArgs := map[string]any{
"srcOpenCid": mustGetFlag(cmd, "src-conversation-id"),
"srcOpenMessageId": mustGetFlag(cmd, "msg-id"),
@@ -3931,7 +4088,21 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if !off {
muteTime, _ := cmd.Flags().GetInt64("mute-time")
if muteTime <= 0 {
return fmt.Errorf("--mute-time is required when muting (supported: 300000/3600000/86400000/604800000/2592000000)")
return chatGuidanceError(
"禁言时必须提供 --mute-time",
"--mute-time 的单位是毫秒,仅支持 5 分钟、1 小时、1 天、7 天或 30 天对应的固定值",
[]string{"选择支持的毫秒值之一", "取消禁言时改用 --off"},
[]string{`dws chat group-mute-member --group <openConversationId> --user <userId> --mute-time 300000 --format json`},
)
}
validMuteTimes := map[int64]bool{300000: true, 3600000: true, 86400000: true, 604800000: true, 2592000000: true}
if !validMuteTimes[muteTime] {
return chatGuidanceError(
"不支持的禁言时长",
"--mute-time 使用毫秒且只支持 300000、3600000、86400000、604800000、2592000000;300 表示的时间不在支持范围内",
[]string{"5 分钟使用 300000", "从支持的五档时长中选择"},
[]string{`dws chat group-mute-member --group <openConversationId> --user <userId> --mute-time 300000 --format json`},
)
}
toolArgs["muteTime"] = muteTime
}
@@ -4098,6 +4269,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "group", "role-id", "name"); err != nil {
return err
}
if roleID := mustGetFlag(cmd, "role-id"); isLikelyPlaceholderID(roleID) {
return chatGuidanceError(
"群身份 ID 仍是占位符",
"--role-id 必须来自 chat group-role list 返回的真实 openRoleId,0 不是有效群身份 ID",
[]string{"先列出目标群的群身份", "从结果读取 openRoleId 后重试"},
[]string{`dws chat group-role list --group <openConversationId> --format json`, `dws chat group-role update --group <openConversationId> --role-id <openRoleId> --name "新名称" --format json`},
)
}
return callMCPToolOnServer("im", "update_custom_group_role", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"openRoleId": mustGetFlag(cmd, "role-id"),
@@ -4392,9 +4571,28 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "src-conversation-id", "msg-ids", "dest-conversation-id"); err != nil {
return err
}
msgIDs := parseCSVValues(mustGetFlag(cmd, "msg-ids"))
if len(msgIDs) < 2 {
return chatGuidanceError(
"合并转发至少需要两条消息",
"--msg-ids 只有一条消息时不构成合并转发;单条消息应使用 message forward",
[]string{"提供至少两个来自同一源会话的 openMessageId", "只有一条时改用 message forward"},
[]string{`dws chat message combine-forward --src-conversation-id <源会话ID> --msg-ids <id1>,<id2> --dest-conversation-id <目标会话ID> --format json`},
)
}
for _, msgID := range msgIDs {
if isLikelyPlaceholderID(msgID) {
return chatGuidanceError(
"合并转发消息列表包含占位符",
"--msg-ids 必须全部是源会话中的真实 openMessageId",
[]string{"先拉取源会话消息", "选择至少两个真实消息 ID"},
[]string{`dws chat message combine-forward --src-conversation-id <源会话ID> --msg-ids <id1>,<id2> --dest-conversation-id <目标会话ID> --format json`},
)
}
}
toolArgs := map[string]any{
"srcOpenCid": mustGetFlag(cmd, "src-conversation-id"),
"srcOpenMessageIds": parseCSVValues(mustGetFlag(cmd, "msg-ids")),
"srcOpenMessageIds": msgIDs,
"destOpenCid": mustGetFlag(cmd, "dest-conversation-id"),
}
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
@@ -4430,6 +4628,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
if err := validateRequiredFlags(cmd, "src-msg-id", "src-conversation-id", "src-thread-id", "dest-conversation-id"); err != nil {
return err
}
if msgID := mustGetFlag(cmd, "src-msg-id"); isLikelyPlaceholderID(msgID) {
return chatGuidanceError(
"话题转发的源消息 ID 仍是占位符",
"--src-msg-id 必须来自源话题中的真实 openMessageId,并与源会话和 thread-id 对应",
[]string{"先拉取源会话的话题消息", "从同一条消息读取 openMessageId 和 openConvThreadId"},
[]string{`dws chat message forward-topic --src-msg-id <openMessageId> --src-conversation-id <源会话ID> --src-thread-id <openConvThreadId> --dest-conversation-id <目标会话ID> --format json`},
)
}
toolArgs := map[string]any{
"srcOpenMessageId": mustGetFlag(cmd, "src-msg-id"),
"srcOpenConversationId": mustGetFlag(cmd, "src-conversation-id"),
@@ -4708,12 +4914,21 @@ status 可选值:
if err != nil {
return fmt.Errorf("--record-id must be a valid integer: %w", err)
}
status := mustGetFlag(cmd, "status")
if status != "AuditApprove" && status != "AuditDelete" {
return chatGuidanceError(
"不支持的入群审批状态:"+status,
"当前服务端仅接受大小写完全一致的 AuditApprove 或 AuditDelete;AuditRefuse 和 approve 均不可用",
[]string{"通过申请使用 AuditApprove", "拒绝或删除申请使用 AuditDelete,并可补充 --description"},
[]string{`dws chat group audit-join-validation --group <openConversationId> --record-id <recordId> --applicant <userId> --inviter <userId> --status AuditApprove --format json`},
)
}
toolArgs := map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"applyRecordId": recordID,
"applicantUid": mustGetFlag(cmd, "applicant"),
"inviterUid": mustGetFlag(cmd, "inviter"),
"status": mustGetFlag(cmd, "status"),
"status": status,
}
if v, _ := cmd.Flags().GetString("description"); v != "" {
toolArgs["auditDescription"] = v
@@ -4839,7 +5054,7 @@ status 可选值:
chatClearMessagesCmd := &cobra.Command{
Use: "clear-messages",
Short: "清空当前用户指定会话的聊天记录",
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。
Long: `清空当前用户在指定会话中的聊天记录。仅清空当前用户视角的消息,不影响其他成员。该操作不可逆;必须先获得用户确认,再追加 --yes 执行。
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
- 群聊:dws chat search --query "群名"
@@ -4851,6 +5066,14 @@ status 可选值:
if convID == "" {
return fmt.Errorf("flag --conversation-id is required\n hint: dws chat clear-messages --conversation-id <openConversationId>")
}
if !commandBoolFlag(cmd, "yes") {
return apperrors.NewValidation(
"清空会话聊天记录不可逆;获得用户确认后加 --yes 执行",
apperrors.WithReason("confirmation_required"),
apperrors.WithHint("先确认目标会话及影响范围;用户明确同意后以相同参数追加 --yes"),
apperrors.WithActions("确认目标会话", "获得用户确认后使用 --yes 执行"),
)
}
return callMCPToolOnServer("im", "clear_conversation_messages", map[string]any{
"openConversationId": convID,
})
@@ -5193,6 +5416,14 @@ status 可选值:
if err := validateRequiredFlags(cmd, "group", "notice-id"); err != nil {
return err
}
if noticeID := mustGetFlag(cmd, "notice-id"); isLikelyPlaceholderID(noticeID) {
return chatGuidanceError(
"群公告 ID 仍是占位符",
"--notice-id 必须来自 chat group notice list 返回的真实 dataId,0 不是有效公告 ID",
[]string{"先查询目标群公告列表", "从结果读取 dataId 后重试;查询操作不需要 --dry-run"},
[]string{`dws chat group notice list --group <openConversationId> --format json`, `dws chat group notice get --group <openConversationId> --notice-id <dataId> --format json`},
)
}
return callMCPToolOnServer("im", "get_group_notice", map[string]any{
"openConversationId": mustGetFlag(cmd, "group"),
"dataId": mustGetFlag(cmd, "notice-id"),
@@ -5254,7 +5485,12 @@ status 可选值:
target, _ := cmd.Flags().GetString("target")
receiver, _ := cmd.Flags().GetString("receiver")
if target == "" && receiver == "" {
return fmt.Errorf("--target or --receiver is required")
return chatGuidanceError(
"缺少群邀请链接的接收目标",
"--target 表示接收分享的目标会话,--receiver 表示接收分享的单聊用户,二者必须选择一个",
[]string{"分享到群或会话时使用 --target", "分享到个人时使用 --receiver openDingTalkId"},
[]string{`dws chat group share-invite --source <源群ID> --target <目标会话ID> --format json`},
)
}
if target != "" && receiver != "" {
return fmt.Errorf("--target and --receiver are mutually exclusive")
@@ -114,6 +114,20 @@ func TestCrossPlatformCoverageChatDirectionAndScalarCoverage(t *testing.T) {
for _, wrap := range []bool{true, false} {
_ = normalizeAtPlaceholders("hello @u1 <@u2>", []string{"", "u1", "u2"}, wrap)
}
for _, tc := range []struct {
value string
want bool
}{
{value: "0", want: true},
{value: "test_msg_id_placeholder", want: true},
{value: "test-media", want: true},
{value: "<openMsgId>", want: true},
{value: "real-message-id", want: false},
} {
if got := isLikelyPlaceholderID(tc.value); got != tc.want {
t.Fatalf("isLikelyPlaceholderID(%q) = %v, want %v", tc.value, got, tc.want)
}
}
if got := NormalizeMessageMentions("hello @u1", []string{"u1"}, true, true); got != "<@all> hello <@u1>" {
t.Fatalf("current-user mention normalization = %q", got)
}
+8 -2
View File
@@ -63,8 +63,14 @@ func newChatMediaUploadCommand() *cobra.Command {
func chatMediaUploadDownlineError() error {
return apperrors.NewValidation(
"chat media upload 已下线,当前 CLI 不提供本地文件到 mediaId 的上传能力。" +
" 本地图片或文件请改用: " + chatMediaUploadReplacement +
"chat media upload 已下线,当前 CLI 不提供本地文件到 mediaId 的上传能力。"+
" 本地图片或文件请改用: "+chatMediaUploadReplacement+
";已有 mediaId 时可使用 dws chat message send --msg-type image --media-id <mediaId>。",
apperrors.WithReason("chat_media_upload_retired"),
apperrors.WithHint("本地图片、PDF、DOCX、XLSX 等统一通过 message send --msg-type file --file-path 发送;不要改用 drive upload。"),
apperrors.WithActions(
"本地文件:dws chat message send --msg-type file --file-path <本地路径>",
"已有图片 mediaId:dws chat message send --msg-type image --media-id <mediaId>",
),
)
}
+34
View File
@@ -16,6 +16,40 @@ import (
"github.com/spf13/cobra"
)
func TestChatConversationDestructiveCommandsRequireConfirmation(t *testing.T) {
for _, args := range [][]string{
{"category", "delete", "--category-id", "42"},
{"clear-messages", "--conversation-id", "cid-1"},
} {
caller := &guardedMutationCaller{}
err := executeGuardedMutationCommand(t, caller, newChatCommand, args...)
if err == nil || !strings.Contains(err.Error(), "确认") {
t.Fatalf("chat %v error = %v, want confirmation error", args, err)
}
if len(caller.calls) != 0 {
t.Fatalf("chat %v made tool calls before confirmation: %#v", args, caller.calls)
}
}
}
func TestChatConversationDestructiveCommandsRunAfterConfirmation(t *testing.T) {
for _, tc := range []struct {
args []string
toolName string
}{
{[]string{"category", "delete", "--category-id", "42", "--yes"}, "delete_conv_category"},
{[]string{"clear-messages", "--conversation-id", "cid-1", "--yes"}, "clear_conversation_messages"},
} {
caller := &guardedMutationCaller{}
if err := executeGuardedMutationCommand(t, caller, newChatCommand, tc.args...); err != nil {
t.Fatalf("chat %v error = %v", tc.args, err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != tc.toolName {
t.Fatalf("chat %v calls = %#v, want one %s call", tc.args, caller.calls, tc.toolName)
}
}
}
type contractDefectCaller struct {
dryRun bool
calls []guardedMutationCall
+2 -9
View File
@@ -14,6 +14,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -669,15 +670,7 @@ func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
// suggestForBusinessError returns a user-facing suggestion for known business
// error patterns in a parsed JSON body, or "" if no specific suggestion applies.
func suggestForBusinessError(body map[string]any) string {
msg := ""
if v, ok := body["errorMsg"].(string); ok {
msg = v
} else if v, ok := body["message"].(string); ok {
msg = v
} else if v, ok := body["error"].(string); ok {
msg = v
}
return suggestForBusinessErrorText(msg)
return apperrors.SuggestBusinessHint(body)
}
// confirmDelete is a convenience wrapper around cmdutil.ConfirmDelete that
+17 -5
View File
@@ -62,13 +62,18 @@ var ConversationSetTop = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "单个会话 openConversationId"},
{Name: "conversation-ids", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId(最多 10 个)"},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat-ids", Type: shortcut.FlagStringSlice, Desc: "--conversation-ids 的兼容别名", Hidden: true},
{Name: "off", Type: shortcut.FlagBool, Desc: "取消置顶(不传则设置置顶)"},
{Name: "top", Type: shortcut.FlagBool, Default: "true", Desc: "置顶状态兼容参数", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"conversation-id", "conversation-ids"}},
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"conversation-id", "conversation-ids", "open-conversation-id", "chat-id", "chat-ids"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"off", "top"}},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"conversation-id", "conversation-ids"},
Flags: []string{"conversation-id", "conversation-ids", "open-conversation-id", "chat-id", "chat-ids"},
Description: "会话 ID 去重后必须为 1-10 个",
},
},
@@ -85,6 +90,10 @@ var ConversationSetTop = shortcut.Shortcut{
},
Execute: func(rt *shortcut.RuntimeContext) error {
ids := conversationSetTopIDs(rt)
top := !rt.Bool("off")
if rt.Changed("top") {
top = rt.Bool("top")
}
items := make([]shortcutBatchWrite, 0, len(ids))
for _, id := range ids {
items = append(items, shortcutBatchWrite{
@@ -92,7 +101,7 @@ var ConversationSetTop = shortcut.Shortcut{
arguments: map[string]any{
"openConversationId": id,
"cid": id,
"top": !rt.Bool("off"),
"top": top,
},
})
}
@@ -102,8 +111,11 @@ var ConversationSetTop = shortcut.Shortcut{
func conversationSetTopIDs(rt *shortcut.RuntimeContext) []string {
values := append([]string{}, rt.StrSlice("conversation-ids")...)
if value := rt.Str("conversation-id"); value != "" {
values = append(values, value)
values = append(values, rt.StrSlice("chat-ids")...)
for _, name := range []string{"conversation-id", "open-conversation-id", "chat-id"} {
if value := rt.Str(name); value != "" {
values = append(values, value)
}
}
return uniqueShortcutStrings(values)
}
+37 -10
View File
@@ -69,15 +69,25 @@ var ChatMembersGet = shortcut.Shortcut{
Intent: "当你已有若干成员的 openDingTalkId、需要批量获取他们在该群内的详情(群昵称、角色等)时使用;只读,需传群 openConversationId 和成员 openDingTalkId 列表。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 openDingTalkId 列表", Required: true},
{Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"},
{Name: "group", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--id 的兼容别名", Hidden: true},
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 openDingTalkId 列表(必填)"},
{Name: "open-dingtalk-ids", Type: shortcut.FlagStringSlice, Desc: "--users 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"id", "group", "chat-id", "conversation-id", "open-conversation-id"}},
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"users", "open-dingtalk-ids"}},
},
Tips: []string{`dws chat +chat-members-get --id <openConversationId> --users odid1,odid2`},
Execute: func(rt *shortcut.RuntimeContext) error {
conversationID := rt.StrFirst("id", "group", "chat-id", "conversation-id", "open-conversation-id")
return rt.CallMCP("list_group_member_by_ids", map[string]any{
"openConversationId": rt.Str("id"),
"cid": rt.Str("id"),
"memberOpenDingTalkIds": rt.StrSlice("users"),
"openConversationId": conversationID,
"cid": conversationID,
"memberOpenDingTalkIds": rt.StrSliceFirst("users", "open-dingtalk-ids"),
})
},
}
@@ -122,14 +132,22 @@ var ChatInviteURL = shortcut.Shortcut{
Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "expires-seconds", Type: shortcut.FlagInt, Desc: "链接有效期(秒),0 表示永久"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "id", "chat-id", "conversation-id", "open-conversation-id"}},
},
Tips: []string{`dws chat +chat-invite-url --group <openConversationId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
conversationID := rt.StrFirst("group", "id", "chat-id", "conversation-id", "open-conversation-id")
params := map[string]any{
"openConversationId": rt.Str("group"),
"cid": rt.Str("group"),
"openConversationId": conversationID,
"cid": conversationID,
}
if rt.Changed("expires-seconds") {
params["expiresSeconds"] = rt.Int("expires-seconds")
@@ -552,11 +570,20 @@ var ChatBots = shortcut.Shortcut{
Intent: "当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "id", "chat-id", "conversation-id", "open-conversation-id"}},
},
Tips: []string{`dws chat +chat-bots --group <openConversationId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": rt.Str("group")})
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{
"openConversationId": rt.StrFirst("group", "id", "chat-id", "conversation-id", "open-conversation-id"),
})
if err != nil {
return err
}
+31 -10
View File
@@ -147,14 +147,23 @@ var MessagesRecall = shortcut.Shortcut{
Intent: "当你想撤回当前用户刚发出的某条消息时使用;会实际撤回消息,需传会话 openConversationId 和消息 openMessageId。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId(必填)"},
{Name: "group", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "msg-id", Type: shortcut.FlagString, Desc: "消息 openMessageId(必填)"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "--msg-id 的兼容别名", Hidden: true},
{Name: "open-message-id", Type: shortcut.FlagString, Desc: "--msg-id 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"conversation-id", "group", "chat-id", "open-conversation-id"}},
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"msg-id", "message-id", "open-message-id"}},
},
Tips: []string{`dws chat +messages-recall --conversation-id <openConversationId> --msg-id <openMessageId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("recall_message", map[string]any{
"openConversationId": rt.Str("conversation-id"),
"openMessageId": rt.Str("msg-id"),
"openConversationId": rt.StrFirst("conversation-id", "group", "chat-id", "open-conversation-id"),
"openMessageId": rt.StrFirst("msg-id", "message-id", "open-message-id"),
})
},
}
@@ -529,26 +538,30 @@ var MessagesMget = shortcut.Shortcut{
Intent: "当你已有一批消息 openMsgId、需要批量取回完整详情、reaction 和可执行资源引用时使用;一次最多 50 条。--download-resources 可把所有可识别 mediaId/fileId 安全下载到工作目录内,并逐资源返回成功/失败 ledger;本地下载路径受限于工作目录、默认不覆盖同名文件,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: append([]shortcut.Flag{
{Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID", Required: true},
{Name: "msg-ids", Type: shortcut.FlagStringSlice, Desc: "消息 openMsgId 列表;--msg-ids 去重后必须包含 1-50 条消息 ID(必填)"},
{Name: "message-id", Type: shortcut.FlagStringSlice, Desc: "--msg-ids 的兼容别名", Hidden: true},
{Name: "message-ids", Type: shortcut.FlagStringSlice, Desc: "--msg-ids 的兼容别名", Hidden: true},
{Name: "open-message-ids", Type: shortcut.FlagStringSlice, Desc: "--msg-ids 的兼容别名", Hidden: true},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
}, MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"msg-ids", "message-id", "message-ids", "open-message-ids"}},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"msg-ids"},
Flags: []string{"msg-ids", "message-id", "message-ids", "open-message-ids"},
Description: "--msg-ids 去重后必须包含 1-50 条消息 ID",
},
}, MessageResourceDownloadConstraints()...),
Tips: []string{`dws chat +messages-mget --msg-ids msgId1,msgId2`},
Validate: func(rt *shortcut.RuntimeContext) error {
ids := uniqueShortcutStrings(rt.StrSlice("msg-ids"))
ids := messageMgetIDs(rt)
if len(ids) < 1 || len(ids) > 50 {
return fmt.Errorf("--msg-ids 去重后必须包含 1-50 条消息 ID,当前 %d 条", len(ids))
}
return ValidateMessageResourceDownload(rt)
},
Execute: func(rt *shortcut.RuntimeContext) error {
ids := uniqueShortcutStrings(rt.StrSlice("msg-ids"))
ids := messageMgetIDs(rt)
data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{"openMsgIds": ids})
if err != nil {
return err
@@ -581,6 +594,10 @@ var MessagesMget = shortcut.Shortcut{
},
}
func messageMgetIDs(rt *shortcut.RuntimeContext) []string {
return uniqueShortcutStrings(rt.StrSliceFirst("msg-ids", "message-id", "message-ids", "open-message-ids"))
}
// MessageResourceDownloadFlags returns the common opt-in resource workflow used
// by message list, search, mget, @me and thread-reading Shortcuts.
func MessageResourceDownloadFlags() []shortcut.Flag {
@@ -833,11 +850,15 @@ var MessagesQuerySendStatus = shortcut.Shortcut{
Intent: "当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "open-task-id", Type: shortcut.FlagString, Desc: "发送消息时返回的 openTaskId", Required: true},
{Name: "open-task-id", Type: shortcut.FlagString, Desc: "发送消息时返回的 openTaskId(必填)"},
{Name: "task-id", Type: shortcut.FlagString, Desc: "--open-task-id 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"open-task-id", "task-id"}},
},
Tips: []string{`dws chat +messages-query-send-status --open-task-id <openTaskId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.Str("open-task-id")})
return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.StrFirst("open-task-id", "task-id")})
},
}
@@ -129,6 +129,72 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
wantTool: "query_msg_read_status",
wantArgs: map[string]any{"openConversationId": "cid-1"},
},
{
name: "chat update id and title aliases",
argv: []string{"chat", "+chat-update", "--open-conversation-id", "cid-1", "--title", "新群名", "--yes"},
wantProduct: "chat",
wantTool: "update_group_name",
wantArgs: map[string]any{"openconversation_id": "cid-1", "group_name": "新群名"},
},
{
name: "member get id and users aliases",
argv: []string{"chat", "+chat-members-get", "--conversation-id", "cid-1", "--open-dingtalk-ids", "D1,D2"},
wantProduct: "im",
wantTool: "list_group_member_by_ids",
wantArgs: map[string]any{
"openConversationId": "cid-1",
"memberOpenDingTalkIds": []string{"D1", "D2"},
},
},
{
name: "invite url id alias",
argv: []string{"chat", "+chat-invite-url", "--chat-id", "cid-1"},
wantProduct: "im",
wantTool: "get_group_invite_url",
wantArgs: map[string]any{"openConversationId": "cid-1"},
},
{
name: "chat bots id alias",
argv: []string{"chat", "+chat-bots", "--id", "cid-1"},
wantProduct: "bot",
wantTool: "list_group_bots",
wantArgs: map[string]any{"openConversationId": "cid-1"},
},
{
name: "recall message aliases",
argv: []string{"chat", "+messages-recall", "--chat-id", "cid-1", "--message-id", "msg-1", "--yes"},
wantProduct: "im",
wantTool: "recall_message",
wantArgs: map[string]any{"openConversationId": "cid-1", "openMessageId": "msg-1"},
},
{
name: "message mget message id alias",
argv: []string{"chat", "+messages-mget", "--message-id", "msg-1,msg-2"},
wantProduct: "im",
wantTool: "list_messages_by_ids",
wantArgs: map[string]any{"openMsgIds": []string{"msg-1", "msg-2"}},
},
{
name: "send status task id alias",
argv: []string{"chat", "+messages-query-send-status", "--task-id", "task-1"},
wantProduct: "im",
wantTool: "query_message_send_status",
wantArgs: map[string]any{"openTaskId": "task-1"},
},
{
name: "conversation top aliases",
argv: []string{"chat", "+conversation-set-top", "--open-conversation-id", "cid-1", "--top=false", "--yes"},
wantProduct: "im",
wantTool: "set_top_conversation",
wantArgs: map[string]any{"openConversationId": "cid-1", "top": false},
},
{
name: "favorite list limit alias",
argv: []string{"chat", "+flag-list", "--limit", "7"},
wantProduct: "im",
wantTool: "list_message_favorites",
wantArgs: map[string]any{"size": "7"},
},
{
name: "at all mute matches live MCP schema",
argv: []string{"chat", "+conversation-mute-at-all", "--conversation-id", "cid-1", "--yes"},
@@ -190,7 +256,7 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
t.Fatalf("call = %s/%s, want %s/%s", fake.product, fake.tool, tc.wantProduct, tc.wantTool)
}
for key, want := range tc.wantArgs {
if got := fake.args[key]; got != want {
if got := fake.args[key]; !reflect.DeepEqual(got, want) {
t.Errorf("%s = %#v, want %#v", key, got, want)
}
}
+18 -6
View File
@@ -119,14 +119,24 @@ var ChatUpdate = shortcut.Shortcut{
Intent: "当你只需要修改群名称时使用;这是 lark-cli +chat-update 的诚实子集,只接受群 openConversationId 和新名称。修改群 description、个人备注、群昵称或其他群设置时不要使用。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
{Name: "name", Type: shortcut.FlagString, Desc: "新的群名称", Required: true},
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId(必填)"},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--group 的兼容别名", Hidden: true},
{Name: "name", Type: shortcut.FlagString, Desc: "新的群名称(必填)"},
{Name: "title", Type: shortcut.FlagString, Desc: "--name 的兼容别名", Hidden: true},
{Name: "new-title", Type: shortcut.FlagString, Desc: "--name 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "id", "chat-id", "conversation-id", "open-conversation-id"}},
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"name", "title", "new-title"}},
},
Tips: []string{`dws chat +chat-update --group <openConversationId> --name "新群名"`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("update_group_name", map[string]any{
"openconversation_id": rt.Str("group"),
"group_name": rt.Str("name"),
"openconversation_id": rt.StrFirst("group", "id", "chat-id", "conversation-id", "open-conversation-id"),
"group_name": rt.StrFirst("name", "title", "new-title"),
})
},
}
@@ -391,6 +401,8 @@ var FlagList = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "数字分页游标,首次传 0"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,范围 1-100"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "--size 的兼容别名", Hidden: true},
{Name: "max", Type: shortcut.FlagInt, Desc: "--size 的兼容别名", Hidden: true},
},
Constraints: []shortcut.Constraint{{
Kind: shortcut.ConstraintCustom,
@@ -402,7 +414,7 @@ var FlagList = shortcut.Shortcut{
if rt.Int("cursor") < 0 {
return apperrors.NewValidation("--cursor 必须大于等于 0")
}
if size := rt.Int("size"); size < 1 || size > 100 {
if size := rt.IntFirst("size", "limit", "max"); size < 1 || size > 100 {
return apperrors.NewValidation("--size 必须在 1-100 之间")
}
return nil
@@ -410,7 +422,7 @@ var FlagList = shortcut.Shortcut{
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("list_message_favorites", map[string]any{
"cursor": rt.Int("cursor"),
"size": strconv.Itoa(rt.Int("size")),
"size": strconv.Itoa(rt.IntFirst("size", "limit", "max")),
})
},
}
+30 -1
View File
@@ -96,6 +96,17 @@ func (rt *RuntimeContext) StrSlice(name string) []string {
return v
}
// StrSliceFirst returns the first non-empty string-slice value across a
// primary flag and its compatibility aliases.
func (rt *RuntimeContext) StrSliceFirst(names ...string) []string {
for _, name := range names {
if v := rt.StrSlice(name); hasNonEmptyString(v) {
return v
}
}
return nil
}
// Changed reports whether the user explicitly set the flag on the command line.
func (rt *RuntimeContext) Changed(name string) bool {
f := rt.cmd.Flags().Lookup(name)
@@ -358,9 +369,27 @@ func shortcutLongHelp(s Shortcut) string {
if len(s.Constraints) == 0 {
return long
}
publicFlags := make(map[string]bool, len(s.Flags))
for _, flag := range s.Flags {
publicFlags[flag.Name] = !flag.Hidden
}
lines := make([]string, 0, len(s.Constraints))
for _, constraint := range s.Constraints {
lines = append(lines, " - "+constraintHelp(constraint))
visible := constraint
visible.Flags = make([]string, 0, len(constraint.Flags))
for _, flag := range constraint.Flags {
if publicFlags[flag] {
visible.Flags = append(visible.Flags, flag)
}
}
if len(visible.Flags) == 0 ||
(visible.Kind != ConstraintCustom && len(visible.Flags) < 2) {
continue
}
lines = append(lines, " - "+constraintHelp(visible))
}
if len(lines) == 0 {
return long
}
return long + "\n\n参数约束:\n" + strings.Join(lines, "\n")
}
+15
View File
@@ -16,6 +16,7 @@ package shortcut
import (
"bytes"
"os"
"reflect"
"strings"
"testing"
@@ -137,6 +138,7 @@ func TestCrossPlatformCoverageSchemaConstraintCollapsesHiddenAliases(t *testing.
cmd := mount(Shortcut{
Service: "chat",
Command: "+search",
Intent: "搜索消息",
Flags: []Flag{
{Name: "query", Type: FlagString},
{Name: "keyword", Type: FlagString, Hidden: true},
@@ -165,6 +167,11 @@ func TestCrossPlatformCoverageSchemaConstraintCollapsesHiddenAliases(t *testing.
if raw := cmd.Annotations["dws.schema.constraints"]; raw != "" {
t.Fatalf("hidden compatibility alias leaked into public Schema constraints: %q", raw)
}
for _, hidden := range []string{"--keyword", "--legacy-id"} {
if strings.Contains(cmd.Long, hidden) {
t.Fatalf("hidden compatibility alias leaked into long help: %q", cmd.Long)
}
}
}
func TestCrossPlatformCoverageAliasAndAIMessageTagHelpers(t *testing.T) {
@@ -179,6 +186,8 @@ func TestCrossPlatformCoverageAliasAndAIMessageTagHelpers(t *testing.T) {
Flags: []Flag{
{Name: "query", Type: FlagString},
{Name: "keyword", Type: FlagString},
{Name: "ids", Type: FlagStringSlice},
{Name: "legacy-ids", Type: FlagStringSlice},
{Name: "limit", Type: FlagInt, Default: "20"},
{Name: "size", Type: FlagInt},
aiTag,
@@ -193,6 +202,12 @@ func TestCrossPlatformCoverageAliasAndAIMessageTagHelpers(t *testing.T) {
if got := rt.StrFirst("query", "keyword"); got != "树莓派" {
t.Fatalf("StrFirst() = %q, want 树莓派", got)
}
if err := cmd.Flags().Set("legacy-ids", "a,b"); err != nil {
t.Fatal(err)
}
if got := rt.StrSliceFirst("ids", "legacy-ids"); !reflect.DeepEqual(got, []string{"a", "b"}) {
t.Fatalf("StrSliceFirst() = %#v, want [a b]", got)
}
if got := rt.IntFirst("limit", "size"); got != 20 {
t.Fatalf("IntFirst() default = %d, want 20", got)
}
@@ -218,6 +218,20 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
wantTool: "search_messages",
wantArgs: map[string]any{"openConversationIds": []string{"cid-1"}, "keyword": "树莓派"},
},
{
name: "dm name alias",
argv: []string{"chat", "+dm", "--name", "张三", "--text", "你好", "--yes"},
wantProduct: "chat",
wantTool: "send_personal_message",
wantArgs: map[string]any{"receiverOpenDingTalkId": "open1"},
},
{
name: "chat members list id alias",
argv: []string{"chat", "+chat-members-list", "--chat-id", "cid-1", "--member-types", "user"},
wantProduct: "chat",
wantTool: "get_group_members",
wantArgs: map[string]any{"openconversation_id": "cid-1"},
},
}
for _, tc := range tests {
+7 -2
View File
@@ -40,16 +40,21 @@ var DM = shortcut.Shortcut{
"内部先按姓名搜通讯录解析出唯一用户,并用其 openDingTalkId 发送,姓名匹配到多人时会列出候选让你区分。会真实发出消息。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "to", Type: shortcut.FlagString, Desc: "收件人姓名/花名", Required: true},
{Name: "to", Type: shortcut.FlagString, Desc: "收件人姓名/花名(必填)"},
{Name: "name", Type: shortcut.FlagString, Desc: "--to 的兼容别名", Hidden: true},
{Name: "keyword", Type: shortcut.FlagString, Desc: "--to 的兼容别名", Hidden: true},
{Name: "text", Type: shortcut.FlagString, Desc: "消息内容(支持 Markdown)", Required: true},
shortcut.AIMessageTagFlag(),
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"to", "name", "keyword"}},
},
Tips: []string{`dws chat +dm --to 张三 --text "周报发我一下"`},
Execute: func(rt *shortcut.RuntimeContext) error {
text := rt.Str("text")
// Step 1 — resolve the recipient name to a unique userId.
user, err := resolveOpenDingTalkUser(rt, rt.Str("to"))
user, err := resolveOpenDingTalkUser(rt, rt.StrFirst("to", "name", "keyword"))
if err != nil {
return err
}
+5 -2
View File
@@ -109,17 +109,20 @@ var ChatMembersList = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群名称(与 --conversation-id 二选一)"},
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "群 openConversationId(与 --group 二选一)"},
{Name: "id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "member-types", Type: shortcut.FlagStringSlice, Desc: "成员类型:user,bot;不传则同时返回"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id"}},
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "chat-id", "open-conversation-id"}},
},
Tips: []string{
`dws chat +chat-members-list --group "项目冲刺"`,
`dws chat +chat-members-list --conversation-id <openConversationId> --member-types user,bot`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
groupID := strings.TrimSpace(rt.Str("conversation-id"))
groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "id", "chat-id", "open-conversation-id"))
groupName := strings.TrimSpace(rt.Str("group"))
if groupID == "" {
resolved, err := resolveGroupName(rt, groupName)
+32 -3
View File
@@ -28,7 +28,7 @@ import (
// NewShortcutCommand builds the `dws shortcut` management command tree:
//
// dws shortcut list [--service x] # list built-in shortcuts
// dws shortcut list [--service x] [--compact] # list built-in shortcuts
// dws shortcut stats [--top N] # high-frequency usage aggregation
// dws shortcut stats --purge # clear the usage log
func NewShortcutCommand() *cobra.Command {
@@ -51,6 +51,7 @@ func newListCommand() *cobra.Command {
RunE: func(cmd *cobra.Command, _ []string) error {
svc, _ := cmd.Flags().GetString("service")
includeHidden, _ := cmd.Flags().GetBool("all")
compact, _ := cmd.Flags().GetBool("compact")
rows := make([]shortcutListRow, 0)
for _, s := range shortcut.All() {
if svc != "" && s.Service != svc {
@@ -61,19 +62,37 @@ func newListCommand() *cobra.Command {
}
rows = append(rows, newShortcutListRow(s))
}
return output.WriteCommandPayload(cmd, map[string]any{
payload := map[string]any{
"catalog": "shortcut",
"runtime_schema": true,
"count": len(rows),
"shortcuts": rows,
}, output.FormatJSON)
}
if compact {
compactRows := make([]shortcutCompactListRow, 0, len(rows))
for _, row := range rows {
compactRows = append(compactRows, row.compact())
}
payload["compact"] = true
payload["shortcuts"] = compactRows
}
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
},
}
cmd.Flags().String("service", "", "只列出指定服务的 shortcut")
cmd.Flags().Bool("all", false, "包含当前未进入公开 Catalog、但仍可直接调用的 shortcut")
cmd.Flags().Bool("compact", false, "仅返回发现和安全决策所需字段;选中后再查 leaf Schema 或完整 catalog")
return cmd
}
type shortcutCompactListRow struct {
CLIPath string `json:"cli_path"`
Risk string `json:"risk"`
Confirmation string `json:"confirmation"`
Description string `json:"description"`
Intent string `json:"intent,omitempty"`
}
type shortcutListRow struct {
Service string `json:"service"`
Command string `json:"command"`
@@ -94,6 +113,16 @@ type shortcutListRow struct {
Reviewed bool `json:"reviewed"`
}
func (r shortcutListRow) compact() shortcutCompactListRow {
return shortcutCompactListRow{
CLIPath: r.CLIPath,
Risk: r.Risk,
Confirmation: r.Confirmation,
Description: r.Description,
Intent: r.Intent,
}
}
func newShortcutListRow(s shortcut.Shortcut) shortcutListRow {
product := s.Product
if product == "" {
+51
View File
@@ -75,6 +75,57 @@ func TestCrossPlatformCoverageShortcutListFiltersHiddenAndService(t *testing.T)
}
}
func TestCrossPlatformCoverageShortcutListCompactPublishesDiscoveryContract(t *testing.T) {
shortcut.Register(shortcut.Shortcut{
Service: "chat",
Command: "+messages-send",
Description: "发送一条消息",
Intent: "用户要求发送消息",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "text", Required: true},
},
Tips: []string{"dws chat +messages-send --text hello"},
})
cmd := newListCommand()
var stdout bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetArgs([]string{"--service", "chat", "--compact"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var payload struct {
Compact bool `json:"compact"`
Count int `json:"count"`
Shortcuts []map[string]any `json:"shortcuts"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if !payload.Compact || payload.Count != 1 || len(payload.Shortcuts) != 1 {
t.Fatalf("unexpected compact payload: %#v", payload)
}
row := payload.Shortcuts[0]
for key, want := range map[string]any{
"cli_path": "chat +messages-send",
"description": "发送一条消息",
"intent": "用户要求发送消息",
"risk": "write",
"confirmation": "user_required",
} {
if got := row[key]; got != want {
t.Fatalf("compact row %s = %#v, want %#v", key, got, want)
}
}
for _, omitted := range []string{"flags", "constraints", "examples", "primary", "reviewed"} {
if _, ok := row[omitted]; ok {
t.Fatalf("compact row unexpectedly contains %q: %#v", omitted, row)
}
}
}
func TestCrossPlatformCoverageShortcutListRowPublishesCompleteContract(t *testing.T) {
row := newShortcutListRow(shortcut.Shortcut{
Service: "chat",
+5 -5
View File
@@ -50,13 +50,13 @@ PRODUCT_START = "<!-- VISIBLE_SHORTCUTS_START -->"
PRODUCT_END = "<!-- VISIBLE_SHORTCUTS_END -->"
# Large, high-frequency product skills should route known intents directly and
# keep their full shortcut inventory in Runtime Catalog/Schema. Add services
# here only after verifying that the product skill has its own reviewed routing
# keep their full shortcut inventory in the Runtime Shortcut Catalog. Curated
# entries may additionally be queried through leaf Schema. Add services here
# only after verifying that the product skill has its own reviewed routing
# section and intent table; compacting a sparse skill without an alternative
# route would make its shortcuts harder to discover.
COMPACT_PRODUCT_SERVICES = {"chat"}
def md_escape(value: Any) -> str:
text = str(value or "")
return text.replace("\\", "\\\\").replace("|", "\\|").replace("\n", " ")
@@ -138,9 +138,9 @@ def compact_product_section(service: str, rows: list[dict[str, Any]]) -> str:
return f"""{PRODUCT_START}
## Shortcut 发现(按需)
`{md_escape(service)}` 当前有 {len(rows)} 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
`{md_escape(service)}` 当前有 {len(rows)} 条公开 shortcut。完整清单保留在 Runtime Shortcut Catalog;已完成 Schema curation 的子集可通过 leaf Schema 查询。高频产品根 Skill 不重复展开完整清单。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service {md_escape(service)} --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service {md_escape(service)} --compact --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
{PRODUCT_END}"""
+1 -1
View File
@@ -21,7 +21,7 @@ shortcut_rows="$(
awk '
/<!-- VISIBLE_SHORTCUTS_START -->/ { in_block = 1; next }
/<!-- VISIBLE_SHORTCUTS_END -->/ { in_block = 0 }
in_block && /^\| `dws chat \+/ { count++ }
in_block && /^\|[[:space:]]*`/ { count++ }
END { print count + 0 }
' "$chat_skill"
)"
+5 -4
View File
@@ -1,7 +1,8 @@
---
name: dws
description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件、实时监听群成员加入、群成员退出、群改名和群解散时使用。
cli_version: ">=1.0.15"
metadata:
cli_version: ">=1.0.15"
---
# 钉钉全产品 Skill
@@ -21,15 +22,15 @@ cli_version: ">=1.0.15"
- 危险操作必须先向用户确认,用户同意后才加 `--yes` 执行
- 单次批量操作不超过 30 条记录
- 所有命令必须**严格遵循**对应产品参考文档里面规定的参数格式(如:如果有参数值,则参数和参数值之间至少用一个空格隔开)
- **脚本优先**:[scripts/](./scripts/) 下的 `python scripts/<name>.py` 已封装翻页/轮询/批量逻辑,遇到对应场景(如 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等)**优先调用脚本**而非手写多步命令。脚本均支持 `--dry-run` 预览、`--format json` 输出,失败时回退到手动步骤
- **CLI 路径必须独立可用**:不要假定用户环境安装了 Python。优先使用 `dws` 原生命令或 Shortcut;[scripts/](./scripts/) 仅在对应运行时已确认可用且能明显简化翻页、轮询或批量操作时作为可选加速项。脚本不可用时直接执行同场景的原生命令流程,不得把缺少 Python 当成能力阻塞
- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,必须走 `dws event consume ... --flatten` 长连接,不要写脚本轮询消息历史
## Shortcut 与原子命令的使用原则
`shortcut` 是对常用操作的高层封装,适合优先承担用户意图;产品参考文档和本 skill 负责判断意图、风险、跨产品流程和复杂参数,CLI 帮助负责声明当前版本真正可调用的命令。
- 先按产品参考、意图表和 recipe 路由。存在精确覆盖场景的专用脚本/recipe 时继续遵循“脚本优先”;否则用户意图可由可见 shortcut 满足时,优先使用 `dws <service> +<verb> ... --format json`,不要手写等价的多步原子命令。
- 公开内建 shortcut 同时进入 Runtime Schema。用 `dws schema --cli-path "<service> +<verb>" --format json` 读取 Agent 选择、参数、跨参数约束、risk/confirmation 与接口语义;`dws shortcut list --service <service> --format json` 只作为轻量批量发现入口。
- 先按产品参考、意图表和 recipe 路由。精确 recipe 或对应解释器(如 `python3`)已可用的专用脚本优先;解释器不可用时直接跳过脚本,不得阻塞任务。否则用户意图可由可见 shortcut 满足时,优先使用 `dws <service> +<verb> ... --format json`,不要手写等价的多步原子命令。
- 公开内建 shortcut 以 `dws shortcut list --service <service> --format json` 为动态 catalog;已进入 Runtime Schema 的 leaf 用 `dws schema --cli-path "<service> +<verb>" --format json` 读取 Agent 选择、参数、跨参数约束、risk/confirmation 与接口语义。若 leaf 暂未进入 Schema,则使用 catalog 中同一 `cli_path` 的完整契约。
- 真正组装参数前用叶子帮助 `dws <service> +<verb> --help` 核对当前 Cobra 接受的 flags。父级 `dws <service> --help` 只能发现子命令,不能替代叶子参数帮助。
- shortcut catalog 中 `confirmation=user_required` 时,必须先获得用户确认,确认后才加 `--yes`;`not_required` 不额外确认。
- 如果 shortcut 不在 help / list 中,改用产品参考里的原子命令、脚本或标准流程;不要猜测未展示的 `+` 命令。
@@ -4,10 +4,10 @@
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| query-group-chat | **优先**:`chat_export_messages.py`(开源版未引入;可手动用 `dws chat message list` 翻页后写入文件)(自动搜群+翻页+导出)<br>备选:1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`<br>4. `--forward=false` 拉给定时间**之前**的消息<br>5. 合并全部消息后总结 |
| query-private-chat | **优先**:`chat_history_with_user.py`(开源版未引入;可手动用 `dws chat search` + `dws chat message list` 组合)(自动搜人+翻页+导出)<br>备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `chat message list --user <userId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:同 query-group-chat<br>4. 合并全部消息后总结 |
| query-group-chat | 1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>" --direction newer --format json`<br>3. **翻页**:`hasMore=true` 时取本页最后一条消息的 `createTime` 作为下一次 `--time`,保持原 `--direction`,禁止自造 cursor/time;直到 `hasMore=false`<br>4. `--direction older` 拉给定时间**之前**的消息<br>5. 合并结果;用户要求导出时由 Agent 将结构化输出写入目标文件 |
| query-private-chat | 1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId` / `openDingTalkId`<br>2. `chat message list --user <userId> --time "<yyyy-MM-dd HH:mm:ss>" --direction newer --format json`;跨组织时可用 `--open-dingtalk-id`<br>3. **翻页**:同 query-group-chat<br>4. 合并结果后总结或按用户要求保存 |
| 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>`(备选) |
| send-by-bot | 1. `chat bot search` → 取 `robotCode`<br>2. `chat search --query "<群名>"` → 取每个 `openConversationId`<br>3. 单群用 `chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "<标题>" --text "<内容>"`<br>4. 多群时先一次性确认全部目标与内容,再对每个已确认群逐一执行同一命令;汇总每群成功/失败结果 |
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat +chat-messages --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `contact user search --query "<姓名>"` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat +messages-send --as user --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--chat-id <openConversationId>` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
| 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>` |
+28 -14
View File
@@ -4,24 +4,26 @@
## Shortcut 优先路由
常见 Agent 意图优先使用公开 `+` Shortcut;下面的原子命令章节保留给需要特定原始返回结构、兼容参数或 Shortcut 未覆盖字段的场景。执行前用 `dws schema --cli-path "chat +<shortcut>" --format json` 读取最终参数、约束和确认语义。
精确脚本 / recipe 未覆盖时,常见 Agent 意图优先使用公开 `+` Shortcut;下面的原子命令章节保留给需要特定原始返回结构、兼容参数或 Shortcut 未覆盖字段的场景。先尝试 `dws shortcut list --service chat --compact --format json` 动态发现;当前 CLI 报 `unknown flag: --compact` 时,立即去掉 `--compact` 重试。选中后读取 `dws schema --cli-path "<cli_path>" --format json`;若 leaf 暂未进入 Schema,则以完整 Catalog 中同一 `cli_path` 的契约为准,最后用 `dws <cli_path> --help` 核对真实 flags。
| 意图 | 首选 |
|---|---|
| 以 current-user / bot / webhook 身份发消息 | `dws chat +messages-send --as <identity> ...` |
| 拉取单个群聊或单聊的消息 | `dws chat +chat-messages ...` |
| 按关键词、发送者、@对象、会话、类型或时间组合搜索 | `dws chat +search-msg ...` |
| 查询 @我的消息 | `dws chat +at-me ...` |
| 查询 @ 我的消息 | `dws chat +at-me ...` |
| 根据消息 ID 批量取详情与 reaction | `dws chat +messages-mget ...` |
| 读取已知 thread/topic 的全部回复 | `dws chat +thread-replies ...` |
| 下载单个 mediaId/fileId | `dws chat +messages-resource-download ...` |
| 创建并按需立即更新流式卡片 | `dws chat +messages-send-card ...` |
- `+messages-send` 只暴露下层真实支持的身份能力:user 支持文本/Markdown、已有 mediaId 图片、本地文件和幂等键;bot 支持群聊或批量单聊文本/Markdown;webhook 的目标由 token 所在群决定。
- `+messages-send` 会自动规范化并补齐 @ 占位符。user 使用 `<@id>` / `<@all>`;bot/webhook 使用 `@id` / `@手机号` / `@all`。声明 `--at-*` / `--at-all` 即可,不要为统一 Shortcut 手工拼 `@10`。
- `+messages-send` 自动规范化并补齐对应身份的 @ 占位符。声明 `--at-*` / `--at-all` 即可,不要为统一 Shortcut 手工拼 `@10`。
- `+search-msg --page-all` 连续翻页并默认按消息 ID 批量富化;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger。
- `+at-me`、`+chat-messages`、`+messages-mget`、`+search-msg`、`+thread-replies` 可用 `--download-resources` 下载资源。引用、回复、合并转发中的资源使用结果 `resourceRefs` 自带的子消息 `messageId`;仅当子消息缺会话 ID 时继承父消息 `openConversationId`。
- 上述五个查询 Shortcut 与 `+messages-resource-download` 都沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。下载只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
- 下载器仅接受经审查的钉钉与公网 OSS HTTPS 地址并逐跳校验重定向;跨主机时不会转发下层提供的请求头。新官方域名被拒绝时记录错误中的 host 供审查,不要放宽为任意 HTTPS。
- 上述五个查询 Shortcut 与 `+messages-resource-download` 都是 `read/not_required`,不要添加 `--yes`。下载只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
- 下载器只接受经审查的钉钉与公网 OSS HTTPS 地址并逐跳校验重定向;跨主机时不会转发下层提供的请求头。新官方域名被拒绝时记录错误中的 host 供审查,不要放宽为任意 HTTPS。
- `+messages-send-card` 的目标为 `--group`、`--receiver`、`--receiver-open-dingtalk-id` 三选一;传 `--content` 时创建并立即更新,不传时返回 `bizId` 供 `message update-card` 使用。
### group (群组管理)
@@ -1169,6 +1171,10 @@ Flags:
#### 下载消息中的资源(图片/视频/语音等)到本地
读取消息时优先在 `+at-me`、`+chat-messages`、`+messages-mget`、`+search-msg`、`+thread-replies` 上加 `--download-resources --output-dir ./downloads`。只下载单个已知资源时优先用 `+messages-resource-download`:mediaId 必须同时传 `--message-id` 和 `--open-conversation-id`,fileId 不需要消息上下文。输出必须是工作目录内相对路径且不能包含 `..`;默认不覆盖,用户明确要求时才传 `--overwrite`。该 Shortcut 暂无 leaf Schema 时按完整 Catalog + 叶子 Help 核对。
以下原子命令保留给需要原始接口行为的场景。
下载聊天消息中的图片、视频、语音等资源到本地文件。流程:先获取下载 URL,再 HTTP GET 下载。
```
Usage:
@@ -1539,10 +1545,12 @@ Flags:
Usage:
dws chat category delete [flags]
Example:
dws chat category delete --category-id <分组ID>
# 先向用户确认,确认后执行
dws chat category delete --category-id <分组ID> --yes
# 分组ID 可通过 dws chat category list 获取
Flags:
--category-id int 会话分组 ID (必填)
--yes 跳过运行时确认;只能在用户明确确认后传入
```
#### 重命名会话分组
@@ -1771,15 +1779,18 @@ Flags:
Usage:
dws chat clear-messages [flags]
Example:
dws chat clear-messages --conversation-id <openConversationId>
dws chat clear-messages --id <openConversationId>
# 先向用户说明目标会话和影响范围,确认后执行
dws chat clear-messages --conversation-id <openConversationId> --yes
dws chat clear-messages --id <openConversationId> --yes
Flags:
--conversation-id string 会话 openConversationId (必填,支持群聊/单聊)
--id string --conversation-id 的别名
--chat string --conversation-id 的别名
--yes 跳过运行时确认;只能在用户明确确认后传入
注意:
- 仅清空当前用户视角的消息,不影响其他成员
- 仍属于高风险操作;未确认时不得传 --yes 或执行
- openConversationId 可通过 chat search(群聊)或 chat conversation-info(单聊)获取
```
@@ -2201,7 +2212,7 @@ dws chat message send --group <openConversationId> --msg-type image --media-id "
群聊传 --group,单聊传 --receiver,二者互斥。
**注意:send-card 必须和 update-card 搭配使用。** 创建卡片时无需传入内容,后续通过 update-card 更新内容,最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。
**注意:本节原子 send-card 必须和 update-card 搭配使用。** 创建卡片时无需传入内容,后续通过 update-card 更新内容,最后一次更新必须将 --flow-status 设为 3(finish),否则卡片会一直处于"生成中"的加载状态。若使用 `+messages-send-card --content ...`,Shortcut 会在创建后立即完成一次更新;不传 `--content` 时仍返回 `bizId` 供本节 update-card 使用。
flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成(FINISH),4=执行中(EXECUTING),5=错误(ERROR)。
```
Usage:
@@ -2320,12 +2331,15 @@ Flags:
- `chat group-mute-member` 指定群成员禁言,需传 --group、--user/--users(userId,逗号分隔)、--mute-time(毫秒,仅禁言时必填,支持 300000/3600000/86400000/604800000/2592000000),传 --off 解除禁言;CLI 会自动把 userId 解析成 openDingTalkId 再调用,直接传 userId 即可;禁言群主会被服务端拒绝
- `chat group set-admin` 设置/取消群管理员,需传 --group(openConversationId)、--user/--users(userId,逗号分隔),默认设为管理员,传 --off 取消
## 自动化脚本
## 可选自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [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"` |
这些脚本仅用于已确认安装 Python 3 的环境,不是默认或唯一执行路径;无
Python 环境时直接使用上文的 `dws` Shortcut / 原子命令。
| 脚本 | 可选场景 | 用法 |
|------|----------|------|
| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python3 scripts/chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` |
| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` |
## 相关产品
+15 -10
View File
@@ -3,19 +3,19 @@
用机器人向多个群批量发送相同消息(如日报提醒)
用法:
python bot_broadcast.py \
python3 scripts/bot_broadcast.py \
--robot-code <ROBOT_CODE> \
--chats "conv_id1,conv_id2,conv_id3" \
--title "日报提醒" \
--text "请大家今天下班前提交日报"
python bot_broadcast.py \
python3 scripts/bot_broadcast.py \
--robot-code <ROBOT_CODE> \
--chats-file groups.txt \
--title "周会通知" \
--text "明天下午3点周会"
python bot_broadcast.py --dry-run ...
python3 scripts/bot_broadcast.py --dry-run ...
"""
import sys
@@ -40,14 +40,19 @@ def run_dws(
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
data = json.loads(result.stdout)
if isinstance(data, dict) and data.get('success') is False:
detail = data.get('errorMsg') or data.get('message') or '未知错误'
print(f" ✗ 业务调用失败:{detail}")
return None
return data
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def main():
def run(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(
description='向多个群批量发送机器人消息'
)
@@ -64,7 +69,7 @@ def main():
'--text', required=True, help='消息内容 Markdown'
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
args = parser.parse_args(argv)
chat_ids: List[str] = []
if args.chats:
@@ -74,13 +79,13 @@ def main():
p = Path(args.chats_file)
if not p.exists():
print(f"错误:文件不存在: {p}")
sys.exit(1)
return 1
chat_ids = [line.strip() for line in
p.read_text(encoding='utf-8').splitlines()
if line.strip() and not line.startswith('#')]
if not chat_ids:
print('错误:需要 --chats 或 --chats-file')
sys.exit(1)
return 1
print(f"📢 批量发送消息到 {len(chat_ids)} 个群")
print(f" 标题: {args.title}")
@@ -105,8 +110,8 @@ def main():
fail += 1
print(f"\n完成: 成功 {success}, 失败 {fail}")
sys.exit(0 if fail == 0 else 1)
return 0 if fail == 0 else 1
if __name__ == '__main__':
main()
sys.exit(run())
+215 -104
View File
@@ -3,12 +3,12 @@
导出群聊消息到 JSON 文件(从指定时间点拉取)
用法:
python chat_export_messages.py \
python3 scripts/chat_export_messages.py \
--group <openconversation_id> \
--time "2026-03-10 00:00:00" \
--output messages.json
python chat_export_messages.py \
python3 scripts/chat_export_messages.py \
--query "项目冲刺" \
--time "2026-03-10 00:00:00" \
--no-forward --limit 100
@@ -16,11 +16,51 @@
import sys
import json
import datetime
import subprocess
import argparse
from typing import List, Any, Optional
class ScriptError(RuntimeError):
"""可预期的脚本执行错误。"""
exit_code = 1
class AmbiguousTargetError(ScriptError):
"""搜索结果不唯一,需要调用方消歧。"""
exit_code = 2
def normalize_boundary_time(value: Any) -> str:
"""将消息时间边界规范为 CLI 接受的 yyyy-MM-dd HH:mm:ss 或原字符串。"""
if value is None or isinstance(value, bool):
return ''
if isinstance(value, (int, float)):
timestamp = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return ''
try:
timestamp = float(text)
except ValueError:
return text
else:
return str(value).strip()
if timestamp > 10_000_000_000:
timestamp /= 1000
try:
dt = datetime.datetime.fromtimestamp(
timestamp, tz=datetime.timezone(datetime.timedelta(hours=8))
)
except (OSError, OverflowError, ValueError) as exc:
raise ScriptError(f'无效的消息时间边界:{value}') from exc
return dt.strftime("%Y-%m-%d %H:%M:%S")
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
@@ -32,14 +72,19 @@ def run_dws(
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
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
raise ScriptError(f'执行 dws 失败:{exc}') from exc
if result.returncode != 0:
detail = result.stderr.strip() or f'退出码 {result.returncode}'
raise ScriptError(f'dws 命令失败:{detail}')
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ScriptError(f'dws 返回的不是合法 JSON:{exc}') from exc
if isinstance(data, dict) and data.get('success') is False:
detail = data.get('errorMsg') or data.get('message') or '未知错误'
raise ScriptError(f'dws 业务调用失败:{detail}')
return data
def search_group(
@@ -51,14 +96,17 @@ def search_group(
], dry_run=dry_run)
if dry_run:
return '<CONV_ID>'
if not data:
return None
if isinstance(data, list):
groups = data
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
groups = inner.get('items', inner.get('groups', []))
groups = (
inner.get('value')
or inner.get('groups')
or inner.get('items')
or []
)
elif isinstance(inner, list):
groups = inner
else:
@@ -66,16 +114,64 @@ def search_group(
else:
groups = []
if not groups:
print(f"未找到群聊: {query}")
return None
g = groups[0]
raise ScriptError(f'未找到群聊:{query}')
exact = [
item for item in groups
if isinstance(item, dict)
and str(item.get('title') or item.get('name') or '').strip().casefold()
== query.strip().casefold()
]
candidates = exact if exact else groups
if len(candidates) != 1:
rendered = []
for item in candidates:
if not isinstance(item, dict):
continue
name = item.get('title') or item.get('name') or '未知'
conv_id = item.get('openConversationId') or item.get('id') or '无ID'
rendered.append(f'{name} ({conv_id})')
detail = ';'.join(rendered) or f'{len(candidates)} 个候选'
raise AmbiguousTargetError(
f'群名“{query}”匹配到多个候选,请指定 --group:{detail}'
)
g = candidates[0]
name = g.get('title') or g.get('name', '未知')
conv_id = g.get('openConversationId') or g.get('id')
if not conv_id:
raise ScriptError(f'群聊“{name}”缺少 openConversationId')
print(f" 找到群聊: {name} ({conv_id})")
return conv_id
def main():
def parse_message_page(data: Any) -> tuple[List[Any], bool]:
"""提取消息页和 hasMore。"""
if isinstance(data, list):
return data, False
if not isinstance(data, dict):
return [], False
inner = data.get('result', data)
if isinstance(inner, dict):
messages = inner.get('messages', [])
return messages if isinstance(messages, list) else [], bool(
inner.get('hasMore', False)
)
if isinstance(inner, list):
return inner, False
return [], False
def message_identity(message: Any) -> Optional[str]:
if not isinstance(message, dict):
return None
value = (
message.get('openMessageId')
or message.get('openMsgId')
or message.get('msgId')
)
return str(value) if value else None
def run(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(
description='导出群聊消息到 JSON'
)
@@ -95,100 +191,115 @@ def main():
)
parser.add_argument('--output', default='', help='输出文件')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
args = parser.parse_args(argv)
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)
try:
conv_id = args.group
if not conv_id:
if not args.query:
raise ScriptError('需要 --group 或 --query 参数')
print(f'🔍 搜索群聊: {args.query}')
conv_id = search_group(args.query, args.dry_run)
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')
print(f'📥 拉取消息 (起始: {args.time})...')
all_messages: List[Any] = []
seen_ids = set()
current_time = args.time
direction = 'older' if args.no_forward else 'newer'
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
has_more = False
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)
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list',
'--group', conv_id or '<CONV_ID>',
'--time', current_time,
'--direction', direction,
'--format', 'json',
]
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time')
return
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 使用末条消息 createTime')
return 0
if not data:
break
page_msgs, has_more = parse_message_page(data)
if not page_msgs:
if has_more:
raise ScriptError('服务端返回 hasMore=true,但本页没有消息')
break
# 兼容两种结构: 顶层 messages / {result: {messages, hasMore}}
if isinstance(data, list):
page_msgs = data
has_more = False
for message in page_msgs:
if not isinstance(message, dict):
raise ScriptError('消息列表包含非对象条目,无法安全导出')
identity = message_identity(message)
if identity and identity in seen_ids:
continue
if identity:
seen_ids.add(identity)
all_messages.append(message)
remaining -= 1
if remaining <= 0:
break
page += 1
if not has_more or remaining <= 0:
break
last_msg = page_msgs[-1]
if not isinstance(last_msg, dict):
raise ScriptError('末条消息不是对象,无法取得翻页时间边界')
boundary_time = normalize_boundary_time(
last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time')
)
if not boundary_time:
raise ScriptError('hasMore=true,但末条消息缺少 createTime')
if boundary_time == current_time:
raise ScriptError('分页边界没有推进,已停止以避免重复循环')
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if has_more and page >= max_pages and remaining > 0:
raise ScriptError(f'达到最大分页数 {max_pages},结果不完整')
if not all_messages:
print('未拉取到消息')
return 0
if args.output:
with open(args.output, 'w', encoding='utf-8') as file:
json.dump(all_messages, file, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
container = data
inner = data.get('result')
if isinstance(inner, dict):
container = inner
page_msgs = container.get('messages')
if page_msgs is None and isinstance(inner, list):
page_msgs = inner
if not isinstance(page_msgs, list):
page_msgs = []
has_more = bool(container.get('hasMore', False))
if not page_msgs:
break
all_messages.extend(page_msgs)
remaining -= len(page_msgs)
page += 1
if not has_more:
break
last_msg = page_msgs[-1]
boundary_time = (last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time', ''))
if not boundary_time or boundary_time == current_time:
break
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if not all_messages:
print('未拉取到消息')
return
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(all_messages, f, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for m in all_messages:
# 实际字段: sender/createTime/content (兼容旧字段名)
sender = (m.get('sender') or m.get('senderNick')
or m.get('senderOpenDingTalkId', '未知'))
text = m.get('content') or m.get('text', '')
time_str = (m.get('createTime') or m.get('createAt')
or m.get('time', ''))
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
for message in all_messages:
sender = (
message.get('sender')
or message.get('senderNick')
or '未知'
)
text = message.get('content') or message.get('text', '')
time_str = (
message.get('createTime')
or message.get('createAt')
or message.get('time', '')
)
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
return 0
except ScriptError as exc:
print(f'错误:{exc}', file=sys.stderr)
return exc.exit_code
except OSError as exc:
print(f'错误:无法写入输出文件:{exc}', file=sys.stderr)
return 1
if __name__ == '__main__':
main()
sys.exit(run())
+216 -108
View File
@@ -3,23 +3,63 @@
查询与某人的单聊聊天记录
用法:
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
python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"
python3 scripts/chat_history_with_user.py --user <userId> --time "2026-03-10 00:00:00" --limit 50
python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-01 00:00:00" --output history.json
工作流:
1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user)
2. 调用 chat message list --user <userId> 拉取单聊消息
2. 调用 chat message list-direct --user <userId> 拉取单聊消息
3. 输出到终端或导出为 JSON 文件
"""
import sys
import json
import datetime
import subprocess
import argparse
from typing import List, Any, Optional
class ScriptError(RuntimeError):
"""可预期的脚本执行错误。"""
exit_code = 1
class AmbiguousTargetError(ScriptError):
"""搜索结果不唯一,需要调用方消歧。"""
exit_code = 2
def normalize_boundary_time(value: Any) -> str:
"""将消息时间边界规范为 CLI 接受的 yyyy-MM-dd HH:mm:ss 或原字符串。"""
if value is None or isinstance(value, bool):
return ''
if isinstance(value, (int, float)):
timestamp = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return ''
try:
timestamp = float(text)
except ValueError:
return text
else:
return str(value).strip()
if timestamp > 10_000_000_000:
timestamp /= 1000
try:
dt = datetime.datetime.fromtimestamp(
timestamp, tz=datetime.timezone(datetime.timedelta(hours=8))
)
except (OSError, OverflowError, ValueError) as exc:
raise ScriptError(f'无效的消息时间边界:{value}') from exc
return dt.strftime("%Y-%m-%d %H:%M:%S")
def run_dws(
args: List[str], dry_run: bool = False,
) -> Optional[Any]:
@@ -32,14 +72,19 @@ def run_dws(
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
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
raise ScriptError(f'执行 dws 失败:{exc}') from exc
if result.returncode != 0:
detail = result.stderr.strip() or f'退出码 {result.returncode}'
raise ScriptError(f'dws 命令失败:{detail}')
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ScriptError(f'dws 返回的不是合法 JSON:{exc}') from exc
if isinstance(data, dict) and data.get('success') is False:
detail = data.get('errorMsg') or data.get('message') or '未知错误'
raise ScriptError(f'dws 业务调用失败:{detail}')
return data
def search_user(
@@ -52,30 +97,80 @@ def search_user(
], dry_run=dry_run)
if dry_run:
return '<USER_ID>'
if not data:
return None
# 解析返回结构
users = data
if isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
users = (inner.get('users', [])
or inner.get('list', []))
users = (
inner.get('value')
or inner.get('users')
or inner.get('list')
or inner.get('items')
or []
)
elif isinstance(inner, list):
users = inner
else:
users = []
if not users or not isinstance(users, list):
print(f"未找到用户: {name}")
return None
u = users[0]
raise ScriptError(f'未找到用户:{name}')
exact = [
item for item in users
if isinstance(item, dict)
and str(item.get('name') or item.get('nick') or '').strip().casefold()
== name.strip().casefold()
]
candidates = exact if exact else users
if len(candidates) != 1:
rendered = []
for item in candidates:
if not isinstance(item, dict):
continue
user_name = item.get('name') or item.get('nick') or '未知'
user_id = item.get('userId') or item.get('userid') or '无ID'
rendered.append(f'{user_name} ({user_id})')
detail = ';'.join(rendered) or f'{len(candidates)} 个候选'
raise AmbiguousTargetError(
f'姓名“{name}”匹配到多个候选,请指定 --user:{detail}'
)
u = candidates[0]
user_name = u.get('name') or u.get('nick', '未知')
user_id = u.get('userId') or u.get('userid', '')
if not user_id:
raise ScriptError(f'用户“{user_name}”缺少 userId')
print(f" 找到用户: {user_name} ({user_id})")
return user_id
def main():
def parse_message_page(data: Any) -> tuple[List[Any], bool]:
"""提取消息页和 hasMore。"""
if isinstance(data, list):
return data, False
if not isinstance(data, dict):
return [], False
inner = data.get('result', data)
if isinstance(inner, dict):
messages = inner.get('messages', [])
return messages if isinstance(messages, list) else [], bool(
inner.get('hasMore', False)
)
if isinstance(inner, list):
return inner, False
return [], False
def message_identity(message: Any) -> Optional[str]:
if not isinstance(message, dict):
return None
value = (
message.get('openMessageId')
or message.get('openMsgId')
or message.get('msgId')
)
return str(value) if value else None
def run(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(
description='查询与某人的单聊聊天记录'
)
@@ -96,100 +191,113 @@ def main():
)
parser.add_argument('--output', default='', help='导出到 JSON 文件')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
args = parser.parse_args(argv)
# 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)
try:
user_id = args.user
if not user_id:
print(f'🔍 搜索用户: {args.name}')
user_id = search_user(args.name, args.dry_run)
# 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')
print(f'📥 拉取与 {user_id} 的聊天记录 (起始: {args.time})...')
all_messages: List[Any] = []
seen_ids = set()
current_time = args.time
direction = 'older' if args.no_forward else 'newer'
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
has_more = False
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list',
'--user', user_id or '<USER_ID>',
'--time', current_time,
'--format', 'json',
]
if args.no_forward:
cmd_args.append('--forward=false')
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list-direct',
'--user', user_id or '<USER_ID>',
'--time', current_time,
'--direction', direction,
'--format', 'json',
]
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time')
return
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 使用末条消息 createTime')
return 0
if not data:
break
page_msgs, has_more = parse_message_page(data)
if not page_msgs:
if has_more:
raise ScriptError('服务端返回 hasMore=true,但本页没有消息')
break
# 兼容两种结构: 顶层 messages / {result: {messages, hasMore}}
if isinstance(data, list):
page_msgs = data
has_more = False
for message in page_msgs:
if not isinstance(message, dict):
raise ScriptError('消息列表包含非对象条目,无法安全导出')
identity = message_identity(message)
if identity and identity in seen_ids:
continue
if identity:
seen_ids.add(identity)
all_messages.append(message)
remaining -= 1
if remaining <= 0:
break
page += 1
if not has_more or remaining <= 0:
break
last_msg = page_msgs[-1]
if not isinstance(last_msg, dict):
raise ScriptError('末条消息不是对象,无法取得翻页时间边界')
boundary_time = normalize_boundary_time(
last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time')
)
if not boundary_time:
raise ScriptError('hasMore=true,但末条消息缺少 createTime')
if boundary_time == current_time:
raise ScriptError('分页边界没有推进,已停止以避免重复循环')
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if has_more and page >= max_pages and remaining > 0:
raise ScriptError(f'达到最大分页数 {max_pages},结果不完整')
if not all_messages:
print('未拉取到消息')
return 0
if args.output:
with open(args.output, 'w', encoding='utf-8') as file:
json.dump(all_messages, file, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
container = data
inner = data.get('result')
if isinstance(inner, dict):
container = inner
page_msgs = container.get('messages')
if page_msgs is None and isinstance(inner, list):
page_msgs = inner
if not isinstance(page_msgs, list):
page_msgs = []
has_more = bool(container.get('hasMore', False))
if not page_msgs:
break
all_messages.extend(page_msgs)
remaining -= len(page_msgs)
page += 1
if not has_more:
break
last_msg = page_msgs[-1]
boundary_time = (last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time', ''))
if not boundary_time or boundary_time == current_time:
break
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if not all_messages:
print('未拉取到消息')
return
# 3. 输出结果
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(all_messages, f, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for m in all_messages:
# 实际字段: sender/createTime/content (兼容旧字段名)
sender = (m.get('sender') or m.get('senderNick')
or m.get('senderOpenDingTalkId', '未知'))
text = m.get('content') or m.get('text', '')
time_str = (m.get('createTime') or m.get('createAt')
or m.get('time', ''))
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
for message in all_messages:
sender = (
message.get('sender')
or message.get('senderNick')
or '未知'
)
text = message.get('content') or message.get('text', '')
time_str = (
message.get('createTime')
or message.get('createAt')
or message.get('time', '')
)
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
return 0
except ScriptError as exc:
print(f'错误:{exc}', file=sys.stderr)
return exc.exit_code
except OSError as exc:
print(f'错误:无法写入输出文件:{exc}', file=sys.stderr)
return 1
if __name__ == '__main__':
main()
sys.exit(run())
+103 -90
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-chat
description: 钉钉群聊与消息。Use when 用户提到 发消息/编辑或撤回消息/单聊/群聊/建群/普通群升级外部群/群昵称/会话分组/群成员管理/@消息/搜索聊天记录/话题回复/收藏消息/机器人群发/Webhook通知/发送或下载消息图片与文件。不做紧急 DING/短信/电话(走 dingtalk-misc)、邮件(走 dingtalk-mail)、班级群(走 dingtalk-misc)。命令前缀:dws chat。
description: 钉钉群聊与消息。Use when 用户提到 发消息/单聊/群聊/建群/搜群或消息/聊天记录/群成员管理/回复转发撤回/@消息/表情回应/图片文件与资源下载/机器人群发/Webhook通知/互动卡片/收藏与Pin/消息或会话置顶/未读与红点/已读状态/会话分类。不做紧急 DING/短信/电话(走 dingtalk-ding)、邮件(走 dingtalk-mail)、班级群(走 dingtalk-misc);找人本身走 dingtalk-contact 或 dingtalk-aisearch。命令前缀:dws chat。
metadata:
cli_version: ">=0.2.14"
category: product
@@ -11,126 +11,139 @@ metadata:
# 钉钉群聊 / 消息 Skill
## 前置条件 — 执行操作前必读
## Preconditions
> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dws-shared`](../dws-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。
> **CRITICAL — Before any `dws` operation, MUST fully read [`dws-shared`](../dws-shared/SKILL.md).** It defines the global execution contract, safety floor, and on-demand shared-reference routing. Do not preload all of its references.
> 命令参考:[chat.md](references/chat.md);表情:[chat-emoji-list.md](references/chat-emoji-list.md);剧本:[01-messaging.md](references/01-messaging.md)。
> Command reference: [chat.md](references/chat.md); emoji list: [chat-emoji-list.md](references/chat-emoji-list.md).
<!-- VISIBLE_SHORTCUTS_START -->
## Shortcut 发现(按需)
`chat` 当前有 97 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
`chat` 当前有 97 条公开 shortcut。完整清单保留在 Runtime Shortcut Catalog;已完成 Schema curation 的子集可通过 leaf Schema 查询。高频产品根 Skill 不重复展开完整清单。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service chat --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service chat --compact --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
<!-- VISIBLE_SHORTCUTS_END -->
## IM Shortcut 优先路由
## 加载与路由顺序
- 统一发送优先用 `chat +messages-send`:通过 `--as user|bot|webhook` 选择身份,并只传该身份真实支持的目标、内容和凭据。仅在需要原子命令的特定返回结构或兼容参数时,才降级到 `chat message send` / `send-by-bot` / `send-by-webhook`。
- 消息查询按意图选择:指定会话用 `+chat-messages`,跨维度过滤/全量翻页用 `+search-msg`,@我用 `+at-me`,已知消息 ID 批量富化用 `+messages-mget`,已知 thread/topic ID 读取回复用 `+thread-replies`。
- 查询结果中的 `resourceRefs` 是可继续执行的资源上下文。引用、回复或合并转发中的子消息必须使用该子消息返回的 `messageId`;子消息缺会话 ID 时才继承父消息的 `openConversationId`。
- 上述五个查询 Shortcut 与 `+messages-resource-download` 都沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。下载只允许工作目录内相对路径、默认不覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
- `+messages-send` 会按身份规范化 @ 占位符:user 使用 `<@id>` / `<@all>`,bot/webhook 使用 `@id` / `@手机号` / `@all`;只需声明 `--at-*` / `--at-all`,缺失占位符会自动补齐。
- `+messages-send --as user --user <userId>` 会通过通讯录关键词搜索并按 userId 精确匹配 openDingTalkId,所有内容类型与 `--dry-run` 都使用同一只读解析链路;已有 openDingTalkId 时直接传 `--open-dingtalk-id`。
- 流式卡片优先用 `+messages-send-card`:群聊传 `--group`,单聊 userId 传 `--receiver` 并由 CLI 通过通讯录关键词搜索做 userId 精确匹配,已有 openDingTalkId 则必须显式传 `--receiver-open-dingtalk-id`;三者严格三选一,禁止根据首字母猜身份类型。
This is the only Shortcut entry point for multi/chat; `references/` documents atomic commands only. Use `exact recipe/runnable script > matching public Shortcut > atomic command`; skip an optional script if its interpreter is unavailable.
## 意图表
1. 已知高频意图:直接使用“核心意图与执行骨架”,命中后照抄参数名,不先调用 `--help`。
2. 已有匹配 Shortcut:直接执行;参数、约束或安全不确定时才读 leaf Schema。
3. 仅当前 Cobra flags 不确定时读 leaf `--help`。
4. 现有路由无法定位低频能力时才查 Runtime Shortcut Catalog;select a real `cli_path` and never guess names.
5. 没有 Shortcut 时,按需读取对应 branch reference,进入原子命令。
| 用户说 | 命令 |
|--------|------|
| "发消息给张三" | `dws chat +messages-send --as user --open-dingtalk-id <id> --text "<内容>"` |
| "发到XX群" | `dws chat +chat-search --query "<群名>"` → `dws chat +messages-send --as user --chat-id <openConversationId> --text "<内容>"` |
| "建群" / "拉人进群" | `dws chat group create` / `dws chat group members add` |
| "改群名" / "踢人" | `dws chat group rename` / `dws chat group members remove --yes`(踢人不可逆,先确认目标) |
| "@我消息" | `dws chat +at-me` |
| "查群聊记录" | `dws chat +chat-messages --group <openConversationId>` |
| "收藏/取消收藏这条消息" | `dws chat +flag-create` / `dws chat +flag-cancel` |
| "查看我收藏的消息" | `dws chat +flag-list` |
| "用机器人发消息" | `dws chat +messages-send --as bot --robot-code <code> --chat-id <id> --text "<内容>"` |
| "Webhook 推一条" | `dws chat +messages-send --as webhook --webhook-token <token> --text "<内容>"` |
| "发送流式卡片" | `dws chat +messages-send-card --group <openConversationId> --content "<内容>"`;单聊 userId 改用 `--receiver`,openDingTalkId 改用 `--receiver-open-dingtalk-id` |
| "撤回消息" | `dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>` |
| "标记未读 / 清除红点 / 全部已读" | `dws chat mark-unread` / `dws chat clear-red-point` / `dws chat clear-all-red-point` |
| "置顶某条消息 / 取消消息置顶" | `dws chat message set-top-msg` / `dws chat message unset-top-msg` |
| "我加入的所有群 / 全部群列表" | `dws chat group list-all` |
For `confirmation=user_required`, confirm before adding `--yes`. On source conflict, use the safer interpretation and report it.
## 标准 SOP(必遵流程)
## 核心对象与 ID
> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 ID。每条命令必须带 `--format json`,执行后必须按"解析"步取真实字段(`openDingTalkId` / `openConversationId` / `openMessageId`)。
| 对象 | 核心标识与边界 |
|---|---|
| 人员 | 姓名必须解析成唯一真实的 `userId` / `openDingTalkId`,不能把名称当 ID |
| 会话 | 使用真实 `openConversationId` / cid;群名只用于 Shortcut 目标解析 |
| 消息 | 使用真实 `openMessageId` / msgId,并保持与身份和会话一致 |
| 发送任务 | `openTaskId` 只用于查询发送状态,不能替代消息 ID |
| Thread | thread/topic ID 必须绑定真实会话,不跨会话复用 |
| 身份 | current-user、app-bot、Webhook 是不同操作者,不能自动互换 |
| 状态 | 收藏、消息置顶、消息 Pin、会话置顶作用于不同对象 |
### SOP-1 发消息(send-message)
## 核心意图与执行骨架
**触发**:发消息/单聊/通知某人/发到群里。
Prefer the exact Shortcut below; otherwise use the atomic fallback. Apply shared `--format json` and take downstream IDs from actual output. Every Chat workflow must work without Python.
1. **解析收件人(必须)**:人名 → 先 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 取 `openDingTalkId`(优先)或 `userId`;群名 → 先 `dws chat search --query "<群名>" --format json` 取 `openConversationId`。
2. **执行(必须)**:单聊 `dws chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>" --format json`(只有拿不到 `openDingTalkId` 时才用 `--user <userId>`);群聊 `dws chat message send --group <openConversationId> --text "<内容>" --format json`。
3. **验证(必须)**:发送接口成功时可能只返回 `result.openTaskId`,它可用于确认提交成功,但**不是撤回所需消息 ID**。需要撤回时必须按 SOP-7 用带 `--time` 的 `message list` 回查,取得真实 `openMessageId`;返回非 `success` 必须如实报错,不要谎报已发。
| 用户意图 | 精确 Shortcut 骨架 / 原子回退 | 必须保留的执行边界 |
|---|---|---|
| 姓名发单聊 / 群名发群消息 | `+dm --to <姓名> --text <内容>` / `+send-to-group --group <群名> --text <内容>` | Resolve one real person or cid; mentions/`@all` use `+messages-send` |
| user / bot / webhook 发消息 | `+messages-send --as user|bot|webhook` / 对应 `message send*` | Identity determines the operator; use the matching target and content flags |
| 建群 / 改群名 / 拉人 | `+chat-create --name <群名> --users <uid,...>` / `+chat-update --group <cid> --name <新群名>` / `group members add` | Resolve every member; extract the new cid before follow-up actions |
| 列成员 / 批查成员 | `+chat-members-list --group <群名>` 或 `--conversation-id <cid>` / `+chat-members-get --id <cid> --users <odid,...>` | Stop on ambiguous group names; keep user and bot bucket failures |
| 拉会话消息 / 查某人记录 | `+chat-messages` / `message list` | Select one group or DM target; use the requested or explicitly narrowed time boundary |
| 搜消息 / 查 @ 我 | `+search-msg --query <关键词>`;全局 `+at-me --days <N>`;群内 `+search-msg --at-me --group <cid>` | Add only real filters; never invent group flags for `+at-me` |
| 消息详情 / 撤回 / 发送状态 | `+messages-mget --msg-ids <mid,...>` / `+messages-recall --conversation-id <cid> --msg-id <mid>` / `+messages-query-send-status --open-task-id <tid>` | Recall only when explicit; use IDs from the same identity and conversation |
| 群邀请链接 / 群机器人 | `+chat-search --query <群名>` → `+chat-invite-url --group <cid>` / `+chat-bots --group <cid>` | Reuse the resolved cid; do not search again by a guessed name |
| 会话置顶 / 收藏列表 | `+conversation-set-top --conversation-id <cid> [--off]` / `+flag-list --size <1-100>` | Do not confuse conversation top with message top, Pin, or favorite |
| 群消息完整导出 | `+chat-messages`; save merged JSON if requested | Follow pagination to completion; report partial results as incomplete |
| 机器人多群广播 | Call `+messages-send-by-bot` once per resolved group | Confirm recipients once; preserve one body and return a per-group ledger |
**禁止**:把人名/群名直接当 ID 传入、跳过 `aisearch person`/`chat search` 解析、跳过 `--format json`、未发送成功就答复"已发送"。
## 统一发送
### SOP-2 建群(create-group)
身份决定真实操作者、可见范围和可用能力;同一目标使用 user、bot 或 webhook 时结果和权限可能不同,禁止自动切换身份重试。Before sending, verify identity, target, body, title, mentions, message type, and attachment path; ask if ambiguous. Reuse the same `--idempotency-key` on retry.
**触发**:建群/拉人进群/新建讨论组。
```bash
dws chat +messages-send --as user --chat-id <openConversationId> --text "内容" --idempotency-key <key> --format json
dws chat +messages-send --as user --open-dingtalk-id <openDingTalkId> --msg-type file --file ./report.pdf --idempotency-key <key> --format json
dws chat +messages-send --as bot --robot-code <robotCode> --chat-id <openConversationId> --markdown "## 标题
1. **解析成员(必须)**:对每个成员 `dws aisearch person --keyword "<姓名>" --dimension name --format json` 取 `userId`,多人英文逗号拼接。
2. **执行(必须)**:`dws chat group create --name "<群名>" --users <userId1,userId2,...> --format json`;外部群加 `--type EXTERNAL`,话题圈加 `--thread`。
3. **验证(必须)**:从返回取 `openConversationId`,可用 `dws chat search --query "<群名>" --format json` 复核。
正文" --format json
dws chat +messages-send --as webhook --webhook-token <token> --title "告警" --text "内容" --at-all --format json
```
**禁止**:跳过成员 userId 解析直接传姓名、编造 `openConversationId`。
- `user` supports text, Markdown, existing mediaId images, and local files. Audio/video use `--file` and are sent as files.
- `bot` supports group or batch-DM text/Markdown. A webhook token selects its chat. Neither identity supports files.
- Pass only required `--at-*` / `--at-all`; never construct `@10` manually. Use real `U+000A` newlines and a blank line between Markdown paragraphs.
### SOP-3 Webhook 推送(send-by-webhook)
## 查询、资源与卡片
**触发**:用机器人群 webhook 推一条消息。
- `+search-msg` 只搜索消息内容;禁止用于按群名找群或解析群 CID。
- `+search-msg --page-all` paginates and enriches in batches; use it only when complete pagination is required.
- Preserve primary results on partial failure and return a per-item ledger; never claim an incomplete result is complete.
- If a sender name is absent, keep the real sender ID; do not guess a name or expand into an unrequested directory lookup.
- A missing optional enrichment is not a primary-query failure; record enrichment failures in the ledger.
- `+at-me`、`+chat-messages`、`+messages-mget`、`+search-msg`、`+thread-replies` support `--download-resources --output-dir ./downloads [--overwrite]`; resource download is opt-in.
- For nested resources, prefer the child `messageId` in `resourceRefs`; inherit the parent only if the chat ID is missing.
- These queries and `+messages-resource-download` are `read/not_required`; never pass `--yes`. Keep output relative, inside the working directory, without `..`; add `--overwrite` only when requested.
- Accept reviewed DingTalk/public OSS HTTPS URLs only. Validate every redirect and never forward headers across hosts.
- `+messages-send-card` 的 `--group`、`--receiver`、`--receiver-open-dingtalk-id` are mutually exclusive. With `--content`, update immediately; otherwise return `bizId`. The final `+messages-update-card` call uses `--flow-status 3`.
1. **执行(必须)**:`dws chat message send-by-webhook --token <webhookToken> --title "<标题>" --text "<内容>" --format json`。
2. **@ 人(必须)**:需要 @ 时,`--text` 中**必须**先包含对应 `@userId` / `@手机号` / `@10`,再配合 `--at-users` / `--at-mobiles` / `--at-all`;否则 @ 不生效。
## 低频原子路由
**禁止**:只传 `--at-users` 而 `--text` 里不含 `@<标识>`。
Use this section only when no public Shortcut matches. If sibling commands are ambiguous, read [intent-guide.md](references/intent-guide.md), locate the leaf in [chat.md](references/chat.md#命令索引表), then load only the matching branch reference.
### SOP-4 共同群查询(search-common-group)
```text
dws chat
├── message # send, list, search, reply, forward, recall, cards, reactions
├── group* / group-role # chats, members, settings, mute, notice, roles
├── bot # bot search
├── category # conversation categories
├── search # chat and common-chat search
├── conversation-info / list-* # conversation info and lists
├── mute* / hide / set-top
├── mark-* / clear-* # read/unread, red dots, message clearing
├── text / chmod / data-auth
└── scripts
```
**触发**:"我和 XX 的共同群"。
Branch references: [消息](references/chat/chat-message.md), [群与成员](references/chat/chat-group.md), [机器人与 Webhook](references/chat/chat-bot.md), [会话状态与分组](references/chat/chat-conversation.md).
1. **取昵称(必须)**:先 `dws contact user get-self --format json` 取自己昵称;对方昵称从历史/上下文取,拿不到必须先问用户。
2. **执行(必须)**:`dws chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0 --format json`;`hasMore=true` 时**必须**用 `nextCursor` 翻页,不要停在第一页。
| 用户意图 | 原子回退 | 关键边界 |
|---|---|---|
| 收藏 / 取消收藏 / 收藏列表 | `message add-favorite` / `remove-favorite` / `list-favorites` | Changes personal favorites; does not recall |
| 编辑已发送消息 | `message edit` | Edits the existing message; does not resend |
| 普通群升级为外部群 | `group upgrade-to-external` | 不可逆; read leaf confirmation before execution |
| 设置或清除群昵称 | `group update-nick` | Omit `--nick` to clear the current user's chat nickname |
| 查会话所属分组 / 批量查分组 | `category list-by-conv` / `batch-info` | Different from listing every category |
| 特别关注人的消息 | `message list-focused` | Lists messages; “我关注了谁” routes to `dingtalk-contact` |
| 我和某人的共同群 | `search-common` | For “我”, get self nickname via `contact user get-self`; paginate by `nextCursor` |
| 群公告 / 群身份 | `group notice *` / `group-role *` | Chat notices differ from company notices; identity is not admin role |
| 消息置顶 / Pin / 会话置顶 | `message set-top-msg` / `set-pin-msg` / `chat set-top` | Three different target types |
| 查未读会话 / 谁看了消息 | `message list-unread-conversations` / `message read-status` | First lists chats; second lists readers of one message |
| 标未读 / 标已读 / 清红点 / 全部已读 | `mark-unread` / `mark-read` / `clear-red-point` / `clear-all-red-point` | Mutates chat/message state; not read-only |
| 行为授权 / 跨组织聊天数据授权 | `chmod` / `data-auth cross-org` | Grants action or cross-org read access; executes neither target action |
| 退群 / 解散群 | `group quit` / `group dismiss` | First exits current user; second dismisses the entire chat |
**禁止**:跳过昵称解析、忽略 `hasMore` 不翻页。
## 错误恢复与按需 Reference
### SOP-5 红点 / 未读管理(manage-red-point)
**触发**:标记未读/清除红点/全部已读。
1. **执行(必须)**:标记某会话未读 `dws chat mark-unread --conversation-id <openConversationId> --format json`;清除某会话红点 `dws chat clear-red-point --conversation-id <openConversationId> --format json`;全部已读 `dws chat clear-all-red-point --format json`。
2. **取会话 ID(必须)**:`openConversationId` 拿不准时先 `dws chat group list-all --format json` 或 `dws chat search --query "<群名>" --format json`,**禁止**编造。
**禁止**:未确认会话就批量"全部已读"(破坏性,必须先与用户确认)。
### SOP-6 特别关注消息(focus-messages)
**触发**:"特别关注的人最近发了什么/聊了什么"。
1. **执行(必须)**:`dws chat message list-focused --limit 50 --format json`,直接基于返回答复。
2. **边界(必须)**:只有用户终点是"我关注了谁"这种**人员列表**时,才切 `dingtalk-contact` 关系查询。
**禁止**:用普通 `message list` 冒充 focused、把人员列表需求硬塞进 chat。
### SOP-7 拉取 / 撤回消息(list-or-recall-message)
**触发**:查某个群或单聊的聊天记录、撤回某条消息。
1. **定位会话(必须)**:群名先 `dws chat search --query "<群名>" --format json` 取 `openConversationId`;单聊对象先解析真实 `userId` 或 `openDingTalkId`。
2. **拉取消息(必须)**:`dws chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>" --direction older --format json`;单聊将 `--group` 换成 `--user` 或 `--open-dingtalk-id`。`--time` 是必填参数,必须来自用户时间范围或明确收敛后的边界。
3. **撤回(必须)**:仅在用户明确要求撤回时,从消息列表 `result.messages[].openMessageId` 取真实 ID,执行 `dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId> --format json`。
**禁止**:省略 `--time`、用发送返回的 `clientMsgId` 代替 `openMessageId`、只传 `--client-msg-id`、编造会话或消息 ID。
- If a leaf is missing or parameters are invalid, follow the established Catalog → Schema → `--help` order and correct once.
- Re-extract every downstream ID from actual output; never repair an ID by guessing.
- For complex messaging, read [01-messaging.md](references/01-messaging.md); for onboarding, read [01-onboarding.md](references/workflows/01-onboarding.md).
- On command error, read [chat-error-recovery.md](references/chat-error-recovery.md) and correct once.
- Stop and report insufficient permission, unresolved ambiguity, no result after re-search, or contract conflict.
## 跨产品协作
- 收件人是人名 → 先用 `dingtalk-contact` 或 `dingtalk-aisearch` 拿 `openDingTalkId` / `userId`
- 要发图片/文件 → 先 `dt_media_upload` 上传 → `python scripts/extract_media_id.py "<URL>"` 提取 mediaId → 再用 `--media-id`
- 紧急升级(应用内/短信/电话)→ 切到 `dingtalk-misc`(`references/ding.md`)
- 发邮件 → 切到 `dingtalk-mail`
## 局部意图与短流程
- [局部意图消歧](references/intent-guide.md);[短流程](references/lite-recipes.md)。
- 收件人是人名 → use `dingtalk-contact` or `dingtalk-aisearch` to obtain one real `openDingTalkId` / `userId`.
- 要发本地图片/文件 → use `dws chat message send --msg-type file --file-path <本地路径>`. Images are downloadable attachments, not inline. Use `--msg-type image --media-id` only with an existing valid mediaId; DWS cannot convert local files to mediaId.
- 紧急升级(应用内/短信/电话)→ route to `dingtalk-ding`.
- 发邮件 → route to `dingtalk-mail`.
@@ -4,10 +4,10 @@
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| query-group-chat | **优先**:`python scripts/chat_export_messages.py --query "<群名>" --time "<yyyy-MM-dd HH:mm:ss>" [--no-forward] [--limit N] [--output messages.json]`(自动搜群+翻页+导出)<br>备选:1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:`hasMore=true` 时取本页最后 `createTime` 作为下次 `--time`,重复至 `hasMore=false`<br>4. `--forward=false` 拉给定时间**之前**的消息<br>5. 合并全部消息后总结 |
| query-private-chat | **优先**:`python scripts/chat_history_with_user.py --name "<姓名>" --time "<yyyy-MM-dd HH:mm:ss>" [--no-forward] [--limit N] [--output messages.json]`(自动搜人+翻页+导出)<br>备选:1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`<br>2. `chat message list --user <userId> --time "<yyyy-MM-dd HH:mm:ss>"` → 取消息列表<br>3. **翻页**:同 query-group-chat<br>4. 合并全部消息后总结 |
| query-group-chat | 1. `chat search --query "<群名>"` → 取 `openConversationId`<br>2. `chat message list --group <openConversationId> --time "<yyyy-MM-dd HH:mm:ss>" --direction newer --format json`<br>3. **翻页**:`hasMore=true` 时取本页最后一条消息的 `createTime` 作为下一次 `--time`,保持原 `--direction`,禁止自造 cursor/time;直到 `hasMore=false`<br>4. `--direction older` 拉给定时间**之前**的消息<br>5. 合并结果;用户要求导出时由 Agent 将结构化输出写入目标文件 |
| query-private-chat | 1. `aisearch person --keyword "<姓名>" --dimension name` → 取 `userId` / `openDingTalkId`<br>2. `chat message list --user <userId> --time "<yyyy-MM-dd HH:mm:ss>" --direction newer --format json`;跨组织时可用 `--open-dingtalk-id`<br>3. **翻页**:同 query-group-chat<br>4. 合并结果后总结或按用户要求保存 |
| escalate-ding | 三级升级:<br>1. `ding message send --robot-code <robotCode> --type app --users <userId> --content "<内容>"`(必填项见 `dingtalk-misc` 的 `references/ding.md`)<br>2. `chat message send --group <openConversationId> --text "<内容>"` 群里提醒(可选 `--title` / `@` 见 [chat.md](./chat.md))<br>3. `todo task create --title "<标题>" --executors <userId> --priority 40` 建紧急待办<br>前置:`aisearch person --keyword "<姓名>" --dimension name` → 取 `userId`;`chat search --query "<群名>"` → 取 `openConversationId` |
| send-by-bot | **多群批量优先**:`python scripts/bot_broadcast.py --robot-code <robotCode> --chats <id1>,<id2> --title "<标题>" --text "<内容>"`<br>单群:1. `chat bot search` → 取 `robotCode`<br>2. `chat search --query "<群名>"` → 取 `openConversationId`<br>3. `chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "<标题>" --text "<内容>"` |
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `aisearch person --keyword "<姓名>" --dimension name` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId> --text "<内容>"` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
| send-by-bot | 1. `chat bot search` → 取 `robotCode`<br>2. `chat search --query "<群名>"` → 取每个 `openConversationId`<br>3. 单群用 `chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title "<标题>" --text "<内容>"`<br>4. 多群时先一次性确认全部目标与内容,再对每个已确认群逐一执行同一命令;汇总每群成功/失败结果 |
| forward-message | 1. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message list --group <openConversationId> --time "<起始时间>"` 拉源消息<br>2. `aisearch person --keyword "<姓名>" --dimension name` → 取 `openDingTalkId`(推荐);或 `chat search --query "<群名>"` → 取目标 `openConversationId`<br>3. `chat message send --open-dingtalk-id <openDingTalkId> --text "<内容>"`(推荐)或 `--group <openConversationId>` 发送。仅当无法获取 openDingTalkId 时才用 `--user <userId>`(备选) |
| search-common-group | `chat search-common --nicks "<昵称1>,<昵称2>" --limit 20 --cursor 0`(`--match-mode AND`=全在/`OR`=任一在,翻页:`hasMore=true` 时用 `nextCursor`)<br>用户说"我和XX的共同群" → nicks 包含"我"时,需先 `contact user get-self` 取自己昵称再拼接 |
| focus-messages | **零参数一行命令**:`chat message list-focused --limit 50`(拉特别关注人发的消息聚合)<br>触发 query:`"我特别关注的人最近发了什么消息"`、`"关注的人最近聊了啥"`、`"星标联系人最近的动态"`<br>**强消歧**:query 含动词【发/说/聊/讲】或名词【消息/聊天/动态】 → **必须**走本命令,**不要**先去拉 `contact relation list-my-followings`;仅当用户终点是"人员列表"(如"我关注了谁")才走 `relation list-my-followings`(详见 `dingtalk-contact` 易混淆硬规则)<br>翻页:`hasMore=true` 时用 `nextCursor` 作为下次 `--cursor`<br>按人精控(可选):先 `contact relation list-my-followings` 取 `openDingTalkId`,再 `chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start <ISO> --end <ISO>` |
@@ -0,0 +1,46 @@
# chat 高频错误与自纠指南
> 本文件是 `dingtalk-chat` 的局部错误契约,与 `dws-shared` 的全局错误规则配合使用。
> 原则:可自纠的错误给出一次明确修正;不可自纠的错误立即停止并报告用户。
## 错误读取顺序
chat 命令由 Agent 执行时统一加 `--format json`。失败时优先读取结构化错误中的 `code`、`message`、`hint`、`actions` 和 `available_flags`;如果当前版本只返回纯文本,则保留原始错误并按同样顺序判断。
## 可自行修复的错误
| 错误表现 | 根因 | Agent 自纠动作 |
|---|---|---|
| `--group` 与单聊收件人参数同时传入 | 单聊/群聊参数互斥 | 删除错误目标;群聊保留 `--group`,单聊保留 `--user` 或 `--open-dingtalk-id` |
| `openConversationId is required` / 群不存在 | 群 ID 错误或缺失 | 执行 `chat search --query "<群名>" --format json`,从返回中取真实 `openConversationId` |
| `userId is required` / 收件人无效 | 未先把人名解析为 ID | 执行 `aisearch person` 或 `contact user search`,取 `userId` / `openDingTalkId` |
| 群成员命令提示 flag 不存在 | 把消息参数用于成员管理 | 改用 `group members --id <openConversationId>`;不要臆造 `members list` |
| 消息搜索结果不符合关键词条件 | 错用了按时间拉取 | 改用 `message search-advanced` |
| `confirmation_required` | 危险操作尚未通过运行时确认 | 停止并向用户说明目标与影响;只有用户明确同意后才用原命令加 `--yes` 重试一次 |
| 消息 ID / 卡片 bizId 无效 | 使用了占位符或产品对象 ID | 从源会话消息或 `message send-card` 的真实返回中重新提取,不自行构造 |
| Webhook Token 无效 | token 错误或失效 | 停止并报告用户,要求确认 Webhook Token |
| 添加/移除群成员失败 | ID 错误或无权限 | 先确认成员 ID;当前用户无管理权限则停止并报告 |
| 机器人无法添加到群 | 当前用户非群管理员或机器人不可用 | 停止并报告用户,不改用普通用户身份代发 |
| 撤回消息失败 | message key 错误或已超时 | 停止并报告用户 |
## 需用户介入的错误
遇到以下情况不得自行重试或猜测:
- 权限不足(`PermissionDenied`)。
- 资源不存在且重新搜索也无结果。
- 配额超限(例如群成员达到上限)。
- 搜索返回多个同名用户或群,目标存在歧义。
- 不可逆操作尚未获得用户明确确认。
- Schema 与叶子 Help 冲突;采用更安全解释并报告契约漂移,不尝试绕过。
报告时包含原始错误、已验证的目标与仍缺少的信息。
## 跨步骤数据传递
1. `chat search --format json` 返回的 `openConversationId` 直接传给下一步群聊参数。
2. `contact user search` / `aisearch person` 返回的 `openDingTalkId`、`userId` 分别传给对应 flag。
3. `chat message list` / `search` 返回的 `openMessageId` 必须与所属 `openConversationId` 同源,再用于回复、转发、收藏、表情或置顶。
4. `chat message send-card` 创建成功后返回的 `bizId` 只用于后续 `message update-card`。
不要自行构造 ID,也不要假设不同命令返回的字段可以互换。
+93 -46
View File
@@ -4,40 +4,20 @@
> 命令别名:`dws im` 等价于 `dws chat`。
## Shortcut 优先路由
常见 Agent 意图优先使用公开 `+` Shortcut;原子命令保留给需要特定原始返回结构、兼容参数或 Shortcut 未覆盖字段的场景。执行前用 `dws schema --cli-path "chat +<shortcut>" --format json` 读取最终参数、约束和默认确认语义。
| 意图 | 首选 |
|---|---|
| 以 current-user / bot / webhook 身份发消息 | `dws chat +messages-send --as <identity> ...` |
| 拉取单个群聊或单聊的消息 | `dws chat +chat-messages ...` |
| 按关键词、发送者、@对象、会话、类型或时间组合搜索 | `dws chat +search-msg ...` |
| 查询 @我的消息 | `dws chat +at-me ...` |
| 根据消息 ID 批量取详情与 reaction | `dws chat +messages-mget ...` |
| 读取已知 thread/topic 的全部回复 | `dws chat +thread-replies ...` |
| 下载单个 mediaId/fileId | `dws chat +messages-resource-download ...` |
- `+messages-send` 只暴露下层真实支持的身份能力,并自动规范化、补齐对应身份的 @ 占位符。
- `+search-msg --page-all` 连续翻页并默认按消息 ID 批量富化;续页或富化失败会保留已取得结果并返回逐项失败 ledger。
- 五个查询 Shortcut 的 `--download-resources` 沿用安全本地下载的 `read/not_required` 契约,不应添加 `--yes` 或触发交互确认。引用、回复、合并转发中的资源使用 `resourceRefs` 自带的子消息 `messageId`;仅当子消息缺会话 ID 时继承父消息 `openConversationId`。
- `+messages-resource-download` 同样无需交互确认,但只允许工作目录内相对路径、默认拒绝覆盖并原子落盘;需要覆盖时必须由用户显式传 `--overwrite`。
- 下载器只接受经审查的钉钉与公网 OSS HTTPS 地址并逐跳校验重定向;跨主机时不会转发下层提供的请求头。
## 适用范围与安全硬约束
`chat` 覆盖钉钉会话、群聊、群成员、会话消息、机器人消息、Webhook、会话状态和群身份管理。
**发消息前参数审查(必须执行)**:
- 发消息类命令包括 `chat message send`、`send-by-bot`、`send-by-webhook`、`send-card`、`reply`、`forward`。
- 发消息类原子命令包括 `message send`、`send-by-bot`、`send-by-webhook`、`send-card`、`reply`、`forward`。
- 执行前必须逐项核对接收对象、群/人、消息内容、@ 对象、消息类型、附件路径,确认全部来自用户原始需求。
- 用户没说发给谁、群名/人名可能匹配多个对象、消息文本由 Agent 组织、是否 @ 某人不明确时,必须先确认,严禁自行补全。
- 重试发送建议复用同一个 `--uuid`,避免重复投递;不传 `--uuid` 时每次调用都视为新消息。
- 重试 `message send` 时复用同一个 `--uuid`,避免重复投递。
**文件、图片、音频、视频发送硬规则**:
- 新场景发送本地文件、音频、视频统一用 `dws chat message send --msg-type file|audio|video --file-path <本地路径>`,CLI 内部完成上传和发送;`audio` / `video` 底层按 `file` 链路处理。
- 发送本地文件、音频、视频使用 `message send --msg-type file|audio|video --file-path <本地路径>`;CLI 完成上传和发送,`audio` / `video` 底层按 `file` 处理。
- 不要先走 `dt_media_upload`、`extract_media_id.py`、`drive upload`;`--msg-type image --media-id` 只保留给已有 mediaId 的旧链路。
**Markdown 换行硬规则**:
@@ -82,7 +62,7 @@ dws chat bot --help
| 群成员昵称 | `dws chat group members --id <openConversationId> --format json` | 返回中包含成员在群里的昵称/展示名;不要只查 contact 个人名 |
| 消息 `openMessageId` / `openMsgId` | `dws chat message list ... --format json` | 撤回、已读状态、回复、转发、表情回应 |
| 话题 `openConvThreadId` | `dws chat message list ... --format json` | 拉话题回复或往话题内回复;禁止自行拼接 |
| 卡片 `bizId` | `dws chat message send-card ... --format json` | `update-card --biz-id` |
| 卡片 `bizId` | `dws chat message send-card ... --format json` | `message update-card --biz-id` |
## 易混淆路由
@@ -90,14 +70,14 @@ dws chat bot --help
|------------|----------|------------|
| “置顶会话” | `chat set-top` 或 `chat list-top-conversations` | 不要用 `message set-top-msg` |
| “置顶某条消息” | `chat message set-top-msg` | 不要用 `chat set-top` |
| “机器人发消息” | `chat message send-by-bot` | 不要用当前用户身份 `message send` 代发 |
| “机器人发消息” | `chat message send-by-bot` | 不要用当前用户身份代发 |
| “给机器人发单聊” | `chat bot find` 取 openDingTalkId → `message send --open-dingtalk-id` | 不要用 `bot search`,它没有 openDingTalkId |
| “发图片/文件/音频/视频” | `message send --msg-type file|audio|video --file-path`;已有图片 mediaId 才用 `image` | 不要先 `drive upload` 或 `dt_media_upload` |
| “特别关注的人最近发了什么” | `message list-focused` | 不要先查 `contact relation list-my-followings` |
| “某人发给我的消息” | `message list-by-sender` | 不要只查单聊,结果应覆盖群聊+单聊 |
| “搜索消息” | 首选 `message search-advanced` | 不要在可组合条件下退回简单 `message search` |
| “消息转待办” | 先用 `chat message list/search-advanced` 取消息内容,再调用 `dws todo task create` | 不要在 chat 内寻找“转待办”命令 |
| “消息转日程” | 先用 `chat message list/search-advanced` 取消息内容,再调用 `dws calendar event create` | 不要在 chat 内寻找“转日程”命令 |
| “搜索消息” | `message search-advanced` | 不要在可组合条件下退回简单 `message search` |
| “消息转待办” | 先用 `message list` / `search-advanced` 取消息内容,再调用 `dws todo task create` | 不要在 chat 内寻找“转待办”命令 |
| “消息转日程” | 先用 `message list` / `search-advanced` 取消息内容,再调用 `dws calendar event create` | 不要在 chat 内寻找“转日程”命令 |
| “共同群” | `chat search-common --nicks` | 不要分别搜索群再手工求交 |
| “拉取群文件 / 群文件列表” | `chat conversation-info --group <openConversationId>` 取 `spaceId` → `dws drive list --space-id <spaceId>` | 不要用 `chat file upload`,它只负责上传 |
| “清空会话聊天记录” | `chat clear-messages` | 不要理解为删除群消息;它只影响当前用户视角 |
@@ -107,11 +87,11 @@ dws chat bot --help
执行任何发送类命令前,至少检查:
1. 接收对象明确:群聊必须有 `openConversationId`,单聊必须有 `userId` 或 `openDingTalkId`。
2. 身份明确:用户身份发送用 `message send`,机器人身份发送用 `send-by-bot`,Webhook 用 `send-by-webhook`。
2. 身份明确:当前用户、应用机器人、Webhook 分别使用 `message send`、`send-by-bot`、`send-by-webhook`。
3. 内容明确:消息正文、标题、附件、卡片内容都能从用户原始需求中找到依据。
4. @ 对象明确:`--at-all`、`--at-user-ids`、`--at-open-dingtalk-ids` 不可自行追加。
5. 文件路径明确:本地文件、音频、视频必须传 `--msg-type file|audio|video --file-path`,并确认路径是用户提供或当前任务生成的目标文件。
6. 重试策略明确:失败重试时复用同一个 `--uuid`,避免重复投递。
5. 文件路径明确:使用 `--file-path`,并确认路径是用户提供或当前任务生成的目标文件。
6. 重试策略明确:`message send` 复用同一个 `--uuid`,避免重复投递。
## 高频一跳命令
@@ -130,11 +110,11 @@ dws chat bot --help
| 发位置/名片 | `dws chat message send --group <openConversationId> --msg-type location ...` / `--msg-type profile --contact-id <openDingTalkId>` |
| 拉群消息 | `dws chat message list --group <openConversationId> --time "2026-03-10 00:00:00" --direction older --format json` |
| 搜消息 | `dws chat message search-advanced --query "关键词" --start <ISO> --end <ISO> --format json` |
| 查 @ 我的消息 | `dws chat message search-advanced --at-me --start <ISO> --end <ISO> --format json` |
| 查 @ 我的消息 | `dws chat message list-mentions --start <ISO> --end <ISO> --format json` |
| 查未读会话 | `dws chat message list-unread-conversations --count 20 --format json` |
| 撤回自己消息 | `dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId> --format json` |
| 查我的机器人 | `dws chat bot search --name "机器人名" --format json` |
| 机器人发群消息 | `dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "标题" --text "## 标题\n\n正文" --format json` |
| 机器人发群消息 | `dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "标题" --text "## 标题<br><br>正文" --format json` |
| Webhook 告警 | `dws chat message send-by-webhook --token <token> --title "告警" --text "内容" --format json` |
| 查单聊会话 ID | `dws chat conversation-info --user <userId> --format json` |
| 查群钉盘空间 | `dws chat conversation-info --group <openConversationId> --format json` |
@@ -154,14 +134,15 @@ dws chat bot --help
|------|------|----------|------|
| `message send` | 当前用户发群聊/单聊文本、Markdown、图片、文件、音频、视频、位置、名片 | `--group` / `--user` / `--open-dingtalk-id` 三选一;文本用 `--text`,本地文件/音视频用 `--msg-type file|audio|video --file-path` | [chat-message](chat/chat-message.md#发送消息) |
| `message list` | 拉取群聊或单聊消息 | `--time` + `--group` / `--user` / `--open-dingtalk-id` 三选一 | [chat-message](chat/chat-message.md#拉取消息) |
| `message list-direct` | 拉取与指定用户的单聊消息 | `--time` + `--user` / `--open-dingtalk-id` 二选一 | [chat-message](chat/chat-message.md#拉取消息) |
| `message list-all` | 指定时间范围内拉取当前用户全部会话消息 | `--start` `--end` `--limit` `--cursor` | [chat-message](chat/chat-message.md#拉取消息) |
| `message list-by-sender` | 跨单聊和群聊查指定发送者消息 | `--sender-user-id` / `--sender-open-dingtalk-id` 二选一 | [chat-message](chat/chat-message.md#拉取消息) |
| `message list-mentions` | 查 @ 我的消息 | 可选 `--group`、`--start`、`--end` | [chat-message](chat/chat-message.md#拉取消息) |
| `message list-focused` | 查特别关注人消息 | 无 | [chat-message](chat/chat-message.md#拉取消息) |
| `message list-unread-conversations` | 获取未读会话列表 | 可选 `--count` | [chat-conversation](chat/chat-conversation.md#会话列表与红点) |
| `message search-advanced` | 多维度搜索消息,首选;支持消息类型、会话类型、机器人消息过滤 | 至少一个搜索条件 | [chat-message](chat/chat-message.md#搜索消息) |
| `message search-advanced` | 原子多维度搜索回退;支持消息类型、会话类型、机器人消息过滤 | 至少一个搜索条件 | [chat-message](chat/chat-message.md#搜索消息) |
| `message search` | 简单关键词搜索消息 | `--query` | [chat-message](chat/chat-message.md#搜索消息) |
| `message read-status` | 查询消息已读/未读状态 | `--group` `--message-id` | [chat-message](chat/chat-message.md#消息状态与撤回) |
| `message read-status` | 查询消息已读/未读状态 | `--conversation-id` `--message-id` | [chat-message](chat/chat-message.md#消息状态与撤回) |
| `message query-send-status` | 查询发送任务状态 | `--open-task-id` | [chat-message](chat/chat-message.md#消息状态与撤回) |
| `message recall` | 撤回当前用户消息;群主/管理员可撤回群内他人消息 | `--conversation-id` `--msg-id` | [chat-message](chat/chat-message.md#消息状态与撤回) |
| `message edit` | 编辑已发送消息内容 | `--conversation-id` `--msg-id`,`--text` / `--content` 二选一 | [chat-message](chat/chat-message.md#消息状态与撤回) |
@@ -236,7 +217,7 @@ dws chat bot --help
| `mute-at-all` / `mute-red-envelope` | 关闭/恢复 @所有人或红包通知 | `--conversation-id` | [chat-conversation](./chat/chat-conversation.md#会话置顶与通知) |
| `mark-unread` / `mark-read` | 标记会话未读 / 标记消息已读 | 会话 ID;`mark-read` 还需消息 ID | [chat-conversation](./chat/chat-conversation.md#已读未读与清理) |
| `clear-red-point` / `clear-all-red-point` | 清除单会话/全部红点 | 单会话需 `--conversation-id` | [chat-conversation](./chat/chat-conversation.md#会话列表与红点) |
| `clear-messages` | 清空当前用户视角会话消息 | `--conversation-id` | [chat-conversation](./chat/chat-conversation.md#已读未读与清理) |
| `clear-messages` | 清空当前用户视角会话消息 | `--conversation-id`;确认后传 `--yes` | [chat-conversation](./chat/chat-conversation.md#已读未读与清理) |
| `category list-by-conv` | 拉取指定会话所属的用户自定义会话分组 | `--group` | [chat-conversation](./chat/chat-conversation.md#会话分组) |
| `category batch-info` | 批量拉取用户自定义会话分组信息 | `--category-ids` | [chat-conversation](./chat/chat-conversation.md#会话分组) |
| `category *` | 自定义会话分组和智能分组管理 | 视子命令而定 | [chat-conversation](./chat/chat-conversation.md#会话分组) |
@@ -248,25 +229,25 @@ dws chat bot --help
| 用户说 | 路由 |
|--------|------|
| “发群消息 / 发到某群” | `chat message send --group`,先用 `chat search` 找 `openConversationId` |
| “发单聊 / 发给某人” | `chat message send --user` 或 `--open-dingtalk-id`,人员信息见 `dingtalk-contact` |
| “发图片 / 发文件 / 发截图 / 发音频 / 发视频” | `chat message send --msg-type file|audio|video --file-path <本地路径>`;`audio/video` 底层按 `file` 发送 |
| “发单聊 / 发给某人” | `chat message send --user/--open-dingtalk-id`,人员信息见 `dingtalk-contact` |
| “发图片 / 发文件 / 发截图 / 发音频 / 发视频” | `chat message send --msg-type file|audio|video --file-path <本地路径>` |
| “发位置 / 分享地址” | `chat message send --msg-type location`,需经用户确认经纬度和地址名 |
| “发联系人名片 / 分享联系人” | `chat message send --msg-type profile --contact-id <openDingTalkId>` |
| “机器人发消息 / 机器人群发” | `chat message send-by-bot`,不要用用户身份代发 |
| “机器人发消息 / 机器人群发” | 单次用 `chat message send-by-bot`;多群广播优先专用脚本 |
| “撤回我发的消息” | `chat message recall` |
| “撤回机器人发的消息” | `chat message recall-by-bot` |
| “查某群聊天记录” | `chat message list --group` |
| “查和某人的单聊记录” | `chat message list --user` 或 `--open-dingtalk-id` |
| “查某群聊天记录” | 单次用 `chat message list --group`;完整导出优先专用脚本 |
| “查和某人的单聊记录” | 单次用 `chat message list-direct --user/--open-dingtalk-id`;完整历史优先专用脚本 |
| “某人发给我的消息 / 指定发送者消息” | `chat message list-by-sender`,跨单聊和群聊 |
| “我今天/最近所有消息” | `chat message list-all --start <ISO> --end <ISO>` |
| “@我的消息” | `chat message list-mentions` 或 `search-advanced --at-me` |
| “@我的消息” | `chat message list-mentions`;组合过滤用 `chat message search-advanced --at-me` |
| “特别关注的人最近发了什么” | `chat message list-focused`,不要先查关注人员列表 |
| “搜索消息里的关键词 / 多维度搜索” | 首选 `chat message search-advanced` |
| “搜索消息里的关键词 / 多维度搜索” | `chat message search-advanced` |
| “只搜文件消息 / 只搜群聊 / 只看机器人消息” | `chat message search-advanced --message-type file --search-conv-type group_chat --only-robot-messages` 按需组合 |
| “翻译这段文字” | `chat text translate --query <文本> --to <语言代码>` |
| “把这条消息转待办 / 消息里提到的事项建待办” | 先取消息内容和相关人员,再按 `dingtalk-todo` 调 `dws todo task create`;标题、执行人、截止时间必须来自消息或用户确认 |
| “把这条消息转日程 / 消息里约的会建日程” | 先取消息内容、时间、地点、参会人,再按 `dingtalk-calendar` 调 `dws calendar event create`;时间不明确必须先确认 |
| “话题回复 / 往话题里回复” | 先 `message list` 获取 `openConvThreadId`,再 `list-topic-replies` 或 `message send --group <openConvThreadId>` |
| “话题回复 / 往话题里回复” | `message list-topic-replies` 读取;发送回复用 `message send --group <openConvThreadId>` |
| “消息已读未读 / 谁看了消息” | `chat message read-status` |
| “置顶某条消息 / 取消消息置顶” | `chat message set-top-msg` / `unset-top-msg` |
@@ -309,7 +290,7 @@ dws chat bot --help
| “隐藏会话” | `chat hide` |
| “清红点 / 全部已读” | `chat clear-red-point` / `chat clear-all-red-point` |
| “标记未读 / 标记已读” | `chat mark-unread` / `chat mark-read` |
| “清空聊天记录” | `chat clear-messages` |
| “清空聊天记录” | `chat clear-messages`;按 leaf Schema 的确认语义执行 |
| “会话分组” | `chat category *` |
| “智能分组 / 按关键词或成员自动分组” | `chat category create-smart` |
| “群文件 / 拉取群文件列表” | `chat conversation-info --group <openConversationId>` 取 `spaceId`,再按 `dingtalk-drive` 调 `dws drive list --space-id <spaceId>` |
@@ -351,12 +332,78 @@ dws chat bot --help
完整字段传递表见 [chat-workflows](./chat/chat-workflows.md#上下文传递表)。
## 注意事项
- **发消息前参数审查(必须执行)**:
- 发消息(`chat message send`、`send-by-bot`、`send-by-webhook`、`send-card`、`reply`、`forward`)是严肃操作,一旦发错人/发错群会导致严重问题,因此在执行发送之前,agent 必须对所有参数进行内部审查
- 审查方式:将即将发送的**全部参数**(收件人/群、消息内容、@对象、消息类型等)与用户的**原始需求**逐一对比,确认每个参数都能从原始需求中找到明确依据
- 如果存在任何不明确、有歧义或原始需求中未提及的参数(例如:用户没说发给谁、没说发到哪个群、消息内容与用户意图有出入、不确定是否需要 @某人等),**必须先向用户确认**,严禁自行假设或补全
- 典型需要确认的场景:用户只说了"发个消息"但没指定群/人;用户的描述可匹配多个群或多个联系人;消息文本由 agent 组织而非用户原文提供时需确认措辞
- uuid 幂等参数(发消息最佳实践):
- 发消息时建议始终带上 `--uuid` 参数,传入用户自行生成的唯一标识(如 UUID v4),用于幂等控制
- 如果发送失败需要重试,重试时 `--uuid` 必须与首次发送保持一致,服务端据此去重,避免重复发消息
- 如果不传 `--uuid`,每次调用都视为新消息,重试可能导致消息重复发送
- 此参数适用于 `chat message send`(群聊和单聊均支持)
- `--group` 为群聊会话 ID (openconversation_id),可从群搜索或群聊信息中获取
- `chat message send` 推荐用 `--text` 传消息内容(含换行或特殊字符时必须使用),也支持一个位置参数;群聊用 `--group`,单聊用 `--user`(userId)或 `--open-dingtalk-id`(openDingTalkId),三者互斥;纯文本/Markdown 单聊传 `--user` 时直接走 userId 发送能力;`--at-all`、`--at-open-dingtalk-ids` 仅在 `--group` 群聊时生效;本地图片/文件/音视频统一用 `--msg-type file --file-path`,其中图片是可下载附件;`--msg-type image --media-id` 仅用于上游已经提供有效 mediaId 的内联图片
- `chat message list-all` 的四个参数(--start、--end、--limit、--cursor)每次请求都必须传递;翻页时用响应中的 nextCursor 值作为下次 --cursor
- `chat message list` 的 `--group`、`--user`、`--open-dingtalk-id` 三者互斥,必须且只能指定其一
- `chat message list-by-sender` 不需要指定单聊/群聊,返回结果自带会话类型标识;`--sender-user-id`(userId)与 `--sender-open-dingtalk-id`(openDingTalkId)二选一;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
- `chat message list-mentions` 可选 `--group` 指定群聊,不传则查全部;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`/`--cursor`
- `chat message list-unread-conversations` 获取当前用户未读会话列表,可选 `--count` 指定返回条数
- `chat message search` 按关键词搜索消息内容,`--query` 必填,可选 `--group` 限定搜索某个会话;时间用 `--start`/`--end`(ISO-8601),分页用 `--limit`(默认 100)/`--cursor`
- `chat message read-status` 查询指定消息的已读/未读状态,仅消息发送者可查询自己发出的消息;`--conversation-id`、`--message-id` 必填;目标用户 userId 用 `--user`/`--users`,openDingTalkId 用 `--target-open-dingtalk-ids`,不传则查所有接收者
- `chat search-common` 搜索共同群,`--nicks` 传人员昵称(逗号分隔),`--match-mode` AND/OR 控制匹配逻辑,分页用 `--limit`(默认 20)/`--cursor`
- `chat list-top-conversations` 只拉取置顶会话列表,分页用 `--limit`(默认 1000)/`--cursor`;置顶或取消置顶某条消息使用 `chat message set-top-msg` / `unset-top-msg`,不要混用
- `--user` 和 `--open-dingtalk-id` 本质上都是发起单聊操作,只是用户标识格式不同:userId 为企业内部应用常用标识,openDingTalkId 为三方应用或跨组织场景下的用户标识,服务端对两种 ID 的解析逻辑不同
- `--time` 格式: `yyyy-MM-dd HH:mm:ss`,为拉取消息的起始时间点;`--direction` 控制方向(newer=从给定时间往现在拉,older=从给定时间往以前拉),`--limit` 控制数量
- `chat search` 挂在 `chat` 下(非 `chat group` 下),路径为 `dws chat search`
- `send-by-bot` 群聊传 `--group`,单聊传 `--users` 或 `--open-dingtalk-ids`,与 `--group` 互斥且必选其一;群聊时可选 `--at-user-ids` @指定成员(传 userId 列表)或 `--at-open-dingtalk-ids` @指定成员(传 openDingtalkId 列表),content 中需包含对应 @标识;`--at-all` @所有人;群聊场景如果返回"机器人不存在"错误,需先通过 `chat group members add-bot --id <openConversationId> --robot-code <robot-code>` 将机器人邀请进群后再发送
- `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回)
- `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--text` 中包含 `@userId` 或 `@手机号` 才能生效;`--at-all` @所有人时需在 `--text` 中包含 `@10`
- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 覆盖某人全部身份(传空 --role-ids 则清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user <userId>`
- 消息**换行符**(`send` / `send-by-bot` / `send-by-webhook` 的 `--text`)有两层要求:(1) 必须是**真实换行符** `U+000A`,不是字面量 `\n`;(2) Markdown 规范下单换行不生效,需用空行 `\n\n`(段落分隔)或行尾两空格 + 换行 / `<br>`(硬换行)
- `chat group transfer-owner` 转让群主,需传 --group(openConversationId);新群主 userId 用 `--user`,openDingTalkId 用 `--new-owner`
- `chat group invite-url` 获取群邀请链接,需传 --group(openConversationId),可选 --expires-seconds 指定有效期(秒,0=永久)
- `chat group quit` 退出群聊,需传 --group(openConversationId)
- `chat group update-icon` 更新群头像,需传 --group(openConversationId)和由可信上游提供的有效 --icon-media-id(mediaId);DWS CLI 不能从本地图片生成该 ID
- `chat group update-settings` 更新群设置,需传 --group(openConversationId)、--setting-key(设置项 key)、--status(0=关闭 1=开启)
- `chat message send-card` 的 `--group`、`--receiver`、`--receiver-open-dingtalk-id` 三选一;创建后用 `message update-card --biz-id` 更新内容
- `chat message update-card` 流式更新卡片内容,需传 --biz-id(创建卡片返回的业务 ID)、--content、--flow-status
- `chat message list-by-ids` 根据消息 ID 批量查询,--msg-ids 逗号分隔,最多 50 条
- `chat message add-emoji` / `remove-emoji` 需传 --group(openConversationId)、--msg-id(openMsgId)、--emoji(表情名称)
- `chat message add-text-emotion` / `remove-text-emotion` 需传 --group、--msg-id、--emotion-id、--emotion-name、--text、--background-id,六个参数全部必填
- `chat message create-text-emotion` 创建文字表情模板,返回 emotionId;--background-id 可选,不传由服务端默认分配
- `chat category list` 无需参数;`category list-conversations` 需传 --category-id(通过 category list 获取)
- `chat mute` 默认开启免打扰,传 --off 关闭;--conversation-id / --id / --chat 三个别名均可用于传入会话 ID
- `chat message reply` 引用回复消息(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段)、--ref-msg-id(被引用消息 openMessageId)、--ref-sender(被引用消息发送者 openDingTalkId)、--text(回复内容);目前回复类型仅支持 text
- `chat message forward` 转发单条消息(**源/目标会话均支持单聊/群聊**,常见组合:群→群、群→单、单→群、单→单),需传 --src-conversation-id(源会话 openConversationId)、--msg-id(源消息 openMessageId)、--dest-conversation-id(目标会话 openConversationId)
- `chat set-top` 设置/取消会话置顶(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段),默认置顶,传 --off 取消
- `chat message reply` 以当前用户身份引用回复,与 `chat message send` 的用户身份发送语义一致
- **如何获取 openConversationId**(如果上层已有则直接使用,不必再查):
- 群聊:`dws chat search --query "群名"`
- 单聊:`dws chat conversation-info --user <userId>` 或 `dws chat conversation-info --open-dingtalk-id <openDingTalkId>`(人员信息可通过 `dws aisearch person --keyword "姓名" --dimension name` 获取)
- `chat group-mute` 全员禁言/取消全员禁言,需传 --group(openConversationId),默认禁言,传 --off 取消
- `chat group-mute-member` 指定群成员禁言,需传 --group、--user/--users(userId,逗号分隔,CLI 自动解析为 openDingTalkId)、--mute-time(毫秒,仅禁言时必填,支持 300000/3600000/86400000/604800000/2592000000),传 --off 解除禁言;禁言群主会被服务端拒绝
- `chat group set-admin` 设置/取消群管理员,需传 --group(openConversationId)、--user/--users(userId,逗号分隔),默认设为管理员,传 --off 取消
## 可选自动化脚本
这些脚本仅用于已确认安装 Python 3 的环境,不是默认或唯一执行路径;无
Python 环境时直接使用上文的 `dws` 原生命令。
| 脚本 | 可选场景 | 用法 |
|------|----------|------|
| [chat_export_messages.py](../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python3 scripts/chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` |
| [chat_history_with_user.py](../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` |
| [bot_broadcast.py](../scripts/bot_broadcast.py) | 使用同一机器人向多个群批量发送消息 | `python3 scripts/bot_broadcast.py --robot-code <code> --chats <id1>,<id2> --title "通知" --text "内容"` |
## 相关产品
- [contact](../../dingtalk-contact/references/contact.md) — 搜索人员,获取 `userId` / `openDingTalkId`。
- [todo](../../dingtalk-todo/references/todo.md) — 消息转待办时使用;从 chat 消息内容提取标题、执行人、截止时间后调用 `dws todo task create`。
- [calendar](../../dingtalk-calendar/references/calendar.md) — 消息转日程时使用;从 chat 消息内容提取标题、开始/结束时间、地点、参会人后调用 `dws calendar event create`。
- [drive](../../dingtalk-drive/references/drive.md) — 云盘/群文件管理;拉群文件先用 `chat conversation-info` 取 `spaceId`,再用 `dws drive list --space-id <spaceId>`;chat 纯文件/音视频发送优先用 `chat message send --msg-type file|audio|video --file-path`。
- [drive](../../dingtalk-drive/references/drive.md) — 云盘/群文件管理;拉群文件先用 `chat conversation-info` 取 `spaceId`,再用 `dws drive list --space-id <spaceId>`;chat 纯文件/音视频发送使用 `chat message send --msg-type file|audio|video --file-path`。
- [aisearch](../../dingtalk-aisearch/references/aisearch.md) — 可用于人员、行为、历史信息搜索,但消息发送与会话管理仍走 `chat`。
- [ding](../../dingtalk-misc/references/ding.md) — DING 通知与升级提醒,不等价于群聊消息。
@@ -8,7 +8,8 @@
## 必读约束
- 用户明确要求“用机器人/机器人身份/robot”发送时,必须用 `chat message send-by-bot`,严禁改用 `chat message send`。
- 用户明确要求“用机器人/机器人身份/robot”发送时使用 `chat message send-by-bot`;严禁改用当前用户身份。
- 用户明确要求 Webhook 时使用 `chat message send-by-webhook`。
- `chat bot search` 只返回我创建的机器人,没有 `openDingTalkId`;给机器人发单聊必须用 `chat bot find`。
- 机器人发群消息前需确认机器人已在群中;报“机器人不存在”时先 `group members add-bot`。
- `send-by-bot --text` 支持 Markdown;需要稳定换行时用空行分隔段落。若以转义形式组织文本,写 `\n\n`,不要只写 `\n`。
@@ -37,7 +38,11 @@ dws chat bot find --query "日报" --limit 20 --cursor <nextCursor>
```bash
# 群聊
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "日报" --text "## 今日完成\n\n- 事项 A\n\n- 事项 B"
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "日报" --text "## 今日完成
- 事项 A
- 事项 B"
# 单聊 userId
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
@@ -105,7 +110,11 @@ dws chat group members remove-bot --id <openConversationId> --bot-id <openBotId>
```bash
dws chat bot search --name "日报" --format json
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "日报" --text "## 今日完成\n\n- 事项 A\n\n- 事项 B" --format json
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "日报" --text "## 今日完成
- 事项 A
- 事项 B" --format json
dws chat message recall-by-bot --robot-code <robot-code> --group <openConversationId> --keys <processQueryKey> --format json
```
@@ -10,7 +10,8 @@
- 会话状态类命令通常需要 `openConversationId`。群聊可由 `chat search` 获取,单聊可由 `chat conversation-info --user/--open-dingtalk-id` 获取。
- `set-top` 是会话置顶;`message set-top-msg` 是会话内消息置顶,二者不能混用。
- `clear-messages` 只清空当前用户视角的消息,不影响其他成员。
- `clear-messages` 只清空当前用户视角的消息,不影响其他成员,但属于高风险操作。必须先向用户说明目标会话和影响范围,获得明确确认后才传 `--yes`。
- `category delete` 会删除会话分组,同样必须先确认,确认后才传 `--yes`;不得因为 Schema 示例未展示 `--yes` 就跳过运行时确认。
- 智能分组规则中的成员使用 openDingTalkId;如果用户只给姓名,先用 `aisearch person --dimension name` 获取。
## 命令明细
@@ -61,12 +62,13 @@ dws chat hide --conversation-id <openConversationId>
|------|------|----------|
| `mark-unread` | 标记指定会话为未读 | `--conversation-id` |
| `mark-read` | 将指定消息及之前消息标记为已读 | `--conversation-id` `--message-id` |
| `clear-messages` | 清空当前用户指定会话的消息 | `--conversation-id` |
| `clear-messages` | 清空当前用户指定会话的消息 | `--conversation-id`;确认后加 `--yes` |
```bash
dws chat mark-unread --conversation-id <openConversationId>
dws chat mark-read --conversation-id <openConversationId> --message-id <openMessageId>
dws chat clear-messages --conversation-id <openConversationId>
# 按 leaf Schema 的确认语义执行
dws chat clear-messages --conversation-id <openConversationId> --yes
```
### 会话分组
@@ -79,7 +81,7 @@ dws chat clear-messages --conversation-id <openConversationId>
| `category batch-info` | 批量拉取用户自定义会话分组信息 | `--category-ids` |
| `category create` | 创建会话分组 | `--title` |
| `category create-smart` | 创建智能会话分组,可按群名称关键词和群内成员匹配 | `--name`,可选 `--keywords` `--members` |
| `category delete` | 删除会话分组 | `--category-id` |
| `category delete` | 删除会话分组 | `--category-id`;确认后加 `--yes` |
| `category rename` | 修改分组名称 | `--category-id` `--title` |
| `category add-conv` | 将会话加入分组 | `--group` `--category-ids` |
| `category remove-conv` | 将会话移出分组 | `--group` `--category-ids` |
@@ -91,6 +93,8 @@ dws chat category batch-info --category-ids 123,456
dws chat category create --title "工作群"
dws chat category create-smart --name "重点群" --keywords "重点,项目" --members openDingTalkId1,openDingTalkId2
dws chat category add-conv --group <openConversationId> --category-ids 123,456
# 按 leaf Schema 的确认语义执行
dws chat category delete --category-id <categoryId> --yes
```
`create-smart` 中 `--keywords` 是群名称关键词列表,`--members` 是群内成员 openDingTalkId 列表;两者可单独使用,也可组合使用。
@@ -136,5 +140,5 @@ dws chat category create-smart --name "重点群" --keywords "重点" --members
- 用户说“置顶消息”:用 `message set-top-msg`,不是 `chat set-top`。
- 用户说“置顶会话”:用 `chat set-top` 或 `list-top-conversations`。
- 单聊没有会话 ID:先 `conversation-info --user` 或 `--open-dingtalk-id`。
- 清空聊天记录前必须确认目标会话;该操作只影响当前用户视角。
- 清空聊天记录或删除会话分组前必须确认目标和影响范围;未确认时不得传 `--yes` 或执行。
- 智能分组没有匹配条件:至少确认分组名称;关键词和成员规则不明确时先向用户确认,不要自行猜成员。
@@ -128,7 +128,9 @@ dws chat group user-settings set --items '[{"openConversationId":"cid1","top":tr
```bash
dws chat group notice create --group <openConversationId> --content "今晚 22 点系统维护,请提前保存工作内容"
dws chat group notice create --group <openConversationId> --content "# 重要通知\n\n请大家查收" --sticky --send-ding
dws chat group notice create --group <openConversationId> --content "# 重要通知
请大家查收" --sticky --send-ding
dws chat group notice create --group <openConversationId> --content "明早九点例会" --run-at "2026-07-03T09:00:00+08:00"
dws chat group notice list --group <openConversationId> --limit 20 --cursor <nextPageCursor>
dws chat group notice get --group <openConversationId> --notice-id <dataId>
@@ -195,7 +197,9 @@ dws chat group members add --id <openConversationId> --users userId3,userId4 --f
```bash
dws chat group share-invite --source <sourceOpenConversationId> --target <targetOpenConversationId> --format json
dws chat group notice create --group <openConversationId> --content "# 项目公告\n\n请大家关注最新安排" --send-ding --format json
dws chat group notice create --group <openConversationId> --content "# 项目公告
请大家关注最新安排" --send-ding --format json
```
### 设置管理员并禁言成员
@@ -10,11 +10,11 @@
- 发消息前必须核对接收对象、消息内容、@ 对象、附件路径和消息类型;不明确时先问用户。
- `--group`、`--user`、`--open-dingtalk-id` 通常互斥,群聊用 `--group`,单聊用 `--user` 或 `--open-dingtalk-id`。
- 发送本地文件、音频、视频统一用 `chat message send --msg-type file|audio|video --file-path <path>`;`audio` / `video` 底层按 `file` 链路发送;旧图片链路 `--msg-type image --media-id` 仅在已有 mediaId 时使用。
- 发送本地文件、音频、视频使用 `chat message send --msg-type file|audio|video --file-path <path>`。`audio` / `video` 底层按 `file` 链路发送;已有 mediaId 的图片可用 `--msg-type image --media-id`。
- 发送位置消息前必须确认纬度、经度、地址名称;地图缩略图需先通过旧媒体上传链路拿到 mediaId。
- 分享联系人名片前必须确认联系人 `openDingTalkId`,不要把 userId 直接当 `--contact-id`。
- 消息内容按 Markdown 渲染,换行必须是真实换行符;需要换行效果时用空行、行尾两个空格或 `<br>`。
- 建议发送时带 `--uuid`,失败重试复用同一个值。
- `message send` 建议带 `--uuid`;失败重试复用同一个值。
## 命令明细
@@ -72,6 +72,7 @@ dws chat message send --group <openConversationId> --msg-type profile --contact-
| 命令 | 用途 | 示例与要点 |
|------|------|------------|
| `message list` | 拉取指定群聊或单聊消息 | `dws chat message list --group <cid> --time "2025-03-01 00:00:00" --direction older`;目标三选一,`--direction newer/older` 优先于旧 `--forward` |
| `message list-direct` | 拉取与指定用户的单聊消息 | `--user` / `--open-dingtalk-id` 二选一,`--time` 必填 |
| `message list-all` | 时间范围内全部会话消息 | `dws chat message list-all --start <ISO> --end <ISO> --limit 100 --cursor 0`;四个参数每次请求都传,翻页用 `nextCursor` |
| `message list-by-sender` | 查指定发送者消息 | `--sender-user-id` 与 `--sender-open-dingtalk-id` 二选一,跨单聊+群聊 |
| `message list-mentions` | 查 @ 我的消息 | 可传 `--group` 限定群,不传查全部 |
@@ -89,7 +90,7 @@ dws chat message send --group <openConversationId> --msg-type profile --contact-
### 搜索消息
优先使用 `message search-advanced`,它是 `message search` 的严格超集。
组合搜索使用 `message search-advanced`。它是 `message search` 的严格超集。
```bash
dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
@@ -156,10 +157,10 @@ dws chat message edit --group <openConversationId> --msg-id <openMessageId> --co
话题完整读取流程:
1. `dws chat message list --group <openConversationId> --time ...` 获取话题主消息。
2. 如果返回 `openConvThreadId`,执行 `dws chat message list-topic-replies --group <openConversationId> --topic-id <openConvThreadId>`。
1. 已知 thread/topic ID 时执行 `message list-topic-replies --group <openConversationId> --topic-id <topicId>`。
2. 未知 ID 时先拉主消息;如果返回 `openConvThreadId`,再将其作为 `--topic-id`。
流式卡片必须 `send-card` 与 `update-card` 搭配:
原子卡片接口先创建,再用返回的 `bizId` 继续流式更新:
```bash
dws chat message send-card --group <openConversationId>
@@ -222,7 +223,7 @@ dws chat text translate --query "Bonjour" --to ja_JP
### 文件与媒体
#### `dws chat message download-media`
使用原子下载接口 `dws chat message download-media`:
```bash
dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/
@@ -232,7 +233,7 @@ dws chat message download-media --type mediaId --resource-id <mediaId> --message
## 常见工作流
### 群聊发文字与文件
### 原子回退:群聊发文字与文件
```bash
dws chat search --query "项目冲刺" --format json
@@ -262,7 +263,7 @@ dws chat message search-advanced --message-type file --search-conv-type group_ch
- 发送目标不唯一:先确认群/人;群用 `chat search`,单聊用 `aisearch person` + `conversation-info`。
- `unknown flag`:立即执行对应命令 `--help`,不要猜参数。
- 文件/音视频发送失败:确认本地路径可读;新链路使用 `--msg-type file|audio|video --file-path`。
- 文件/音视频发送失败:确认本地路径可读且在允许的工作目录范围内,并使用 `message send --msg-type file|audio|video --file-path`。
- 位置消息参数不完整:先确认经纬度、地址名称和缩略图 mediaId。
- 名片发送失败:确认 `--contact-id` 是 openDingTalkId,不是 userId。
- 话题回复缺失:检查是否只拉了主消息,需继续用 `list-topic-replies`。
@@ -63,8 +63,12 @@ dws chat message send --open-dingtalk-id <openDingTalkId> --text "这是本周
# 查我的机器人,提取 robotCode
dws chat bot search --name "日报" --format json
# 机器人发群消息,提取 processQueryKey;Markdown 稳定换行用 \n\n,不要只用 \n
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "日报" --text "## 今日完成\n\n- 事项 A\n\n- 事项 B" --format json
# 机器人发群消息,提取 processQueryKey;命令中使用真实空行,模型内部转义表示才写 \n\n
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> --title "日报" --text "## 今日完成
- 事项 A
- 事项 B" --format json
# 撤回机器人消息
dws chat message recall-by-bot --robot-code <robot-code> --group <openConversationId> --keys <processQueryKey> --format json
@@ -92,13 +96,13 @@ dws chat message send-by-bot --robot-code <robot-code> --group <openConversation
--title "提醒" --text "@openDingtalkId1 @openDingtalkId2 请查收本周报告" --format json
dws chat message send-by-bot --robot-code <robot-code> --group <openConversationId> \
--at-all --title "通知" --text "请所有人注意" --format json
--at-all --title "通知" --text "@all 请所有人注意" --format json
```
### Webhook 告警
```bash
dws chat message send-by-webhook --token <webhook-token> --title "告警" --text "CPU 超 90% @10" --at-all --format json
dws chat message send-by-webhook --token <webhook-token> --title "告警" --text "@10 CPU 超 90%" --at-all --format json
```
### 话题完整读取与回复
@@ -116,7 +120,9 @@ dws chat message send --group <openConvThreadId> --text "回复话题内容" --f
dws chat group share-invite --source <sourceOpenConversationId> --target <targetOpenConversationId> --format json
# 发布群公告,Markdown 段落换行用空行
dws chat group notice create --group <openConversationId> --content "# 重要通知\n\n请大家查收" --send-ding --format json
dws chat group notice create --group <openConversationId> --content "# 重要通知
请大家查收" --send-ding --format json
# 修改公告前先列表查询 notice-id/dataId
dws chat group notice list --group <openConversationId> --format json
@@ -135,12 +141,12 @@ dws chat text translate --query "你好世界" --to en_US --format json
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `chat search` | `openConversationId` | `message send/list`、`group members`、`set-top`、`group-mute` |
| `chat search` | `openConversationId` | `message send`、`message list`、`group members`、`set-top`、`group-mute` |
| `chat group create` | `openConversationId` | 新群后续发消息、成员管理、群设置 |
| `chat group get-by-group-id` | `openConversationId` | 将数字群号转为可用会话 ID |
| `chat conversation-info` | `openConversationId` | 单聊消息搜索、转发、会话状态操作 |
| `aisearch person` | `userId` | `message send --user`、`send-by-bot --users`、`--at-user-ids`、`list-by-sender --sender-user-id` |
| `aisearch person` | `openDingTalkId` | `message send --open-dingtalk-id`、`--at-open-dingtalk-ids`、`send-by-bot --open-dingtalk-ids` |
| `aisearch person` | `userId` | `message send --user`、`send-by-bot --users/--at-user-ids`、`list-by-sender --sender-user-id` |
| `aisearch person` | `openDingTalkId` | `message send --open-dingtalk-id`、`send-by-bot --open-dingtalk-ids/--at-open-dingtalk-ids` |
| `aisearch person` | `userId` | 可替代人员搜索结果,继续用于 userId 参数 |
| `chat bot search` | `robotCode` | `send-by-bot`、`recall-by-bot` |
| `chat bot find` | `openDingTalkId` | 给机器人发单聊 |
@@ -160,15 +166,17 @@ dws chat text translate --query "你好世界" --to en_US --format json
| `chat message list/search-advanced` | `openMessageId` / `openMsgId` | `list-emotion-replies --msg-ids` |
| `chat category list` | `categoryId` | `category list-conversations/add-conv/remove-conv --category-id(s)` |
| `chat category create-smart` | 智能分组 ID/规则结果 | 后续查看或调整智能分组 |
| `chat message send-card` | `bizId` | `update-card --biz-id` |
| `chat message send-card` | `bizId` | `message update-card --biz-id` |
## 自动化脚本
## 可选自动化脚本
| 脚本 | 场景 | 用法 |
|------|------|------|
| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` |
| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` |
| [extract_media_id.py](../../scripts/extract_media_id.py) | 旧链路:从 dt_media_upload URL 提取 mediaId | 新场景不要用,文件/音视频直接 `--msg-type file|audio|video --file-path` |
这些脚本仅用于已确认安装 Python 3 的环境;无 Python 环境时使用本页的
`dws` 原生命令流程。
| 脚本 | 可选场景 | 用法 |
|------|----------|------|
| [chat_export_messages.py](../../scripts/chat_export_messages.py) | 导出群聊消息到 JSON 文件 | `python3 scripts/chat_export_messages.py --query "项目冲刺" --time "2026-03-10 00:00:00"` |
| [chat_history_with_user.py](../../scripts/chat_history_with_user.py) | 查询与某人的单聊聊天记录 | `python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"` |
## 常见错误与回退
@@ -176,4 +184,4 @@ dws chat text translate --query "你好世界" --to en_US --format json
- `chat bot search` 没有 openDingTalkId:改用 `chat bot find`。
- 群号是纯数字:先 `chat group get-by-group-id`。
- 翻页不要自造 cursor:使用响应中的 `nextCursor` 原值。
- 文件/音视频发送不要走旧上传链路:优先 `message send --msg-type file|audio|video --file-path`;`audio/video` 底层按 `file` 发送。
- 文件/音视频发送不要走旧上传链路;使用 `message send --msg-type file|audio|video --file-path`。
@@ -0,0 +1,71 @@
# Workflow:新人入职群聊接待
目标:完成“查人 → 拉群 → 发欢迎消息 → 创建待办 → 预约会议”的闭环。所有 Agent 调用统一补充 `--format json`。
## 触发语
- “给新人 XXX 办理入职”
- “欢迎 XXX 加入团队,拉他进项目群并发 onboarding”
- “给 XXX 建入职待办和欢迎会”
## 步骤
### 1. 获取新人身份
```bash
dws aisearch person --keyword "<新人姓名>" --dimension name --format json
# 或
dws contact user search --query "<新人姓名>" --format json
```
提取 `openDingTalkId` 和 `userId`。返回多条时让用户确认;返回零条时停止并报告。
### 2. 搜索目标群并添加成员
```bash
dws chat search --query "<项目群名>" --format json
dws chat group members add --id <openConversationId> --users <userId> --format json
```
群不存在时重新核对群名;无权限时停止并报告群管理员。
### 3. 发送欢迎消息
```bash
dws chat message send --group <openConversationId> \
--title "欢迎 <姓名> 加入" \
--text "欢迎 <姓名> 加入项目组!入职待办和欢迎会已安排。" \
--at-all \
--format json
```
### 4. 创建入职待办
```bash
dws todo task create --title "<姓名> 入职待办:完成环境配置" \
--executors <userId> \
--priority 40 \
--due-time "<3 天后 18:00>" \
--format json
```
### 5. 预约欢迎会议
```bash
dws calendar event create --summary "<姓名> 入职欢迎会" \
--start-time "<明天 14:00>" \
--end-time "<明天 15:00>" \
--attendees <userId> \
--format json
```
## 验收
完成后汇报:
- 已拉入的群名和 `openConversationId`。
- 欢迎消息发送结果。
- 待办 ID 和截止时间。
- 会议 ID 和时间。
任一步失败时保留已完成结果,并说明失败步骤与是否需要回滚或人工处理;不要把中间步骤成功误报为整个 Workflow 完成。
@@ -3,19 +3,19 @@
用机器人向多个群批量发送相同消息(如日报提醒)
用法:
python bot_broadcast.py \
python3 scripts/bot_broadcast.py \
--robot-code <ROBOT_CODE> \
--chats "conv_id1,conv_id2,conv_id3" \
--title "日报提醒" \
--text "请大家今天下班前提交日报"
python bot_broadcast.py \
python3 scripts/bot_broadcast.py \
--robot-code <ROBOT_CODE> \
--chats-file groups.txt \
--title "周会通知" \
--text "明天下午3点周会"
python bot_broadcast.py --dry-run ...
python3 scripts/bot_broadcast.py --dry-run ...
"""
import sys
@@ -40,14 +40,19 @@ def run_dws(
if result.returncode != 0:
print(f" ✗ 错误:{result.stderr.strip()}")
return None
return json.loads(result.stdout)
data = json.loads(result.stdout)
if isinstance(data, dict) and data.get('success') is False:
detail = data.get('errorMsg') or data.get('message') or '未知错误'
print(f" ✗ 业务调用失败:{detail}")
return None
return data
except (subprocess.TimeoutExpired, json.JSONDecodeError,
FileNotFoundError) as e:
print(f" ✗ 错误:{e}")
return None
def main():
def run(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(
description='向多个群批量发送机器人消息'
)
@@ -64,7 +69,7 @@ def main():
'--text', required=True, help='消息内容 Markdown'
)
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
args = parser.parse_args(argv)
chat_ids: List[str] = []
if args.chats:
@@ -74,13 +79,13 @@ def main():
p = Path(args.chats_file)
if not p.exists():
print(f"错误:文件不存在: {p}")
sys.exit(1)
return 1
chat_ids = [line.strip() for line in
p.read_text(encoding='utf-8').splitlines()
if line.strip() and not line.startswith('#')]
if not chat_ids:
print('错误:需要 --chats 或 --chats-file')
sys.exit(1)
return 1
print(f"📢 批量发送消息到 {len(chat_ids)} 个群")
print(f" 标题: {args.title}")
@@ -105,8 +110,8 @@ def main():
fail += 1
print(f"\n完成: 成功 {success}, 失败 {fail}")
sys.exit(0 if fail == 0 else 1)
return 0 if fail == 0 else 1
if __name__ == '__main__':
main()
sys.exit(run())
@@ -3,12 +3,12 @@
导出群聊消息到 JSON 文件(从指定时间点拉取)
用法:
python chat_export_messages.py \
python3 scripts/chat_export_messages.py \
--group <openconversation_id> \
--time "2026-03-10 00:00:00" \
--output messages.json
python chat_export_messages.py \
python3 scripts/chat_export_messages.py \
--query "项目冲刺" \
--time "2026-03-10 00:00:00" \
--no-forward --limit 100
@@ -22,11 +22,42 @@ import argparse
from typing import List, Any, Optional
def timestamp_to_datetime(ts_ms: int) -> str:
"""将毫秒时间戳转换为 yyyy-MM-dd HH:mm:ss 格式"""
dt = datetime.datetime.fromtimestamp(
ts_ms / 1000, tz=datetime.timezone(datetime.timedelta(hours=8))
)
class ScriptError(RuntimeError):
"""可预期的脚本执行错误。"""
exit_code = 1
class AmbiguousTargetError(ScriptError):
"""搜索结果不唯一,需要调用方消歧。"""
exit_code = 2
def normalize_boundary_time(value: Any) -> str:
"""将消息时间边界规范为 CLI 接受的 yyyy-MM-dd HH:mm:ss 或原字符串。"""
if value is None or isinstance(value, bool):
return ''
if isinstance(value, (int, float)):
timestamp = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return ''
try:
timestamp = float(text)
except ValueError:
return text
else:
return str(value).strip()
if timestamp > 10_000_000_000:
timestamp /= 1000
try:
dt = datetime.datetime.fromtimestamp(
timestamp, tz=datetime.timezone(datetime.timedelta(hours=8))
)
except (OSError, OverflowError, ValueError) as exc:
raise ScriptError(f'无效的消息时间边界:{value}') from exc
return dt.strftime("%Y-%m-%d %H:%M:%S")
@@ -41,14 +72,19 @@ def run_dws(
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
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
raise ScriptError(f'执行 dws 失败:{exc}') from exc
if result.returncode != 0:
detail = result.stderr.strip() or f'退出码 {result.returncode}'
raise ScriptError(f'dws 命令失败:{detail}')
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ScriptError(f'dws 返回的不是合法 JSON:{exc}') from exc
if isinstance(data, dict) and data.get('success') is False:
detail = data.get('errorMsg') or data.get('message') or '未知错误'
raise ScriptError(f'dws 业务调用失败:{detail}')
return data
def search_group(
@@ -60,11 +96,6 @@ def search_group(
], dry_run=dry_run)
if dry_run:
return '<CONV_ID>'
if not data:
return None
if isinstance(data, dict) and data.get('success') is False:
print(f"搜索失败: {data.get('errorMsg', '未知错误')}", file=sys.stderr)
return None
if isinstance(data, list):
groups = data
elif isinstance(data, dict):
@@ -83,16 +114,64 @@ def search_group(
else:
groups = []
if not groups:
print(f"未找到群聊: {query}")
return None
g = groups[0]
raise ScriptError(f'未找到群聊:{query}')
exact = [
item for item in groups
if isinstance(item, dict)
and str(item.get('title') or item.get('name') or '').strip().casefold()
== query.strip().casefold()
]
candidates = exact if exact else groups
if len(candidates) != 1:
rendered = []
for item in candidates:
if not isinstance(item, dict):
continue
name = item.get('title') or item.get('name') or '未知'
conv_id = item.get('openConversationId') or item.get('id') or '无ID'
rendered.append(f'{name} ({conv_id})')
detail = ';'.join(rendered) or f'{len(candidates)} 个候选'
raise AmbiguousTargetError(
f'群名“{query}”匹配到多个候选,请指定 --group:{detail}'
)
g = candidates[0]
name = g.get('title') or g.get('name', '未知')
conv_id = g.get('openConversationId') or g.get('id')
if not conv_id:
raise ScriptError(f'群聊“{name}”缺少 openConversationId')
print(f" 找到群聊: {name} ({conv_id})")
return conv_id
def main():
def parse_message_page(data: Any) -> tuple[List[Any], bool]:
"""提取消息页和 hasMore。"""
if isinstance(data, list):
return data, False
if not isinstance(data, dict):
return [], False
inner = data.get('result', data)
if isinstance(inner, dict):
messages = inner.get('messages', [])
return messages if isinstance(messages, list) else [], bool(
inner.get('hasMore', False)
)
if isinstance(inner, list):
return inner, False
return [], False
def message_identity(message: Any) -> Optional[str]:
if not isinstance(message, dict):
return None
value = (
message.get('openMessageId')
or message.get('openMsgId')
or message.get('msgId')
)
return str(value) if value else None
def run(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(
description='导出群聊消息到 JSON'
)
@@ -112,119 +191,115 @@ def main():
)
parser.add_argument('--output', default='', help='输出文件')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
args = parser.parse_args(argv)
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)
try:
conv_id = args.group
if not conv_id:
if not args.query:
raise ScriptError('需要 --group 或 --query 参数')
print(f'🔍 搜索群聊: {args.query}')
conv_id = search_group(args.query, args.dry_run)
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')
print(f'📥 拉取消息 (起始: {args.time})...')
all_messages: List[Any] = []
seen_ids = set()
current_time = args.time
direction = 'older' if args.no_forward else 'newer'
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
has_more = False
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)
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list',
'--group', conv_id or '<CONV_ID>',
'--time', current_time,
'--direction', direction,
'--format', 'json',
]
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time')
return
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 使用末条消息 createTime')
return 0
if not data:
break
page_msgs, has_more = parse_message_page(data)
if not page_msgs:
if has_more:
raise ScriptError('服务端返回 hasMore=true,但本页没有消息')
break
if isinstance(data, dict) and data.get('success') is False:
print(f"拉取失败: {data.get('errorMsg', '未知错误')}", file=sys.stderr)
break
for message in page_msgs:
if not isinstance(message, dict):
raise ScriptError('消息列表包含非对象条目,无法安全导出')
identity = message_identity(message)
if identity and identity in seen_ids:
continue
if identity:
seen_ids.add(identity)
all_messages.append(message)
remaining -= 1
if remaining <= 0:
break
page += 1
if isinstance(data, list):
page_msgs = data
has_more = False
next_cursor = None
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
page_msgs = inner.get('messages', [])
has_more = inner.get('hasMore', False)
next_cursor = inner.get('nextCursor')
elif isinstance(inner, list):
page_msgs = inner
has_more = False
next_cursor = None
else:
page_msgs = []
has_more = False
next_cursor = None
else:
page_msgs = []
has_more = False
next_cursor = None
if not has_more or remaining <= 0:
break
if not page_msgs:
break
all_messages.extend(page_msgs)
remaining -= len(page_msgs)
page += 1
if not has_more:
break
# 翻页:优先用 nextCursor(毫秒精度,转为时间字符串),降级用最后消息的 createTime
if next_cursor:
current_time = timestamp_to_datetime(next_cursor)
else:
last_msg = page_msgs[-1]
boundary_time = (
if not isinstance(last_msg, dict):
raise ScriptError('末条消息不是对象,无法取得翻页时间边界')
boundary_time = normalize_boundary_time(
last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time', '')
or last_msg.get('time')
)
if not boundary_time or boundary_time == current_time:
break
if not boundary_time:
raise ScriptError('hasMore=true,但末条消息缺少 createTime')
if boundary_time == current_time:
raise ScriptError('分页边界没有推进,已停止以避免重复循环')
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if not all_messages:
print('未拉取到消息')
return
if has_more and page >= max_pages and remaining > 0:
raise ScriptError(f'达到最大分页数 {max_pages},结果不完整')
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(all_messages, f, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for m in all_messages:
sender = (
m.get('sender') or m.get('senderNick') or '未知'
)
text = m.get('content') or m.get('text', '')
time_str = (
m.get('createTime') or m.get('createAt')
or m.get('time', '')
)
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
if not all_messages:
print('未拉取到消息')
return 0
if args.output:
with open(args.output, 'w', encoding='utf-8') as file:
json.dump(all_messages, file, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for message in all_messages:
sender = (
message.get('sender')
or message.get('senderNick')
or '未知'
)
text = message.get('content') or message.get('text', '')
time_str = (
message.get('createTime')
or message.get('createAt')
or message.get('time', '')
)
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
return 0
except ScriptError as exc:
print(f'错误:{exc}', file=sys.stderr)
return exc.exit_code
except OSError as exc:
print(f'错误:无法写入输出文件:{exc}', file=sys.stderr)
return 1
if __name__ == '__main__':
main()
sys.exit(run())
@@ -3,13 +3,13 @@
查询与某人的单聊聊天记录
用法:
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
python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-10 00:00:00"
python3 scripts/chat_history_with_user.py --user <userId> --time "2026-03-10 00:00:00" --limit 50
python3 scripts/chat_history_with_user.py --name "张三" --time "2026-03-01 00:00:00" --output history.json
工作流:
1. 通过 --name 搜索通讯录,获取 userId(或直接传 --user)
2. 调用 chat message list --user <userId> 拉取单聊消息
2. 调用 chat message list-direct --user <userId> 拉取单聊消息
3. 输出到终端或导出为 JSON 文件
"""
@@ -21,11 +21,42 @@ import argparse
from typing import List, Any, Optional
def timestamp_to_datetime(ts_ms: int) -> str:
"""将毫秒时间戳转换为 yyyy-MM-dd HH:mm:ss 格式"""
dt = datetime.datetime.fromtimestamp(
ts_ms / 1000, tz=datetime.timezone(datetime.timedelta(hours=8))
)
class ScriptError(RuntimeError):
"""可预期的脚本执行错误。"""
exit_code = 1
class AmbiguousTargetError(ScriptError):
"""搜索结果不唯一,需要调用方消歧。"""
exit_code = 2
def normalize_boundary_time(value: Any) -> str:
"""将消息时间边界规范为 CLI 接受的 yyyy-MM-dd HH:mm:ss 或原字符串。"""
if value is None or isinstance(value, bool):
return ''
if isinstance(value, (int, float)):
timestamp = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return ''
try:
timestamp = float(text)
except ValueError:
return text
else:
return str(value).strip()
if timestamp > 10_000_000_000:
timestamp /= 1000
try:
dt = datetime.datetime.fromtimestamp(
timestamp, tz=datetime.timezone(datetime.timedelta(hours=8))
)
except (OSError, OverflowError, ValueError) as exc:
raise ScriptError(f'无效的消息时间边界:{value}') from exc
return dt.strftime("%Y-%m-%d %H:%M:%S")
@@ -41,14 +72,19 @@ def run_dws(
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
except (subprocess.TimeoutExpired, FileNotFoundError) as exc:
raise ScriptError(f'执行 dws 失败:{exc}') from exc
if result.returncode != 0:
detail = result.stderr.strip() or f'退出码 {result.returncode}'
raise ScriptError(f'dws 命令失败:{detail}')
try:
data = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise ScriptError(f'dws 返回的不是合法 JSON:{exc}') from exc
if isinstance(data, dict) and data.get('success') is False:
detail = data.get('errorMsg') or data.get('message') or '未知错误'
raise ScriptError(f'dws 业务调用失败:{detail}')
return data
def search_user(
@@ -61,30 +97,80 @@ def search_user(
], dry_run=dry_run)
if dry_run:
return '<USER_ID>'
if not data:
return None
# 解析返回结构
users = data
if isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
users = (inner.get('users', [])
or inner.get('list', []))
users = (
inner.get('value')
or inner.get('users')
or inner.get('list')
or inner.get('items')
or []
)
elif isinstance(inner, list):
users = inner
else:
users = []
if not users or not isinstance(users, list):
print(f"未找到用户: {name}")
return None
u = users[0]
raise ScriptError(f'未找到用户:{name}')
exact = [
item for item in users
if isinstance(item, dict)
and str(item.get('name') or item.get('nick') or '').strip().casefold()
== name.strip().casefold()
]
candidates = exact if exact else users
if len(candidates) != 1:
rendered = []
for item in candidates:
if not isinstance(item, dict):
continue
user_name = item.get('name') or item.get('nick') or '未知'
user_id = item.get('userId') or item.get('userid') or '无ID'
rendered.append(f'{user_name} ({user_id})')
detail = ';'.join(rendered) or f'{len(candidates)} 个候选'
raise AmbiguousTargetError(
f'姓名“{name}”匹配到多个候选,请指定 --user:{detail}'
)
u = candidates[0]
user_name = u.get('name') or u.get('nick', '未知')
user_id = u.get('userId') or u.get('userid', '')
if not user_id:
raise ScriptError(f'用户“{user_name}”缺少 userId')
print(f" 找到用户: {user_name} ({user_id})")
return user_id
def main():
def parse_message_page(data: Any) -> tuple[List[Any], bool]:
"""提取消息页和 hasMore。"""
if isinstance(data, list):
return data, False
if not isinstance(data, dict):
return [], False
inner = data.get('result', data)
if isinstance(inner, dict):
messages = inner.get('messages', [])
return messages if isinstance(messages, list) else [], bool(
inner.get('hasMore', False)
)
if isinstance(inner, list):
return inner, False
return [], False
def message_identity(message: Any) -> Optional[str]:
if not isinstance(message, dict):
return None
value = (
message.get('openMessageId')
or message.get('openMsgId')
or message.get('msgId')
)
return str(value) if value else None
def run(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(
description='查询与某人的单聊聊天记录'
)
@@ -105,119 +191,113 @@ def main():
)
parser.add_argument('--output', default='', help='导出到 JSON 文件')
parser.add_argument('--dry-run', action='store_true')
args = parser.parse_args()
args = parser.parse_args(argv)
# 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)
try:
user_id = args.user
if not user_id:
print(f'🔍 搜索用户: {args.name}')
user_id = search_user(args.name, args.dry_run)
# 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')
print(f'📥 拉取与 {user_id} 的聊天记录 (起始: {args.time})...')
all_messages: List[Any] = []
seen_ids = set()
current_time = args.time
direction = 'older' if args.no_forward else 'newer'
page = 0
max_pages = 50
remaining = args.limit if args.limit > 0 else float('inf')
has_more = False
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list',
'--user', user_id or '<USER_ID>',
'--time', current_time,
'--format', 'json',
]
if args.no_forward:
cmd_args.extend(['--forward', 'false'])
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
while page < max_pages and remaining > 0:
cmd_args = [
'chat', 'message', 'list-direct',
'--user', user_id or '<USER_ID>',
'--time', current_time,
'--direction', direction,
'--format', 'json',
]
page_limit = min(int(remaining), 200) if args.limit > 0 else 0
if page_limit > 0:
cmd_args.extend(['--limit', str(page_limit)])
data = run_dws(cmd_args, dry_run=args.dry_run)
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 继续用边界 createTime 作为 --time')
return
if args.dry_run:
print('[dry-run] 翻页循环: hasMore → 使用末条消息 createTime')
return 0
if not data:
break
page_msgs, has_more = parse_message_page(data)
if not page_msgs:
if has_more:
raise ScriptError('服务端返回 hasMore=true,但本页没有消息')
break
if isinstance(data, dict) and data.get('success') is False:
print(f"拉取失败: {data.get('errorMsg', '未知错误')}", file=sys.stderr)
break
for message in page_msgs:
if not isinstance(message, dict):
raise ScriptError('消息列表包含非对象条目,无法安全导出')
identity = message_identity(message)
if identity and identity in seen_ids:
continue
if identity:
seen_ids.add(identity)
all_messages.append(message)
remaining -= 1
if remaining <= 0:
break
page += 1
if isinstance(data, list):
page_msgs = data
has_more = False
next_cursor = None
elif isinstance(data, dict):
inner = data.get('result', data)
if isinstance(inner, dict):
page_msgs = inner.get('messages', [])
has_more = inner.get('hasMore', False)
next_cursor = inner.get('nextCursor')
elif isinstance(inner, list):
page_msgs = inner
has_more = False
next_cursor = None
else:
page_msgs = []
has_more = False
next_cursor = None
else:
page_msgs = []
has_more = False
next_cursor = None
if not has_more or remaining <= 0:
break
if not page_msgs:
break
all_messages.extend(page_msgs)
remaining -= len(page_msgs)
page += 1
if not has_more:
break
# 翻页:优先用 nextCursor(毫秒精度,转为时间字符串),降级用最后消息的 createTime
if next_cursor:
current_time = timestamp_to_datetime(next_cursor)
else:
last_msg = page_msgs[-1]
boundary_time = (
if not isinstance(last_msg, dict):
raise ScriptError('末条消息不是对象,无法取得翻页时间边界')
boundary_time = normalize_boundary_time(
last_msg.get('createTime')
or last_msg.get('createAt')
or last_msg.get('time', '')
or last_msg.get('time')
)
if not boundary_time or boundary_time == current_time:
break
if not boundary_time:
raise ScriptError('hasMore=true,但末条消息缺少 createTime')
if boundary_time == current_time:
raise ScriptError('分页边界没有推进,已停止以避免重复循环')
current_time = boundary_time
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
print(f" 翻页 {page}: 已累计 {len(all_messages)} 条, 继续...")
if not all_messages:
print('未拉取到消息')
return
if has_more and page >= max_pages and remaining > 0:
raise ScriptError(f'达到最大分页数 {max_pages},结果不完整')
# 3. 输出结果
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(all_messages, f, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for m in all_messages:
sender = (
m.get('sender') or m.get('senderNick') or '未知'
)
text = m.get('content') or m.get('text', '')
time_str = (
m.get('createTime') or m.get('createAt')
or m.get('time', '')
)
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
if not all_messages:
print('未拉取到消息')
return 0
if args.output:
with open(args.output, 'w', encoding='utf-8') as file:
json.dump(all_messages, file, ensure_ascii=False, indent=2)
print(f" ✓ 已导出 {len(all_messages)} 条消息到 {args.output}")
else:
for message in all_messages:
sender = (
message.get('sender')
or message.get('senderNick')
or '未知'
)
text = message.get('content') or message.get('text', '')
time_str = (
message.get('createTime')
or message.get('createAt')
or message.get('time', '')
)
print(f" [{time_str}] {sender}: {text[:80]}")
print(f"\n合计: {len(all_messages)} 条消息 ({page} 页)")
return 0
except ScriptError as exc:
print(f'错误:{exc}', file=sys.stderr)
return exc.exit_code
except OSError as exc:
print(f'错误:无法写入输出文件:{exc}', file=sys.stderr)
return 1
if __name__ == '__main__':
main()
sys.exit(run())
+460
View File
@@ -0,0 +1,460 @@
#!/usr/bin/env python3
"""Regression tests for the Chat Skill's bundled Python scripts."""
import contextlib
import importlib.util
import io
import json
import re
import types
import unittest
from pathlib import Path
from unittest import mock
ROOT = Path(__file__).resolve().parents[2]
MULTI_ROOT = ROOT / 'skills' / 'multi' / 'dingtalk-chat'
MONO_ROOT = ROOT / 'skills' / 'mono'
SCRIPT_NAMES = (
'chat_export_messages.py',
'chat_history_with_user.py',
'bot_broadcast.py',
)
def load_script(name: str):
path = MULTI_ROOT / 'scripts' / f'{name}.py'
spec = importlib.util.spec_from_file_location(f'chat_skill_{name}', path)
if spec is None or spec.loader is None:
raise RuntimeError(f'cannot load {path}')
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
class ChatSkillScriptPathTest(unittest.TestCase):
def test_shortcuts_use_progressive_discovery_in_multi_skill(self):
text = (MULTI_ROOT / 'SKILL.md').read_text(encoding='utf-8')
block = text.split('<!-- VISIBLE_SHORTCUTS_START -->', 1)[1].split(
'<!-- VISIBLE_SHORTCUTS_END -->', 1
)[0]
catalog = json.loads(
(ROOT / 'docs' / 'shortcut-public-catalog.json').read_text(
encoding='utf-8'
)
)
chat_rows = [
row for row in catalog['results'] if row.get('service') == 'chat'
]
schema_catalog = json.loads(
(
ROOT
/ 'internal'
/ 'cli'
/ 'schema_catalog'
/ 'tools'
/ 'chat.json'
).read_text(encoding='utf-8')
)
schema_shortcuts = [
path
for path in schema_catalog['tools']
if path.startswith('chat.shortcut_')
]
exclusions = json.loads(
(
ROOT / 'internal' / 'cli' / 'schema_command_exclusions.json'
).read_text(encoding='utf-8')
)
pending_shortcuts = next(
group['commands']
for group in exclusions['groups']
if group['id'] == 'chat-shortcuts-pending-schema-curation'
)
self.assertEqual(97, len(chat_rows))
self.assertEqual(47, len(schema_shortcuts))
self.assertEqual(50, len(pending_shortcuts))
self.assertEqual(
len(chat_rows), len(schema_shortcuts) + len(pending_shortcuts)
)
self.assertIn('## Shortcut 发现(按需)', block)
self.assertIn('`chat` 当前有 97 条公开 shortcut', block)
self.assertIn(
'完整清单保留在 Runtime Shortcut Catalog;已完成 Schema curation '
'的子集可通过 leaf Schema 查询。',
block,
)
self.assertNotIn('Runtime Catalog 与 Schema', block)
self.assertIn(
'dws shortcut list --service chat --compact --format json',
block,
)
self.assertNotRegex(block, r'(?m)^\|\s*`\+[a-z0-9-]+`')
self.assertNotRegex(block, r'(?m)^\|\s*`dws chat \+[a-z0-9-]+`')
self.assertNotIn('dws chat +', block)
for required in (
'This is the only Shortcut entry point for multi/chat',
'exact recipe/runnable script > matching public Shortcut > atomic command',
'dws shortcut list --service chat --compact --format json',
'never guess names',
'confirmation=user_required',
'--idempotency-key',
'--download-resources',
'--receiver-open-dingtalk-id',
):
self.assertIn(required, text)
def test_chat_references_are_atomic_only(self):
references = list((MULTI_ROOT / 'references').rglob('*.md'))
self.assertTrue(references)
for path in references:
text = path.read_text(encoding='utf-8')
with self.subTest(path=path.relative_to(MULTI_ROOT)):
self.assertNotRegex(text, r'(?i)\bshortcut\b')
self.assertNotRegex(text, r'\+[a-z][a-z0-9-]+')
chat_ref = (
MULTI_ROOT / 'references' / 'chat.md'
).read_text(encoding='utf-8')
message_ref = (
MULTI_ROOT / 'references' / 'chat' / 'chat-message.md'
).read_text(encoding='utf-8')
conversation_ref = (
MULTI_ROOT / 'references' / 'chat' / 'chat-conversation.md'
).read_text(encoding='utf-8')
for required in (
'message send --group',
'message search-advanced',
'message send-by-bot',
'message send-by-webhook',
):
self.assertIn(required, chat_ref)
for required in (
'message send-card',
'message update-card',
'--flow-status',
'message download-media',
'--file-path',
):
self.assertIn(required, message_ref)
for required in (
'clear-messages',
'category delete',
'按 leaf Schema 的确认语义执行',
):
self.assertIn(required, conversation_ref)
def test_documented_chat_commands_do_not_pass_literal_newline_escapes(self):
docs = [MULTI_ROOT / 'SKILL.md']
docs.extend((MULTI_ROOT / 'references').rglob('*.md'))
offenders = []
for path in docs:
for line_number, line in enumerate(
path.read_text(encoding='utf-8').splitlines(),
start=1,
):
if 'dws chat ' in line and r'\n' in line:
offenders.append(
f'{path.relative_to(ROOT)}:{line_number}: {line}'
)
self.assertEqual([], offenders)
def test_markdown_python_links_resolve(self):
link_re = re.compile(r'\[[^\]]+\.py\]\(([^)]+\.py)\)')
docs = list(MULTI_ROOT.rglob('*.md'))
docs.extend((MONO_ROOT / 'references').rglob('*.md'))
missing = []
for doc in docs:
text = doc.read_text(encoding='utf-8')
for target in link_re.findall(text):
resolved = (doc.parent / target).resolve()
if not resolved.is_file():
missing.append(f'{doc.relative_to(ROOT)} -> {target}')
self.assertEqual([], missing)
def test_optional_python_invocations_use_python3_and_skill_relative_paths(self):
docs = {
MULTI_ROOT: (
MULTI_ROOT / 'SKILL.md',
MULTI_ROOT / 'references' / '01-messaging.md',
MULTI_ROOT / 'references' / 'chat.md',
MULTI_ROOT / 'references' / 'chat' / 'chat-workflows.md',
),
MONO_ROOT: (
MONO_ROOT / 'references' / 'best_practices' / '01-messaging.md',
MONO_ROOT / 'references' / 'products' / 'chat.md',
),
}
bad_python = re.compile(r'(?<![A-Za-z0-9_])python\s+')
invocation = re.compile(r'python3\s+(scripts/[A-Za-z0-9_.-]+\.py)')
errors = []
for skill_root, paths in docs.items():
for doc in paths:
text = doc.read_text(encoding='utf-8')
if bad_python.search(text):
errors.append(f'{doc.relative_to(ROOT)} uses bare python')
for target in invocation.findall(text):
if not (skill_root / target).is_file():
errors.append(
f'{doc.relative_to(ROOT)} -> {target} is missing'
)
self.assertEqual([], errors)
def test_chat_workflows_do_not_require_python(self):
multi_skill = (MULTI_ROOT / 'SKILL.md').read_text(encoding='utf-8')
multi_recipes = (
MULTI_ROOT / 'references' / '01-messaging.md'
).read_text(encoding='utf-8')
mono_recipes = (
MONO_ROOT / 'references' / 'best_practices' / '01-messaging.md'
).read_text(encoding='utf-8')
self.assertRegex(multi_skill, r'must work without\s+Python')
self.assertNotRegex(multi_skill, r'\|\s*“[^”]+”\s*\|\s*`python3 ')
for recipes in (multi_recipes, mono_recipes):
self.assertNotRegex(recipes, r'\*\*优先\*\*[^\\n]*python3')
self.assertIn('chat message list', recipes)
self.assertIn('chat message send-by-bot', recipes)
def test_mono_and_multi_script_copies_match(self):
bad_python = re.compile(r'(?<![A-Za-z0-9_])python\s+')
for name in SCRIPT_NAMES:
multi_script = MULTI_ROOT / 'scripts' / name
self.assertEqual(
multi_script.read_bytes(),
(MONO_ROOT / 'scripts' / name).read_bytes(),
name,
)
self.assertIsNone(
bad_python.search(multi_script.read_text(encoding='utf-8')),
name,
)
class ChatExportMessagesTest(unittest.TestCase):
def setUp(self):
self.module = load_script('chat_export_messages')
def run_script(self, argv, responses):
calls = []
def fake_run_dws(args, dry_run=False):
self.assertFalse(dry_run)
calls.append(args)
response = responses.pop(0)
if isinstance(response, Exception):
raise response
return response
stdout = io.StringIO()
stderr = io.StringIO()
with mock.patch.object(self.module, 'run_dws', fake_run_dws):
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(
stderr
):
code = self.module.run(argv)
return code, calls, stdout.getvalue(), stderr.getvalue()
def test_paginates_with_last_message_time_and_deduplicates(self):
responses = [
{
'result': {
'value': [
{
'title': '项目群',
'openConversationId': 'cid-project',
}
]
}
},
{
'result': {
'messages': [
{
'openMessageId': 'm1',
'createTime': '2026-07-01 10:00:00',
'content': 'one',
},
{
'openMessageId': 'm2',
'createTime': '2026-07-01 11:00:00',
'content': 'two',
},
],
'hasMore': True,
'nextCursor': 'opaque-not-a-time',
}
},
{
'result': {
'messages': [
{
'openMessageId': 'm2',
'createTime': '2026-07-01 11:00:00',
'content': 'two',
},
{
'openMessageId': 'm3',
'createTime': '2026-07-01 12:00:00',
'content': 'three',
},
],
'hasMore': False,
}
},
]
code, calls, stdout, stderr = self.run_script(
['--query', '项目群', '--time', '2026-07-01 09:00:00'],
responses,
)
self.assertEqual(0, code, stderr)
second_page = calls[2]
self.assertEqual(
'2026-07-01 11:00:00',
second_page[second_page.index('--time') + 1],
)
self.assertEqual(
'newer', second_page[second_page.index('--direction') + 1]
)
self.assertNotIn('opaque-not-a-time', second_page)
self.assertIn('合计: 3 条消息', stdout)
def test_ambiguous_group_requires_explicit_id(self):
responses = [
{
'result': {
'value': [
{'title': '项目一群', 'openConversationId': 'cid-1'},
{'title': '项目二群', 'openConversationId': 'cid-2'},
]
}
}
]
code, calls, _, stderr = self.run_script(
['--query', '项目', '--time', '2026-07-01 09:00:00'],
responses,
)
self.assertEqual(2, code)
self.assertEqual(1, len(calls))
self.assertIn('请指定 --group', stderr)
def test_command_failure_is_not_reported_as_empty_result(self):
error = self.module.ScriptError('upstream failed')
code, _, stdout, stderr = self.run_script(
['--group', 'cid-project', '--time', '2026-07-01 09:00:00'],
[error],
)
self.assertEqual(1, code)
self.assertNotIn('未拉取到消息', stdout)
self.assertIn('upstream failed', stderr)
def test_successful_empty_page_returns_zero(self):
code, _, stdout, stderr = self.run_script(
['--group', 'cid-project', '--time', '2026-07-01 09:00:00'],
[{'result': {'messages': [], 'hasMore': False}}],
)
self.assertEqual(0, code, stderr)
self.assertIn('未拉取到消息', stdout)
class ChatHistoryWithUserTest(unittest.TestCase):
def setUp(self):
self.module = load_script('chat_history_with_user')
def test_ambiguous_user_requires_explicit_id(self):
calls = []
def fake_run_dws(args, dry_run=False):
calls.append(args)
return {
'result': {
'users': [
{'name': '张三(研发)', 'userId': 'u-1'},
{'name': '张三(销售)', 'userId': 'u-2'},
]
}
}
stdout = io.StringIO()
stderr = io.StringIO()
with mock.patch.object(self.module, 'run_dws', fake_run_dws):
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(
stderr
):
code = self.module.run(
['--name', '张三', '--time', '2026-07-01 09:00:00']
)
self.assertEqual(2, code)
self.assertEqual(1, len(calls))
self.assertIn('请指定 --user', stderr.getvalue())
def test_no_forward_uses_public_older_direction(self):
calls = []
responses = [
{
'result': {
'messages': [
{
'openMessageId': 'm1',
'createTime': '2026-07-01 08:00:00',
'content': 'hello',
}
],
'hasMore': False,
}
}
]
def fake_run_dws(args, dry_run=False):
calls.append(args)
return responses.pop(0)
stdout = io.StringIO()
with mock.patch.object(self.module, 'run_dws', fake_run_dws):
with contextlib.redirect_stdout(stdout):
code = self.module.run(
[
'--user',
'u-1',
'--time',
'2026-07-01 09:00:00',
'--no-forward',
]
)
self.assertEqual(0, code)
self.assertEqual(
'older', calls[0][calls[0].index('--direction') + 1]
)
self.assertNotIn('--forward', calls[0])
class BotBroadcastTest(unittest.TestCase):
def test_business_failure_counts_as_failure(self):
module = load_script('bot_broadcast')
completed = types.SimpleNamespace(
returncode=0,
stdout='{"success":false,"errorMsg":"robot rejected"}',
stderr='',
)
stdout = io.StringIO()
with mock.patch.object(module.subprocess, 'run', return_value=completed):
with contextlib.redirect_stdout(stdout):
code = module.run(
[
'--robot-code',
'robot',
'--chats',
'cid-1',
'--title',
'title',
'--text',
'text',
]
)
self.assertEqual(1, code)
self.assertIn('业务调用失败', stdout.getvalue())
if __name__ == '__main__':
unittest.main()
+30
View File
@@ -0,0 +1,30 @@
package unit_test
import (
"os/exec"
"path/filepath"
"runtime"
"testing"
)
func TestChatSkillPythonScripts(t *testing.T) {
python, err := exec.LookPath("python3")
if err != nil {
t.Skip("python3 is not installed")
}
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed")
}
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
cmd := exec.Command(
python,
"test/scripts/chat_skill_scripts_test.py",
)
cmd.Dir = root
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("chat skill Python tests failed: %v\n%s", err, output)
}
}