Compare commits
25
Commits
b2
...
codex/skill-align2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ef88d696b | ||
|
|
891cf87c7f | ||
|
|
6182169b3e | ||
|
|
ab2fba868b | ||
|
|
796cee3d95 | ||
|
|
32c82e1246 | ||
|
|
fe2f64dc43 | ||
|
|
32c9109c71 | ||
|
|
7a42c83d3a | ||
|
|
8b1564eff4 | ||
|
|
b8b5583440 | ||
|
|
8f8ba3f290 | ||
|
|
fe856a8f38 | ||
|
|
583b453abf | ||
|
|
eef94425e2 | ||
|
|
e17ffe5bff | ||
|
|
e83e3a3e2c | ||
|
|
5323129e5e | ||
|
|
014dea52f0 | ||
|
|
20c2ff98c2 | ||
|
|
c7ea642aef | ||
|
|
b5af90c089 | ||
|
|
a5ee9dffe2 | ||
|
|
7179928c75 | ||
|
|
8e48ede81a |
@@ -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 时同时维护多张表和多处规则的漂移风险。
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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))),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"""
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
)"
|
||||
|
||||
@@ -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>` |
|
||||
|
||||
@@ -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"` |
|
||||
|
||||
## 相关产品
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,也不要假设不同命令返回的字段可以互换。
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user