Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d3a2976cf | ||
|
|
600404abd0 | ||
|
|
247926d0fa | ||
|
|
9ef2a4e652 | ||
|
|
d4daf9525c | ||
|
|
63a89e68fa | ||
|
|
29b73a7d5e | ||
|
|
3488e11129 | ||
|
|
596bdce3a1 | ||
|
|
58eea98f6c | ||
|
|
b53b84616e | ||
|
|
15a2fea0dc | ||
|
|
d8da9a2e9f |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat card update evidence** — distinguishes an accepted update request from an independently verified visible update, preserving the real `bizId` and warning callers not to repeat an unverified write.
|
||||
- **Chat command guidance** — splits message and group references by task and explains that `--from` is ambiguous between sender and time-range intent.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Wiki Shortcut workflows** — publishes 20 reviewed space, member, node, and
|
||||
activity shortcuts with strict collection validation, cursor handling,
|
||||
write-terminal evidence, safe read-backs where the backend supports them,
|
||||
task-oriented routing, and documented backend
|
||||
boundaries.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-12T00:10:44.511794",
|
||||
"count": 399,
|
||||
"generated_at": "2026-08-14T13:50:36.324505",
|
||||
"count": 418,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3659,11 +3659,204 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+delete-space",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "删除前读取影响目标,经高风险确认后只接受 success=true 终态。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+feed-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "补充知识库协作动态查询,严格验证 feeds 并保留游标。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-add",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "支持 1-30 个 userId 与四类角色;严格要求写接口终态,并明确后端无法提供精确成员读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证成员数组并公开真实单次上限 50;后端无游标时不伪造 page-all。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-remove",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "支持批量 userId 移除;严格要求写接口终态,并明确后端无法提供精确成员读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+member-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "补充成员角色更新;严格要求写接口终态,并明确后端无法提供精确成员读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+move",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "同一入口覆盖 Wiki 内移动和我的文档在线节点入 Wiki,并校验目标 workspace/folder。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+move-to-drive",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "同步移动 Wiki 节点到我的文档并读回验证 workspace 变化,免去异步任务轮询。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-copy",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "高风险确认后复制,必须取得新 nodeId 并读回副本,避免空响应被当作成功。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "支持七种 Wiki 节点类型,创建后要求 nodeId 并读取元数据验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "删除前读取并核对 workspace,经高风险确认后要求 success=true 终态证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "统一节点 ID 或在线文档 URL 的元数据读取,补充文档域属性视角。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空目录与假空成功,稳定投影节点并保留 nextCursor/hasMore。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+node-search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "补充库内关键词和扩展名搜索,并拒绝假空结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+resolve-space",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "把关键词搜索收敛为唯一 workspaceId;零命中与多命中显式分流,绝不猜测。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建后要求 workspaceId 并通过空间详情读回验证真实落库。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "补充知识库详情入口,并要求 workspaceId 业务证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空知识库列表与缺失、畸形或内部错误响应,并保留真实分页证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+space-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按关键词搜索知识库并拒绝把缺失业务数组误报为零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
"command": "+wiki-new-doc",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按空间名精确唯一解析、创建在线文档并读回验证;零命中和歧义均显式失败。",
|
||||
"availability": "available"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>DWS Wiki Shortcut 全景评审</title>
|
||||
<style>
|
||||
:root{--ink:#14213d;--muted:#5c677d;--line:#dbe4f0;--paper:#fff;--bg:#f3f7fb;--blue:#1769e0;--cyan:#00a6a6;--green:#178746;--amber:#a45b00;--red:#b42318;--shadow:0 14px 34px rgba(20,33,61,.08)}
|
||||
*{box-sizing:border-box}body{margin:0;overflow-x:hidden;background:linear-gradient(150deg,#edf5ff 0,#f8fbff 45%,#eef8f5 100%);color:var(--ink);font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}
|
||||
main,.card,.two>*{min-width:0}main{width:min(1180px,calc(100% - 32px));margin:28px auto 72px}.hero,.card{background:rgba(255,255,255,.96);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow)}
|
||||
.hero{padding:38px;background:radial-gradient(circle at 95% 0,#dff8f3,transparent 36%),linear-gradient(135deg,#fff,#f5f9ff)}h1{font-size:34px;line-height:1.2;margin:0 0 10px}.lead{font-size:17px;color:var(--muted);max-width:900px}.meta{display:flex;gap:10px;flex-wrap:wrap;margin-top:20px}.pill{border:1px solid #cbd9ea;border-radius:999px;padding:5px 11px;background:#fff;font-size:13px}
|
||||
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0}.metric{padding:20px}.metric b{display:block;font-size:31px;color:var(--blue)}.metric span{color:var(--muted)}
|
||||
section{margin-top:22px}.card{padding:26px}h2{font-size:23px;margin:0 0 14px}h3{font-size:17px;margin:22px 0 8px}.callout{border-left:4px solid var(--blue);background:#f2f7ff;padding:14px 16px;border-radius:8px}.warn{border-color:var(--amber);background:#fff8eb}.ok{border-color:var(--green);background:#effbf4}
|
||||
table{width:100%;border-collapse:collapse;font-size:14px}th,td{text-align:left;vertical-align:top;border-bottom:1px solid var(--line);padding:11px 9px}th{color:#41516b;background:#f7f9fc;position:sticky;top:0}code{background:#edf2f8;border-radius:5px;padding:2px 5px;color:#24466e}.tag{display:inline-block;border-radius:999px;padding:2px 8px;font-size:12px;font-weight:650;white-space:nowrap}.full{background:#e6f6ec;color:#116436}.partial{background:#fff0d5;color:#875000}.extra{background:#e8f1ff;color:#1854a5}.fixed{background:#f1eaff;color:#6338a5}
|
||||
.toolbar{display:flex;flex-wrap:wrap;gap:10px;margin:12px 0}.toolbar input,.toolbar select{border:1px solid #bdcada;border-radius:10px;padding:9px 11px;background:#fff;min-width:min(220px,100%);max-width:100%;flex:1 1 220px}.matrix{max-height:620px;overflow:auto;border:1px solid var(--line);border-radius:12px}.two{display:grid;grid-template-columns:1fr 1fr;gap:18px}.small{color:var(--muted);font-size:13px}ul{padding-left:20px}.footer{color:var(--muted);text-align:center;margin-top:22px}@media(max-width:850px){.grid,.two{grid-template-columns:1fr 1fr}.hero{padding:25px}}@media(max-width:560px){.grid,.two{grid-template-columns:1fr}main{width:min(100% - 18px,1180px)}.card{padding:18px}h1{font-size:28px}}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<header class="hero">
|
||||
<h1>DWS Wiki Shortcut 全景评审</h1>
|
||||
<p class="lead">以 13 项成熟 Wiki 用户任务为基线,重新审视 DWS 的空间、成员、节点与动态能力。本次不是按命令名凑数:每个入口都要求真实业务证据,缺失数组、畸形响应、空确认或读回不一致一律失败。</p>
|
||||
<div class="meta"><span class="pill">评审日期 2026-08-14</span><span class="pill">独立 worktree / 独立分支</span><span class="pill">真实组织数据 E2E 28/28</span><span class="pill">报告已去标识化</span></div>
|
||||
</header>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card metric"><b>20</b><span>公开 Wiki Shortcuts</span></div>
|
||||
<div class="card metric"><b>13/13</b><span>基线用户任务有对应路径</span></div>
|
||||
<div class="card metric"><b>7</b><span>DWS 额外场景</span></div>
|
||||
<div class="card metric"><b>20/20</b><span>真实数据能力已触达</span></div>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>结论先行</h2>
|
||||
<div class="callout ok"><strong>DWS 已形成比“API 快捷别名”更完整的 Wiki 任务层。</strong> 基线中的 13 个用户任务均有对应入口;DWS 还提供空间搜索/详情/唯一解析、成员角色更新、库内节点搜索、协作动态和按空间名新建文档。创建、复制、移动等关键写能力从“请求发出”升级为“终态 + ID + 读回”成功标准。</div>
|
||||
<div class="callout warn" style="margin-top:12px"><strong>能力边界必须诚实表达。</strong> DingTalk 成员接口不提供游标,单次真实上限是 50,因此不能实现成员 <code>--page-all</code>;成员身份只接受同组织可用的 userId,无法提供 email/open_id 等多种身份模式;节点创建也没有等价的 origin/shortcut 模式。这些差异保留为明确边界,而不是用本地循环或空结果伪装。</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>13 项基线任务逐条映射</h2>
|
||||
<div class="matrix"><table><thead><tr><th>基线任务</th><th>DWS 主入口</th><th>结论</th><th>DWS 视角与边界</th></tr></thead><tbody>
|
||||
<tr><td><code>+space-list</code></td><td><code>wiki +space-list</code></td><td><span class="tag full">完整对齐</span></td><td>严格空集合、游标续传、自动翻页、停滞检测;支持组织/我的知识库。</td></tr>
|
||||
<tr><td><code>+space-create</code></td><td><code>wiki +space-create</code></td><td><span class="tag full">超过</span></td><td>公开真实 32 字符名称上限;创建后按 workspaceId 读回。</td></tr>
|
||||
<tr><td><code>+delete-space</code></td><td><code>wiki +delete-space</code></td><td><span class="tag full">超过</span></td><td>预读目标、高风险确认、只接受 <code>success=true</code>;兼容 <code>+space-delete</code>。</td></tr>
|
||||
<tr><td><code>+member-add</code></td><td><code>wiki +member-add</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 1–30 个 userId 与四种角色;以写接口终态作为成功证据,不把最多 50 条的名单误作精确读回。</td></tr>
|
||||
<tr><td><code>+member-list</code></td><td><code>wiki +member-list</code></td><td><span class="tag partial">任务对齐</span></td><td>严格成员数组、角色过滤、真实上限 50;后端无游标,不能提供诚实的 page-all。</td></tr>
|
||||
<tr><td><code>+member-remove</code></td><td><code>wiki +member-remove</code></td><td><span class="tag partial">任务对齐</span></td><td>支持批量 userId;只接受写接口明确终态,并公开无法进行精确成员读回的边界。</td></tr>
|
||||
<tr><td><code>+node-list</code></td><td><code>wiki +node-list</code></td><td><span class="tag full">完整对齐</span></td><td>正确跨域路由 doc/list_nodes,严格空目录、分页与自动翻页。</td></tr>
|
||||
<tr><td><code>+node-get</code></td><td><code>wiki +node-get</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 DingTalk 节点 ID/在线文档 URL 并返回文档域元数据;不接受跨平台专用的 token/type 组合。</td></tr>
|
||||
<tr><td><code>+node-create</code></td><td><code>wiki +node-create</code></td><td><span class="tag partial">任务对齐</span></td><td>支持 adoc/axls/able/appt/adraw/amind/folder 并读回;无 origin/shortcut 等价接口。</td></tr>
|
||||
<tr><td><code>+node-copy</code></td><td><code>wiki +node-copy</code></td><td><span class="tag full">超过</span></td><td>确认后要求新 nodeId 并读取副本;底层面向在线节点,不把 .dlink 当独立副本。</td></tr>
|
||||
<tr><td><code>+move</code></td><td><code>wiki +move</code></td><td><span class="tag partial">任务对齐</span></td><td>同一入口支持 Wiki 内移动和“我的文档”在线节点入 Wiki,读回 workspace/folder;底层接口没有 apply 权限迁移开关。</td></tr>
|
||||
<tr><td><code>+move-to-drive</code></td><td><code>wiki +move-to-drive</code></td><td><span class="tag full">超过</span></td><td>DWS 当前接口同步完成并读回 workspace 变化,无需暴露异步 task 轮询。</td></tr>
|
||||
<tr><td><code>+node-delete</code></td><td><code>wiki +node-delete</code></td><td><span class="tag full">超过</span></td><td>预读并核对 workspace,高风险确认,要求删除终态。</td></tr>
|
||||
</tbody></table></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>DWS 可挖掘的 7 个额外场景</h2>
|
||||
<div class="two">
|
||||
<div><h3>定位与创建链</h3><ul><li><code>+space-search</code>:严格关键词搜索。</li><li><code>+space-get</code>:空间详情与 workspaceId 证据。</li><li><code>+resolve-space</code>:唯一命中直出 ID,多命中拒绝猜测。</li><li><code>+wiki-new-doc</code>:空间名解析 → 创建 → 文档读回。</li></ul></div>
|
||||
<div><h3>治理与巡检链</h3><ul><li><code>+member-update</code>:角色变更终态与不可精确读回声明。</li><li><code>+node-search</code>:库内关键词/扩展名搜索,严格零命中。</li><li><code>+feed-list</code>:知识库动态时间线与服务端 exclude-file 过滤。</li></ul></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>隐藏问题与修复</h2>
|
||||
<table><thead><tr><th>原问题</th><th>错误风险</th><th>本次修复</th></tr></thead><tbody>
|
||||
<tr><td>5 个旧 Wiki Shortcut 可直接执行,但只有 1 个进入公开目录。</td><td>Help、Schema、Skill 发现链与运行面漂移。</td><td><span class="tag fixed">20 项统一评审</span> 全部具备 Contract/Safety/Result 与语义目录记录。</td></tr>
|
||||
<tr><td>列表投影找不到数组或遇到坏元素时返回空 slice。</td><td>把内部错误、字段漂移误报为“没有数据”。</td><td><span class="tag fixed">失败关闭</span> 只有响应中真实存在的 <code>[]</code> 才是合法空集合。</td></tr>
|
||||
<tr><td>节点列表 Shortcut 调错 Wiki MCP 服务。</td><td>真实后端 <code>success=false</code>,Mock/静态检查看不出。</td><td><span class="tag fixed">跨域路由</span> 明确调用 doc/list_nodes,并纳入真实 E2E。</td></tr>
|
||||
<tr><td>成员帮助宣称最大 200。</td><td>真实接口超过 50 直接参数错误。</td><td><span class="tag fixed">真实上限</span> Shortcut 与原子 Help 均改为 50,并在本地提前拒绝。</td></tr>
|
||||
<tr><td>成员写操作从最多 50 条、不可分页的名单推断成员存在或缺失。</td><td>目标在截断部分时会误报写失败,或把未验证的移除报告为已读回。</td><td><span class="tag fixed">终态证据</span> 只接受写接口 <code>success=true</code>,并在结果中明确 <code>readbackAvailable=false</code>。</td></tr>
|
||||
<tr><td>空间搜索的稳定工作流属性名与实际请求属性名不同。</td><td>直接改写已发布的 <code>query/limit</code> 会造成无版本 Schema 破坏;继续隐式转换又会让审计者误以为请求同名透传。</td><td><span class="tag fixed">显式复合适配</span> 最终 Schema 保留兼容属性并明确声明转换为 <code>keyword/pageSize</code>;回归测试同时锁定最终交付和精确请求参数。</td></tr>
|
||||
<tr><td>知识库名称帮助宣称最大 100。</td><td>真实接口超过 32 失败。</td><td><span class="tag fixed">真实上限</span> Help 与 Shortcut 校验统一为 32。</td></tr>
|
||||
<tr><td>复制/移动/创建只把无异常视为成功。</td><td>空确认、未知远端效果或移动未到目标仍可能被接受。</td><td><span class="tag fixed">读回证明</span> 在后端具备精确查询能力时检查 success、业务 ID、workspace/folder 等最终状态。</td></tr>
|
||||
</tbody></table>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>真实数据 E2E 证据矩阵</h2>
|
||||
<p class="small">28 项业务断言全部通过。测试使用一次性空知识库、临时在线文档与一名同组织内部测试成员;所有对象在 finally 清理。报告不保存对象 ID、成员身份、组织信息、URL、trace 或原始响应。</p>
|
||||
<div class="toolbar"><input id="q" placeholder="筛选命令或证据"><select id="g"><option value="">全部分组</option><option>空间</option><option>成员</option><option>节点</option><option>动态</option></select></div>
|
||||
<div class="matrix"><table id="catalog"><thead><tr><th>分组</th><th>Shortcut</th><th>实际业务断言</th><th>状态</th></tr></thead><tbody>
|
||||
<tr><td>空间</td><td><code>+space-list</code></td><td>真实 count、hasMore、nextCursor;自动翻页返回两页结果。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+space-search</code></td><td>等待搜索索引后命中一次性 workspaceId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+space-get</code></td><td>读回 workspaceId 与创建结果一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+resolve-space</code></td><td>唯一名称解析为同一 workspaceId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+space-create</code></td><td>success=true、workspaceId 非空、详情读回一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>空间</td><td><code>+delete-space</code></td><td>目标预读、确认、success=true;兼容别名执行 finally 清理。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-list</code></td><td>真实 owner 条目与显式 members 数组,limit=50。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-add</code></td><td>命令只报告写终态;一次性小规模空间另行确认名单完整且角色为 READER。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-update</code></td><td>命令只报告写终态;一次性小规模空间另行确认角色变为 EDITOR。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>成员</td><td><code>+member-remove</code></td><td>命令只报告写终态;一次性小规模空间另行确认完整名单中不存在该 userId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-list</code></td><td>空库返回真实 nodes:[];有数据时验证游标与自动翻页。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-get</code></td><td>读回 nodeId 与请求一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-search</code></td><td>等待索引后按标题命中真实 nodeId。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-create</code></td><td>分别创建 folder/adoc,均取得 nodeId 和元数据读回。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-copy</code></td><td>取得不同的新 nodeId,副本元数据可读。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+move</code></td><td>读回 workspaceId 与 folderId 均等于目标;兼容 +node-move。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+move-to-drive</code></td><td>移动后读回 workspace 发生变化,再通过 +move 移回。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+node-delete</code></td><td>目标预读与 workspace 核对后收到 success=true。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>节点</td><td><code>+wiki-new-doc</code></td><td>按唯一空间名创建,nodeId 与文档详情读回一致。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
<tr><td>动态</td><td><code>+feed-list</code></td><td>创建/移动操作后返回真实 feeds 数组,缺字段不会被接受。</td><td><span class="tag full">PASS</span></td></tr>
|
||||
</tbody></table></div>
|
||||
<p class="small">可复跑入口:<code>make build</code> 后设置临时 <code>DWS_WIKI_E2E_MEMBER_ID</code>,在交互终端运行 <code>./scripts/dev/wiki-shortcut-e2e.py</code>。脚本只输出能力标签,不输出业务对象;受保护操作及最终清理均由命令逐项获取终端确认,非交互环境会在创建测试数据前拒绝运行。</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>成功判定与发布门</h2>
|
||||
<div class="two"><div><h3>运行时证据层</h3><ol><li>传输/MCP 调用成功。</li><li>响应契约存在且类型正确。</li><li>写操作必须有 <code>success=true</code>;创建类操作还必须有业务 ID。</li><li>后端具备精确查询时必须读回;不具备时明确发布不可读回,而非从截断集合推断。</li><li>集合只有显式数组才允许为空。</li></ol></div><div><h3>交付门</h3><ol><li>20/20 语义目录与注册面精确覆盖。</li><li>Contract、Safety、Result、统一输出完整。</li><li>生成漂移、Schema、确认真值、全量 Go 测试。</li><li>独立真实数据 E2E 与 finally 清理。</li><li>diff PII/密钥/本地绝对路径扫描。</li></ol></div></div>
|
||||
</section>
|
||||
<p class="footer">DWS Wiki Shortcut business review · sanitized engineering artifact</p>
|
||||
</main>
|
||||
<script>
|
||||
const q=document.querySelector('#q'),g=document.querySelector('#g'),rows=[...document.querySelectorAll('#catalog tbody tr')];
|
||||
function filter(){const text=q.value.trim().toLowerCase(),group=g.value;rows.forEach(r=>{const okText=!text||r.textContent.toLowerCase().includes(text),okGroup=!group||r.children[0].textContent===group;r.style.display=okText&&okGroup?'':'none'})}q.addEventListener('input',filter);g.addEventListener('change',filter);
|
||||
</script></body></html>
|
||||
@@ -82,6 +82,46 @@ func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagErrorWithSuggestionsChatFromExplainsBothMeanings(t *testing.T) {
|
||||
t.Parallel()
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
search := &cobra.Command{Use: "+search-msg", Run: func(*cobra.Command, []string) {}}
|
||||
search.Flags().String("sender", "", "sender target")
|
||||
search.Flags().String("start", "", "start time")
|
||||
root.AddCommand(chat)
|
||||
chat.AddCommand(search)
|
||||
|
||||
orig := fmt.Errorf("unknown flag: --from")
|
||||
err := flagErrorWithSuggestions(search, orig)
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Reason != "ambiguous_flag" || !strings.Contains(ae.Hint, "--sender") || !strings.Contains(ae.Hint, "--start") {
|
||||
t.Fatalf("structured error = reason %q hint %q", ae.Reason, ae.Hint)
|
||||
}
|
||||
if !strings.HasSuffix(ae.Message, "See 'dws chat +search-msg --help' for usage.") {
|
||||
t.Fatalf("Message = %q", ae.Message)
|
||||
}
|
||||
|
||||
for _, flag := range []string{"from-file", "from-user"} {
|
||||
t.Run(flag, func(t *testing.T) {
|
||||
err := flagErrorWithSuggestions(search, fmt.Errorf("unknown flag: --%s", flag))
|
||||
var structured *apperrors.Error
|
||||
if stderrors.As(err, &structured) && structured.Reason == "ambiguous_flag" {
|
||||
t.Fatalf("--%s incorrectly used --from ambiguity handling: %#v", flag, structured)
|
||||
}
|
||||
if strings.Contains(err.Error(), "--from 在消息查询中含义不明确") {
|
||||
t.Fatalf("--%s incorrectly received --from ambiguity hint: %v", flag, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown flag: --"+flag) {
|
||||
t.Fatalf("error = %q, want original flag --%s", err, flag)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFlagErrorWithSuggestions_fallbackTailHint 验证 fallback 路径(非 unknown flag 类错误,
|
||||
// 如 missing required flag / ambiguous shorthand)也带尾部 See '<cmd> --help' for usage.
|
||||
// 这是 wukong / docker / kubectl 的通用 UX——任何 flag 解析错误都给用户一条 help 入口。
|
||||
|
||||
+31
-7
@@ -511,6 +511,22 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
|
||||
// 无论哪种格式,子串 "--help' for usage." 都可被检索到。
|
||||
tail := fmt.Sprintf("\nSee '%s --help' for usage.", cmd.CommandPath())
|
||||
msgWithTail := errMsg + tail
|
||||
if flag, ok := unknownFlagName(errMsg); ok && flag == "from" {
|
||||
switch cmd.CommandPath() {
|
||||
case "dws chat +search-msg", "dws chat +chat-messages":
|
||||
return apperrors.NewValidation(
|
||||
msgWithTail,
|
||||
apperrors.WithHint("--from 在消息查询中含义不明确:按发送者过滤请使用 --sender <姓名|userId|openDingTalkId>;指定时间起点请使用 --start <RFC3339>"),
|
||||
apperrors.WithReason("ambiguous_flag"),
|
||||
apperrors.WithCause(err),
|
||||
apperrors.WithActions(
|
||||
"Use --sender <姓名|userId|openDingTalkId> to filter by sender",
|
||||
"Use --start <RFC3339> together with --end <RFC3339> to set a time range",
|
||||
),
|
||||
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
|
||||
)
|
||||
}
|
||||
}
|
||||
if flag, protection, ok := reviewedFlagProtection(cmd, errMsg); ok {
|
||||
hint := fmt.Sprintf("Parameter --%s is blocked from automatic normalization on %q; choose an explicit flag from --help.", flag, cmd.CommandPath())
|
||||
reason := "blocked_flag"
|
||||
@@ -579,15 +595,10 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline
|
||||
if cmd == nil {
|
||||
return "", "", false
|
||||
}
|
||||
const prefix = "unknown flag: --"
|
||||
idx := strings.Index(errMsg, prefix)
|
||||
if idx < 0 {
|
||||
flag, ok := unknownFlagName(errMsg)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
flag := strings.TrimSpace(errMsg[idx+len(prefix):])
|
||||
if i := strings.IndexAny(flag, " =\n\t"); i >= 0 {
|
||||
flag = flag[:i]
|
||||
}
|
||||
entry, ok := cli.LookupParamAlias(cmd.CommandPath())
|
||||
if !ok {
|
||||
return "", "", false
|
||||
@@ -602,6 +613,19 @@ func reviewedFlagProtection(cmd *cobra.Command, errMsg string) (string, pipeline
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func unknownFlagName(errMsg string) (string, bool) {
|
||||
const prefix = "unknown flag: --"
|
||||
idx := strings.Index(errMsg, prefix)
|
||||
if idx < 0 {
|
||||
return "", false
|
||||
}
|
||||
flag := strings.TrimSpace(errMsg[idx+len(prefix):])
|
||||
if i := strings.IndexAny(flag, " =\n\t"); i >= 0 {
|
||||
flag = flag[:i]
|
||||
}
|
||||
return flag, flag != ""
|
||||
}
|
||||
|
||||
func printExecutionError(root *cobra.Command, stdout, stderr io.Writer, err error) error {
|
||||
var raw apperrors.RawStderrError
|
||||
if stderrors.As(err, &raw) {
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 399
|
||||
publicShortcutCount = 418
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 401
|
||||
schemaPublishedShortcutCount = 420
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 399
|
||||
publiclyDeliveredShortcutCount = 418
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -140,6 +140,78 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func TestDeliveryWikiSpaceSearchDeclaresCompatibilityAdapter(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "wiki +space-search")
|
||||
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("wiki +space-search interface_mode = %q, want composite", got)
|
||||
}
|
||||
reason := schemaContractString(leaf["interface_reason"])
|
||||
for _, fragment := range []string{"query/limit", "search_wikiSpaces.keyword/pageSize", "versioned Schema migration"} {
|
||||
if !strings.Contains(reason, fragment) {
|
||||
t.Fatalf("wiki +space-search interface_reason = %q, want fragment %q", reason, fragment)
|
||||
}
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
for name, want := range map[string]string{"query": "query", "limit": "limit"} {
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("wiki +space-search missing --%s parameter: %#v", name, parameters)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Fatalf("wiki +space-search --%s property = %q, want compatibility value %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
checked := 0
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service != "wiki" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
canonical := shortcutSchemaCanonical(declared)
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Fatalf("delivery schema --all is missing %s", canonical)
|
||||
}
|
||||
examples := schemaContractStringSlice(tool["examples"])
|
||||
if len(examples) == 0 {
|
||||
t.Fatalf("%s has no delivered examples", canonical)
|
||||
}
|
||||
for _, example := range examples {
|
||||
argv, err := cli.ParseAgentExampleArgv(example)
|
||||
if err != nil {
|
||||
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
|
||||
}
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Required {
|
||||
continue
|
||||
}
|
||||
names := append([]string{flag.Name}, flag.Aliases...)
|
||||
if !schemaExampleHasLongFlag(argv, names...) {
|
||||
t.Errorf("%s example %q is missing required --%s", canonical, example, flag.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != 20 {
|
||||
t.Fatalf("checked Wiki shortcut examples = %d, want 20", checked)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
for _, argument := range argv {
|
||||
for _, name := range names {
|
||||
if argument == "--"+name || strings.HasPrefix(argument, "--"+name+"=") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func assertSchemaSummarySafety(
|
||||
t testing.TB,
|
||||
summaries map[string]map[string]any,
|
||||
|
||||
@@ -428,7 +428,7 @@ func newWikiCommand() *cobra.Command {
|
||||
})
|
||||
|
||||
// space create flags
|
||||
spaceCreateCmd.Flags().String("name", "", "知识库名称 (必填,不超过 100 字符)")
|
||||
spaceCreateCmd.Flags().String("name", "", "知识库名称 (必填,不超过 32 字符)")
|
||||
spaceCreateCmd.Flags().String("desc", "", "知识库描述 (选填,不超过 500 字符)")
|
||||
spaceCreateCmd.Flags().String("icon", "", "知识库图标标识 (选填)")
|
||||
|
||||
@@ -696,7 +696,7 @@ func newWikiCommand() *cobra.Command {
|
||||
Short: "查询知识库成员列表",
|
||||
Long: `查询指定知识库的成员列表,返回每位成员的 userId、姓名、角色等信息。
|
||||
|
||||
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。
|
||||
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 50)。
|
||||
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围;
|
||||
ORG 类型授权不会出现在查询结果中。`,
|
||||
Example: ` dws wiki member list --workspace <workspaceId>
|
||||
@@ -717,6 +717,9 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
limit, _ = cmd.Flags().GetInt("max-results")
|
||||
}
|
||||
if limit > 0 {
|
||||
if limit > 50 {
|
||||
return fmt.Errorf("--limit 不能超过 50;底层成员接口不提供游标续页")
|
||||
}
|
||||
toolArgs["maxResults"] = limit
|
||||
}
|
||||
if v := mustGetFlag(cmd, "filter-role"); v != "" {
|
||||
@@ -762,7 +765,7 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
})
|
||||
|
||||
memberListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
|
||||
memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||||
memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50;底层不支持游标续页")
|
||||
memberListCmd.Flags().Int("max-results", 0, "")
|
||||
_ = memberListCmd.Flags().MarkHidden("max-results")
|
||||
memberListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageWikiSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_wiki.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Service != "wiki" {
|
||||
t.Fatalf("service = %q", source.Service)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "wiki" {
|
||||
if _, exists := registered[item.Command]; exists {
|
||||
t.Fatalf("duplicate %s", item.Command)
|
||||
}
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if len(registered) != 20 || len(source.Shortcuts) != 20 {
|
||||
t.Fatalf("registered/catalog = %d/%d, want 20/20", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
var missing, stale []string
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed || item.Hidden || !record.Public {
|
||||
t.Errorf("%s: not publicly reviewed", command)
|
||||
}
|
||||
if strings.TrimSpace(record.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta || item.Disposition != record.Disposition {
|
||||
t.Errorf("%s: semantic facts drifted", command)
|
||||
}
|
||||
if item.Risk != record.Risk {
|
||||
t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk)
|
||||
}
|
||||
if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s: incomplete contract/safety/result/output", command)
|
||||
}
|
||||
}
|
||||
for command := range source.Shortcuts {
|
||||
if _, ok := registered[command]; !ok {
|
||||
stale = append(stale, command)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(stale)
|
||||
if len(missing) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
|
||||
}
|
||||
}
|
||||
@@ -1644,13 +1644,20 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err)
|
||||
}
|
||||
if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated); err != nil {
|
||||
verification, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated)
|
||||
if err != nil {
|
||||
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新结果不可信: %w", bizID, cardUpdateVerificationError(bizID, err))
|
||||
}
|
||||
payload := chatmsg.ProjectStreamingCardReceipt(created, bizID)
|
||||
payload["bizId"] = bizID
|
||||
payload["flowStatus"] = status
|
||||
payload["updated"] = updated
|
||||
payload["updateAccepted"] = verification.Accepted
|
||||
payload["updateVerified"] = verification.Verified
|
||||
payload["updateVerificationEvidence"] = verification.Evidence
|
||||
if verification.Accepted && !verification.Verified {
|
||||
payload["updateWarning"] = "服务端已接受卡片更新请求,但未返回可独立证明可见内容已更新的字段;不要重复执行相同更新"
|
||||
}
|
||||
return rt.Output(payload)
|
||||
},
|
||||
}
|
||||
@@ -1793,11 +1800,11 @@ var MessagesUpdateCard = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proof, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated)
|
||||
verification, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated)
|
||||
if err != nil {
|
||||
return cardUpdateVerificationError(bizID, err)
|
||||
}
|
||||
return rt.Output(chatmsg.ProjectStreamingCardUpdate(updated, bizID, proof))
|
||||
return rt.Output(chatmsg.ProjectStreamingCardUpdate(updated, bizID, verification))
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1049,6 +1049,14 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
},
|
||||
wantError: "biz-preserved",
|
||||
},
|
||||
{
|
||||
name: "unverified update preserves id",
|
||||
fake: &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/create_and_send_card": `{"bizId":"biz-unverified"}`,
|
||||
"im/update_streaming_card": `{"result":{"updated":false}}`,
|
||||
}},
|
||||
wantError: "biz-unverified",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
helpers.InitDeps(tc.fake)
|
||||
@@ -1073,6 +1081,8 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card",
|
||||
"--group", "cid",
|
||||
@@ -1085,6 +1095,13 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
if len(fake.calls) != 2 || fake.calls[1].tool != "update_streaming_card" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["updateAccepted"] != true || payload["updateVerified"] != false || payload["updateWarning"] == "" {
|
||||
t.Fatalf("card payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
for _, args := range [][]string{
|
||||
@@ -1157,12 +1174,14 @@ func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("success acknowledgement is verified", func(t *testing.T) {
|
||||
t.Run("success acknowledgement is accepted but unverified", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-update-card",
|
||||
"--biz-id", "中文乱串",
|
||||
@@ -1176,6 +1195,13 @@ func TestCrossPlatformCoverageMessagesUpdateCardVerifiesSuccess(t *testing.T) {
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "update_streaming_card" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["accepted"] != true || payload["verified"] != false || payload["warning"] == "" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit update evidence succeeds", func(t *testing.T) {
|
||||
|
||||
@@ -55,11 +55,15 @@ func ProjectStreamingCardReceipt(created map[string]any, bizID string) map[strin
|
||||
|
||||
// ProjectStreamingCardUpdate preserves the lower response while making the
|
||||
// verified target explicit for downstream consumers.
|
||||
func ProjectStreamingCardUpdate(updated map[string]any, bizID, proof string) map[string]any {
|
||||
func ProjectStreamingCardUpdate(updated map[string]any, bizID string, verification CardUpdateVerification) map[string]any {
|
||||
payload := cloneSendStatusMap(updated)
|
||||
payload["contractVersion"] = StreamingCardContractVersion
|
||||
payload["cardRef"] = map[string]any{"bizId": strings.TrimSpace(bizID)}
|
||||
payload["verified"] = true
|
||||
payload["verificationEvidence"] = proof
|
||||
payload["accepted"] = verification.Accepted
|
||||
payload["verified"] = verification.Verified
|
||||
payload["verificationEvidence"] = verification.Evidence
|
||||
if verification.Accepted && !verification.Verified {
|
||||
payload["warning"] = "服务端已接受卡片更新请求,但未返回可独立证明可见内容已更新的字段;不要重复执行相同更新"
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestCrossPlatformCoverageProjectStreamingCardReceipt(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectStreamingCardUpdate(t *testing.T) {
|
||||
payload := ProjectStreamingCardUpdate(map[string]any{"result": map[string]any{"updated": true}}, "biz-1", "updated=true")
|
||||
payload := ProjectStreamingCardUpdate(map[string]any{"result": map[string]any{"updated": true}}, "biz-1", CardUpdateVerification{Accepted: true, Verified: true, Evidence: "updated=true"})
|
||||
if payload["contractVersion"] != StreamingCardContractVersion || payload["verified"] != true || payload["verificationEvidence"] != "updated=true" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
@@ -40,3 +40,10 @@ func TestCrossPlatformCoverageProjectStreamingCardUpdate(t *testing.T) {
|
||||
t.Fatal("lower response was not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectAcceptedUnverifiedStreamingCardUpdate(t *testing.T) {
|
||||
payload := ProjectStreamingCardUpdate(map[string]any{"success": true}, "biz-1", CardUpdateVerification{Accepted: true, Verified: false, Evidence: "success=true"})
|
||||
if payload["accepted"] != true || payload["verified"] != false || payload["warning"] == "" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,16 @@ var (
|
||||
ErrCardUpdateBizIDDrift = errors.New("streaming card update returned a different bizId")
|
||||
)
|
||||
|
||||
// CardUpdateVerification distinguishes an accepted write from an independently
|
||||
// verified write. Some server versions return success=true without an updated
|
||||
// flag or affected count; that is sufficient to report acceptance, but not to
|
||||
// claim that the card's visible content was observed after the write.
|
||||
type CardUpdateVerification struct {
|
||||
Accepted bool
|
||||
Verified bool
|
||||
Evidence string
|
||||
}
|
||||
|
||||
// NormalizeCardBizID performs only format-independent checks. bizId is an
|
||||
// opaque server-issued identifier; a stricter character or prefix contract
|
||||
// must not be invented by the CLI without an authoritative API declaration.
|
||||
@@ -58,33 +68,40 @@ func isCardBizIDPlaceholder(value string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyStreamingCardUpdate requires affirmative evidence that the requested
|
||||
// write took effect. update_streaming_card may acknowledge an applied write
|
||||
// with success=true without returning an updated flag or affected count.
|
||||
func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (string, error) {
|
||||
// VerifyStreamingCardUpdate separates server acceptance from affirmative
|
||||
// evidence that the requested write took effect.
|
||||
func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (CardUpdateVerification, error) {
|
||||
requestedBizID = strings.TrimSpace(requestedBizID)
|
||||
observation := cardUpdateObservation{bizIDs: map[string]struct{}{}}
|
||||
observeCardUpdate(response, &observation)
|
||||
|
||||
for responseBizID := range observation.bizIDs {
|
||||
if requestedBizID != "" && responseBizID != requestedBizID {
|
||||
return "", fmt.Errorf("%w: requested %q, response %q", ErrCardUpdateBizIDDrift, requestedBizID, responseBizID)
|
||||
return CardUpdateVerification{}, fmt.Errorf("%w: requested %q, response %q", ErrCardUpdateBizIDDrift, requestedBizID, responseBizID)
|
||||
}
|
||||
}
|
||||
if observation.positiveEvidence != "" && observation.negativeEvidence != "" {
|
||||
return "", fmt.Errorf("%w: conflicting evidence %s and %s", ErrCardUpdateUnverified, observation.positiveEvidence, observation.negativeEvidence)
|
||||
if observation.negativeEvidence != "" && (observation.positiveEvidence != "" || observation.acceptedEvidence != "") {
|
||||
positive := observation.positiveEvidence
|
||||
if positive == "" {
|
||||
positive = observation.acceptedEvidence
|
||||
}
|
||||
return CardUpdateVerification{}, fmt.Errorf("%w: conflicting evidence %s and %s", ErrCardUpdateUnverified, positive, observation.negativeEvidence)
|
||||
}
|
||||
if observation.positiveEvidence != "" {
|
||||
return observation.positiveEvidence, nil
|
||||
return CardUpdateVerification{Accepted: true, Verified: true, Evidence: observation.positiveEvidence}, nil
|
||||
}
|
||||
if observation.negativeEvidence != "" {
|
||||
return "", fmt.Errorf("%w: %s", ErrCardUpdateNotApplied, observation.negativeEvidence)
|
||||
return CardUpdateVerification{}, fmt.Errorf("%w: %s", ErrCardUpdateNotApplied, observation.negativeEvidence)
|
||||
}
|
||||
return "", ErrCardUpdateUnverified
|
||||
if observation.acceptedEvidence != "" {
|
||||
return CardUpdateVerification{Accepted: true, Verified: false, Evidence: observation.acceptedEvidence}, nil
|
||||
}
|
||||
return CardUpdateVerification{}, ErrCardUpdateUnverified
|
||||
}
|
||||
|
||||
type cardUpdateObservation struct {
|
||||
bizIDs map[string]struct{}
|
||||
acceptedEvidence string
|
||||
positiveEvidence string
|
||||
negativeEvidence string
|
||||
}
|
||||
@@ -107,6 +124,9 @@ func observeCardUpdate(value any, observation *cardUpdateObservation) {
|
||||
}
|
||||
|
||||
func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservation) {
|
||||
if accepted, ok := value["success"].(bool); ok && accepted && observation.acceptedEvidence == "" {
|
||||
observation.acceptedEvidence = "success=true"
|
||||
}
|
||||
for _, key := range []string{"bizId", "bizID", "biz_id"} {
|
||||
if candidate, ok := value[key].(string); ok && strings.TrimSpace(candidate) != "" {
|
||||
observation.bizIDs[strings.TrimSpace(candidate)] = struct{}{}
|
||||
@@ -137,14 +157,7 @@ func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservati
|
||||
setNegativeCardUpdateEvidence(observation, "errorCode=non-empty")
|
||||
}
|
||||
if success, ok := value["success"].(bool); ok {
|
||||
if success {
|
||||
// Record success=true only when the same response envelope explicitly
|
||||
// includes its business-error field. A non-empty code is already
|
||||
// negative evidence above, so the two signals reject the conflict.
|
||||
if hasErrorCode {
|
||||
setPositiveCardUpdateEvidence(observation, "success=true")
|
||||
}
|
||||
} else {
|
||||
if !success {
|
||||
setNegativeCardUpdateEvidence(observation, "success=false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,19 +52,19 @@ func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
response map[string]any
|
||||
wantProof string
|
||||
want CardUpdateVerification
|
||||
wantErrIs error
|
||||
}{
|
||||
{name: "updated", response: map[string]any{"result": map[string]any{"updated": true}}, wantProof: "updated=true"},
|
||||
{name: "affected", response: map[string]any{"data": map[string]any{"affectedCount": float64(1)}}, wantProof: "affectedCount=1"},
|
||||
{name: "boolean result", response: map[string]any{"result": true}, wantProof: "result=true"},
|
||||
{name: "updated", response: map[string]any{"result": map[string]any{"updated": true}}, want: CardUpdateVerification{Accepted: true, Verified: true, Evidence: "updated=true"}},
|
||||
{name: "affected", response: map[string]any{"data": map[string]any{"affectedCount": float64(1)}}, want: CardUpdateVerification{Accepted: true, Verified: true, Evidence: "affectedCount=1"}},
|
||||
{name: "boolean result", response: map[string]any{"result": true}, want: CardUpdateVerification{Accepted: true, Verified: true, Evidence: "result=true"}},
|
||||
{name: "boolean false result", response: map[string]any{"result": false}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
{name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, wantProof: "applied=true"},
|
||||
{name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, want: CardUpdateVerification{Accepted: true, Verified: true, Evidence: "applied=true"}},
|
||||
{name: "conflicting evidence", response: map[string]any{"updated": true, "applied": false}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "zero affected", response: map[string]any{"affectedCount": 0}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
{name: "success acknowledgement", response: map[string]any{"success": true, "errorCode": nil}, wantProof: "success=true"},
|
||||
{name: "success acknowledgement with empty error code", response: map[string]any{"success": true, "errorCode": " "}, wantProof: "success=true"},
|
||||
{name: "success without explicit error code", response: map[string]any{"success": true}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "success acknowledgement", response: map[string]any{"success": true, "errorCode": nil}, want: CardUpdateVerification{Accepted: true, Verified: false, Evidence: "success=true"}},
|
||||
{name: "success acknowledgement with empty error code", response: map[string]any{"success": true, "errorCode": " "}, want: CardUpdateVerification{Accepted: true, Verified: false, Evidence: "success=true"}},
|
||||
{name: "success without explicit error code", response: map[string]any{"success": true}, want: CardUpdateVerification{Accepted: true, Verified: false, Evidence: "success=true"}},
|
||||
{name: "success conflicts with error code", response: map[string]any{"success": true, "errorCode": "InternalError"}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "success conflicts with numeric error code", response: map[string]any{"success": true, "errorCode": float64(500)}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "error code without success", response: map[string]any{"errorCode": "InternalError"}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
@@ -74,15 +74,15 @@ func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) {
|
||||
{name: "unrelated extension ignored", response: map[string]any{"extension": map[string]any{"updated": true}}, wantErrIs: ErrCardUpdateUnverified},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
proof, err := VerifyStreamingCardUpdate("biz-1", test.response)
|
||||
got, err := VerifyStreamingCardUpdate("biz-1", test.response)
|
||||
if test.wantErrIs != nil {
|
||||
if !errors.Is(err, test.wantErrIs) {
|
||||
t.Fatalf("VerifyStreamingCardUpdate error = %v, want errors.Is(_, %v)", err, test.wantErrIs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || proof != test.wantProof {
|
||||
t.Fatalf("VerifyStreamingCardUpdate = %q, %v; want %q", proof, err, test.wantProof)
|
||||
if err != nil || got != test.want {
|
||||
t.Fatalf("VerifyStreamingCardUpdate = %#v, %v; want %#v", got, err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -404,6 +404,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
"todo\u0000+overdue": {},
|
||||
"todo\u0000+remind": {},
|
||||
"todo\u0000+todo-done": {},
|
||||
"wiki\u0000+delete-space": {},
|
||||
"wiki\u0000+feed-list": {},
|
||||
"wiki\u0000+member-add": {},
|
||||
"wiki\u0000+member-list": {},
|
||||
"wiki\u0000+member-remove": {},
|
||||
"wiki\u0000+member-update": {},
|
||||
"wiki\u0000+move": {},
|
||||
"wiki\u0000+move-to-drive": {},
|
||||
"wiki\u0000+node-copy": {},
|
||||
"wiki\u0000+node-create": {},
|
||||
"wiki\u0000+node-delete": {},
|
||||
"wiki\u0000+node-get": {},
|
||||
"wiki\u0000+node-list": {},
|
||||
"wiki\u0000+node-search": {},
|
||||
"wiki\u0000+resolve-space": {},
|
||||
"wiki\u0000+space-create": {},
|
||||
"wiki\u0000+space-get": {},
|
||||
"wiki\u0000+space-list": {},
|
||||
"wiki\u0000+space-search": {},
|
||||
"wiki\u0000+wiki-new-doc": {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ var minutesSemanticCatalogJSON []byte
|
||||
//go:embed semantic_catalog_drive.json
|
||||
var driveSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_wiki.json
|
||||
var wikiSemanticCatalogJSON []byte
|
||||
|
||||
type semanticCatalogFile struct {
|
||||
Version int `json:"version"`
|
||||
Service string `json:"service"`
|
||||
@@ -48,6 +51,7 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
|
||||
aitableSemanticCatalogJSON,
|
||||
minutesSemanticCatalogJSON,
|
||||
driveSemanticCatalogJSON,
|
||||
wikiSemanticCatalogJSON,
|
||||
)
|
||||
|
||||
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "wiki",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+space-list": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空知识库列表与缺失、畸形或内部错误响应,并保留真实分页证据。","risk":"read","public":true,"reviewed":true},
|
||||
"+space-search": {"disposition":"semantic_adapter","semantic_delta":"按关键词搜索知识库并拒绝把缺失业务数组误报为零命中。","risk":"read","public":true,"reviewed":true},
|
||||
"+resolve-space": {"disposition":"primary_smart","semantic_delta":"把关键词搜索收敛为唯一 workspaceId;零命中与多命中显式分流,绝不猜测。","risk":"read","public":true,"reviewed":true},
|
||||
"+space-get": {"disposition":"schema_leaf","semantic_delta":"补充知识库详情入口,并要求 workspaceId 业务证据。","risk":"read","public":true,"reviewed":true},
|
||||
"+space-create": {"disposition":"primary_smart","semantic_delta":"创建后要求 workspaceId 并通过空间详情读回验证真实落库。","risk":"write","public":true,"reviewed":true},
|
||||
"+delete-space": {"disposition":"semantic_adapter","semantic_delta":"删除前读取影响目标,经高风险确认后只接受 success=true 终态。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
|
||||
"+member-add": {"disposition":"semantic_adapter","semantic_delta":"支持 1-30 个 userId 与四类角色;严格要求写接口终态,并明确后端无法提供精确成员读回。","risk":"write","public":true,"reviewed":true},
|
||||
"+member-update": {"disposition":"semantic_adapter","semantic_delta":"补充成员角色更新;严格要求写接口终态,并明确后端无法提供精确成员读回。","risk":"write","public":true,"reviewed":true},
|
||||
"+member-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证成员数组并公开真实单次上限 50;后端无游标时不伪造 page-all。","risk":"read","public":true,"reviewed":true},
|
||||
"+member-remove": {"disposition":"semantic_adapter","semantic_delta":"支持批量 userId 移除;严格要求写接口终态,并明确后端无法提供精确成员读回。","risk":"write","public":true,"reviewed":true},
|
||||
|
||||
"+node-list": {"disposition":"semantic_adapter","semantic_delta":"严格区分显式空目录与假空成功,稳定投影节点并保留 nextCursor/hasMore。","risk":"read","public":true,"reviewed":true},
|
||||
"+node-get": {"disposition":"schema_leaf","semantic_delta":"统一节点 ID 或在线文档 URL 的元数据读取,补充文档域属性视角。","risk":"read","public":true,"reviewed":true},
|
||||
"+node-search": {"disposition":"semantic_adapter","semantic_delta":"补充库内关键词和扩展名搜索,并拒绝假空结果。","risk":"read","public":true,"reviewed":true},
|
||||
"+node-create": {"disposition":"primary_smart","semantic_delta":"支持七种 Wiki 节点类型,创建后要求 nodeId 并读取元数据验证。","risk":"write","public":true,"reviewed":true},
|
||||
"+node-copy": {"disposition":"primary_smart","semantic_delta":"高风险确认后复制,必须取得新 nodeId 并读回副本,避免空响应被当作成功。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
"+move": {"disposition":"primary_smart","semantic_delta":"同一入口覆盖 Wiki 内移动和我的文档在线节点入 Wiki,并校验目标 workspace/folder。","risk":"write","public":true,"reviewed":true},
|
||||
"+move-to-drive": {"disposition":"primary_smart","semantic_delta":"同步移动 Wiki 节点到我的文档并读回验证 workspace 变化,免去异步任务轮询。","risk":"write","public":true,"reviewed":true},
|
||||
"+node-delete": {"disposition":"semantic_adapter","semantic_delta":"删除前读取并核对 workspace,经高风险确认后要求 success=true 终态证据。","risk":"high-risk-write","public":true,"reviewed":true},
|
||||
|
||||
"+feed-list": {"disposition":"semantic_adapter","semantic_delta":"补充知识库协作动态查询,严格验证 feeds 并保留游标。","risk":"read","public":true,"reviewed":true},
|
||||
"+wiki-new-doc": {"disposition":"primary_smart","semantic_delta":"按空间名精确唯一解析、创建在线文档并读回验证;零命中和歧义均显式失败。","risk":"write","public":true,"reviewed":true}
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -103,6 +104,8 @@ func runShortcut(t *testing.T, fake *stubMailboxCaller, argv ...string) string {
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
var buf bytes.Buffer
|
||||
root.SetOut(&buf)
|
||||
@@ -121,6 +124,8 @@ func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
|
||||
@@ -14,7 +14,13 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
@@ -35,15 +41,25 @@ import (
|
||||
//
|
||||
// dws wiki +resolve-space --name 产品文档
|
||||
var ResolveSpace = shortcut.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+resolve-space",
|
||||
Product: "wiki",
|
||||
Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "wiki",
|
||||
Command: "+resolve-space",
|
||||
Product: "wiki",
|
||||
Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)",
|
||||
Intent: "当你只知道某个知识空间(wiki space)的名称(或名称里的关键词)、想把它解析成可直接用于后续工具的 spaceId 时使用;" +
|
||||
"内部按 --name 关键词调用 search_wikiSpaces 搜索知识空间,再在本地投影出每个候选的 spaceId 和 name。" +
|
||||
"如果只命中一个知识空间就直接返回它的 spaceId;如果命中多个则列出全部候选让你消歧,绝不替你瞎猜;如果一个都没命中则提示未找到。" +
|
||||
"这是纯只读操作,只做搜索与本地投影,不会修改任何知识空间。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Description: "按名称搜索知识空间并解析出唯一 spaceId(只读)",
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"知识库名称解析结果","properties":{"resolved":{"type":"boolean","description":"是否唯一解析"},"spaceId":{"type":"string","description":"唯一知识库 ID"},"name":{"type":"string","description":"唯一知识库名称"},"count":{"type":"integer","description":"候选数量"},"candidates":{"type":"array","description":"需要消歧的候选知识库","items":{"type":"object","description":"知识库候选","additionalProperties":true}}},"required":["resolved"],"additionalProperties":true}`)},
|
||||
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Reviewed Wiki resolver: the executable CLI strictly validates search results and refuses to guess when multiple spaces match."},
|
||||
Selection: contract.SelectionSpec{AgentSummary: "按名称搜索知识空间并解析出唯一 spaceId(只读)", UseWhen: []string{"当你只知道某个知识空间(wiki space)的名称(或名称里的关键词)、想把它解析成可直接用于后续工具的 spaceId 时使用;内部按 --name 关键词调用 search_wikiSpaces 搜索知识空间,再在本地投影出每个候选的 spaceId 和 name。如果只命中一个知识空间就直接返回它的 spaceId;如果命中多个则列出全部候选让你消歧,绝不替你瞎猜;如果一个都没命中则提示未找到。这是纯只读操作,只做搜索与本地投影,不会修改任何知识空间。"}, AvoidWhen: []string{"只想浏览所有匹配项用 wiki +space-search;已知 workspaceId 时无需解析"}, Examples: []string{`dws wiki +resolve-space --name "产品文档"`}},
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: "shortcut_resolve_space", CanonicalPath: "wiki.shortcut_resolve_space", CLIPath: "wiki +resolve-space", PrimaryCLIPath: "wiki +resolve-space"},
|
||||
Parameters: []contract.ParamDecl{{Name: "name", Property: "keyword"}},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "name", Type: shortcut.FlagString, Desc: "要搜索的知识空间名称关键词(必填)", Required: true},
|
||||
},
|
||||
@@ -62,7 +78,10 @@ var ResolveSpace = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
// Project candidates to {spaceId, name}, defensively unwrapping the list.
|
||||
items := resolveSpaceItems(data)
|
||||
items, err := resolveSpaceItemsStrict(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidates := make([]map[string]any, 0, len(items))
|
||||
for _, s := range items {
|
||||
candidates = append(candidates, map[string]any{
|
||||
@@ -123,7 +142,7 @@ func resolveSpaceItems(data map[string]any) []map[string]any {
|
||||
|
||||
// resolveSpaceID reads a space's identifier, tolerating the common id keys.
|
||||
func resolveSpaceID(s map[string]any) string {
|
||||
for _, key := range []string{"spaceId", "space_id", "id"} {
|
||||
for _, key := range []string{"workspaceId", "spaceId", "space_id", "id"} {
|
||||
if v, ok := s[key].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
@@ -131,6 +150,53 @@ func resolveSpaceID(s map[string]any) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveSpaceItemsStrict(data map[string]any) ([]map[string]any, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 返回空响应,不能当作零命中", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("empty_tool_response"))
|
||||
}
|
||||
if success, present := data["success"]; present {
|
||||
value, ok := success.(bool)
|
||||
if !ok || !value {
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 未成功", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("remote_failure"))
|
||||
}
|
||||
}
|
||||
containers := []map[string]any{data}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if raw, present := data[wrapper]; present {
|
||||
inner, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 响应包装不是对象", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_envelope"))
|
||||
}
|
||||
containers = append(containers, inner)
|
||||
}
|
||||
}
|
||||
for _, container := range containers {
|
||||
for _, key := range []string{"wikiSpaces", "spaces", "items", "list", "records"} {
|
||||
raw, present := container[key]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 业务集合不是数组", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection"))
|
||||
}
|
||||
out := make([]map[string]any, 0, len(list))
|
||||
for index, item := range list {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 第 %d 项不是对象", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item"))
|
||||
}
|
||||
if resolveSpaceID(object) == "" || resolveSpaceName(object) == "" {
|
||||
return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 第 %d 项缺少名称或 workspaceId", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item"))
|
||||
}
|
||||
out = append(out, object)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 缺少 wikiSpaces 数组,不能投影为空", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("missing_collection"))
|
||||
}
|
||||
|
||||
// resolveSpaceName reads a space's display name, tolerating the common name keys.
|
||||
func resolveSpaceName(s map[string]any) string {
|
||||
for _, key := range []string{"name", "spaceName", "title"} {
|
||||
|
||||
@@ -14,10 +14,14 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
@@ -33,15 +37,25 @@ import (
|
||||
//
|
||||
// dws wiki +wiki-new-doc --space "产品文档库" --title "需求评审纪要"
|
||||
var WikiNewDoc = shortcut.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+wiki-new-doc",
|
||||
Product: "wiki",
|
||||
Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "wiki",
|
||||
Command: "+wiki-new-doc",
|
||||
Product: "wiki",
|
||||
Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)",
|
||||
Intent: "当你只知道知识库(知识空间)的名字、想直接在它下面新建一篇文档,却不想先搜索空间、复制 workspaceId 再建节点时使用;" +
|
||||
"内部先按空间名搜索知识库,若唯一命中则拿到它的 workspaceId,再在该库根目录下创建一个在线文档节点。" +
|
||||
"如果这个名字没有匹配到任何知识库,或匹配到多个,会报错让你用更精确的名字,绝不乱猜。" +
|
||||
"这会真实创建一个新的文档节点。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "non_idempotent"},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Description: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)",
|
||||
Result: &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(`{"type":"object","description":"已验证的新建 Wiki 文档","properties":{"success":{"type":"boolean","description":"是否成功"},"nodeId":{"type":"string","description":"新文档节点 ID"},"space":{"type":"string","description":"请求的知识库名称"},"title":{"type":"string","description":"请求的文档标题"},"document":{"type":"object","description":"读回的文档元数据","additionalProperties":true}},"required":["success","nodeId","space","title","document"],"additionalProperties":true}`)},
|
||||
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Reviewed Wiki smart Shortcut: the executable CLI strictly resolves one exact space, creates a document, and verifies it through a metadata read-back."},
|
||||
Selection: contract.SelectionSpec{AgentSummary: "在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId)", UseWhen: []string{"当你只知道知识库(知识空间)的名字、想直接在它下面新建一篇文档,却不想先搜索空间、复制 workspaceId 再建节点时使用;内部先按空间名搜索知识库,若唯一命中则拿到它的 workspaceId,再在该库根目录下创建一个在线文档节点。如果这个名字没有匹配到任何知识库,或匹配到多个,会报错让你用更精确的名字,绝不乱猜。这会真实创建一个新的文档节点。"}, AvoidWhen: []string{"已知 workspaceId 时用 wiki +node-create;空间名不唯一时先用 wiki +space-search"}, Examples: []string{`dws wiki +wiki-new-doc --space "产品文档库" --title "需求评审纪要"`}},
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: "shortcut_wiki_new_doc", CanonicalPath: "wiki.shortcut_wiki_new_doc", CLIPath: "wiki +wiki-new-doc", PrimaryCLIPath: "wiki +wiki-new-doc"},
|
||||
Parameters: []contract.ParamDecl{{Name: "space", Property: "keyword"}, {Name: "title", Property: "name"}},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "space", Type: shortcut.FlagString, Desc: "知识库(知识空间)名称", Required: true},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "新建文档的标题", Required: true},
|
||||
@@ -88,7 +102,7 @@ var WikiNewDoc = shortcut.Shortcut{
|
||||
"wouldCreateIn": workspaceID,
|
||||
})
|
||||
}
|
||||
created, err := rt.CallMCPWriteData("doc", "create_file", map[string]any{
|
||||
created, err := rt.CallMCPWriteDataStrict("doc", "create_file", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"name": title,
|
||||
"type": "adoc",
|
||||
@@ -96,13 +110,28 @@ var WikiNewDoc = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Surface the created doc (id/url in the response) instead of returning
|
||||
// silently — previously the caller got no confirmation of the new doc.
|
||||
if success, ok := created["success"].(bool); !ok || !success {
|
||||
return apperrors.NewAPI("create_file 未返回 success=true,无法证明文档已创建", apperrors.WithOperation("doc/create_file"), apperrors.WithReason("missing_terminal_success"))
|
||||
}
|
||||
nodeID := wikiNewDocFirstString(created, "nodeId", "fileId", "id")
|
||||
if nodeID == "" {
|
||||
return apperrors.NewAPI("create_file 未返回 nodeId,远端效果未知", apperrors.WithOperation("doc/create_file"), apperrors.WithReason("missing_created_id"))
|
||||
}
|
||||
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": nodeID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if success, present := verified["success"]; present {
|
||||
value, ok := success.(bool)
|
||||
if !ok || !value {
|
||||
return apperrors.NewAPI("新建文档读回未成功", apperrors.WithOperation("doc/get_document_info"), apperrors.WithReason("readback_failed"))
|
||||
}
|
||||
}
|
||||
if wikiNewDocFirstString(verified, "nodeId", "fileId", "id") != nodeID {
|
||||
return apperrors.NewAPI("新建文档读回 nodeId 不一致", apperrors.WithOperation("doc/get_document_info"), apperrors.WithReason("readback_id_mismatch"))
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"created": true,
|
||||
"space": spaceName,
|
||||
"title": title,
|
||||
"result": created,
|
||||
"success": true, "nodeId": nodeID, "space": spaceName, "title": title, "document": verified,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -117,7 +146,10 @@ type wikiSpaceCandidate struct {
|
||||
// out of a search_wikiSpaces response and returns its workspaceId. It errors
|
||||
// clearly when nothing matches or when the name is ambiguous, never guessing.
|
||||
func wikiNewDocResolveSpaceID(data map[string]any, spaceName string) (string, error) {
|
||||
spaces := wikiNewDocExtractSpaces(data)
|
||||
spaces, err := wikiNewDocExtractSpaces(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(spaces) == 0 {
|
||||
return "", apperrors.NewValidation(fmt.Sprintf(
|
||||
"没找到名为 %q 的知识库;换个更完整/精确的空间名再试。", spaceName))
|
||||
@@ -156,9 +188,15 @@ func wikiNewDocResolveSpaceID(data map[string]any, spaceName string) (string, er
|
||||
// wikiNewDocExtractSpaces flattens the several shapes a search_wikiSpaces
|
||||
// response may take into a list of {id, name} candidates. The gateway wraps the
|
||||
// list under one of several common container keys, so probe them defensively.
|
||||
func wikiNewDocExtractSpaces(data map[string]any) []wikiSpaceCandidate {
|
||||
func wikiNewDocExtractSpaces(data map[string]any) ([]wikiSpaceCandidate, error) {
|
||||
if data == nil {
|
||||
return nil
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 返回空响应,不能当作零命中", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("empty_tool_response"))
|
||||
}
|
||||
if success, present := data["success"]; present {
|
||||
value, ok := success.(bool)
|
||||
if !ok || !value {
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 未成功", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("remote_failure"))
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"result", "data", "list", "wikiSpaces", "spaces", "items", "records"} {
|
||||
switch v := data[key].(type) {
|
||||
@@ -166,21 +204,25 @@ func wikiNewDocExtractSpaces(data map[string]any) []wikiSpaceCandidate {
|
||||
return wikiNewDocToCandidates(v)
|
||||
case map[string]any:
|
||||
for _, k2 := range []string{"list", "wikiSpaces", "spaces", "items", "records", "result"} {
|
||||
if arr, ok := v[k2].([]any); ok {
|
||||
if raw, present := v[k2]; present {
|
||||
arr, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 业务集合不是数组", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection"))
|
||||
}
|
||||
return wikiNewDocToCandidates(arr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return nil, apperrors.NewAPI("search_wikiSpaces 缺少 wikiSpaces 数组,不能投影为空", apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("missing_collection"))
|
||||
}
|
||||
|
||||
func wikiNewDocToCandidates(arr []any) []wikiSpaceCandidate {
|
||||
func wikiNewDocToCandidates(arr []any) ([]wikiSpaceCandidate, error) {
|
||||
out := make([]wikiSpaceCandidate, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
for index, it := range arr {
|
||||
m, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 结果第 %d 项不是对象", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item"))
|
||||
}
|
||||
id := ""
|
||||
for _, k := range []string{"workspaceId", "spaceId", "id"} {
|
||||
@@ -196,12 +238,30 @@ func wikiNewDocToCandidates(arr []any) []wikiSpaceCandidate {
|
||||
break
|
||||
}
|
||||
}
|
||||
if id == "" && name == "" {
|
||||
continue
|
||||
if id == "" || name == "" {
|
||||
return nil, apperrors.NewAPI(fmt.Sprintf("search_wikiSpaces 结果第 %d 项缺少名称或 workspaceId", index), apperrors.WithOperation("wiki/search_wikiSpaces"), apperrors.WithReason("malformed_collection_item"))
|
||||
}
|
||||
out = append(out, wikiSpaceCandidate{id: id, name: name})
|
||||
}
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func wikiNewDocFirstString(data map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if inner, ok := data[wrapper].(map[string]any); ok {
|
||||
for _, key := range keys {
|
||||
if value, ok := inner[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func wikiNewDocLabels(spaces []wikiSpaceCandidate) []string {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageResolveSpaceStrictContracts(t *testing.T) {
|
||||
invalid := []map[string]any{
|
||||
nil,
|
||||
{"success": false},
|
||||
{"success": "yes"},
|
||||
{"result": "bad"},
|
||||
{"wikiSpaces": "bad"},
|
||||
{"wikiSpaces": []any{"bad"}},
|
||||
{"wikiSpaces": []any{map[string]any{"workspaceId": "w"}}},
|
||||
{"wikiSpaces": []any{map[string]any{"name": "Docs"}}},
|
||||
{"success": true},
|
||||
}
|
||||
for index, data := range invalid {
|
||||
if _, err := resolveSpaceItemsStrict(data); err == nil {
|
||||
t.Fatalf("invalid response %d succeeded: %#v", index, data)
|
||||
}
|
||||
}
|
||||
|
||||
for _, data := range []map[string]any{
|
||||
{"wikiSpaces": []any{}},
|
||||
{"spaces": []any{map[string]any{"spaceId": "s", "spaceName": "Docs"}}},
|
||||
{"result": map[string]any{"items": []any{map[string]any{"id": "i", "title": "Plan"}}}},
|
||||
{"data": map[string]any{"records": []any{map[string]any{"workspaceId": "w", "name": "Roadmap"}}}},
|
||||
} {
|
||||
if _, err := resolveSpaceItemsStrict(data); err != nil {
|
||||
t.Fatalf("valid response rejected: %#v: %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
data map[string]any
|
||||
id string
|
||||
name string
|
||||
}{
|
||||
{map[string]any{"workspaceId": "w", "name": "N"}, "w", "N"},
|
||||
{map[string]any{"spaceId": "s", "spaceName": "S"}, "s", "S"},
|
||||
{map[string]any{"space_id": "legacy", "title": "T"}, "legacy", "T"},
|
||||
{map[string]any{"id": "i"}, "i", ""},
|
||||
{map[string]any{}, "", ""},
|
||||
} {
|
||||
if got := resolveSpaceID(tc.data); got != tc.id {
|
||||
t.Fatalf("resolveSpaceID(%#v)=%q want %q", tc.data, got, tc.id)
|
||||
}
|
||||
if got := resolveSpaceName(tc.data); got != tc.name {
|
||||
t.Fatalf("resolveSpaceName(%#v)=%q want %q", tc.data, got, tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveSpaceExecution(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
fake *stubMailboxCaller
|
||||
ok bool
|
||||
}{
|
||||
{"transport error", &stubMailboxCaller{errTool: "search_wikiSpaces"}, false},
|
||||
{"malformed result", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"success":true}`}}, false},
|
||||
{"zero result", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[]}`}}, false},
|
||||
{"one result", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[{"workspaceId":"w","name":"Docs"}]}`}}, true},
|
||||
{"multiple results", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[{"workspaceId":"w1","name":"Docs"},{"workspaceId":"w2","name":"Docs 2"}]}`}}, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := runShortcutErr(t, tc.fake, "wiki", "+resolve-space", "--name", "Docs", "--format", "json")
|
||||
if (err == nil) != tc.ok {
|
||||
t.Fatalf("success=%v want %v; err=%v", err == nil, tc.ok, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiNewDocParsing(t *testing.T) {
|
||||
invalid := []map[string]any{
|
||||
nil,
|
||||
{"success": false},
|
||||
{"success": "yes"},
|
||||
{"result": map[string]any{"wikiSpaces": "bad"}},
|
||||
{"wikiSpaces": []any{"bad"}},
|
||||
{"wikiSpaces": []any{map[string]any{"workspaceId": "w"}}},
|
||||
{"success": true},
|
||||
}
|
||||
for index, data := range invalid {
|
||||
if _, err := wikiNewDocExtractSpaces(data); err == nil {
|
||||
t.Fatalf("invalid response %d succeeded: %#v", index, data)
|
||||
}
|
||||
}
|
||||
|
||||
for _, data := range []map[string]any{
|
||||
{"wikiSpaces": []any{}},
|
||||
{"list": []any{map[string]any{"workspaceId": "w", "name": "Docs"}}},
|
||||
{"data": map[string]any{"spaces": []any{map[string]any{"spaceId": "s", "spaceName": "Plan"}}}},
|
||||
{"result": map[string]any{"result": []any{map[string]any{"id": "i", "title": "Roadmap"}}}},
|
||||
} {
|
||||
if _, err := wikiNewDocExtractSpaces(data); err != nil {
|
||||
t.Fatalf("valid response rejected: %#v: %v", data, err)
|
||||
}
|
||||
}
|
||||
|
||||
if got := wikiNewDocFirstString(map[string]any{"nodeId": " n "}, "nodeId"); got != "n" {
|
||||
t.Fatalf("direct node id=%q", got)
|
||||
}
|
||||
if got := wikiNewDocFirstString(map[string]any{"result": map[string]any{"fileId": " f "}}, "nodeId", "fileId"); got != "f" {
|
||||
t.Fatalf("nested node id=%q", got)
|
||||
}
|
||||
if got := wikiNewDocFirstString(map[string]any{"data": map[string]any{"id": " i "}}, "id"); got != "i" {
|
||||
t.Fatalf("data node id=%q", got)
|
||||
}
|
||||
if got := wikiNewDocFirstString(map[string]any{"id": 1}, "id"); got != "" {
|
||||
t.Fatalf("invalid node id=%q", got)
|
||||
}
|
||||
labels := wikiNewDocLabels([]wikiSpaceCandidate{{id: "w1", name: "Docs"}, {id: "w2", name: "Plan"}})
|
||||
if strings.Join(labels, ",") != "Docs(w1),Plan(w2)" {
|
||||
t.Fatalf("labels=%v", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiNewDocResolution(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
data map[string]any
|
||||
spaceName string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"parser error", nil, "Docs", "", false},
|
||||
{"zero", map[string]any{"wikiSpaces": []any{}}, "Docs", "", false},
|
||||
{"unique exact", map[string]any{"wikiSpaces": []any{map[string]any{"workspaceId": "w1", "name": " docs "}, map[string]any{"workspaceId": "w2", "name": "Plan"}}}, "Docs", "w1", true},
|
||||
{"unique fallback", map[string]any{"wikiSpaces": []any{map[string]any{"workspaceId": "w1", "name": "Docs Team"}}}, "Docs", "w1", true},
|
||||
{"multiple", map[string]any{"wikiSpaces": []any{map[string]any{"workspaceId": "w1", "name": "Docs 1"}, map[string]any{"workspaceId": "w2", "name": "Docs 2"}}}, "Docs", "", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := wikiNewDocResolveSpaceID(tc.data, tc.spaceName)
|
||||
if (err == nil) != tc.ok || got != tc.want {
|
||||
t.Fatalf("got=%q err=%v want=%q ok=%v", got, err, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiNewDocExecution(t *testing.T) {
|
||||
if err := runShortcutErr(t, &stubMailboxCaller{}, "wiki", "+wiki-new-doc", "--space", " ", "--title", "Doc"); err == nil {
|
||||
t.Fatal("blank space succeeded")
|
||||
}
|
||||
if err := runShortcutErr(t, &stubMailboxCaller{}, "wiki", "+wiki-new-doc", "--space", "Docs", "--title", " "); err == nil {
|
||||
t.Fatal("blank title succeeded")
|
||||
}
|
||||
|
||||
validSearch := `{"wikiSpaces":[{"workspaceId":"w","name":"Docs"}]}`
|
||||
cases := []struct {
|
||||
name string
|
||||
fake *stubMailboxCaller
|
||||
args []string
|
||||
ok bool
|
||||
}{
|
||||
{"search error", &stubMailboxCaller{errTool: "search_wikiSpaces"}, nil, false},
|
||||
{"resolve error", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": `{"wikiSpaces":[]}`}}, nil, false},
|
||||
{"dry run", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch}}, []string{"--dry-run"}, true},
|
||||
{"create error", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch}, errTool: "create_file"}, nil, false},
|
||||
{"missing terminal success", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"result":{"fileId":"n"}}`}}, nil, false},
|
||||
{"missing created id", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true}`}}, nil, false},
|
||||
{"readback error", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`}, errTool: "get_document_info"}, nil, false},
|
||||
{"readback failed", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`, "get_document_info": `{"success":false}`}}, nil, false},
|
||||
{"readback malformed success", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`, "get_document_info": `{"success":"yes"}`}}, nil, false},
|
||||
{"readback id mismatch", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"fileId":"n"}`, "get_document_info": `{"success":true,"nodeId":"other"}`}}, nil, false},
|
||||
{"verified", &stubMailboxCaller{byTool: map[string]string{"search_wikiSpaces": validSearch, "create_file": `{"success":true,"data":{"fileId":"n"}}`, "get_document_info": `{"success":true,"result":{"nodeId":"n"}}`}}, nil, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := []string{"wiki", "+wiki-new-doc", "--space", "Docs", "--title", "Doc", "--format", "json"}
|
||||
args = append(args, tc.args...)
|
||||
err := runShortcutErr(t, tc.fake, args...)
|
||||
if (err == nil) != tc.ok {
|
||||
t.Fatalf("success=%v want %v; err=%v", err == nil, tc.ok, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const wikiCompositeReason = "Reviewed Wiki Shortcut composite: the executable CLI owns strict response validation, pagination projection, optional multi-step orchestration, read-back verification, and confirmation; no single MCP interface represents the complete command contract."
|
||||
|
||||
const wikiSpaceSearchCompositeReason = "Reviewed Wiki space-search compatibility adapter: the published workflow properties query/limit remain stable while execution translates them to search_wikiSpaces.keyword/pageSize; redirecting an existing non-empty property requires a versioned Schema migration."
|
||||
|
||||
func wikiContract(command, description, useWhen string, avoidWhen, examples []string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params ...contract.ParamDecl) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||
cliPath := "wiki " + command
|
||||
return corecmd.ContractDecl{
|
||||
Description: description, Parameters: params, Result: result, Pagination: pagination,
|
||||
Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: wikiCompositeReason},
|
||||
Selection: contract.SelectionSpec{AgentSummary: description, UseWhen: []string{useWhen}, AvoidWhen: avoidWhen, Examples: examples},
|
||||
Identity: contract.ToolIdentitySpec{ProductID: "wiki", Name: name, CanonicalPath: "wiki." + name, CLIPath: cliPath, PrimaryCLIPath: cliPath},
|
||||
}
|
||||
}
|
||||
|
||||
func wikiWithInterfaceReason(declared shortcut.Shortcut, reason string) shortcut.Shortcut {
|
||||
declared.Contract.Interface.Reason = reason
|
||||
return declared
|
||||
}
|
||||
|
||||
func wikiObjectResult(description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"additionalProperties":true}`, description))}
|
||||
}
|
||||
|
||||
func wikiCollectionResult(collection, description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess}, DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"有效结果数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"Wiki 业务条目","additionalProperties":true}},"nextCursor":{"type":"string","description":"下一页游标"},"hasMore":{"type":"boolean","description":"服务端是否仍有下一页"}},"required":["count",%q],"additionalProperties":true}`,
|
||||
description, collection, description, collection))}
|
||||
}
|
||||
|
||||
func wikiCursorPagination() *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor", MetaPath: contract.PaginationMetaPath, EndpointExhaustedPath: contract.PaginationExhaustedPath, NextTokenPath: contract.PaginationNextTokenPath}
|
||||
}
|
||||
|
||||
func requireWikiResponse(data map[string]any, operation string) (map[string]any, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, wikiResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功")
|
||||
}
|
||||
if value, present := data["success"]; present {
|
||||
success, ok := value.(bool)
|
||||
if !ok {
|
||||
return nil, wikiResponseError(operation, "malformed_success", "响应 success 字段不是布尔值")
|
||||
}
|
||||
if !success {
|
||||
message := firstWikiString(data, "errorMsg", "message", "error")
|
||||
if message == "" {
|
||||
message = "服务明确返回 success=false"
|
||||
}
|
||||
return nil, wikiResponseError(operation, "remote_failure", message)
|
||||
}
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func requireWikiWrite(data map[string]any, operation string) (map[string]any, error) {
|
||||
data, err := requireWikiResponse(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if success, ok := data["success"].(bool); !ok || !success {
|
||||
return nil, wikiResponseError(operation, "missing_terminal_success", "写操作响应没有 success=true 终态证据")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func requireWikiObject(data map[string]any, operation string) (map[string]any, error) {
|
||||
data, err := requireWikiResponse(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if value, present := data[wrapper]; present {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok || len(object) == 0 {
|
||||
return nil, wikiResponseError(operation, "malformed_object", "响应业务对象缺失或畸形")
|
||||
}
|
||||
return object, nil
|
||||
}
|
||||
}
|
||||
if len(data) == 1 {
|
||||
return nil, wikiResponseError(operation, "missing_business_result", "响应没有可验证的业务对象")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func requireWikiCollection(data map[string]any, operation string, keys ...string) ([]any, map[string]any, error) {
|
||||
data, err := requireWikiResponse(data, operation)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
containers := []map[string]any{data}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if value, present := data[wrapper]; present {
|
||||
inner, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil, wikiResponseError(operation, "malformed_envelope", fmt.Sprintf("响应 %s 字段不是对象", wrapper))
|
||||
}
|
||||
containers = append(containers, inner)
|
||||
}
|
||||
}
|
||||
for _, container := range containers {
|
||||
for _, key := range keys {
|
||||
value, present := container[key]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
items, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, nil, wikiResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key))
|
||||
}
|
||||
for index, item := range items {
|
||||
if _, ok := item.(map[string]any); !ok {
|
||||
return nil, nil, wikiResponseError(operation, "malformed_collection_item", fmt.Sprintf("响应 %s[%d] 不是对象", key, index))
|
||||
}
|
||||
}
|
||||
return items, container, nil
|
||||
}
|
||||
}
|
||||
return nil, nil, wikiResponseError(operation, "missing_collection", "响应缺少声明的业务数组;不能把缺字段或内部错误投影成空结果")
|
||||
}
|
||||
|
||||
func projectWikiRows(items []any, aliases map[string][]string) []map[string]any {
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
source := item.(map[string]any)
|
||||
row := make(map[string]any)
|
||||
for canonical, candidates := range aliases {
|
||||
for _, candidate := range candidates {
|
||||
if value, ok := source[candidate]; ok && value != nil {
|
||||
row[canonical] = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func addWikiPagination(out, page map[string]any) {
|
||||
for _, pair := range [][2]string{{"nextCursor", "nextCursor"}, {"nextToken", "nextCursor"}, {"nextPageToken", "nextCursor"}, {"pageToken", "nextCursor"}, {"hasMore", "hasMore"}, {"truncated", "truncated"}, {"totalCount", "totalCount"}, {"autoPageComplete", "autoPageComplete"}, {"autoPageStopReason", "autoPageStopReason"}, {"pagesFetched", "pagesFetched"}} {
|
||||
if value, ok := page[pair[0]]; ok && value != nil {
|
||||
out[pair[1]] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstWikiString(data map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nestedWikiString(data map[string]any, keys ...string) string {
|
||||
if value := firstWikiString(data, keys...); value != "" {
|
||||
return value
|
||||
}
|
||||
for _, wrapper := range []string{"result", "data"} {
|
||||
if inner, ok := data[wrapper].(map[string]any); ok {
|
||||
if value := firstWikiString(inner, keys...); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func wikiStringInt(rt *shortcut.RuntimeContext, name string, fallback, min, max int) (int, error) {
|
||||
raw := rt.Str(name)
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < min || value > max {
|
||||
return 0, fmt.Errorf("--%s 必须是 %d-%d 之间的整数", name, min, max)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func wikiStringSliceFirst(rt *shortcut.RuntimeContext, primary string, aliases ...string) []string {
|
||||
if rt.Changed(primary) {
|
||||
return rt.StrSlice(primary)
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
if rt.Changed(alias) {
|
||||
values, _ := rt.Command().Flags().GetStringSlice(alias)
|
||||
return values
|
||||
}
|
||||
}
|
||||
return rt.StrSlice(primary)
|
||||
}
|
||||
|
||||
func wikiResponseError(operation, reason, message string) error {
|
||||
return apperrors.NewAPI(message, apperrors.WithOperation(operation), apperrors.WithOrigin("mcp"), apperrors.WithFailureStage("response_validation"), apperrors.WithRetryable(false), apperrors.WithReason(reason))
|
||||
}
|
||||
|
||||
func wikiReadSafety() contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
|
||||
}
|
||||
func wikiWriteSafety(confirm bool) contract.SafetySpec {
|
||||
confirmation := "not_required"
|
||||
if confirm {
|
||||
confirmation = "user_required"
|
||||
}
|
||||
return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: confirmation, Idempotency: "unknown"}
|
||||
}
|
||||
func wikiDeleteSafety() contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"}
|
||||
}
|
||||
|
||||
func wikiAutoPageFlags() []shortcut.Flag {
|
||||
const evidence = "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"
|
||||
return append([]shortcut.Flag{{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动沿游标取完所有页。" + evidence}, {Name: "page-limit", Type: shortcut.FlagInt, Default: "20", Desc: "自动翻页最多请求页数。" + evidence}}, shortcut.AutoPageControlFlags()...)
|
||||
}
|
||||
|
||||
func enableWikiAutoPage(item *shortcut.Shortcut) {
|
||||
item.Flags = append(item.Flags, wikiAutoPageFlags()...)
|
||||
item.Constraints = append(item.Constraints, shortcut.AutoPageControlConstraints()...)
|
||||
item.Validate = func(rt *shortcut.RuntimeContext) error {
|
||||
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.Bool("page-all") && rt.Int("page-limit") < 1 {
|
||||
return fmt.Errorf("--page-limit 必须大于 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type wikiPageFetcher func(cursor string, pageSize int) (map[string]any, error)
|
||||
|
||||
func collectWikiPages(rt *shortcut.RuntimeContext, operation string, pageSize int, keys []string, fetch wikiPageFetcher) ([]any, map[string]any, error) {
|
||||
if !rt.Bool("page-all") {
|
||||
data, err := fetch(rt.StrFirst("cursor", "page-token"), pageSize)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return requireWikiCollection(data, operation, keys...)
|
||||
}
|
||||
all := make([]any, 0)
|
||||
cursor := rt.StrFirst("cursor", "page-token")
|
||||
lastPage := map[string]any{}
|
||||
for pageNumber := 1; pageNumber <= rt.Int("page-limit"); pageNumber++ {
|
||||
requestSize := shortcut.AutoPageRequestSize(rt, pageSize, len(all))
|
||||
data, err := fetch(cursor, requestSize)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
items, page, err := requireWikiCollection(data, operation, keys...)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
maxItems := rt.Int("max-items")
|
||||
if maxItems > 0 {
|
||||
remaining := maxItems - len(all)
|
||||
if len(items) > remaining {
|
||||
items = items[:remaining]
|
||||
}
|
||||
}
|
||||
all = append(all, items...)
|
||||
lastPage = page
|
||||
if maxItems > 0 && len(all) >= maxItems {
|
||||
lastPage["autoPageComplete"] = false
|
||||
lastPage["autoPageStopReason"] = "max_items"
|
||||
lastPage["pagesFetched"] = pageNumber
|
||||
return all, lastPage, nil
|
||||
}
|
||||
hasMore, present := page["hasMore"]
|
||||
if !present {
|
||||
return nil, nil, wikiResponseError(operation, "missing_has_more", "--page-all 要求每页响应提供 hasMore 布尔值")
|
||||
}
|
||||
more, ok := hasMore.(bool)
|
||||
if !ok {
|
||||
return nil, nil, wikiResponseError(operation, "malformed_has_more", "分页响应 hasMore 不是布尔值")
|
||||
}
|
||||
if !more {
|
||||
lastPage["autoPageComplete"] = true
|
||||
lastPage["pagesFetched"] = pageNumber
|
||||
return all, lastPage, nil
|
||||
}
|
||||
next := firstWikiString(page, "nextCursor", "nextToken", "nextPageToken", "pageToken")
|
||||
if next == "" {
|
||||
return nil, nil, wikiResponseError(operation, "missing_next_cursor", "hasMore=true 但响应缺少下一页游标")
|
||||
}
|
||||
if next == cursor {
|
||||
return nil, nil, wikiResponseError(operation, "stalled_cursor", "下一页游标未变化,已停止以避免死循环")
|
||||
}
|
||||
cursor = next
|
||||
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
return nil, nil, wikiResponseError(operation, "page_limit_reached", "达到 --page-limit 时服务端仍有下一页;提高页数上限或使用返回游标续传")
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
var NodeList = readShortcut("+node-list", "严格分页列出知识库节点", "浏览知识库根目录或指定文件夹;只有显式 nodes:[] 才表示空目录,并完整保留 nextCursor/hasMore。", "nodes", "dws wiki +node-list --workspace <workspaceId> --format json", []shortcut.Flag{
|
||||
{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "父节点 ID"}, {Name: "limit", Type: shortcut.FlagInt, Default: "50", Desc: "每页数量 1-50"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true},
|
||||
}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "folder", Property: "folderId"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
items, page, err := collectWikiPages(rt, "wiki/list_nodes", rt.Int("limit"), []string{"nodes", "items", "list"}, func(cursor string, size int) (map[string]any, error) {
|
||||
params := map[string]any{"workspaceId": rt.Str("workspace"), "pageSize": size}
|
||||
if rt.Changed("folder") {
|
||||
params["folderId"] = rt.Str("folder")
|
||||
}
|
||||
if cursor != "" {
|
||||
params["pageToken"] = cursor
|
||||
}
|
||||
return rt.CallMCPData("doc", "list_nodes", params)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodes := projectWikiRows(items, nodeAliases())
|
||||
out := map[string]any{"count": len(nodes), "nodes": nodes}
|
||||
addWikiPagination(out, page)
|
||||
return rt.Output(out)
|
||||
})
|
||||
|
||||
func nodeAliases() map[string][]string {
|
||||
return map[string][]string{"nodeId": {"nodeId", "id", "dentryUuid", "fileId"}, "name": {"name", "title", "nodeName", "fileName"}, "type": {"type", "nodeType", "docType", "fileType"}, "contentType": {"contentType"}, "folderId": {"folderId", "parentId"}, "workspaceId": {"workspaceId", "spaceId"}, "url": {"docUrl", "url", "webUrl"}}
|
||||
}
|
||||
|
||||
var NodeGet = readShortcut("+node-get", "获取知识库节点详情", "已知节点 ID 或在线文档 URL 时读取元数据,并在节点信息之外统一返回文档/文件属性。", "", "dws wiki +node-get --node <nodeId> --format json", []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID 或 URL"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
object, err := requireWikiObject(data, "doc/get_document_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if firstWikiString(object, "nodeId", "id", "fileId") == "" {
|
||||
return wikiResponseError("doc/get_document_info", "missing_node_id", "节点详情缺少 nodeId")
|
||||
}
|
||||
return rt.Output(object)
|
||||
})
|
||||
|
||||
var NodeSearch = readShortcut("+node-search", "严格搜索知识库节点", "在指定知识库内按关键词和扩展名搜索节点;零命中必须来自显式 documents:[]。", "nodes", "dws wiki +node-search --workspace <workspaceId> --query \"方案\" --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "query", Type: shortcut.FlagString, Required: true, Desc: "关键词"}, {Name: "extensions", Type: shortcut.FlagStringSlice, Desc: "扩展名过滤"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量 1-30"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceIds"}, {Name: "query", Property: "keyword"}, {Name: "extensions", Property: "extensions"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"workspaceIds": []string{rt.Str("workspace")}, "keyword": rt.Str("query"), "pageSize": rt.Int("limit")}
|
||||
if rt.Changed("extensions") {
|
||||
params["extensions"] = rt.StrSlice("extensions")
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["pageToken"] = rt.Str("cursor")
|
||||
}
|
||||
data, err := rt.CallMCPData("doc", "search_documents", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, page, err := requireWikiCollection(data, "doc/search_documents", "documents", "docs", "nodes", "items", "list")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodes := projectWikiRows(items, nodeAliases())
|
||||
out := map[string]any{"count": len(nodes), "nodes": nodes}
|
||||
addWikiPagination(out, page)
|
||||
return rt.Output(out)
|
||||
})
|
||||
|
||||
var NodeCreate = writeShortcut("+node-create", "创建知识库节点并读回验证", "在知识库根目录或文件夹中创建文档、表格、白板、脑图或文件夹;取得 nodeId 并读回后才成功。", "dws wiki +node-create --workspace <workspaceId> --name \"新文档\" --format json", shortcut.RiskWrite, wikiWriteSafety(false), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID"}, {Name: "name", Type: shortcut.FlagString, Required: true, Desc: "节点名称"}, {Name: "type", Type: shortcut.FlagString, Default: "adoc", Desc: "节点类型", Enum: []string{"adoc", "axls", "able", "appt", "adraw", "amind", "folder"}}, {Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "name", Property: "name"}, {Name: "type", Property: "type"}, {Name: "folder", Property: "folderId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"workspaceId": rt.Str("workspace"), "name": rt.Str("name"), "type": rt.Str("type")}
|
||||
if rt.Changed("folder") {
|
||||
params["folderId"] = rt.Str("folder")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/create_file", "arguments": params})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("doc", "create_file", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err = requireWikiWrite(written, "doc/create_file")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := nestedWikiString(written, "nodeId", "fileId", "id")
|
||||
if id == "" {
|
||||
return wikiResponseError("doc/create_file", "missing_created_id", "创建响应没有 nodeId;远端效果未知")
|
||||
}
|
||||
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireWikiObject(verified, "doc/get_document_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if firstWikiString(verified, "nodeId", "id", "fileId") != id {
|
||||
return wikiResponseError("doc/create_file", "readback_id_mismatch", "创建后读回节点 ID 不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": id, "node": verified})
|
||||
})
|
||||
|
||||
var NodeCopy = writeShortcut("+node-copy", "复制知识库节点并读回验证", "复制现有在线节点到目标知识库/文件夹;高风险确认后要求新 nodeId 并读取副本元数据。", "dws wiki +node-copy --workspace <workspaceId> --node <nodeId> --format json", shortcut.RiskHighWrite, contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "non_idempotent"}, []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "目标知识库 ID"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "源节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"workspaceId": rt.Str("workspace"), "nodeId": rt.Str("node")}
|
||||
if rt.Changed("folder") {
|
||||
params["targetFolderId"] = rt.Str("folder")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/copy_document", "arguments": params})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("doc", "copy_document", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err = requireWikiWrite(written, "doc/copy_document")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := nestedWikiString(written, "nodeId", "fileId", "id")
|
||||
if id == "" {
|
||||
return wikiResponseError("doc/copy_document", "missing_created_id", "复制响应没有新 nodeId;远端效果未知")
|
||||
}
|
||||
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireWikiObject(verified, "doc/get_document_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if firstWikiString(verified, "nodeId", "id", "fileId") != id {
|
||||
return wikiResponseError("doc/copy_document", "readback_id_mismatch", "复制后读回节点 ID 不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "sourceNodeId": rt.Str("node"), "nodeId": id, "copy": verified})
|
||||
})
|
||||
|
||||
func executeMove(rt *shortcut.RuntimeContext, toDrive bool) error {
|
||||
preflight, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preflight, err = requireWikiObject(preflight, "doc/get_document_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
beforeWorkspace := firstWikiString(preflight, "workspaceId", "spaceId")
|
||||
params := map[string]any{"nodeId": rt.Str("node")}
|
||||
if !toDrive {
|
||||
params["workspaceId"] = rt.Str("workspace")
|
||||
}
|
||||
if rt.Changed("folder") {
|
||||
params["targetFolderId"] = rt.Str("folder")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/move_document", "arguments": params, "target": preflight})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("doc", "move_document", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = requireWikiWrite(written, "doc/move_document"); err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireWikiObject(verified, "doc/get_document_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if firstWikiString(verified, "nodeId", "id", "fileId") != rt.Str("node") {
|
||||
return wikiResponseError("doc/move_document", "readback_id_mismatch", "移动后读回节点 ID 不一致")
|
||||
}
|
||||
afterWorkspace := firstWikiString(verified, "workspaceId", "spaceId")
|
||||
if toDrive {
|
||||
if beforeWorkspace == "" || afterWorkspace == "" || beforeWorkspace == afterWorkspace {
|
||||
return wikiResponseError("doc/move_document", "drive_move_not_verified", "移动到我的文档后 workspace 未发生可验证变化")
|
||||
}
|
||||
} else if afterWorkspace != rt.Str("workspace") {
|
||||
return wikiResponseError("doc/move_document", "workspace_readback_mismatch", "移动后读回的目标知识库不一致")
|
||||
}
|
||||
if rt.Changed("folder") && firstWikiString(verified, "folderId", "parentId") != rt.Str("folder") {
|
||||
return wikiResponseError("doc/move_document", "folder_readback_mismatch", "移动后读回的目标文件夹不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "node": verified})
|
||||
}
|
||||
|
||||
var Move = writeShortcut("+move", "移动节点到知识库并读回验证", "将 Wiki 节点或我的文档在线节点移动到目标知识库/文件夹;同一入口覆盖库内移动与在线文档入库场景。", "dws wiki +move --workspace <workspaceId> --node <nodeId> --format json", shortcut.RiskWrite, wikiWriteSafety(true), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "目标知识库 ID"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 ID"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { return executeMove(rt, false) })
|
||||
|
||||
var MoveToDrive = writeShortcut("+move-to-drive", "移动 Wiki 节点到我的文档", "将 Wiki 在线节点同步移动到我的文档或指定文件夹,并以元数据读回证明任务完成。", "dws wiki +move-to-drive --node <nodeId> --format json", shortcut.RiskWrite, wikiWriteSafety(true), []shortcut.Flag{{Name: "node", Type: shortcut.FlagString, Required: true, Desc: "Wiki 节点 ID"}, {Name: "folder", Type: shortcut.FlagString, Desc: "我的文档目标文件夹 ID"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}, {Name: "folder", Property: "targetFolderId"}}, func(rt *shortcut.RuntimeContext) error { return executeMove(rt, true) })
|
||||
|
||||
var NodeDelete = writeShortcut("+node-delete", "删除知识库节点", "明确确认后将节点移入回收站;先读取目标,且删除响应必须提供 success=true 终态证据。", "dws wiki +node-delete --workspace <workspaceId> --node <nodeId> --format json", shortcut.RiskHighWrite, wikiDeleteSafety(), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID,用于确认影响范围"}, {Name: "node", Type: shortcut.FlagString, Required: true, Desc: "节点 ID"}}, []contract.ParamDecl{{Name: "node", Property: "nodeId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
preflight, err := rt.CallMCPData("doc", "get_document_info", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preflight, err = requireWikiObject(preflight, "doc/get_document_info")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if workspace := firstWikiString(preflight, "workspaceId", "spaceId"); workspace != "" && workspace != rt.Str("workspace") {
|
||||
return wikiResponseError("doc/delete_document", "workspace_preflight_mismatch", "节点不属于请求确认的知识库")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "doc/delete_document", "target": preflight})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("doc", "delete_document", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = requireWikiWrite(written, "doc/delete_document"); err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "nodeId": rt.Str("node"), "deleted": true})
|
||||
})
|
||||
|
||||
var FeedList = readShortcut("+feed-list", "严格分页列出知识库动态", "查看谁在何时创建、更新或评论了知识库内容;严格验证 feeds 数组并保留游标。", "feeds", "dws wiki +feed-list --workspace <workspaceId> --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "limit", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量 1-20"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, {Name: "exclude-file", Type: shortcut.FlagBool, Desc: "排除普通文件动态"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "limit", Property: "maxResults"}, {Name: "cursor", Property: "nextToken"}, {Name: "exclude-file", Property: "excludeFile"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
items, page, err := collectWikiPages(rt, "wiki/list_workspace_feeds", rt.Int("limit"), []string{"feeds", "items", "list"}, func(cursor string, size int) (map[string]any, error) {
|
||||
params := map[string]any{"workspaceId": rt.Str("workspace"), "maxResults": size}
|
||||
if cursor != "" {
|
||||
params["nextToken"] = cursor
|
||||
}
|
||||
if rt.Changed("exclude-file") {
|
||||
params["excludeFile"] = rt.Bool("exclude-file")
|
||||
}
|
||||
return rt.CallMCPData("wiki", "list_workspace_feeds", params)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
feeds := projectWikiRows(items, map[string][]string{"id": {"id", "feedId"}, "type": {"type", "feedType", "action"}, "time": {"time", "createTime"}, "name": {"name", "title", "fileName"}, "nodeId": {"nodeId", "fileId"}})
|
||||
out := map[string]any{"count": len(feeds), "feeds": feeds}
|
||||
addWikiPagination(out, page)
|
||||
return rt.Output(out)
|
||||
})
|
||||
|
||||
func init() {
|
||||
Move.Aliases = []string{"+node-move"}
|
||||
for _, item := range []*shortcut.Shortcut{&NodeList, &FeedList} {
|
||||
enableWikiAutoPage(item)
|
||||
}
|
||||
for _, item := range []*shortcut.Shortcut{&NodeList, &NodeSearch, &FeedList} {
|
||||
item.Contract.Pagination = wikiCursorPagination()
|
||||
}
|
||||
shortcut.Register(NodeList, NodeGet, NodeSearch, NodeCreate, NodeCopy, Move, MoveToDrive, NodeDelete, FeedList)
|
||||
_ = fmt.Sprintf
|
||||
_ = output.RolloutUnifiedActive
|
||||
}
|
||||
@@ -18,11 +18,11 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSpaceListProjectWikiSpacesShape guards against projection-data-loss:
|
||||
// TestCrossPlatformCoverageWikiSpaceListShape guards against projection-data-loss:
|
||||
// list_wikiSpaces / search_wikiSpaces nest the list under result.wikiSpaces;
|
||||
// the resolver must probe "wikiSpaces" or +space-list / +space-search silently
|
||||
// return empty despite the backend returning spaces.
|
||||
func TestSpaceListProjectWikiSpacesShape(t *testing.T) {
|
||||
func TestCrossPlatformCoverageWikiSpaceListShape(t *testing.T) {
|
||||
const raw = `{"result":{"hasMore":false,"wikiSpaces":[
|
||||
{"workspaceId":"w1","name":"R&D wiki"},
|
||||
{"workspaceId":"w2","name":"product wiki"}
|
||||
@@ -31,19 +31,47 @@ func TestSpaceListProjectWikiSpacesShape(t *testing.T) {
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if spaces := spaceListProject(data); len(spaces) != 2 {
|
||||
items, _, err := requireWikiCollection(data, "wiki/list_wikiSpaces", "wikiSpaces")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId"}, "name": {"name"}})
|
||||
if len(spaces) != 2 {
|
||||
t.Fatalf("lower/upper mismatch: result.wikiSpaces has 2 entries, projection returned %d (%v)", len(spaces), spaces)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpaceListProjectTopLevelWikiSpaces covers the already-unwrapped shape.
|
||||
func TestSpaceListProjectTopLevelWikiSpaces(t *testing.T) {
|
||||
func TestCrossPlatformCoverageWikiSpaceListTopLevelShape(t *testing.T) {
|
||||
const raw = `{"wikiSpaces":[{"workspaceId":"w1","name":"R&D wiki"}]}`
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
t.Fatalf("unmarshal fixture: %v", err)
|
||||
}
|
||||
if spaces := spaceListProject(data); len(spaces) != 1 {
|
||||
items, _, err := requireWikiCollection(data, "wiki/list_wikiSpaces", "wikiSpaces")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId"}, "name": {"name"}})
|
||||
if len(spaces) != 1 {
|
||||
t.Fatalf("top-level wikiSpaces: want 1, got %d (%v)", len(spaces), spaces)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiCollectionsRejectFalseEmptySuccess(t *testing.T) {
|
||||
for name, data := range map[string]map[string]any{
|
||||
"missing": {"success": true, "hasMore": false},
|
||||
"malformed": {"success": true, "wikiSpaces": map[string]any{}},
|
||||
"bad item": {"success": true, "wikiSpaces": []any{"not-an-object"}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, _, err := requireWikiCollection(data, "wiki/list_wikiSpaces", "wikiSpaces"); err == nil {
|
||||
t.Fatal("malformed response was accepted as an empty success")
|
||||
}
|
||||
})
|
||||
}
|
||||
items, _, err := requireWikiCollection(map[string]any{"success": true, "wikiSpaces": []any{}}, "wiki/list_wikiSpaces", "wikiSpaces")
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("explicit empty array must remain valid: items=%v err=%v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
+209
-316
@@ -1,344 +1,237 @@
|
||||
// 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.
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package wiki declares high-fidelity shortcuts for the DingTalk wiki
|
||||
// (knowledge base) service: space management, member management and node
|
||||
// management. Tool names and parameters mirror internal/helpers/wiki.go.
|
||||
// Package wiki declares reviewed, truth-preserving shortcuts for Wiki spaces,
|
||||
// members, nodes, and activity feeds.
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
// ── space (知识库) ────────────────────────────────────────────
|
||||
var collectionAvoid = []string{"需要原始 MCP 响应或未公开底层参数时改用对应原子命令;缺失业务数组不是合法空结果"}
|
||||
|
||||
// SpaceCreate → create_wikiSpace
|
||||
// SpaceGet → get_wikiSpace
|
||||
// SpaceList → list_wikiSpaces
|
||||
var SpaceList = shortcut.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+space-list",
|
||||
Product: "wiki",
|
||||
Description: "列出组织 / 个人知识库",
|
||||
Intent: "当你想浏览自己有权限访问的知识库、拿到目标知识库的 workspaceId 却不确定具体名称时使用;可按类型(组织知识库或我的知识库)分页列出,返回知识库列表,是定位知识库的常用入口。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "orgWikiSpace", Desc: "知识库类型: orgWikiSpace(默认) / myWikiSpace", Enum: []string{"orgWikiSpace", "myWikiSpace"}},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页数量 1-50 (默认 20)"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (首页留空)"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws wiki +space-list`,
|
||||
`dws wiki +space-list --type myWikiSpace`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Changed("type") {
|
||||
params["wikiSpaceType"] = rt.Str("type")
|
||||
func readShortcut(command, description, intent, collection, example string, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
|
||||
result := wikiObjectResult(description)
|
||||
if collection != "" {
|
||||
result = wikiCollectionResult(collection, description)
|
||||
}
|
||||
return shortcut.Shortcut{OutputRollout: output.RolloutUnifiedActive, Service: "wiki", Command: command, Product: "wiki", Description: description, Intent: intent, Risk: shortcut.RiskRead, Safety: wikiReadSafety(), Contract: wikiContract(command, description, intent, collectionAvoid, []string{example}, result, nil, params...), Flags: flags, Execute: execute}
|
||||
}
|
||||
|
||||
func writeShortcut(command, description, intent, example string, risk shortcut.Risk, safety contract.SafetySpec, flags []shortcut.Flag, params []contract.ParamDecl, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut {
|
||||
return shortcut.Shortcut{OutputRollout: output.RolloutUnifiedActive, Service: "wiki", Command: command, Product: "wiki", Description: description, Intent: intent, Risk: risk, Safety: safety, Contract: wikiContract(command, description, intent, []string{"只需读取或影响范围未确认时不要执行写操作"}, []string{example}, wikiObjectResult(description), nil, params...), Flags: flags, Execute: execute}
|
||||
}
|
||||
|
||||
var SpaceList = readShortcut("+space-list", "严格分页列出知识库", "浏览有权访问的组织或个人知识库,并保留服务端分页证据;只有显式 wikiSpaces:[] 才是空结果。", "spaces", "dws wiki +space-list --limit 20 --format json", []shortcut.Flag{
|
||||
{Name: "type", Type: shortcut.FlagString, Default: "orgWikiSpace", Desc: "知识库类型", Enum: []string{"orgWikiSpace", "myWikiSpace"}},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页数量 1-50(默认 20)"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标", Aliases: []string{"page-token"}, AliasesVisible: true},
|
||||
}, []contract.ParamDecl{{Name: "type", Property: "wikiSpaceType"}, {Name: "limit", Property: "pageSize"}, {Name: "cursor", Property: "pageToken"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
pageSize, err := wikiStringInt(rt, "limit", 20, 1, 50)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, page, err := collectWikiPages(rt, "wiki/list_wikiSpaces", pageSize, []string{"wikiSpaces", "spaces"}, func(cursor string, size int) (map[string]any, error) {
|
||||
params := map[string]any{"wikiSpaceType": rt.Str("type"), "pageSize": size}
|
||||
if cursor != "" {
|
||||
params["pageToken"] = cursor
|
||||
}
|
||||
if rt.Changed("limit") {
|
||||
params["pageSize"] = rt.Str("limit")
|
||||
return rt.CallMCPData("wiki", "list_wikiSpaces", params)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId", "spaceId", "id"}, "name": {"name", "spaceName", "title"}, "description": {"description", "desc"}, "createTime": {"createTime", "createdAt"}, "url": {"spaceUrl", "url"}})
|
||||
out := map[string]any{"count": len(spaces), "spaces": spaces}
|
||||
addWikiPagination(out, page)
|
||||
return rt.Output(out)
|
||||
})
|
||||
|
||||
var SpaceSearch = wikiWithInterfaceReason(readShortcut("+space-search", "严格搜索知识库", "按名称关键词定位知识库;严格验证搜索数组,避免内部异常被误报为零命中。", "spaces", "dws wiki +space-search --query \"产品文档\" --format json", []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Required: true, Desc: "搜索关键词"}, {Name: "limit", Type: shortcut.FlagString, Desc: "返回数量 1-20(默认 10)"},
|
||||
}, []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "limit", Property: "limit"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
pageSize, err := wikiStringInt(rt, "limit", 10, 1, 20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rt.CallMCPData("wiki", "search_wikiSpaces", map[string]any{"keyword": rt.Str("query"), "pageSize": pageSize})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, page, err := requireWikiCollection(data, "wiki/search_wikiSpaces", "wikiSpaces", "spaces")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spaces := projectWikiRows(items, map[string][]string{"workspaceId": {"workspaceId", "spaceId", "id"}, "name": {"name", "spaceName", "title"}, "description": {"description", "desc"}, "url": {"spaceUrl", "url"}})
|
||||
out := map[string]any{"count": len(spaces), "spaces": spaces}
|
||||
addWikiPagination(out, page)
|
||||
return rt.Output(out)
|
||||
}), wikiSpaceSearchCompositeReason)
|
||||
|
||||
var SpaceGet = readShortcut("+space-get", "获取知识库详情", "已知 workspace ID 或知识库 URL 时读取并验证空间详情。", "", "dws wiki +space-get --workspace <workspaceId> --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
object, err := requireWikiObject(data, "wiki/get_wikiSpace")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if firstWikiString(object, "workspaceId", "spaceId", "id") == "" {
|
||||
return wikiResponseError("wiki/get_wikiSpace", "missing_workspace_id", "空间详情缺少 workspaceId")
|
||||
}
|
||||
return rt.Output(object)
|
||||
})
|
||||
|
||||
var SpaceCreate = writeShortcut("+space-create", "创建知识库并读回验证", "创建新的知识库容器;必须取得 workspaceId 并通过详情读回后才报告成功。", "dws wiki +space-create --name \"产品文档库\" --format json", shortcut.RiskWrite, wikiWriteSafety(false), []shortcut.Flag{{Name: "name", Type: shortcut.FlagString, Required: true, Desc: "知识库名称,不超过 32 个字符。--name 不超过 32 个字符;--desc 不超过 500 个字符"}, {Name: "desc", Type: shortcut.FlagString, Desc: "知识库描述,不超过 500 个字符。--name 不超过 32 个字符;--desc 不超过 500 个字符"}, {Name: "icon", Type: shortcut.FlagString, Desc: "图标标识"}}, []contract.ParamDecl{{Name: "name", Property: "name"}, {Name: "desc", Property: "description"}, {Name: "icon", Property: "icon"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"name": rt.Str("name")}
|
||||
if rt.Changed("desc") {
|
||||
params["description"] = rt.Str("desc")
|
||||
}
|
||||
if rt.Changed("icon") {
|
||||
params["icon"] = rt.Str("icon")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "wiki/create_wikiSpace", "arguments": params})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("wiki", "create_wikiSpace", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err = requireWikiWrite(written, "wiki/create_wikiSpace")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id := nestedWikiString(written, "workspaceId", "spaceId", "id")
|
||||
if id == "" {
|
||||
return wikiResponseError("wiki/create_wikiSpace", "missing_created_id", "创建响应没有 workspaceId;远端效果未知")
|
||||
}
|
||||
verified, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": id})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verified, err = requireWikiObject(verified, "wiki/get_wikiSpace")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if firstWikiString(verified, "workspaceId", "spaceId", "id") != id {
|
||||
return wikiResponseError("wiki/create_wikiSpace", "readback_id_mismatch", "创建后读回的 workspaceId 不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "workspaceId": id, "space": verified})
|
||||
})
|
||||
|
||||
var DeleteSpace = writeShortcut("+delete-space", "删除知识库", "用户明确确认后将整个知识库移入回收站;删除前读取目标,删除响应必须有 success=true。", "dws wiki +delete-space --workspace <workspaceId> --format json", shortcut.RiskHighWrite, wikiDeleteSafety(), []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
preflight, err := rt.CallMCPData("wiki", "get_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
preflight, err = requireWikiObject(preflight, "wiki/get_wikiSpace")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "wiki/delete_wikiSpace", "target": preflight})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("wiki", "delete_wikiSpace", map[string]any{"workspaceId": rt.Str("workspace")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err = requireWikiWrite(written, "wiki/delete_wikiSpace")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"success": true, "workspaceId": rt.Str("workspace"), "deleted": true})
|
||||
})
|
||||
|
||||
func memberFlags(withRole bool) []shortcut.Flag {
|
||||
flags := []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "users", Type: shortcut.FlagStringSlice, Required: true, Desc: "用户 userId,最多 30 个", Aliases: []string{"user"}, AliasesVisible: true}}
|
||||
if withRole {
|
||||
flags = append(flags, shortcut.Flag{Name: "role", Type: shortcut.FlagString, Required: true, Desc: "角色", Enum: []string{"MANAGER", "EDITOR", "DOWNLOADER", "READER"}})
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func memberWrite(command, tool, description, intent, example string, withRole bool) shortcut.Shortcut {
|
||||
params := []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "users", Property: "userIds"}}
|
||||
if withRole {
|
||||
params = append(params, contract.ParamDecl{Name: "role", Property: "roleId"})
|
||||
}
|
||||
return writeShortcut(command, description, intent, example, shortcut.RiskWrite, wikiWriteSafety(false), memberFlags(withRole), params, func(rt *shortcut.RuntimeContext) error {
|
||||
users := wikiStringSliceFirst(rt, "users", "user")
|
||||
if len(users) == 0 || len(users) > 30 {
|
||||
return fmt.Errorf("--users 必须包含 1-30 个 userId")
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["pageToken"] = rt.Str("cursor")
|
||||
args := map[string]any{"workspaceId": rt.Str("workspace"), "userIds": users}
|
||||
if withRole {
|
||||
args["roleId"] = strings.ToUpper(rt.Str("role"))
|
||||
}
|
||||
data, err := rt.CallMCPData("wiki", "list_wikiSpaces", params)
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"dryRun": true, "executed": false, "operation": "wiki/" + tool, "arguments": args})
|
||||
}
|
||||
written, err := rt.CallMCPWriteDataStrict("wiki", tool, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spaces := spaceListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(spaces), "spaces": spaces})
|
||||
},
|
||||
}
|
||||
|
||||
// spaceListProject reshapes list_wikiSpaces into a clean space list
|
||||
// ({workspaceId, name, description, createTime}) — output-projection fidelity
|
||||
// for clean output. The list container and per-item field names are probed defensively
|
||||
// across candidate keys, so an unrecognized shape yields an empty list.
|
||||
func spaceListProject(data map[string]any) []map[string]any {
|
||||
raw := wikiSpaceRawList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v := wikiSpaceFirst(m, "workspaceId", "workspace_id", "spaceId", "space_id", "id"); v != nil {
|
||||
row["workspaceId"] = v
|
||||
}
|
||||
if v := wikiSpaceFirst(m, "name", "title", "spaceName"); v != nil {
|
||||
row["name"] = v
|
||||
}
|
||||
if v := wikiSpaceFirst(m, "description", "desc"); v != nil {
|
||||
row["description"] = v
|
||||
}
|
||||
if v := wikiSpaceFirst(m, "createTime", "create_time", "gmtCreate", "createdAt"); v != nil {
|
||||
row["createTime"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wikiSpaceRawList locates the space array across candidate container keys,
|
||||
// tolerating a nested {result|data:{list|items|spaces}} wrapper.
|
||||
func wikiSpaceRawList(data map[string]any) []any {
|
||||
// list_wikiSpaces / search_wikiSpaces nest the space list under
|
||||
// result.wikiSpaces (or a top-level wikiSpaces once unwrapped); "wikiSpaces"
|
||||
// MUST be probed or +space-list / +space-search silently return empty.
|
||||
for _, k := range []string{"result", "data", "list", "items", "wikiSpaces", "spaces", "workspaces"} {
|
||||
if arr, ok := data[k].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := data[k].(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "wikiSpaces", "spaces", "workspaces", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// wikiSpaceFirst returns the first present value among candidate keys.
|
||||
func wikiSpaceFirst(m map[string]any, keys ...string) any {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SpaceSearch → search_wikiSpaces
|
||||
var SpaceSearch = shortcut.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+space-search",
|
||||
Product: "wiki",
|
||||
Description: "搜索知识库",
|
||||
Intent: "当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "wiki",
|
||||
Name: "shortcut_space_search",
|
||||
CanonicalPath: "wiki.shortcut_space_search",
|
||||
CLIPath: "wiki +space-search",
|
||||
PrimaryCLIPath: "wiki +space-search",
|
||||
},
|
||||
Description: "搜索知识库",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "搜索知识库",
|
||||
UseWhen: []string{"当你只记得知识库名称的部分关键词、想快速按名称定位某个知识库时使用;输入关键词返回匹配的知识库列表,比逐页 +space-list 更快找到目标 workspaceId。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws wiki +space-search --query \"产品文档\""},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "返回数量 1-20 (默认 10)"},
|
||||
},
|
||||
Tips: []string{`dws wiki +space-search --query "产品文档"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"keyword": rt.Str("query")}
|
||||
if rt.Changed("limit") {
|
||||
params["pageSize"] = rt.Str("limit")
|
||||
}
|
||||
data, err := rt.CallMCPData("wiki", "search_wikiSpaces", params)
|
||||
written, err = requireWikiWrite(written, "wiki/"+tool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spaces := spaceListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(spaces), "spaces": spaces})
|
||||
},
|
||||
return rt.Output(map[string]any{
|
||||
"success": true, "workspaceId": rt.Str("workspace"), "userCount": len(users), "operation": tool,
|
||||
"verifiedBy": "write_terminal_success",
|
||||
"verification": map[string]any{
|
||||
"status": "terminal_response_only",
|
||||
"readbackAvailable": false,
|
||||
"reason": "member_list_is_capped_and_has_no_cursor",
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// SpaceDelete → delete_wikiSpace
|
||||
// ── member (知识库成员) ───────────────────────────────────────
|
||||
var MemberAdd = memberWrite("+member-add", "add_member", "添加知识库成员", "向知识库授予一个或多个用户容器级角色;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", "dws wiki +member-add --workspace <workspaceId> --users <userId> --role READER --format json", true)
|
||||
var MemberUpdate = memberWrite("+member-update", "update_member", "更新知识库成员角色", "调整已有成员的知识库容器级角色;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", "dws wiki +member-update --workspace <workspaceId> --users <userId> --role EDITOR --format json", true)
|
||||
var MemberRemove = memberWrite("+member-remove", "remove_member", "移除知识库成员", "移除一个或多个用户的知识库容器级访问;仅以写接口 success=true 作为终态证据,并明确成员列表无法完成精确读回。", "dws wiki +member-remove --workspace <workspaceId> --users <userId> --format json", false)
|
||||
|
||||
// MemberAdd → add_member
|
||||
// MemberUpdate → update_member
|
||||
// MemberList → list_member
|
||||
// MemberRemove → remove_member
|
||||
// ── node (知识库节点,路由到 doc MCP server) ──────────────────
|
||||
|
||||
// NodeList → list_nodes (doc)
|
||||
var NodeList = shortcut.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+node-list",
|
||||
Product: "doc",
|
||||
Description: "列出知识库节点",
|
||||
Intent: "当你要浏览某个知识库的目录结构、查看某文件夹下有哪些文档/子文件夹并拿到它们的 nodeId 时使用;输入 workspace(可选父节点 folder),分页返回该层级的节点列表,是逐层进入知识库定位文档的常用方式。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true},
|
||||
{Name: "folder", Type: shortcut.FlagString, Desc: "父节点 nodeId (不传则列出根目录)"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量 (默认 50,最大 50)"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"},
|
||||
},
|
||||
Tips: []string{`dws wiki +node-list --workspace <workspaceId> --folder <parentNodeId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"workspaceId": rt.Str("workspace")}
|
||||
if rt.Changed("folder") {
|
||||
params["folderId"] = rt.Str("folder")
|
||||
}
|
||||
if rt.Changed("limit") {
|
||||
params["pageSize"] = rt.Int("limit")
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["pageToken"] = rt.Str("cursor")
|
||||
}
|
||||
data, err := rt.CallMCPData("wiki", "list_nodes", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodes := nodeListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(nodes), "nodes": nodes})
|
||||
},
|
||||
}
|
||||
|
||||
// nodeListProject reshapes list_nodes into a clean node list (name/nodeId/type)
|
||||
// — clean output projection. Container and field keys are probed
|
||||
// defensively across candidate aliases; an unrecognized shape yields an empty list.
|
||||
func nodeListProject(data map[string]any) []map[string]any {
|
||||
raw := nodeListRawList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v := nodeListFirst(m, "name", "title", "nodeName"); v != nil {
|
||||
row["name"] = v
|
||||
}
|
||||
if v := nodeListFirst(m, "nodeId", "node_id", "id", "uuid", "dentryUuid"); v != nil {
|
||||
row["nodeId"] = v
|
||||
}
|
||||
if v := nodeListFirst(m, "type", "nodeType", "docType", "fileType"); v != nil {
|
||||
row["type"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
var MemberList = readShortcut("+member-list", "严格列出知识库成员", "列出知识库成员及角色;后端不提供可续游标且单次真实上限为 50,不伪造 page-all。", "members", "dws wiki +member-list --workspace <workspaceId> --format json", []shortcut.Flag{{Name: "workspace", Type: shortcut.FlagString, Required: true, Desc: "知识库 ID 或 URL"}, {Name: "limit", Type: shortcut.FlagInt, Default: "30", Desc: "返回上限 1-50"}, {Name: "filter-role", Type: shortcut.FlagStringSlice, Desc: "角色过滤"}}, []contract.ParamDecl{{Name: "workspace", Property: "workspaceId"}, {Name: "limit", Property: "maxResults"}, {Name: "filter-role", Property: "filterRoleIds"}}, func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("limit") < 1 || rt.Int("limit") > 50 {
|
||||
return fmt.Errorf("--limit 必须在 1-50 之间;服务端不支持超过 50 或游标续页")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nodeListRawList locates the node array across candidate container keys,
|
||||
// tolerating a nested {result|data:{list|items|nodes}} wrapper.
|
||||
func nodeListRawList(data map[string]any) []any {
|
||||
for _, k := range []string{"result", "data", "list", "items", "nodes"} {
|
||||
if arr, ok := data[k].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := data[k].(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "nodes", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
params := map[string]any{"workspaceId": rt.Str("workspace"), "maxResults": rt.Int("limit")}
|
||||
if rt.Changed("filter-role") {
|
||||
params["filterRoleIds"] = rt.StrSlice("filter-role")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nodeListFirst returns the first present value among candidate keys.
|
||||
func nodeListFirst(m map[string]any, keys ...string) any {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v
|
||||
}
|
||||
data, err := rt.CallMCPData("wiki", "list_member", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
items, page, err := requireWikiCollection(data, "wiki/list_member", "members")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
members := projectWikiRows(items, map[string][]string{"id": {"id", "userId"}, "name": {"name", "nick"}, "role": {"role", "roleId"}, "type": {"type"}, "outer": {"outer"}})
|
||||
out := map[string]any{"count": len(members), "members": members}
|
||||
addWikiPagination(out, page)
|
||||
return rt.Output(out)
|
||||
})
|
||||
|
||||
// NodeCreate → create_file (doc)
|
||||
// NodeCopy → copy_document (doc)
|
||||
var NodeCopy = shortcut.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+node-copy",
|
||||
Product: "doc",
|
||||
Description: "复制知识库节点",
|
||||
Intent: "当你想基于已有文档/文件夹快速生成一份副本(如用模板起草新文档、留档备份)时使用;指定源 node 和目标 folder,会实际在知识库中复制出一个新节点,原节点保持不变。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true},
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true},
|
||||
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 nodeId (不传则复制到根目录)"},
|
||||
},
|
||||
Tips: []string{`dws wiki +node-copy --workspace <workspaceId> --node <nodeId> --folder <targetFolderId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"nodeId": rt.Str("node"),
|
||||
"workspaceId": rt.Str("workspace"),
|
||||
}
|
||||
if rt.Changed("folder") {
|
||||
params["targetFolderId"] = rt.Str("folder")
|
||||
}
|
||||
return rt.CallMCP("copy_document", params)
|
||||
},
|
||||
}
|
||||
|
||||
// NodeMove → move_document (doc)
|
||||
var NodeMove = shortcut.Shortcut{
|
||||
Service: "wiki",
|
||||
Command: "+node-move",
|
||||
Product: "doc",
|
||||
Description: "移动知识库节点",
|
||||
Intent: "当你要重新整理知识库目录、把某个文档或文件夹从当前位置挪到另一个文件夹(或根目录)下时使用;指定源 node 和目标 folder,会实际改变该节点在知识库中的所属位置。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "workspace", Type: shortcut.FlagString, Desc: "知识库 ID", Required: true},
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "源节点 ID", Required: true},
|
||||
{Name: "folder", Type: shortcut.FlagString, Desc: "目标文件夹 nodeId (不传则移动到根目录)"},
|
||||
},
|
||||
Tips: []string{`dws wiki +node-move --workspace <workspaceId> --node <nodeId> --folder <targetFolderId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"nodeId": rt.Str("node"),
|
||||
"workspaceId": rt.Str("workspace"),
|
||||
}
|
||||
if rt.Changed("folder") {
|
||||
params["targetFolderId"] = rt.Str("folder")
|
||||
}
|
||||
return rt.CallMCP("move_document", params)
|
||||
},
|
||||
}
|
||||
|
||||
// NodeDelete → delete_document (doc)
|
||||
// NodeSearch → search_documents (doc)
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
SpaceList,
|
||||
SpaceSearch,
|
||||
NodeList,
|
||||
NodeCopy,
|
||||
NodeMove,
|
||||
)
|
||||
DeleteSpace.Aliases = []string{"+space-delete"}
|
||||
SpaceCreate.Validate = func(rt *shortcut.RuntimeContext) error {
|
||||
if len([]rune(rt.Str("name"))) > 32 {
|
||||
return fmt.Errorf("--name 不能超过 32 个字符")
|
||||
}
|
||||
if len([]rune(rt.Str("desc"))) > 500 {
|
||||
return fmt.Errorf("--desc 不能超过 500 个字符")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
SpaceCreate.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"name", "desc"}, Description: "--name 不超过 32 个字符;--desc 不超过 500 个字符"}}
|
||||
enableWikiAutoPage(&SpaceList)
|
||||
SpaceList.Contract.Pagination = wikiCursorPagination()
|
||||
shortcut.Register(SpaceList, SpaceSearch, SpaceGet, SpaceCreate, DeleteSpace, MemberAdd, MemberUpdate, MemberList, MemberRemove)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package wiki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type wikiCoverageCall struct {
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type wikiCoverageCaller struct {
|
||||
responses map[string][]string
|
||||
errors map[string][]error
|
||||
indexes map[string]int
|
||||
calls []wikiCoverageCall
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *wikiCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
key := product + "/" + tool
|
||||
c.calls = append(c.calls, wikiCoverageCall{product: product, tool: tool, args: args})
|
||||
if c.indexes == nil {
|
||||
c.indexes = map[string]int{}
|
||||
}
|
||||
index := c.indexes[key]
|
||||
c.indexes[key]++
|
||||
if sequence := c.errors[key]; index < len(sequence) && sequence[index] != nil {
|
||||
return nil, sequence[index]
|
||||
}
|
||||
sequence := c.responses[key]
|
||||
if len(sequence) == 0 {
|
||||
return nil, fmt.Errorf("unexpected MCP call %s", key)
|
||||
}
|
||||
if index >= len(sequence) {
|
||||
index = len(sequence) - 1
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: sequence[index]}}}, nil
|
||||
}
|
||||
|
||||
func (c *wikiCoverageCaller) Format() string { return "json" }
|
||||
func (c *wikiCoverageCaller) DryRun() bool { return c.dryRun }
|
||||
func (c *wikiCoverageCaller) Fields() string { return "" }
|
||||
func (c *wikiCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func runWikiCoverageCLI(t *testing.T, caller *wikiCoverageCaller, args ...string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.PersistentFlags().String("jq", "", "")
|
||||
root.PersistentFlags().String("fields", "", "")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
stdout := &bytes.Buffer{}
|
||||
root.SetOut(stdout)
|
||||
root.SetErr(&bytes.Buffer{})
|
||||
root.SetArgs(append([]string{"wiki"}, args...))
|
||||
executed, err := root.ExecuteC()
|
||||
if err == nil {
|
||||
if _, _, emitErr := output.EmitStoredResult(executed); emitErr != nil {
|
||||
err = emitErr
|
||||
}
|
||||
}
|
||||
if stdout.Len() == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &payload); decodeErr != nil {
|
||||
t.Fatalf("decode output %q: %v", stdout.String(), decodeErr)
|
||||
}
|
||||
if data, ok := payload["data"].(map[string]any); ok {
|
||||
return data, err
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiSpaceWorkflows(t *testing.T) {
|
||||
t.Run("list default string limit", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/list_wikiSpaces": {`{"success":true,"result":{"wikiSpaces":[{"workspaceId":"w1","name":"Docs"}],"hasMore":false}}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-list")
|
||||
if err != nil || out["count"] != float64(1) || caller.calls[0].args["pageSize"] != 20 {
|
||||
t.Fatalf("list output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list explicit legacy string limit and cursor", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/list_wikiSpaces": {`{"wikiSpaces":[],"hasMore":false}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-list", "--type", "myWikiSpace", "--limit", "7", "--page-token", "next")
|
||||
if err != nil || out["count"] != float64(0) || caller.calls[0].args["pageSize"] != 7 || caller.calls[0].args["pageToken"] != "next" {
|
||||
t.Fatalf("list output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
for _, value := range []string{"bad", "0", "51"} {
|
||||
t.Run("list rejects "+value, func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", value); err == nil || len(caller.calls) != 0 {
|
||||
t.Fatalf("limit %q err=%v calls=%#v", value, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("search parses string limit", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/search_wikiSpaces": {`{"success":true,"wikiSpaces":[{"spaceId":"w2","spaceName":"Plan"}]}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-search", "--query", "Plan", "--limit", "12")
|
||||
if err != nil || out["count"] != float64(1) || len(caller.calls) != 1 {
|
||||
t.Fatalf("search output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
if got := caller.calls[0]; got.product != "wiki" || got.tool != "search_wikiSpaces" || len(got.args) != 2 || got.args["keyword"] != "Plan" || got.args["pageSize"] != 12 {
|
||||
t.Fatalf("search call = %#v, want exact keyword/pageSize request", got)
|
||||
}
|
||||
if _, exists := caller.calls[0].args["query"]; exists {
|
||||
t.Fatalf("search request leaked compatibility property query: %#v", caller.calls[0].args)
|
||||
}
|
||||
if _, exists := caller.calls[0].args["limit"]; exists {
|
||||
t.Fatalf("search request leaked compatibility property limit: %#v", caller.calls[0].args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get requires business id", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true,"result":{"name":"missing id"}}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+space-get", "--workspace", "w"); err == nil {
|
||||
t.Fatal("space without id succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create dry-run", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-create", "--name", "Docs", "--desc", "D", "--icon", "I", "--dry-run")
|
||||
if err != nil || out["executed"] != false || len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create and exact readback", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/create_wikiSpace": {`{"success":true,"result":{"workspaceId":"w3"}}`},
|
||||
"wiki/get_wikiSpace": {`{"success":true,"data":{"workspaceId":"w3","name":"Docs"}}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-create", "--name", "Docs")
|
||||
if err != nil || out["workspaceId"] != "w3" || len(caller.calls) != 2 {
|
||||
t.Fatalf("create output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete preflight dry-run and terminal", func(t *testing.T) {
|
||||
dry := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w4","name":"Docs"}`}}}
|
||||
out, err := runWikiCoverageCLI(t, dry, "+delete-space", "--workspace", "w4", "--dry-run", "--yes")
|
||||
if err != nil || out["executed"] != false || len(dry.calls) != 1 {
|
||||
t.Fatalf("delete dry-run output=%#v err=%v calls=%#v", out, err, dry.calls)
|
||||
}
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/get_wikiSpace": {`{"workspaceId":"w4","name":"Docs"}`},
|
||||
"wiki/delete_wikiSpace": {`{"success":true}`},
|
||||
}}
|
||||
out, err = runWikiCoverageCLI(t, caller, "+space-delete", "--workspace", "w4", "--yes")
|
||||
if err != nil || out["deleted"] != true || len(caller.calls) != 2 {
|
||||
t.Fatalf("delete output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
for _, args := range [][]string{{"--name", strings.Repeat("界", 33)}, {"--name", "x", "--desc", strings.Repeat("界", 501)}} {
|
||||
caller := &wikiCoverageCaller{}
|
||||
if _, err := runWikiCoverageCLI(t, caller, append([]string{"+space-create"}, args...)...); err == nil || len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid create args succeeded: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiMemberWritesUseTerminalEvidenceOnly(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
command string
|
||||
tool string
|
||||
extra []string
|
||||
}{
|
||||
{command: "+member-add", tool: "add_member", extra: []string{"--role", "READER"}},
|
||||
{command: "+member-update", tool: "update_member", extra: []string{"--role", "EDITOR"}},
|
||||
{command: "+member-remove", tool: "remove_member"},
|
||||
} {
|
||||
t.Run(tc.command, func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/" + tc.tool: {`{"success":true}`}}}
|
||||
args := append([]string{tc.command, "--workspace", "w", "--users", "u1,u2"}, tc.extra...)
|
||||
out, err := runWikiCoverageCLI(t, caller, args...)
|
||||
verification, _ := out["verification"].(map[string]any)
|
||||
if err != nil || out["verifiedBy"] != "write_terminal_success" || verification["readbackAvailable"] != false || len(caller.calls) != 1 {
|
||||
t.Fatalf("member output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
if caller.calls[0].tool == "list_member" {
|
||||
t.Fatal("member write used capped list as readback")
|
||||
}
|
||||
if tc.tool != "remove_member" && caller.calls[0].args["roleId"] != strings.ToUpper(tc.extra[1]) {
|
||||
t.Fatalf("role was not normalized: %#v", caller.calls[0].args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("dry-run has no write", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+member-add", "--workspace", "w", "--users", "u", "--role", "READER", "--dry-run")
|
||||
if err != nil || out["executed"] != false || len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects missing terminal evidence", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/add_member": {`{"result":{"accepted":true}}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+member-add", "--workspace", "w", "--users", "u", "--role", "READER"); err == nil {
|
||||
t.Fatal("member write without success=true succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
many := make([]string, 31)
|
||||
for index := range many {
|
||||
many[index] = fmt.Sprintf("u%d", index)
|
||||
}
|
||||
caller := &wikiCoverageCaller{}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+member-remove", "--workspace", "w", "--users", strings.Join(many, ",")); err == nil || len(caller.calls) != 0 {
|
||||
t.Fatal("more than 30 members reached MCP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiMemberListContracts(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/list_member": {`{"success":true,"members":[{"userId":"u1","nick":"A","roleId":"READER","type":"USER","outer":false}],"truncated":false}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+member-list", "--workspace", "w", "--limit", "50", "--filter-role", "READER,EDITOR")
|
||||
if err != nil || out["count"] != float64(1) || caller.calls[0].args["maxResults"] != 50 {
|
||||
t.Fatalf("member list output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
for _, value := range []string{"0", "51"} {
|
||||
caller = &wikiCoverageCaller{}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+member-list", "--workspace", "w", "--limit", value); err == nil || len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid member limit %s reached MCP", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiReadWorkflows(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
responses map[string][]string
|
||||
wantTool string
|
||||
wantCount float64
|
||||
}{
|
||||
{name: "node list", args: []string{"+node-list", "--workspace", "w", "--folder", "f", "--limit", "2", "--cursor", "c"}, responses: map[string][]string{"doc/list_nodes": {`{"success":true,"nodes":[{"id":"n","title":"Doc","parentId":"f","spaceId":"w"}],"hasMore":false}`}}, wantTool: "list_nodes", wantCount: 1},
|
||||
{name: "node search", args: []string{"+node-search", "--workspace", "w", "--query", "Doc", "--extensions", "adoc", "--limit", "2", "--cursor", "c"}, responses: map[string][]string{"doc/search_documents": {`{"success":true,"documents":[],"hasMore":false}`}}, wantTool: "search_documents", wantCount: 0},
|
||||
{name: "feed list", args: []string{"+feed-list", "--workspace", "w", "--limit", "2", "--cursor", "c", "--exclude-file"}, responses: map[string][]string{"wiki/list_workspace_feeds": {`{"success":true,"feeds":[{"feedId":"x","feedType":"update","fileId":"n"}],"hasMore":false}`}}, wantTool: "list_workspace_feeds", wantCount: 1},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: tc.responses}
|
||||
out, err := runWikiCoverageCLI(t, caller, tc.args...)
|
||||
if err != nil || out["count"] != tc.wantCount || caller.calls[0].tool != tc.wantTool {
|
||||
t.Fatalf("output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("node get", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true,"result":{"nodeId":"n","title":"Doc"}}`}}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+node-get", "--node", "n")
|
||||
if err != nil || out["nodeId"] != "n" {
|
||||
t.Fatalf("node get output=%#v err=%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiWriteWorkflows(t *testing.T) {
|
||||
t.Run("confirmation gates precede every remote call", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "delete space", args: []string{"+delete-space", "--workspace", "w"}},
|
||||
{name: "copy node", args: []string{"+node-copy", "--workspace", "w", "--node", "source"}},
|
||||
{name: "move node", args: []string{"+move", "--workspace", "target", "--node", "n"}},
|
||||
{name: "move node to drive", args: []string{"+move-to-drive", "--node", "n"}},
|
||||
{name: "delete node", args: []string{"+node-delete", "--workspace", "w", "--node", "n"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{}
|
||||
_, err := runWikiCoverageCLI(t, caller, tc.args...)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "confirmation_required" {
|
||||
t.Fatalf("unconfirmed error = %#v, want confirmation_required", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed shortcut reached MCP: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("node create dry and verified", func(t *testing.T) {
|
||||
dry := &wikiCoverageCaller{}
|
||||
out, err := runWikiCoverageCLI(t, dry, "+node-create", "--workspace", "w", "--folder", "f", "--name", "Doc", "--type", "adoc", "--dry-run")
|
||||
if err != nil || out["executed"] != false || len(dry.calls) != 0 {
|
||||
t.Fatalf("create dry output=%#v err=%v calls=%#v", out, err, dry.calls)
|
||||
}
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"doc/create_file": {`{"success":true,"data":{"fileId":"n"}}`},
|
||||
"doc/get_document_info": {`{"success":true,"nodeId":"n","workspaceId":"w","folderId":"f"}`},
|
||||
}}
|
||||
out, err = runWikiCoverageCLI(t, caller, "+node-create", "--workspace", "w", "--folder", "f", "--name", "Doc")
|
||||
if err != nil || out["nodeId"] != "n" || len(caller.calls) != 2 {
|
||||
t.Fatalf("create output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("node copy dry and verified", func(t *testing.T) {
|
||||
dry := &wikiCoverageCaller{}
|
||||
out, err := runWikiCoverageCLI(t, dry, "+node-copy", "--workspace", "w", "--folder", "f", "--node", "source", "--dry-run", "--yes")
|
||||
if err != nil || out["executed"] != false || len(dry.calls) != 0 {
|
||||
t.Fatalf("copy dry output=%#v err=%v calls=%#v", out, err, dry.calls)
|
||||
}
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"doc/copy_document": {`{"success":true,"nodeId":"copy"}`},
|
||||
"doc/get_document_info": {`{"success":true,"nodeId":"copy","workspaceId":"w"}`},
|
||||
}}
|
||||
out, err = runWikiCoverageCLI(t, caller, "+node-copy", "--workspace", "w", "--node", "source", "--yes")
|
||||
if err != nil || out["nodeId"] != "copy" || len(caller.calls) != 2 {
|
||||
t.Fatalf("copy output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("move within wiki and to drive", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old","folderId":"old-f"}`, `{"nodeId":"n","workspaceId":"target","folderId":"f"}`},
|
||||
"doc/move_document": {`{"success":true}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+move", "--workspace", "target", "--folder", "f", "--node", "n", "--yes")
|
||||
if err != nil || out["nodeId"] != "n" || len(caller.calls) != 3 {
|
||||
t.Fatalf("move output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
caller = &wikiCoverageCaller{responses: map[string][]string{
|
||||
"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"n","workspaceId":"drive"}`},
|
||||
"doc/move_document": {`{"success":true}`},
|
||||
}}
|
||||
out, err = runWikiCoverageCLI(t, caller, "+move-to-drive", "--node", "n", "--yes")
|
||||
if err != nil || out["nodeId"] != "n" {
|
||||
t.Fatalf("move-to-drive output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("move dry-run only preflights", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+move", "--workspace", "target", "--node", "n", "--dry-run", "--yes")
|
||||
if err != nil || out["executed"] != false || len(caller.calls) != 1 {
|
||||
t.Fatalf("move dry output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("node delete dry and terminal", func(t *testing.T) {
|
||||
dry := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}}}
|
||||
out, err := runWikiCoverageCLI(t, dry, "+node-delete", "--workspace", "w", "--node", "n", "--dry-run", "--yes")
|
||||
if err != nil || out["executed"] != false || len(dry.calls) != 1 {
|
||||
t.Fatalf("delete dry output=%#v err=%v calls=%#v", out, err, dry.calls)
|
||||
}
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`},
|
||||
"doc/delete_document": {`{"success":true}`},
|
||||
}}
|
||||
out, err = runWikiCoverageCLI(t, caller, "+node-delete", "--workspace", "w", "--node", "n", "--yes")
|
||||
if err != nil || out["deleted"] != true || len(caller.calls) != 2 {
|
||||
t.Fatalf("delete output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiResponseValidationBranches(t *testing.T) {
|
||||
if _, err := requireWikiResponse(nil, "op"); err == nil {
|
||||
t.Fatal("empty response accepted")
|
||||
}
|
||||
for _, data := range []map[string]any{{"success": "yes"}, {"success": false}, {"success": false, "message": "denied"}} {
|
||||
if _, err := requireWikiResponse(data, "op"); err == nil {
|
||||
t.Fatalf("invalid response accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if _, err := requireWikiWrite(map[string]any{"result": map[string]any{"id": "x"}}, "op"); err == nil {
|
||||
t.Fatal("write without terminal success accepted")
|
||||
}
|
||||
if _, err := requireWikiWrite(map[string]any{"success": false}, "op"); err == nil {
|
||||
t.Fatal("failed write response accepted")
|
||||
}
|
||||
for _, data := range []map[string]any{
|
||||
{"success": false}, {"result": "bad"}, {"result": map[string]any{}}, {"only": true},
|
||||
} {
|
||||
if _, err := requireWikiObject(data, "op"); err == nil {
|
||||
t.Fatalf("invalid object accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if object, err := requireWikiObject(map[string]any{"success": true, "id": "x"}, "op"); err != nil || object["id"] != "x" {
|
||||
t.Fatalf("direct object=%#v err=%v", object, err)
|
||||
}
|
||||
for _, data := range []map[string]any{
|
||||
{"success": false}, {"result": "bad"}, {"result": map[string]any{"items": "bad"}}, {"items": []any{1}}, {"success": true},
|
||||
} {
|
||||
if _, _, err := requireWikiCollection(data, "op", "items"); err == nil {
|
||||
t.Fatalf("invalid collection accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
if value := nestedWikiString(map[string]any{"data": map[string]any{"id": " nested "}}, "id"); value != "nested" {
|
||||
t.Fatalf("nested string=%q", value)
|
||||
}
|
||||
if value := nestedWikiString(map[string]any{"id": " direct "}, "id"); value != "direct" {
|
||||
t.Fatalf("direct string=%q", value)
|
||||
}
|
||||
if value := nestedWikiString(map[string]any{"id": 1}, "id"); value != "" {
|
||||
t.Fatalf("non-string=%q", value)
|
||||
}
|
||||
|
||||
page := map[string]any{"nextToken": "n", "hasMore": true, "truncated": false, "totalCount": 1, "autoPageComplete": true, "autoPageStopReason": "done", "pagesFetched": 2}
|
||||
out := map[string]any{}
|
||||
addWikiPagination(out, page)
|
||||
for _, key := range []string{"nextCursor", "hasMore", "truncated", "totalCount", "autoPageComplete", "autoPageStopReason", "pagesFetched"} {
|
||||
if _, ok := out[key]; !ok {
|
||||
t.Fatalf("pagination output missing %s: %#v", key, out)
|
||||
}
|
||||
}
|
||||
rows := projectWikiRows([]any{map[string]any{"legacy": "x", "empty": nil}}, map[string][]string{"value": {"empty", "legacy"}})
|
||||
if len(rows) != 1 || rows[0]["value"] != "x" {
|
||||
t.Fatalf("projected rows=%#v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiAliasAndCancellationBranches(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/remove_member": {`{"success":true}`}}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+member-remove", "--workspace", "w", "--user", "u1")
|
||||
if err != nil || out["userCount"] != float64(1) {
|
||||
t.Fatalf("visible member alias output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "probe"}
|
||||
cmd.Flags().StringSlice("users", nil, "")
|
||||
cmd.Flags().StringSlice("user", nil, "")
|
||||
rt := shortcut.RuntimeContextForTest(cmd, MemberRemove)
|
||||
if got := wikiStringSliceFirst(rt, "users", "user"); len(got) != 0 {
|
||||
t.Fatalf("unset string slice=%v", got)
|
||||
}
|
||||
|
||||
pageCmd := &cobra.Command{Use: "page"}
|
||||
pageCmd.Flags().Bool("page-all", true, "")
|
||||
pageCmd.Flags().Int("page-limit", 2, "")
|
||||
pageCmd.Flags().Int("max-items", 0, "")
|
||||
pageCmd.Flags().Int("page-delay", 1, "")
|
||||
pageCmd.Flags().String("cursor", "", "")
|
||||
pageCmd.Flags().String("page-token", "", "")
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
pageCmd.SetContext(cancelled)
|
||||
pageRT := shortcut.RuntimeContextForTest(pageCmd, SpaceList)
|
||||
_, _, err = collectWikiPages(pageRT, "probe", 1, []string{"items"}, func(string, int) (map[string]any, error) {
|
||||
return map[string]any{"items": []any{}, "hasMore": true, "nextCursor": "next"}, nil
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancelled page delay err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiPaginationFailureModes(t *testing.T) {
|
||||
t.Run("two pages", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/list_wikiSpaces": {
|
||||
`{"wikiSpaces":[{"workspaceId":"w1"}],"hasMore":true,"nextCursor":"next"}`,
|
||||
`{"wikiSpaces":[{"workspaceId":"w2"}],"hasMore":false}`,
|
||||
},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", "1", "--page-all", "--page-limit", "2")
|
||||
if err != nil || out["count"] != float64(2) || out["autoPageComplete"] != true {
|
||||
t.Fatalf("pagination output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("max items trims first page", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/list_wikiSpaces": {`{"wikiSpaces":[{"workspaceId":"w1"},{"workspaceId":"w2"}],"hasMore":true,"nextCursor":"next"}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", "2", "--page-all", "--max-items", "1")
|
||||
spaces, ok := out["spaces"].([]any)
|
||||
if err != nil || !ok || out["count"] != float64(1) || len(spaces) != 1 || out["autoPageStopReason"] != "max_items" {
|
||||
t.Fatalf("max-items output=%#v err=%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("max items trims remaining page", func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/list_wikiSpaces": {
|
||||
`{"wikiSpaces":[{"workspaceId":"w1"},{"workspaceId":"w2"}],"hasMore":true,"nextCursor":"next"}`,
|
||||
`{"wikiSpaces":[{"workspaceId":"w3"},{"workspaceId":"w4"}],"hasMore":false}`,
|
||||
},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+space-list", "--limit", "2", "--page-all", "--page-limit", "2", "--max-items", "3")
|
||||
spaces, ok := out["spaces"].([]any)
|
||||
if err != nil || !ok || out["count"] != float64(3) || len(spaces) != 3 || out["autoPageStopReason"] != "max_items" {
|
||||
t.Fatalf("max-items output=%#v err=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[1].args["pageSize"] != 1 {
|
||||
t.Fatalf("remaining page request=%#v, want pageSize=1", caller.calls)
|
||||
}
|
||||
third, ok := spaces[2].(map[string]any)
|
||||
if !ok || third["workspaceId"] != "w3" {
|
||||
t.Fatalf("trimmed spaces=%#v, want w3 as final item", spaces)
|
||||
}
|
||||
})
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
response string
|
||||
args []string
|
||||
}{
|
||||
{name: "missing has more", response: `{"wikiSpaces":[]}`, args: []string{"--page-all"}},
|
||||
{name: "malformed has more", response: `{"wikiSpaces":[],"hasMore":"yes"}`, args: []string{"--page-all"}},
|
||||
{name: "missing cursor", response: `{"wikiSpaces":[],"hasMore":true}`, args: []string{"--page-all"}},
|
||||
{name: "stalled cursor", response: `{"wikiSpaces":[],"hasMore":true,"nextCursor":"same"}`, args: []string{"--cursor", "same", "--page-all"}},
|
||||
{name: "page limit", response: `{"wikiSpaces":[],"hasMore":true,"nextCursor":"next"}`, args: []string{"--page-all", "--page-limit", "1"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/list_wikiSpaces": {tc.response}}}
|
||||
if _, err := runWikiCoverageCLI(t, caller, append([]string{"+space-list"}, tc.args...)...); err == nil {
|
||||
t.Fatalf("pagination failure %s succeeded", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, args := range [][]string{{"--page-limit", "0", "--page-all"}, {"--max-items", "1"}, {"--page-delay", "1"}} {
|
||||
caller := &wikiCoverageCaller{}
|
||||
if _, err := runWikiCoverageCLI(t, caller, append([]string{"+space-list"}, args...)...); err == nil || len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid auto-page controls succeeded: %v", args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiMCPFailuresPropagate(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{
|
||||
responses: map[string][]string{"wiki/list_wikiSpaces": {`{"wikiSpaces":[]}`}},
|
||||
errors: map[string][]error{"wiki/list_wikiSpaces": {errors.New("read failed")}},
|
||||
}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+space-list"); err == nil {
|
||||
t.Fatal("MCP failure was swallowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiSpaceFailureBranches(t *testing.T) {
|
||||
assertErr := func(name string, caller *wikiCoverageCaller, args ...string) {
|
||||
t.Helper()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := runWikiCoverageCLI(t, caller, args...); err == nil {
|
||||
t.Fatalf("%s unexpectedly succeeded; calls=%#v", name, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
backend := errors.New("backend failed")
|
||||
assertErr("search invalid limit", &wikiCoverageCaller{}, "+space-search", "--query", "x", "--limit", "bad")
|
||||
assertErr("search call", &wikiCoverageCaller{errors: map[string][]error{"wiki/search_wikiSpaces": {backend}}}, "+space-search", "--query", "x")
|
||||
assertErr("search collection", &wikiCoverageCaller{responses: map[string][]string{"wiki/search_wikiSpaces": {`{"success":true}`}}}, "+space-search", "--query", "x")
|
||||
assertErr("get call", &wikiCoverageCaller{errors: map[string][]error{"wiki/get_wikiSpace": {backend}}}, "+space-get", "--workspace", "w")
|
||||
assertErr("get object", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true}`}}}, "+space-get", "--workspace", "w")
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w","name":"Docs"}`}}}
|
||||
if out, err := runWikiCoverageCLI(t, caller, "+space-get", "--workspace", "w"); err != nil || out["workspaceId"] != "w" {
|
||||
t.Fatalf("space get output=%#v err=%v", out, err)
|
||||
}
|
||||
|
||||
assertErr("create write call", &wikiCoverageCaller{errors: map[string][]error{"wiki/create_wikiSpace": {backend}}}, "+space-create", "--name", "Docs")
|
||||
assertErr("create terminal", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"result":{"workspaceId":"w"}}`}}}, "+space-create", "--name", "Docs")
|
||||
assertErr("create id", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true}`}}}, "+space-create", "--name", "Docs")
|
||||
assertErr("create readback call", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"workspaceId":"w"}`}}, errors: map[string][]error{"wiki/get_wikiSpace": {backend}}}, "+space-create", "--name", "Docs")
|
||||
assertErr("create readback object", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"workspaceId":"w"}`}, "wiki/get_wikiSpace": {`{"success":true}`}}}, "+space-create", "--name", "Docs")
|
||||
assertErr("create readback mismatch", &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"workspaceId":"w"}`}, "wiki/get_wikiSpace": {`{"workspaceId":"other"}`}}}, "+space-create", "--name", "Docs")
|
||||
spaceMismatch := &wikiCoverageCaller{responses: map[string][]string{"wiki/create_wikiSpace": {`{"success":true,"result":{"workspaceId":"w"}}`}, "wiki/get_wikiSpace": {`{"success":true,"data":{"workspaceId":"other"}}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, spaceMismatch, "+space-create", "--name", "Docs"); err == nil || !strings.Contains(err.Error(), "不一致") {
|
||||
t.Fatalf("space create mismatch error=%v calls=%#v", err, spaceMismatch.calls)
|
||||
}
|
||||
|
||||
assertErr("delete preflight call", &wikiCoverageCaller{errors: map[string][]error{"wiki/get_wikiSpace": {backend}}}, "+delete-space", "--workspace", "w", "--yes")
|
||||
assertErr("delete preflight object", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true}`}}}, "+delete-space", "--workspace", "w", "--yes")
|
||||
assertErr("delete write call", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w"}`}}, errors: map[string][]error{"wiki/delete_wikiSpace": {backend}}}, "+delete-space", "--workspace", "w", "--yes")
|
||||
assertErr("delete terminal", &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"workspaceId":"w"}`}, "wiki/delete_wikiSpace": {`{"result":{}}`}}}, "+delete-space", "--workspace", "w", "--yes")
|
||||
delWrite := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true,"result":{"workspaceId":"w"}}`}}, errors: map[string][]error{"wiki/delete_wikiSpace": {backend}}}
|
||||
if _, err := runWikiCoverageCLI(t, delWrite, "+delete-space", "--workspace", "w", "--yes"); err == nil || !strings.Contains(err.Error(), "backend failed") {
|
||||
t.Fatalf("delete write error=%v calls=%#v", err, delWrite.calls)
|
||||
}
|
||||
delTerminal := &wikiCoverageCaller{responses: map[string][]string{"wiki/get_wikiSpace": {`{"success":true,"result":{"workspaceId":"w"}}`}, "wiki/delete_wikiSpace": {`{"result":{}}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, delTerminal, "+delete-space", "--workspace", "w", "--yes"); err == nil || !strings.Contains(err.Error(), "success=true") {
|
||||
t.Fatalf("delete terminal error=%v calls=%#v", err, delTerminal.calls)
|
||||
}
|
||||
|
||||
assertErr("member list call", &wikiCoverageCaller{errors: map[string][]error{"wiki/list_member": {backend}}}, "+member-list", "--workspace", "w")
|
||||
assertErr("member list collection", &wikiCoverageCaller{responses: map[string][]string{"wiki/list_member": {`{"success":true}`}}}, "+member-list", "--workspace", "w")
|
||||
assertErr("member write call", &wikiCoverageCaller{errors: map[string][]error{"wiki/add_member": {backend}}}, "+member-add", "--workspace", "w", "--users", "u", "--role", "READER")
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiNodeFailureBranches(t *testing.T) {
|
||||
assertErr := func(name string, caller *wikiCoverageCaller, args ...string) {
|
||||
t.Helper()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := runWikiCoverageCLI(t, caller, args...); err == nil {
|
||||
t.Fatalf("%s unexpectedly succeeded; calls=%#v", name, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
backend := errors.New("backend failed")
|
||||
assertErr("list call", &wikiCoverageCaller{errors: map[string][]error{"doc/list_nodes": {backend}}}, "+node-list", "--workspace", "w")
|
||||
assertErr("list collection", &wikiCoverageCaller{responses: map[string][]string{"doc/list_nodes": {`{"success":true}`}}}, "+node-list", "--workspace", "w")
|
||||
assertErr("get call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, "+node-get", "--node", "n")
|
||||
assertErr("get object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true}`}}}, "+node-get", "--node", "n")
|
||||
assertErr("get id", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"name":"Doc"}`}}}, "+node-get", "--node", "n")
|
||||
missingID := &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true,"result":{"name":"Doc"}}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, missingID, "+node-get", "--node", "n"); err == nil || !strings.Contains(err.Error(), "nodeId") {
|
||||
t.Fatalf("missing node id error=%v calls=%#v", err, missingID.calls)
|
||||
}
|
||||
assertErr("search call", &wikiCoverageCaller{errors: map[string][]error{"doc/search_documents": {backend}}}, "+node-search", "--workspace", "w", "--query", "x")
|
||||
assertErr("search collection", &wikiCoverageCaller{responses: map[string][]string{"doc/search_documents": {`{"success":true}`}}}, "+node-search", "--workspace", "w", "--query", "x")
|
||||
|
||||
createArgs := []string{"+node-create", "--workspace", "w", "--name", "Doc"}
|
||||
assertErr("create write", &wikiCoverageCaller{errors: map[string][]error{"doc/create_file": {backend}}}, createArgs...)
|
||||
assertErr("create terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"result":{"fileId":"n"}}`}}}, createArgs...)
|
||||
assertErr("create id", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true}`}}}, createArgs...)
|
||||
assertErr("create readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"fileId":"n"}`}}, errors: map[string][]error{"doc/get_document_info": {backend}}}, createArgs...)
|
||||
assertErr("create readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"success":true}`}}}, createArgs...)
|
||||
assertErr("create mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"nodeId":"other"}`}}}, createArgs...)
|
||||
createMismatch := &wikiCoverageCaller{responses: map[string][]string{"doc/create_file": {`{"success":true,"result":{"fileId":"n"}}`}, "doc/get_document_info": {`{"success":true,"result":{"nodeId":"other"}}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, createMismatch, createArgs...); err == nil || !strings.Contains(err.Error(), "不一致") {
|
||||
t.Fatalf("create mismatch error=%v calls=%#v", err, createMismatch.calls)
|
||||
}
|
||||
|
||||
copyArgs := []string{"+node-copy", "--workspace", "w", "--node", "source", "--yes"}
|
||||
assertErr("copy write", &wikiCoverageCaller{errors: map[string][]error{"doc/copy_document": {backend}}}, copyArgs...)
|
||||
assertErr("copy terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"result":{"fileId":"n"}}`}}}, copyArgs...)
|
||||
assertErr("copy id", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true}`}}}, copyArgs...)
|
||||
assertErr("copy readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"fileId":"n"}`}}, errors: map[string][]error{"doc/get_document_info": {backend}}}, copyArgs...)
|
||||
assertErr("copy readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"fileId":"n"}`}, "doc/get_document_info": {`{"success":true}`}}}, copyArgs...)
|
||||
copyMismatch := &wikiCoverageCaller{responses: map[string][]string{"doc/copy_document": {`{"success":true,"result":{"fileId":"n"}}`}, "doc/get_document_info": {`{"success":true,"result":{"nodeId":"other"}}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, copyMismatch, copyArgs...); err == nil || !strings.Contains(err.Error(), "不一致") {
|
||||
t.Fatalf("copy mismatch error=%v calls=%#v", err, copyMismatch.calls)
|
||||
}
|
||||
|
||||
moveArgs := []string{"+move", "--workspace", "target", "--node", "n", "--yes"}
|
||||
assertErr("move preflight call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, moveArgs...)
|
||||
assertErr("move preflight object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true}`}}}, moveArgs...)
|
||||
assertErr("move write", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}}, errors: map[string][]error{"doc/move_document": {backend}}}, moveArgs...)
|
||||
assertErr("move terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}, "doc/move_document": {`{"result":{}}`}}}, moveArgs...)
|
||||
assertErr("move readback call", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`}, "doc/move_document": {`{"success":true}`}}, errors: map[string][]error{"doc/get_document_info": {nil, backend}}}, moveArgs...)
|
||||
assertErr("move readback object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"success":true}`}, "doc/move_document": {`{"success":true}`}}}, moveArgs...)
|
||||
assertErr("move id mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"other","workspaceId":"target"}`}, "doc/move_document": {`{"success":true}`}}}, moveArgs...)
|
||||
assertErr("move workspace mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"n","workspaceId":"other"}`}, "doc/move_document": {`{"success":true}`}}}, moveArgs...)
|
||||
assertErr("move folder mismatch", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"old"}`, `{"nodeId":"n","workspaceId":"target","folderId":"other"}`}, "doc/move_document": {`{"success":true}`}}}, append(moveArgs, "--folder", "f")...)
|
||||
assertErr("drive move unchanged", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"same"}`, `{"nodeId":"n","workspaceId":"same"}`}, "doc/move_document": {`{"success":true}`}}}, "+move-to-drive", "--node", "n", "--yes")
|
||||
|
||||
deleteArgs := []string{"+node-delete", "--workspace", "w", "--node", "n", "--yes"}
|
||||
assertErr("delete preflight call", &wikiCoverageCaller{errors: map[string][]error{"doc/get_document_info": {backend}}}, deleteArgs...)
|
||||
assertErr("delete preflight object", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"success":true}`}}}, deleteArgs...)
|
||||
assertErr("delete workspace", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"other"}`}}}, deleteArgs...)
|
||||
assertErr("delete write", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}}, errors: map[string][]error{"doc/delete_document": {backend}}}, deleteArgs...)
|
||||
assertErr("delete terminal", &wikiCoverageCaller{responses: map[string][]string{"doc/get_document_info": {`{"nodeId":"n","workspaceId":"w"}`}, "doc/delete_document": {`{"result":{}}`}}}, deleteArgs...)
|
||||
assertErr("feed collection", &wikiCoverageCaller{responses: map[string][]string{"wiki/list_workspace_feeds": {`{"success":true}`}}}, "+feed-list", "--workspace", "w")
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiSecondPageFailures(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{
|
||||
responses: map[string][]string{"wiki/list_wikiSpaces": {`{"wikiSpaces":[],"hasMore":true,"nextCursor":"next"}`}},
|
||||
errors: map[string][]error{"wiki/list_wikiSpaces": {nil, errors.New("second page failed")}},
|
||||
}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+space-list", "--page-all"); err == nil {
|
||||
t.Fatal("second-page transport error was swallowed")
|
||||
}
|
||||
caller = &wikiCoverageCaller{responses: map[string][]string{"wiki/list_wikiSpaces": {`{"wikiSpaces":[],"hasMore":true,"nextCursor":"next"}`, `{"success":true}`}}}
|
||||
if _, err := runWikiCoverageCLI(t, caller, "+space-list", "--page-all"); err == nil {
|
||||
t.Fatal("second-page malformed collection was swallowed")
|
||||
}
|
||||
}
|
||||
Executable
+216
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run interactive, real-data Wiki Shortcut verification on a disposable space.
|
||||
|
||||
Requires an authenticated `dws` session. Set DWS_WIKI_E2E_MEMBER_ID to a real
|
||||
internal user ID that may be granted temporary access to the empty fixture.
|
||||
The script prints capability labels only; business IDs, names, URLs, and raw
|
||||
responses remain in memory. Commands that require confirmation use the normal
|
||||
`dws` terminal prompt, including the disposable-space cleanup in `finally`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DWS = ROOT / "dws"
|
||||
MEMBER_ID = os.environ.get("DWS_WIKI_E2E_MEMBER_ID", "").strip()
|
||||
|
||||
|
||||
class E2EFailure(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def invoke(args: list[str], *, require_confirmation: bool = False) -> dict:
|
||||
process = subprocess.run(
|
||||
[str(DWS), "wiki", *args, "--format", "json"],
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.PIPE,
|
||||
# Confirmation prompts are written to stderr. Keep that stream on the
|
||||
# terminal for guarded operations so dws itself obtains the user's
|
||||
# answer; ordinary calls stay quiet and redact raw backend errors.
|
||||
stderr=None if require_confirmation else subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
try:
|
||||
envelope = json.loads(process.stdout)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise E2EFailure(f"non-JSON response (exit {process.returncode})") from exc
|
||||
if process.returncode != 0 or envelope.get("ok") is not True:
|
||||
error = envelope.get("error") or {}
|
||||
reason = error.get("reason") or error.get("category") or "command_failed"
|
||||
raise E2EFailure(f"{reason} (exit {process.returncode})")
|
||||
data = envelope.get("data")
|
||||
if not isinstance(data, dict):
|
||||
raise E2EFailure("success envelope lacks object data")
|
||||
return data
|
||||
|
||||
|
||||
def check(label: str, condition: bool) -> None:
|
||||
if not condition:
|
||||
raise E2EFailure(f"{label}: business assertion failed")
|
||||
print(f"PASS {label}")
|
||||
|
||||
|
||||
def member_role(data: dict, user_id: str) -> str:
|
||||
for member in data.get("members", []):
|
||||
if member.get("id") == user_id:
|
||||
return str(member.get("role") or "").upper()
|
||||
return ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not sys.stdin.isatty() or not sys.stderr.isatty():
|
||||
raise E2EFailure(
|
||||
"run in an interactive terminal; guarded operations require an explicit dws confirmation"
|
||||
)
|
||||
if not DWS.exists():
|
||||
raise E2EFailure("build ./dws first with make build")
|
||||
if not MEMBER_ID:
|
||||
raise E2EFailure("set DWS_WIKI_E2E_MEMBER_ID to a temporary internal test member")
|
||||
|
||||
stamp = time.strftime("%m%d%H%M%S")
|
||||
space_name = f"DWS Wiki E2E {stamp}" # <= 32 characters
|
||||
workspace = ""
|
||||
disposable_nodes: list[str] = []
|
||||
member_added = False
|
||||
try:
|
||||
created = invoke(["+space-create", "--name", space_name, "--desc", "Disposable E2E fixture"])
|
||||
workspace = str(created.get("workspaceId") or "")
|
||||
check("space-create-readback", bool(workspace) and created.get("space", {}).get("workspaceId") == workspace)
|
||||
|
||||
page = invoke(["+space-list", "--limit", "1"])
|
||||
check("space-list-cursor", page.get("count") == 1 and isinstance(page.get("hasMore"), bool))
|
||||
all_spaces = invoke(["+space-list", "--limit", "1", "--page-all", "--max-items", "2"])
|
||||
check("space-list-auto-page", all_spaces.get("count") == 2 and len(all_spaces.get("spaces", [])) == 2)
|
||||
search_ok = False
|
||||
for _ in range(8):
|
||||
searched = invoke(["+space-search", "--query", space_name])
|
||||
if any(row.get("workspaceId") == workspace for row in searched.get("spaces", [])):
|
||||
search_ok = True
|
||||
break
|
||||
time.sleep(2)
|
||||
check("space-search", search_ok)
|
||||
detail = invoke(["+space-get", "--workspace", workspace])
|
||||
check("space-get", detail.get("workspaceId") == workspace)
|
||||
resolved = invoke(["+resolve-space", "--name", space_name])
|
||||
check("resolve-space", resolved.get("resolved") is True and resolved.get("spaceId") == workspace)
|
||||
|
||||
empty_nodes = invoke(["+node-list", "--workspace", workspace])
|
||||
check("node-list-explicit-empty", empty_nodes.get("count") == 0 and empty_nodes.get("nodes") == [])
|
||||
|
||||
folder = invoke(["+node-create", "--workspace", workspace, "--name", "E2E Folder", "--type", "folder"])
|
||||
folder_id = str(folder.get("nodeId") or "")
|
||||
disposable_nodes.append(folder_id)
|
||||
check("node-create-folder-readback", bool(folder_id) and folder.get("node", {}).get("nodeId") == folder_id)
|
||||
document = invoke(["+node-create", "--workspace", workspace, "--folder", folder_id, "--name", "E2E Document"])
|
||||
node_id = str(document.get("nodeId") or "")
|
||||
disposable_nodes.append(node_id)
|
||||
check("node-create-document-readback", bool(node_id) and document.get("node", {}).get("nodeId") == node_id)
|
||||
|
||||
listed = invoke(["+node-list", "--workspace", workspace, "--limit", "1"])
|
||||
check("node-list-cursor", listed.get("count") == 1 and listed.get("hasMore") is True and bool(listed.get("nextCursor")))
|
||||
all_nodes = invoke(["+node-list", "--workspace", workspace, "--limit", "1", "--page-all"])
|
||||
check(
|
||||
"node-list-auto-page",
|
||||
all_nodes.get("count", 0) >= 1
|
||||
and (all_nodes.get("autoPageComplete") is True or all_nodes.get("hasMore") is False),
|
||||
)
|
||||
info = invoke(["+node-get", "--node", node_id])
|
||||
check("node-get", info.get("nodeId") == node_id)
|
||||
|
||||
# Search indexing can lag after a create; retry without accepting a
|
||||
# malformed/missing collection as an empty success.
|
||||
search_ok = False
|
||||
for _ in range(6):
|
||||
found = invoke(["+node-search", "--workspace", workspace, "--query", "E2E Document"])
|
||||
if any(row.get("nodeId") == node_id for row in found.get("nodes", [])):
|
||||
search_ok = True
|
||||
break
|
||||
time.sleep(2)
|
||||
check("node-search", search_ok)
|
||||
|
||||
copied = invoke(
|
||||
["+node-copy", "--workspace", workspace, "--folder", folder_id, "--node", node_id],
|
||||
require_confirmation=True,
|
||||
)
|
||||
copy_id = str(copied.get("nodeId") or "")
|
||||
disposable_nodes.append(copy_id)
|
||||
check("node-copy-readback", bool(copy_id) and bool(copied.get("copy")))
|
||||
|
||||
moved = invoke(
|
||||
["+move", "--workspace", workspace, "--folder", folder_id, "--node", node_id],
|
||||
require_confirmation=True,
|
||||
)
|
||||
check("move-readback", moved.get("node", {}).get("workspaceId") == workspace and moved.get("node", {}).get("folderId") == folder_id)
|
||||
moved_out = invoke(["+move-to-drive", "--node", node_id], require_confirmation=True)
|
||||
check("move-to-drive-readback", moved_out.get("node", {}).get("workspaceId") != workspace)
|
||||
moved_back = invoke(
|
||||
["+node-move", "--workspace", workspace, "--folder", folder_id, "--node", node_id],
|
||||
require_confirmation=True,
|
||||
)
|
||||
check("node-move-alias-readback", moved_back.get("node", {}).get("workspaceId") == workspace)
|
||||
|
||||
named = invoke(["+wiki-new-doc", "--space", space_name, "--title", "E2E Name Resolved"])
|
||||
named_id = str(named.get("nodeId") or "")
|
||||
disposable_nodes.append(named_id)
|
||||
check("wiki-new-doc-readback", bool(named_id) and named.get("document", {}).get("nodeId") == named_id)
|
||||
|
||||
members = invoke(["+member-list", "--workspace", workspace, "--limit", "50"])
|
||||
check("member-list", members.get("count", 0) >= 1 and isinstance(members.get("members"), list))
|
||||
added = invoke(["+member-add", "--workspace", workspace, "--users", MEMBER_ID, "--role", "READER"])
|
||||
member_added = True
|
||||
check("member-add-terminal", added.get("success") is True and added.get("verifiedBy") == "write_terminal_success")
|
||||
after_add = invoke(["+member-list", "--workspace", workspace, "--limit", "50"])
|
||||
check("member-add-fixture-readback", after_add.get("truncated") is not True and member_role(after_add, MEMBER_ID) == "READER")
|
||||
updated = invoke(["+member-update", "--workspace", workspace, "--users", MEMBER_ID, "--role", "EDITOR"])
|
||||
check("member-update-terminal", updated.get("success") is True and updated.get("verifiedBy") == "write_terminal_success")
|
||||
after_update = invoke(["+member-list", "--workspace", workspace, "--limit", "50"])
|
||||
check("member-update-fixture-readback", after_update.get("truncated") is not True and member_role(after_update, MEMBER_ID) == "EDITOR")
|
||||
removed = invoke(["+member-remove", "--workspace", workspace, "--users", MEMBER_ID])
|
||||
member_added = False
|
||||
check("member-remove-terminal", removed.get("success") is True and removed.get("verifiedBy") == "write_terminal_success")
|
||||
after_remove = invoke(["+member-list", "--workspace", workspace, "--limit", "50"])
|
||||
check("member-remove-fixture-readback", after_remove.get("truncated") is not True and member_role(after_remove, MEMBER_ID) == "")
|
||||
|
||||
feeds = invoke(["+feed-list", "--workspace", workspace, "--limit", "10"])
|
||||
check("feed-list", isinstance(feeds.get("feeds"), list))
|
||||
|
||||
# Exercise high-risk delete before final whole-space cleanup.
|
||||
delete_target = disposable_nodes.pop()
|
||||
deleted = invoke(
|
||||
["+node-delete", "--workspace", workspace, "--node", delete_target],
|
||||
require_confirmation=True,
|
||||
)
|
||||
check("node-delete-terminal", deleted.get("success") is True and deleted.get("deleted") is True)
|
||||
return 0
|
||||
finally:
|
||||
if workspace:
|
||||
if member_added:
|
||||
try:
|
||||
invoke(["+member-remove", "--workspace", workspace, "--users", MEMBER_ID])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
deleted = invoke(
|
||||
["+space-delete", "--workspace", workspace],
|
||||
require_confirmation=True,
|
||||
)
|
||||
check("space-delete-alias-cleanup", deleted.get("success") is True and deleted.get("deleted") is True)
|
||||
except Exception as exc:
|
||||
print(f"CLEANUP FAILED: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except E2EFailure as exc:
|
||||
print(f"FAIL {exc}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -24,6 +24,7 @@ SEMANTIC_PATHS = [
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_aitable.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_minutes.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_drive.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_wiki.json",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ event_skill="skills/multi/dingtalk-event/SKILL.md"
|
||||
mono_skill="skills/mono/SKILL.md"
|
||||
runtime_contract="skills/multi/dingtalk-shared/references/runtime-contract.md"
|
||||
chat_target_bytes=10000
|
||||
chat_max_overage_percent=5
|
||||
chat_max_overage_percent=10
|
||||
chat_max_bytes=$((chat_target_bytes * (100 + chat_max_overage_percent) / 100))
|
||||
doc_max_bytes=10000
|
||||
event_max_bytes=10000
|
||||
|
||||
@@ -25,8 +25,7 @@
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -37,8 +36,7 @@
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -53,7 +51,7 @@
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/message-media.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-bot.md"
|
||||
]
|
||||
},
|
||||
@@ -72,7 +70,7 @@
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/message-query.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-conversation.md"
|
||||
]
|
||||
},
|
||||
@@ -91,7 +89,7 @@
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
"skills/multi/dingtalk-chat/references/chat/message-query.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -103,7 +101,7 @@
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-group.md"
|
||||
"skills/multi/dingtalk-chat/references/chat/group-admin.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -115,7 +113,7 @@
|
||||
"references": [
|
||||
"skills/multi/dingtalk-chat/SKILL.md",
|
||||
"skills/multi/dingtalk-chat/references/01-messaging.md",
|
||||
"skills/multi/dingtalk-chat/references/chat/chat-message.md"
|
||||
"skills/multi/dingtalk-chat/references/chat/message-actions.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -58,7 +58,7 @@ cli_version: ">=1.0.15"
|
||||
| `report` | 2 | `dingtalk-misc` |
|
||||
| `sheet` | 2 | `dingtalk-misc` |
|
||||
| `todo` | 11 | `dingtalk-todo` |
|
||||
| `wiki` | 1 | `dingtalk-wiki` |
|
||||
| `wiki` | 20 | `dingtalk-wiki` |
|
||||
<!-- VISIBLE_SHORTCUTS_OVERVIEW_END -->
|
||||
|
||||
## 多组织 / 多账号
|
||||
|
||||
@@ -72,6 +72,8 @@ metadata:
|
||||
|
||||
- `+dm`:姓名目标的简单文本/Markdown,参数空间最小。
|
||||
- `+send-to-group`:群名或稳定 ID 目标的简单文本/Markdown,避免暴露无关身份矩阵。
|
||||
- Markdown 中的公网图片必须写成 `` 才会内联展示;
|
||||
省略开头的 `!` 时只会显示为链接。
|
||||
- `+messages-send`:文件、Bot、Webhook、复杂 @ 或幂等控制。user 已知 ID 可直接传,也可用 `--user-query` / `--chat-query` 运行同一只读解析链;Bot 多群使用 `--groups/--groups-file`,返回 `im.batch-write.v1`;bot/webhook 只使用下层真实支持的文本/Markdown 能力。
|
||||
- 文件直接传 `+messages-send --file <相对路径>`;不要先独立上传并提取 mediaId。
|
||||
- Webhook 使用 `+messages-send --as webhook --webhook-token <token>`;不要退回原子 Webhook 命令。
|
||||
@@ -92,10 +94,13 @@ metadata:
|
||||
|
||||
| 场景 | Reference |
|
||||
|---|---|
|
||||
| 复杂发送、跨会话转发、共同群或组合流程 | [01-messaging.md](references/01-messaging.md) |
|
||||
| 编辑/撤回/引用/转发/卡片/reaction/Pin/Top/Favorite | [chat-message.md](references/chat/chat-message.md) |
|
||||
| 建群、成员、管理员、群公告、群设置 | [chat-group.md](references/chat/chat-group.md) |
|
||||
| Bot 搜索、入群、群发和撤回 | [chat-bot.md](references/chat/chat-bot.md) |
|
||||
| 需要跨步骤传递真实结果的消息/群组合流程 | [01-messaging.md](references/01-messaging.md) |
|
||||
| 消息读取与查询 | [message-query](references/chat/message-query.md) |
|
||||
| 编辑、撤回、回复、转发、Pin、Top、Favorite 或 reaction 写入 | [message-actions](references/chat/message-actions.md) |
|
||||
| 位置、联系人名片、底层媒体与资源下载 | [message-media](references/chat/message-media.md) |
|
||||
| 群列表、群搜索、共同群、成员与群内机器人读取 | [group-discovery](references/chat/group-discovery.md) |
|
||||
| 建群、成员或已知机器人增删、管理员、公告与群设置 | [group-admin](references/chat/group-admin.md) |
|
||||
| 搜索未知机器人、机器人消息发送/撤回与 Webhook | [chat-bot.md](references/chat/chat-bot.md) |
|
||||
| 会话置顶、分类、红点、免打扰和隐藏 | [chat-conversation.md](references/chat/chat-conversation.md) |
|
||||
| 低频意图之间仍需消歧 | [intent-guide.md](references/intent-guide.md) |
|
||||
| 表情名称与 ID | [chat-emoji-list.md](references/chat-emoji-list.md) |
|
||||
|
||||
@@ -13,9 +13,11 @@ Runtime 会唯一解析为 openDingTalkId;已有 openDingTalkId 时传
|
||||
或艾特占位符。
|
||||
- `--dry-run` 仍执行只读 userId 解析,只输出两步计划,不执行写入。
|
||||
|
||||
自动更新结果不确定时,不要再次更新或重复创建;保留返回结果并告知用户。若结果中已经
|
||||
包含 `openTaskId`,可以按用户需要查询一次投递状态;该查询只确认消息投递,不代表卡片
|
||||
正文已经更新成功。
|
||||
创建成功后保留真实 `bizId`。自动更新返回 `verified=true` 时已有明确生效证据;返回
|
||||
`accepted=true, verified=false` 时仅表示服务端已接受请求但未提供独立更新证据,应如实说明,
|
||||
不要重复创建或重复执行相同更新。只有错误明确标记 `retryable=true` 时,才使用原 `bizId`
|
||||
重试;明确未应用或 `bizId` 不一致时停止并保留真实错误。若结果中已经包含 `openTaskId`,
|
||||
可以按用户需要查询一次投递状态;该查询只确认消息投递,不代表卡片正文已经更新成功。
|
||||
|
||||
当前内容仅为 streaming text,不接受 Lark Card JSON、组件树或按钮 callback。
|
||||
|
||||
|
||||
@@ -7,3 +7,7 @@
|
||||
|
||||
更新是写操作,confirmation 以精确 leaf Schema 与 Runtime gate 为准。失败后保留原
|
||||
`bizId` 和状态,不创建新卡片来掩盖更新失败。
|
||||
|
||||
结果中 `verified=true` 表示已有明确更新证据;`accepted=true, verified=false` 仅表示服务端
|
||||
接受了请求但未提供独立生效证据,应如实说明且不得重复执行相同更新。只有错误明确标记
|
||||
`retryable=true` 时才重试;明确未应用或 `bizId` 不一致时停止。
|
||||
|
||||
@@ -19,9 +19,12 @@
|
||||
| 用户终点 | 返回入口 |
|
||||
|---|---|
|
||||
| 姓名/群名简单发送、文件、Bot、Webhook、复杂 @ | 根 Skill Golden Route |
|
||||
| 单会话消息、跨会话搜索、资源下载 | [消息任务级流程](01-messaging.md) |
|
||||
| 引用、转发、卡片、reaction、Pin/Top/Favorite | [chat-message](chat/chat-message.md) |
|
||||
| 基础建群、成员、公告、管理员和群设置 | [chat-group](chat/chat-group.md) |
|
||||
| 消息读取、条件搜索、@我、Favorite/reaction 查询和批量详情 | [message-query](chat/message-query.md) |
|
||||
| 编辑、撤回、引用、转发、reaction/Pin/Top/Favorite 写入 | [message-actions](chat/message-actions.md) |
|
||||
| 位置、名片、资源下载和特殊媒体 fallback | [message-media](chat/message-media.md) |
|
||||
| 群列表、群搜索、成员读取、Bot 列表和邀请链接 | [group-discovery](chat/group-discovery.md) |
|
||||
| 建群、改群、成员写入、管理员、禁言、公告和群设置 | [group-admin](chat/group-admin.md) |
|
||||
| 跨步骤消息/群组合流程 | [消息任务级流程](01-messaging.md) |
|
||||
| Bot 搜索、进群和撤回 | [chat-bot](chat/chat-bot.md) |
|
||||
| 会话置顶、状态和分组 | [chat-conversation](chat/chat-conversation.md) |
|
||||
| 相邻低频意图仍需消歧 | [intent-guide](intent-guide.md) |
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
# chat-group:群聊、成员、设置与群身份
|
||||
|
||||
> 返回入口:[chat.md](../chat.md)
|
||||
|
||||
## 适用场景
|
||||
|
||||
用于群搜索、建群、成员增删、机器人进群、群设置、群主转让、群邀请分享、群公告、入群审批、群身份和群禁言。
|
||||
|
||||
<!-- dws-intent: chat.create.group -->基础建群默认使用 `dws chat +chat-create`:已知成员 ID 传
|
||||
`--users`,姓名/花名传 `--member-query`;群主默认当前用户,也可传
|
||||
`--owner-open-dingtalk-id` 或 `--owner-query`。全部自然身份唯一解析并去重后才执行一次创建。
|
||||
|
||||
## 必读约束
|
||||
|
||||
- 群聊目标统一使用 `openConversationId`。只有数字群号时,先用 `chat group get-by-group-id` 转换。
|
||||
- 群搜索唯一推荐 `dws chat +chat-search --query <群名>`;`chat search`、`chat group search`、
|
||||
`+chat-group-search` 和 `+search-group` 仅是兼容拼法,不应被写成并列默认路线。
|
||||
- 群成员操作中,`--users` 常为逗号分隔列表;具体要求以命令 `--help` 为准。
|
||||
- 解散群、踢人、转让群主、禁言、管理员设置都是高影响操作,执行前必须确认目标群和用户。
|
||||
- 发布或修改群公告会触达群成员;公告正文是 Markdown,定时公告 `--run-at` 建议带时区。
|
||||
|
||||
## 命令明细
|
||||
|
||||
### 搜索与基础信息
|
||||
|
||||
| 命令 | 用途 | 示例与要点 |
|
||||
|------|------|------------|
|
||||
| `+chat-search` | 按关键词搜索群聊 | 默认一页;要求全部候选时用 `dws chat +chat-search --query "项目冲刺" --page-all`;可用 `--page-size/--page-token` 或兼容的 `--limit/--cursor`,并检查完整性 ledger;多候选必须消歧 |
|
||||
| `chat search-common` | 搜索共同群 | `dws chat search-common --nicks "风雷,山乔" --match-mode AND --limit 20 --cursor 0` |
|
||||
| `chat group get-by-group-id` | 数字群号转 openConversationId | `dws chat group get-by-group-id --group-id 12345678` |
|
||||
| `+chat-bots` | 查看群内所有机器人 | `dws chat +chat-bots --group <群名或openConversationId>`;内部唯一解析自然群名 |
|
||||
|
||||
`search-common` 中 `--match-mode AND` 表示所有人都在群里,`OR` 表示任一人在群里。
|
||||
|
||||
### 群创建与基础操作
|
||||
|
||||
#### `dws chat group create`(底层 fallback)
|
||||
|
||||
只有需要 `+chat-create` 尚未发布的底层字段时才评估原子创建命令,并先读取精确 leaf
|
||||
Schema。`+chat-create` 已支持 `--thread` 和显式群主;普通内部/外部群创建不得回流到手工
|
||||
`aisearch → group create` 链路。
|
||||
|
||||
```bash
|
||||
dws chat group create --name "Q1 项目冲刺群" --users userId1,userId2,userId3
|
||||
dws chat group create --name "外部合作群" --users userId1,userId2 --type EXTERNAL
|
||||
dws chat group create --name "话题圈" --users userId1,userId2 --thread
|
||||
```
|
||||
|
||||
关键 flags:
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--name` | 群名称,必填 |
|
||||
| `--users` | 成员 userId 或 openDingTalkId,逗号分隔,必填 |
|
||||
| `--type` | `INTERNAL` / `EXTERNAL` / `NORMAL`,默认 `INTERNAL` |
|
||||
| `--thread` | 开启话题模式,创建话题圈 |
|
||||
|
||||
创建成功后提取 `openConversationId`,用于发消息、成员管理、群设置。
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group rename` | 更新群名称 | `--id` `--name`;只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID,不猜 `+chat-rename` |
|
||||
| `group quit` | 当前用户退出群聊 | `--group` |
|
||||
| `group dismiss` | 解散群聊,不可逆 | `--group` |
|
||||
|
||||
### 成员与机器人
|
||||
|
||||
| 命令 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `+chat-members-list` | 全量查看群成员并分桶用户/机器人 | `--group <群名或openConversationId>`;显式 ID 也可用 `--conversation-id`,检查 buckets/complete/failures |
|
||||
| `group members add` | 添加群成员 | `dws chat group members add --id <openConversationId> --users userId1,userId2` |
|
||||
| `group members remove` | 移除群成员 | `dws chat group members remove --id <openConversationId> --users userId1,userId2` |
|
||||
| `group members list-by-ids` | 按 openDingTalkId 批量查成员详情 | `dws chat group members list-by-ids --id <openConversationId> --users openDingTalkId1,openDingTalkId2` |
|
||||
| `group members add-bot` | 将自定义机器人加入群 | `dws chat group members add-bot --id <openConversationId> --robot-code <robot-code>` |
|
||||
| `group members remove-bot` | 从群内移除机器人 | `dws chat group members remove-bot --id <openConversationId> --bot-id <openBotId>` |
|
||||
|
||||
机器人发群消息如果报“机器人不存在”,先 `group members add-bot` 再重发。
|
||||
|
||||
### 群设置与权限
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group transfer-owner` | 转让群主 | `--group` + `--user`(userId) 或 `--new-owner`(openDingTalkId) |
|
||||
| `group upgrade-to-external` | 将普通群升级为外部群(不可逆,需先确认) | `--group` `--yes`;可选 `--extension`(拓展字段) |
|
||||
| `+chat-invite-url` | 获取群邀请链接 | `--group <群名或openConversationId>`;可选 `--expires-seconds` |
|
||||
| `group share-invite` | 将指定群的邀请链接分享到另一个会话或单聊用户 | `--source` + `--target` / `--receiver` 二选一 |
|
||||
| `group update-icon` | 更新群头像 | `--group` `--icon-media-id` |
|
||||
| `group update-settings` | 更新管理员级别的群功能开关 | `--group` `--setting-key` `--status` |
|
||||
| `group user-settings query` | 批量查询当前用户自己的群会话设置 | `--groups` |
|
||||
| `group user-settings set` | 批量更新当前用户自己的群会话设置 | `--items` |
|
||||
| `group update-nick` | 设置或清除当前用户群昵称 | `--group`,可选 `--nick`;不传则清除群昵称 |
|
||||
| `group update-alias` | 设置当前用户群备注 | `--group` `--alias-title` |
|
||||
| `group set-history` | 设置新成员可查看历史消息范围 | `--group` `--option` |
|
||||
| `group get-mute-config` | 查询群用户禁言配置 | `--group` |
|
||||
| `group-mute` | 全员禁言/解除全员禁言 | `--group`,默认禁言,`--off` 解除 |
|
||||
| `group-mute-member` | 指定成员禁言/解除禁言 | `--group` `--user`/`--users`;禁言需 `--mute-time` |
|
||||
| `group set-admin` | 设置/取消管理员 | `--group` `--user`/`--users`;`--off` 取消 |
|
||||
|
||||
`update-settings` 是管理员级别的群功能开关操作,常用 settingKey:`authority`、`joinValidation`、`onlyAdminCanAtAll`、`searchable`、`addFriendForbidden`、`onlyAdminCanDING`、`onlyAdminCanPinMsg`、`onlyAdminCanSendFile`、`groupEmailDisabled`、`groupLiveAuthority`、`groupBillAuthority`。
|
||||
|
||||
`group user-settings` 是当前登录用户自己的群会话设置批量入口(置顶、免打扰、群昵称、群备注等),不是管理员级别的群功能开关;单个群昵称/群备注仍优先使用 `group update-nick` / `group update-alias`,管理员级别的群功能开关继续使用 `group update-settings`。
|
||||
|
||||
`group user-settings set --items` 传 JSON 数组,每个元素表示一个群会话的当前用户设置。字段含义:
|
||||
|
||||
| 字段 | 含义 | 值说明 |
|
||||
|------|------|--------|
|
||||
| `openConversationId` | 群会话 ID | 必填,来自 `chat search` / `group list-all` 等真实返回 |
|
||||
| `top` | 当前用户是否置顶该群会话 | `true`=置顶,`false`=取消置顶 |
|
||||
| `mute` | 当前用户是否开启该群会话免打扰 | `true`=开启免打扰,`false`=关闭免打扰 |
|
||||
| `groupNick` | 当前用户在该群里的群昵称 | 字符串;空字符串表示清空昵称 |
|
||||
| `groupAlias` | 当前用户给该群设置的备注 | 字符串;空字符串表示清空备注 |
|
||||
|
||||
只传本次要改的字段;不要补用户没要求修改的字段。批量设置多个群时,`items` 放多个对象。
|
||||
|
||||
`group-mute-member --mute-time` 单位毫秒,常用值:`300000`、`3600000`、`86400000`、`604800000`、`2592000000`。
|
||||
|
||||
`group share-invite` 的 `--source` 是被分享群的 `openConversationId`;`--target` 是接收分享消息的会话,`--receiver` 是接收分享消息的单聊用户 `openDingTalkId`,二者必须二选一。
|
||||
|
||||
```bash
|
||||
dws chat group share-invite --source <sourceOpenConversationId> --target <targetOpenConversationId>
|
||||
dws chat group share-invite --source <sourceOpenConversationId> --receiver <receiverOpenDingTalkId>
|
||||
dws chat group share-invite --source <sourceOpenConversationId> --target <targetOpenConversationId> --expires-seconds 86400 --uuid <uuid>
|
||||
dws chat group user-settings query --groups <openConversationId1>,<openConversationId2>
|
||||
dws chat group user-settings set --items '[{"openConversationId":"cid1","top":true,"mute":false}]'
|
||||
```
|
||||
|
||||
### 群公告
|
||||
|
||||
群公告正文使用 Markdown。支持标题、加粗、斜体、删除线、行内代码、链接、代码块、列表、表格、引用、分割线、图片、段落和换行;下划线、字体色、背景色、字号属于编辑器专属能力,无法通过 Markdown 表达。
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group notice create` | 发布群公告,支持置顶、DING 和定时发布 | `--group` `--content` |
|
||||
| `group notice edit` | 整体替换指定群公告内容 | `--group` `--notice-id` `--content` |
|
||||
| `group notice get` | 查询单条群公告详情 | `--group` `--notice-id` |
|
||||
| `group notice list` | 分页拉取群公告列表 | `--group` |
|
||||
|
||||
```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 "明早九点例会" --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>
|
||||
dws chat group notice edit --group <openConversationId> --notice-id <dataId> --content "更新后的公告内容"
|
||||
```
|
||||
|
||||
注意事项:
|
||||
|
||||
- `notice edit` 会整体替换原公告正文,必须传完整的新内容。
|
||||
- `notice list --scheduled` 查询尚未到发布时间的定时公告;默认查询已发布公告。
|
||||
- `hasMore=true` 时,用返回的 `nextPageCursor` 继续翻页。
|
||||
- `notice get` 返回正文摘要、置顶状态、发布者、已读人数/应收人数、点赞/评论数、是否可编辑、是否已读、是否定时公告等信息。
|
||||
|
||||
### 群列表与入群审批
|
||||
|
||||
| 命令 | 用途 | 示例与要点 |
|
||||
|------|------|------------|
|
||||
| `group list-my-groups` | 拉取我创建/管理的群 | 可选 `--role OWNER/ADMIN`、`--limit`、`--exclude-muted` |
|
||||
| `group list-all` | 分页拉取我加入的所有群 | `--limit` 默认 100,最大 200;翻页用 `nextCursor` |
|
||||
| `group list-join-validations` | 拉取入群验证记录 | 包括自己被拒绝的记录以及作为审批者的记录 |
|
||||
| `group audit-join-validation` | 审批入群验证 | `--group` `--record-id` `--applicant` `--inviter` `--status` |
|
||||
|
||||
审批动作 `--status`:`AuditApprove`、`AuditDelete`、`AuditIgnore`、`AuditRefuse`、`AuditBlock`。
|
||||
|
||||
```bash
|
||||
dws chat group list-join-validations --limit 20
|
||||
dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <openDingTalkId> --inviter <openDingTalkId> --status AuditApprove
|
||||
```
|
||||
|
||||
### 群身份
|
||||
|
||||
`chat group-role` 管理群内自定义身份标签。
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `group-role list` | 查看群身份列表 | `--group` |
|
||||
| `group-role add` | 新增群身份 | `--group` `--name` |
|
||||
| `group-role update` | 修改群身份名称 | `--group` `--role-id` `--name` |
|
||||
| `group-role remove` | 删除群身份 | `--group` `--role-id` |
|
||||
| `group-role set-user` | 覆盖用户全部群身份,空 `--role-ids` 表示清除 | `--group` `--user` `--role-ids` |
|
||||
| `group-role remove-user` | 移除用户指定群身份 | `--group` `--user` `--role-ids` |
|
||||
| `group-role query-user` | 查询用户当前群身份 | `--group` `--user` |
|
||||
|
||||
`openRoleId` 来自 `group-role list` 返回。
|
||||
|
||||
## 常见工作流
|
||||
|
||||
### 搜索群并发消息
|
||||
|
||||
```bash
|
||||
dws chat +send-to-group --group "项目冲刺" --text "请大家看一下最新进展" --format json
|
||||
```
|
||||
|
||||
### 建群并拉人
|
||||
|
||||
```bash
|
||||
dws chat +chat-create --name "Q1 项目冲刺群" --member-query "张三,李四" --format json
|
||||
dws chat +chat-create --name "合作群" --member-query "张三,李四" --owner-query "王五" --format json
|
||||
dws chat group members add --id <openConversationId> --users userId3,userId4 --format json
|
||||
```
|
||||
|
||||
### 分享群邀请并发布公告
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
### 设置管理员并禁言成员
|
||||
|
||||
```bash
|
||||
dws chat group set-admin --group <openConversationId> --users userId1,userId2 --format json
|
||||
dws chat group-mute-member --group <openConversationId> --users userId3 --mute-time 3600000 --format json
|
||||
```
|
||||
|
||||
## 常见错误与回退
|
||||
|
||||
- 只有数字群号:先 `group get-by-group-id`,不要直接当 `--group`。
|
||||
- 找不到群:使用 `+chat-search --query` 扩大关键词;零命中或多候选时停止,不臆测 openConversationId。
|
||||
- 入群审批缺参数:从 `group list-join-validations` 提取 `record-id`、`applicant`、`inviter`。
|
||||
- 机器人进群失败:确认当前用户有群管理权限。
|
||||
- 分享群邀请目标不明确:`--target` 和 `--receiver` 只能二选一,先确认是发到群/会话还是发给个人。
|
||||
- 修改公告前没有完整新正文:先向用户确认完整公告内容,不要只传增量片段。
|
||||
@@ -1,410 +0,0 @@
|
||||
# chat-message:消息、文件、搜索与卡片
|
||||
|
||||
> 返回入口:[chat.md](../chat.md)
|
||||
|
||||
## 适用场景
|
||||
|
||||
用于当前用户发消息、拉消息、搜索消息、撤回、已读状态、文件/图片/音频/视频/位置/名片发送、话题回复、转发、Pin/Top、表情回应、文本翻译和流式卡片。
|
||||
|
||||
## 默认入口
|
||||
|
||||
| 用户终点 | 默认入口 |
|
||||
|---|---|
|
||||
| <!-- dws-intent: chat.send.dm -->按姓名发送简单文本/Markdown | `dws chat +dm` |
|
||||
| <!-- dws-intent: chat.send.group -->按群名发送简单文本/Markdown | `dws chat +send-to-group` |
|
||||
| <!-- dws-intent: chat.send.advanced -->文件、Bot、Webhook、复杂 @、已知 ID 或幂等发送 | `dws chat +messages-send` |
|
||||
| <!-- dws-intent: chat.read.conversation -->读取或导出指定群聊/单聊,可附带发送者姓名 | `dws chat +chat-messages`;姓名用非必填 `--sender-query` |
|
||||
| <!-- dws-intent: chat.search.filtered -->直接按发送者、关键词、@对象或消息类型搜索,可限定单个或跨多个会话 | `dws chat +search-msg` |
|
||||
| <!-- dws-intent: chat.reply.quote -->引用回复已有消息 | `dws chat +messages-reply` |
|
||||
| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名> --page-all` |
|
||||
| 撤回当前用户消息 | `dws chat +messages-recall --msg-id <openMessageId>` |
|
||||
|
||||
以下原子命令只用于 Shortcut 未暴露的底层字段、原始响应或精确分页控制。不得把它们重新写成
|
||||
上述高频任务的默认路径;写入原子命令若与 Golden Shortcut 的 confirmation 不一致,停止并
|
||||
报告交付漂移,不以文档确认代替 Runtime gate。
|
||||
|
||||
## 必读约束
|
||||
|
||||
- 发消息前必须核对接收对象、消息内容、@ 对象、附件路径和消息类型;不明确时先问用户。
|
||||
- `--group`、`--user`、`--open-dingtalk-id` 通常互斥,群聊用 `--group`,单聊用 `--user` 或 `--open-dingtalk-id`。
|
||||
- 发送本地文件、音频、视频默认用 `dws chat +messages-send --as user --file <相对路径>`;
|
||||
`audio` / `video` 的具体类型以 leaf Schema 为准。只有 Shortcut 未暴露的位置、名片等底层
|
||||
类型才进入本文件的原子 fallback。
|
||||
- 发送位置消息前必须确认纬度、经度、地址名称;地图缩略图需先通过旧媒体上传链路拿到 mediaId。
|
||||
- 分享联系人名片前必须确认联系人 `openDingTalkId`,不要把 userId 直接当 `--contact-id`。
|
||||
- 消息内容按 Markdown 渲染,换行必须是真实换行符;需要换行效果时用空行、行尾两个空格或 `<br>`。
|
||||
- 图文混排 Markdown 中,公网图片 URL 需要写成 `` 才会以内联图片展示;省略开头的 `!` 时会按链接/URL 展示,不会渲染为图片。
|
||||
- 建议发送时带 `--uuid`,失败重试复用同一个值。
|
||||
- Bot/Webhook 只支持文本/Markdown;Bot 多群使用 `+messages-send --groups/--groups-file` 的逐项
|
||||
ledger。不要把 user 文件/图片能力外推到 Bot。
|
||||
- `+at-me` 和 `+messages-list-direct` 要求全量时使用 `--page-all`,并检查 `complete`、
|
||||
`hasMore`、`stopReason` 和 `failures`;`+messages-list-direct` 的续页时间来自下层毫秒
|
||||
`nextCursor`,不得用只有秒精度的消息展示时间手工拼接。
|
||||
|
||||
## 原子 fallback 命令明细
|
||||
|
||||
### 发送消息的底层 fallback
|
||||
|
||||
#### `dws chat message send`(非默认入口)
|
||||
|
||||
以当前用户身份发送群聊或单聊消息。
|
||||
|
||||
```bash
|
||||
# 文本/Markdown
|
||||
dws chat message send --group <openConversationId> --text "hello"
|
||||
dws chat message send --user <userId> --text "请查收"
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --text "请查收"
|
||||
dws chat message send --group <openConversationId> --title "周报提醒" --text "请大家本周五前提交周报" --uuid <uuid>
|
||||
dws chat message send --group <openConversationId> --text $'这是图文说明\n\n'
|
||||
|
||||
# @ 群成员
|
||||
dws chat message send --group <openConversationId> --at-all "<@all> 请大家注意"
|
||||
dws chat message send --group <openConversationId> --at-open-dingtalk-ids odt1,odt2 "<@odt1> <@odt2> 请查收"
|
||||
|
||||
# 图片/文件/音频/视频,一条命令直发
|
||||
dws chat message send --group <openConversationId> --msg-type file --file-path ./screenshot.png
|
||||
dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file-path ./report.pdf
|
||||
dws chat message send --group <openConversationId> --msg-type audio --file-path ./voice.mp3
|
||||
dws chat message send --group <openConversationId> --msg-type video --file-path ./demo.mp4
|
||||
|
||||
# 位置/联系人名片
|
||||
dws chat message send --group <openConversationId> --msg-type location --latitude <纬度> --longitude <经度> --location-name <地址名称> --map-thumbnail-url "@mediaId"
|
||||
dws chat message send --group <openConversationId> --msg-type profile --contact-id <openDingTalkId>
|
||||
```
|
||||
|
||||
关键 flags:
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--group` | 群聊 openConversationId;别名 `--id` / `--chat` / `--conversation-id` |
|
||||
| `--user` | 单聊接收人 userId |
|
||||
| `--open-dingtalk-id` | 单聊接收人 openDingTalkId |
|
||||
| `--text` | 消息内容,推荐使用;也支持位置参数 |
|
||||
| `--title` | 消息标题,未传时使用安全标题 |
|
||||
| `--at-all` | 群聊 @所有人,正文需含 `<@all>` |
|
||||
| `--at-open-dingtalk-ids` | 群聊 @指定 openDingTalkId,正文需含 `<@id>` |
|
||||
| `--msg-type` | `file` / `audio` / `video` / `image` / `location` / `profile`;本地音视频用 `audio` / `video`,底层按 `file` 发送 |
|
||||
| `--file-path` | 本地文件路径,`msg-type=file/audio/video` 时自动上传并发送 |
|
||||
| `--media-id` | 旧图片链路 mediaId |
|
||||
| `--latitude` / `--longitude` / `--location-name` | 位置消息参数 |
|
||||
| `--map-thumbnail-url` | 位置消息缩略图 mediaId,形如 `@mediaId` |
|
||||
| `--contact-id` | 联系人名片 openDingTalkId |
|
||||
| `--uuid` | 幂等 UUID,24h 内相同值不重复投递 |
|
||||
|
||||
当前没有经过验证的 Thread writer。`openConvThreadId` 只用于 `+thread-replies` 读取或
|
||||
`+messages-forward-topic` 转发;不要把它作为普通 `--group` 猜测写入。引用回复使用
|
||||
`+messages-reply`,但这不等于 Thread 内新增回复。
|
||||
|
||||
读取话题回复可直接传话题主消息 `--message-id`,CLI 会先通过只读消息详情解析出
|
||||
`conversationId/threadId`;也可显式传 `--group` 加 `--thread-id/--topic-id`。前一种模式如果同时传
|
||||
`--group`,会校验它与消息解析出的会话一致;解析失败只会报错,不会错误转去查询通讯录。
|
||||
|
||||
默认只读一页;完整读取必须显式加 `--page-all`。可用
|
||||
`--limit/--page-size` 控制每页条数、用 `--page-limit` 限制最大页数;自动续页使用下层返回的
|
||||
毫秒级 `nextCursor` 无损生成下一次 `startTime`,不能使用只有秒级精度的回复 `createTime`。
|
||||
输出默认 `--order desc`(兼容 `--sort`)。由于下层的 `newer/older` 表示读取方向而不是结果排序,
|
||||
`asc` 只允许与 `--page-all` 一起使用:完整拉取后对整体结果升序排列,避免把单个“最新页”的本地反转
|
||||
伪装为全局升序。结果中的 `orderScope=complete_result` 表示完整结果排序;读取被页数上限或错误截断时为
|
||||
`fetched_pages`,并仍须结合完整性 ledger 判断。
|
||||
必须检查 `complete`、`hasMore`、`stopReason` 和
|
||||
`failures`,`complete=false` 时不得声称已经拿到全部回复。
|
||||
|
||||
```bash
|
||||
dws chat +thread-replies --message-id <rootOpenMessageId> --page-all --order asc
|
||||
dws chat +thread-replies --group <openConversationId> --thread-id <openConvThreadId> --page-all --page-limit 50
|
||||
```
|
||||
|
||||
### 拉取消息的底层 fallback
|
||||
|
||||
默认使用 `dws chat +chat-messages`。群聊的 `--group` 可传群名或 openConversationId;
|
||||
也可用 `--chat-query` 显式按群名解析、用 `--conversation-id` 显式传稳定 ID。全量读取加 `--page-all`,必要时用 `--page-limit`、
|
||||
`--max-results` 控制边界,用 `--output <相对.json>` 原子导出。只有需要原始响应或显式手工
|
||||
continuation 时才使用下表;原子 `message list` 不代表自动全量分页。
|
||||
|
||||
可附带非必填的 `--sender-query <姓名>` 做读取后筛选。未传姓名时正常返回全部;姓名未解析出稳定 ID 时保留全部并记录失败;唯一解析出 userId/openDingTalkId 后按消息 `senderId` 筛选,覆盖最终 `messages/count` 并返回 `resolvedFilters`。该调用已经完成读取、解析与筛选,不要补跑 `+search-msg`。
|
||||
|
||||
时间范围参数同样公开但非必填:`--start`(包含)、`--end`(不包含)、`--order asc|desc`,
|
||||
兼容别名为 `--start-time/--end-time/--sort`。范围固定为 `[start,end)`;仅开始时间表示到本次执行当前时间,
|
||||
仅结束时间只支持 `desc`,`asc` 必须提供开始时间。旧 `--time/--direction` 保持兼容但不能和范围模式混用。
|
||||
|
||||
```bash
|
||||
dws chat +chat-messages --group "项目群" --sender-query "测试用户甲" --page-all --format json
|
||||
dws chat +chat-messages --group "项目群" --start "2026-08-01T00:00:00+08:00" --end "2026-08-02T00:00:00+08:00" --order asc --page-all --format json
|
||||
```
|
||||
|
||||
当会话已经确定、任务只需要完整读取结果中可由消息字段判断的子集时,不新增按条件专用的
|
||||
Shortcut 参数。在同一次 `+chat-messages` 调用中使用全局 `--jq`,让 Runtime 完成读取后、
|
||||
在 stdout 前筛选;表达式必须保留根信封、用筛选结果覆盖 `messages` 并同步重算 `count`,
|
||||
不得丢失 `complete`、`hasMore`、`failures` 等完整性 ledger。不要先输出全量 JSON,再由
|
||||
Agent 或另一条命令二次处理。
|
||||
|
||||
```bash
|
||||
# 例:读取完整会话后,只返回存在 reaction 的消息
|
||||
dws chat +chat-messages --group "项目群" --page-all --format json \
|
||||
--jq '. as $root | [.messages[] | select((.reactions // []) | length > 0)] as $matched | $root | .messages = $matched | .count = ($matched | length)'
|
||||
```
|
||||
|
||||
发送者姓名不是普通结果字段条件:仍用 `--sender-query <姓名>` 先解析稳定身份,再按
|
||||
`senderId` 筛选,不能用 `--jq` 对展示名做字符串匹配。
|
||||
|
||||
| 命令 | 用途 | 示例与要点 |
|
||||
|------|------|------------|
|
||||
| `message list` | 拉取指定群聊或单聊消息 | `dws chat message list --group <cid> --time "2025-03-01 00:00:00" --direction older`;目标三选一,`--direction newer/older` 优先于旧 `--forward` |
|
||||
| `message list-all` | 时间范围内全部会话消息 | `dws chat message list-all --start <ISO> --end <ISO> --limit 100 --cursor 0`;默认一页,完整遍历加 `--page-all`,保留并合并 `result.conversationMessagesList` |
|
||||
| `message list-by-sender` | 查指定发送者消息 | `--sender-user-id` 与 `--sender-open-dingtalk-id` 二选一,跨单聊+群聊;完整遍历加 `--page-all`,同一会话跨页合并 messages |
|
||||
| `message list-mentions` | 查 @ 我的消息 | 可传 `--group` 限定群,不传查全部;完整遍历加 `--page-all`,同一会话跨页合并 messages |
|
||||
| `message list-focused` | 查特别关注人消息 | 零参数可用,可加 `--limit` / `--cursor`;完整遍历加 `--page-all`,cursor 为 int64 |
|
||||
| `message list-unread-conversations` | 未读会话列表 | 可选 `--count` |
|
||||
| `message list-topic-replies` | 拉取话题回复 | `--group <openConversationId> --topic-id <openConvThreadId>` |
|
||||
| `message list-by-ids` | 按消息 ID 批量查询 | `--msg-ids msgId1,msgId2`,最多 50 条 |
|
||||
|
||||
Typed `chat message` 自动翻页只由 `--page-all` 触发;只传 `--page-limit`、`--max-items` 或 `--page-delay` 仍保持默认单页 fallback。`conversationMessagesList` 结构会保留并按 `openConversationId` 合并 messages。分页元数据输出到顶层 `paging`,包含 `truncated`、`hasMore`、`lastCursor`、`pages`、`total`;非第一页失败时会输出 partial 结果和 `failedPage` / `failedCursor` / `pagesFetched` / `itemsFetched`。
|
||||
|
||||
`message list` 注意事项:
|
||||
|
||||
- `--group`、`--user`、`--open-dingtalk-id` 互斥且必须指定一个。
|
||||
- `--time` 格式为 `yyyy-MM-dd HH:mm:ss`。
|
||||
- `hasMore=true` 时,用结果中的边界 `createTime` 作为下次 `--time`。
|
||||
- 返回 `openConvThreadId` 表示话题消息,完整内容需再拉 `list-topic-replies`。
|
||||
|
||||
### 搜索消息的底层 fallback
|
||||
|
||||
直接按发送者、关键词、@对象或消息类型检索时优先使用 `dws chat +search-msg`;搜索范围可以是单个、多个或全部会话。若已选择 `+chat-messages` 读取指定会话,可由其非必填 `--sender-query` 在同一次调用完成姓名解析和筛选。
|
||||
|
||||
- 搜索内容使用公开参数 `--query`。
|
||||
- 已知稳定会话 ID 使用 `--group` / `--groups`;已知稳定发送者 ID 使用 `--senders`。
|
||||
- 只有群名时使用非必填参数 `--chat-query`,由 CLI 唯一解析会话。
|
||||
- 只有发送者姓名时使用非必填参数 `--sender-query`,由 CLI 唯一解析人员。
|
||||
- 不要把群名传给只接受稳定 ID 的会话参数,也不要把姓名传给 `--senders`。零命中或多候选时停止,不选择第一项。
|
||||
- 不传会话过滤时搜索全部会话;`--page-all` 只翻完当前时间范围内的游标页,默认时间范围是最近 7 天。
|
||||
- 精确范围使用成对的 `--start/--end`(兼容 `--start-time/--end-time`);`--order`(兼容 `--sort`)稳定排列本次实际取得的结果。未 `--page-all` 或 `complete=false` 时不能称为完整范围的全局排序。
|
||||
|
||||
需要 Shortcut 未暴露的原始过滤字段或响应时,才评估 `message search-advanced`。它是原子 `message search` 的严格超集,但不是 Agent 高频默认入口。
|
||||
|
||||
```bash
|
||||
# 单群 + 发送者姓名
|
||||
dws chat +search-msg --chat-query "项目群" --sender-query "测试用户甲" --page-all --format json
|
||||
|
||||
# 单群 + 关键词
|
||||
dws chat +search-msg --chat-query "项目群" --query "发布计划" --page-all --format json
|
||||
|
||||
# 跨全部会话 + 发送者姓名
|
||||
dws chat +search-msg --sender-query "测试用户甲" --page-all --format json
|
||||
dws chat +search-msg --query "发布计划" --start-time "2026-08-01T00:00:00+08:00" --end-time "2026-08-02T00:00:00+08:00" --sort asc --page-all --format json
|
||||
```
|
||||
|
||||
```bash
|
||||
dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
|
||||
dws chat message search-advanced --user <userId> --start <ISO> --end <ISO>
|
||||
dws chat message search-advanced --at-me --start <ISO> --end <ISO>
|
||||
dws chat message search-advanced --conversation-ids <cid1>,<cid2> --query "合同" --limit 50 --cursor 0
|
||||
dws chat message search-advanced --query "周报" --start <ISO> --end <ISO> --page-all --max-items 200
|
||||
dws chat message search-advanced --message-type file --search-conv-type group_chat --query "附件"
|
||||
dws chat message search-advanced --only-robot-messages --query "通知"
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--query` | 搜索关键词,可选 |
|
||||
| `--user` / `--users` | 发送者 userId |
|
||||
| `--sender-ids` | 发送者 openDingTalkId |
|
||||
| `--at-me` / `--at-ids` | @ 我 / @ 指定 openDingTalkId |
|
||||
| `--conversation-ids` | 多个群聊或单聊 openConversationId;别名 `--groups` |
|
||||
| `--message-type` | 按消息类型过滤,例如 `file` |
|
||||
| `--search-conv-type` | 按会话类型过滤,例如 `group_chat` |
|
||||
| `--only-robot-messages` | 只搜索机器人消息 |
|
||||
| `--start` / `--end` | ISO-8601 时间范围 |
|
||||
| `--cursor` / `--limit` | 分页,翻页用 `nextCursor` |
|
||||
| `--page-all` / `--page-limit` / `--max-items` / `--page-delay` | 自动翻页;`--page-all` 是唯一触发开关 |
|
||||
|
||||
仅简单关键词搜索时可用:
|
||||
|
||||
```bash
|
||||
dws chat message search --query "changefree" --start <ISO> --end <ISO> --limit 50 --cursor 0
|
||||
dws chat message search --query "codereview" --group <openConversationId> --start <ISO> --end <ISO>
|
||||
dws chat message search --query "发布计划" --start <ISO> --end <ISO> --page-all --page-delay 0
|
||||
```
|
||||
|
||||
### 消息状态与撤回
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `message query-send-status` | 查询当前用户发消息任务状态 | `--open-task-id`,来自 `message send` 返回 |
|
||||
| `+messages-recall` | 撤回当前用户消息 | `--msg-id`;可选 `--conversation-id`,省略时 CLI 从消息详情补齐;兼容单值 `--message-ids` |
|
||||
| `message edit` | 编辑已发送消息内容 | `--conversation-id` `--msg-id`,并在 `--text` / `--content` 中二选一;可选 `--title` `--at-all` `--at-open-dingtalk-ids` |
|
||||
| `message read-status` | 查消息已读/未读状态 | `--group` `--message-id`;可选目标用户 |
|
||||
|
||||
刚由 `message send` 发出的消息会返回 `openTaskId`。先用 `message query-send-status` 查询,成功结果中的 `openMessageId` 和 `openConversationId` 可直接传给 `message edit` 或 `message recall`,无需再按消息内容从列表反查 ID。
|
||||
|
||||
```bash
|
||||
# 1. 发送后保留 openTaskId
|
||||
dws chat message send --group <openConversationId> --text "原始内容"
|
||||
# 2. 查询得到 openMessageId 和 openConversationId
|
||||
dws chat message query-send-status --open-task-id <openTaskId>
|
||||
# 3. 编辑消息
|
||||
dws chat message edit --conversation-id <openConversationId> --msg-id <openMessageId> --text "更新后的内容"
|
||||
|
||||
# 发送后撤回使用同一 ID 链
|
||||
dws chat message send --group <openConversationId> --text "待撤回的内容"
|
||||
dws chat message query-send-status --open-task-id <openTaskId>
|
||||
dws chat message recall --conversation-id <openConversationId> --msg-id <openMessageId>
|
||||
```
|
||||
|
||||
`+messages-recall` 与 `recall-by-bot` 不同:前者使用 `openMessageId`,缺少会话 ID 时先只读查询消息详情;后者撤回机器人消息,需要 `robot-code + processQueryKey`。不要把 `processQueryKey` 当 `openMessageId`。
|
||||
|
||||
编辑消息使用 `message edit`。推荐传 `--text`,CLI 会生成 markdown content JSON:`{"title":"标题","text":"正文"}`;可选 `--title`,不传时会从正文自动生成标题。高级场景可直接传 `--content`,此时必须是完整 markdown content JSON,且不能同时传 `--text`。
|
||||
|
||||
@ 规则:`--at-all` 会传 `atAll=true`,正文应包含 `<@all>`,未包含时 CLI 会自动补到开头;`--at-open-dingtalk-ids` 会传 `atOpenDingTalkIds`,正文需包含对应 `<@openDingTalkId>` 占位符,裸 `@openDingTalkId` 会自动补成尖括号格式。
|
||||
|
||||
```bash
|
||||
dws chat message edit --conversation-id <openConversationId> --msg-id <openMessageId> --text "更新后的内容"
|
||||
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --title "标题" --text "更新后的内容"
|
||||
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --text "<@all> 请查看" --at-all
|
||||
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --text "<@openDingTalkId1> 请查看" --at-open-dingtalk-ids <openDingTalkId1>
|
||||
dws chat message edit --group <openConversationId> --msg-id <openMessageId> --content '{"title":"标题","text":"更新后的内容"}'
|
||||
```
|
||||
|
||||
### 回复与转发的底层 fallback
|
||||
|
||||
引用回复默认使用 `dws chat +messages-reply`。以下原子 reply 只保留底层字段 fallback;转发
|
||||
仍按各自精确 Shortcut/leaf Schema 选择。
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `message reply` | 引用回复,单聊/群聊均可;群聊可 @指定成员或 @所有人 | `--conversation-id` `--ref-msg-id` `--ref-sender` `--text`;可选 `--at-open-dingtalk-ids` `--at-all` |
|
||||
| `message forward` | 转发单条消息,源/目标均支持单聊/群聊 | `--src-conversation-id` `--msg-id` `--dest-conversation-id` |
|
||||
| `message combine-forward` | 多条消息合并为一条转发 | `--src-conversation-id` `--msg-ids` `--dest-conversation-id`,可选 `--uuid` |
|
||||
| `message forward-topic` | 转发话题消息 | `--src-msg-id` `--src-conversation-id` `--src-thread-id` `--dest-conversation-id` |
|
||||
|
||||
群聊引用回复使用 `--at-open-dingtalk-ids` 传 `atOpenDingTalkIds`;正文缺少对应 `<@openDingTalkId>` 时自动补齐,已有裸 `@openDingTalkId` 会规范化。`--at-all` 会传 `atAll=true`,正文缺少 `<@all>` 时自动补齐。
|
||||
|
||||
```bash
|
||||
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <senderOpenDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>
|
||||
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <senderOpenDingTalkId> --text "请大家确认" --at-all
|
||||
```
|
||||
|
||||
### 话题与卡片
|
||||
|
||||
话题完整读取流程:
|
||||
|
||||
1. `dws chat message list --group <openConversationId> --time ...` 获取话题主消息。
|
||||
2. 如果返回 `openConvThreadId`,执行 `dws chat message list-topic-replies --group <openConversationId> --topic-id <openConvThreadId>`。
|
||||
|
||||
流式卡片优先使用公开 Shortcut;创建可选在同一次调用中写入内容:
|
||||
|
||||
```bash
|
||||
dws chat +messages-send-card --group <openConversationId> --at-open-dingtalk-ids <mentionedOpenDingTalkId> --content "开始处理" --flow-status 1
|
||||
dws chat +messages-update-card --biz-id <bizId> --content "更新的卡片内容" --flow-status 2
|
||||
dws chat +messages-update-card --biz-id <bizId> --content "最终内容" --flow-status 3
|
||||
```
|
||||
|
||||
`flow-status`:1=处理中,2=输入中,3=完成,4=执行中,5=错误,Runtime 拒绝范围外值。
|
||||
群聊还可传 `--at-all`;两种艾特参数只随创建请求发送。send-card 同时带正文时,
|
||||
Runtime 会把创建响应的 `atTag` 自动放在正文前;不要自行写 ID 或占位符。
|
||||
当前只支持 streaming text;不支持 Card JSON 组件或 action callback。精确边界见
|
||||
[card references](../card/schema.md)。
|
||||
|
||||
### Pin / Top / Favorite
|
||||
|
||||
| 命令 | 用途 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `message set-pin-msg` / `unset-pin-msg` | 钉住/取消钉住消息 | `--open-conversation-id` `--msg-id` |
|
||||
| `message list-pin-msg` | 拉取钉住消息列表 | `--open-conversation-id`,可选 `--cursor` `--size` |
|
||||
| `message set-top-msg` / `unset-top-msg` | 置顶/取消置顶会话内某条消息 | `--open-conversation-id` `--msg-id` |
|
||||
| `message add-favorite` | 收藏消息 | `--open-message-id` `--open-conversation-id` |
|
||||
| `message remove-favorite` | 取消收藏消息 | `--open-message-id` `--open-conversation-id` |
|
||||
| `+flag-list` | 查询收藏消息列表的默认 Shortcut | 默认一页;要求全部时加 `--page-all`;`--page-size/--size` 范围为 1-30,可用 `--page-token` 或兼容的 `--cursor` 续页,检查 `complete` / `failures` |
|
||||
| `message list-favorites` | typed 收藏消息列表 | 默认一页;完整遍历加 `--page-all`,数字 cursor,聚合 `result.items`;可选 `--cursor` `--size` |
|
||||
|
||||
`+flag-list` 查询钉钉 message favorite,底层使用数字 cursor;它与消息 Pin、消息 Top 和会话置顶属于不同对象层级。
|
||||
`message list-favorites --page-all` 仍遵守 `--size` 1-30;`--page-limit` 只控制最多请求页数,`--max-items` 可精确截断输出。
|
||||
消息置顶 `set-top-msg` 与会话置顶 `chat set-top` 不同:前者置顶会话内消息,后者置顶整个会话。
|
||||
|
||||
### 表情回应
|
||||
|
||||
优先查 [chat-emoji-list.md](../chat-emoji-list.md) 中的默认表情名称。
|
||||
|
||||
| 命令 | 场景 | 必填参数 |
|
||||
|------|------|----------|
|
||||
| `message add-emoji` / `remove-emoji` | 默认表情命中时使用 | `--conversation-id` `--msg-id` `--emoji` |
|
||||
| `message create-text-emotion` | 默认表情没有合适项时先创建 | `--emotion-name` `--text`,可选 `--background-id` |
|
||||
| `message add-text-emotion` / `remove-text-emotion` | 添加/移除文字表情 | `--conversation-id` `--msg-id` `--emotion-id` `--emotion-name` `--text` `--background-id` |
|
||||
| `message update-text-emotion` | 用新的文字表情替换消息上的原回应 | `--conversation-id` `--msg-id` `--old-emotion-id` `--emotion-id` `--emotion-name` `--text` `--background-id` |
|
||||
| `message list-emotion-replies` | 批量查询消息的表情回复和文字回复 | `--msg-ids` |
|
||||
|
||||
```bash
|
||||
dws chat message list-emotion-replies --msg-ids msgId1,msgId2,msgId3
|
||||
```
|
||||
|
||||
消息 ID 可通过 `dws chat message list` 获取;该命令用于一次性查看多条消息上的 emoji 回应和文字表情回应。
|
||||
|
||||
### 文本工具
|
||||
|
||||
#### `dws chat text translate`
|
||||
|
||||
将指定文本翻译成目标语言。用户只说“翻译这段文字”时使用;不要误走 `message send`。
|
||||
|
||||
```bash
|
||||
dws chat text translate --query "你好世界" --to en_US
|
||||
dws chat text translate --query "Hello World" --to zh_CN
|
||||
dws chat text translate --query "Bonjour" --to ja_JP
|
||||
```
|
||||
|
||||
关键 flags:
|
||||
|
||||
| Flag | 说明 |
|
||||
|------|------|
|
||||
| `--query` | 待翻译文本,必填 |
|
||||
| `--to` | 目标语言代码,必填,默认 `en_US` |
|
||||
|
||||
### 文件与媒体
|
||||
|
||||
#### `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/
|
||||
```
|
||||
|
||||
`resource-id` 来自消息内容中的 mediaId,`message-id` 来自 `openMessageId`,会话 ID 来自 `chat search` 或 `conversation-info`。
|
||||
|
||||
公开 `+messages-resource-download` 使用工作目录内安全相对路径、默认不覆盖、整文件临时落盘后
|
||||
原子发布。当前没有 Range/断点续传;失败时保留 ledger 或错误,显式重试整个文件,不拼接残片。
|
||||
|
||||
## 常见工作流
|
||||
|
||||
### 群聊发文字与文件
|
||||
|
||||
```bash
|
||||
dws chat +send-to-group --group "项目冲刺" --text "请大家本周五前提交周报" --format json
|
||||
dws chat +messages-send --as user --chat-query "项目冲刺" --file ./report.pdf --uuid <uuid> --format json
|
||||
dws chat +messages-send --as user --chat-query "项目冲刺" --msg-type audio --file ./voice.mp3 --uuid <uuid> --format json
|
||||
dws chat +messages-send --as user --chat-query "项目冲刺" --msg-type video --file ./demo.mp4 --uuid <uuid> --format json
|
||||
|
||||
# 仅 Shortcut 尚未覆盖的位置/名片底层类型才使用原子 fallback
|
||||
dws chat message send --group <openConversationId> --msg-type location --latitude <纬度> --longitude <经度> --location-name <地址名称> --map-thumbnail-url "@mediaId" --format json
|
||||
dws chat message send --group <openConversationId> --msg-type profile --contact-id <openDingTalkId> --format json
|
||||
```
|
||||
|
||||
### 查消息并撤回
|
||||
|
||||
```bash
|
||||
dws chat +chat-messages --group <openConversationId> --direction older --format json
|
||||
dws chat +messages-recall --msg-id <openMessageId> --format json
|
||||
```
|
||||
|
||||
### 多维度搜索
|
||||
|
||||
```bash
|
||||
dws chat message search-advanced --query "合同" --conversation-ids <cid1>,<cid2> --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00" --limit 50 --cursor 0 --format json
|
||||
dws chat message search-advanced --message-type file --search-conv-type group_chat --query "附件" --format json
|
||||
```
|
||||
|
||||
## 常见错误与回退
|
||||
|
||||
- 发送目标不唯一:保留 resolver 返回的候选并让用户消歧;不要退回手工搜索后选择第一项。
|
||||
- `unknown flag`:立即执行对应命令 `--help`,不要猜参数。
|
||||
- 文件/音视频发送失败:确认本地路径可读;新链路使用 `--msg-type file|audio|video --file-path`。
|
||||
- 位置消息参数不完整:先确认经纬度、地址名称和缩略图 mediaId。
|
||||
- 名片发送失败:确认 `--contact-id` 是 openDingTalkId,不是 userId。
|
||||
- 话题回复缺失:检查是否只拉了主消息,需继续用 `list-topic-replies`。
|
||||
- `search-advanced` 无条件:至少提供 query、sender、@、conversation、时间等任一有效过滤条件。
|
||||
@@ -0,0 +1,144 @@
|
||||
# group-admin:群创建、成员写入与管理
|
||||
|
||||
> 返回入口:[DingTalk Chat Skill](../../SKILL.md)
|
||||
|
||||
用于建群、修改群资料、成员增删、邀请卡片分享、群主和管理员、禁言、公告、群设置、
|
||||
入群审批、群身份、退出、解散和升级外部群。只读群发现、成员读取和邀请链接使用
|
||||
[group-discovery.md](group-discovery.md)。
|
||||
|
||||
## 安全与目标
|
||||
|
||||
- 群目标统一使用当前 profile 下真实 `openConversationId`;支持自然群名的 Shortcut 由 CLI
|
||||
唯一解析,多候选时停止。
|
||||
- 解散群、踢人、转让群主、禁言、管理员和外部群升级都是高影响操作;以最终 Runtime gate
|
||||
和精确 leaf Schema 为准确认对象、动作与影响。
|
||||
- 所有自然成员和群主必须先完成唯一解析并按稳定 ID 去重,再开始任何写入;不得边解析边
|
||||
产生部分副作用。
|
||||
- 群公告会触达成员;`notice edit` 是整体替换,必须有完整新正文。
|
||||
|
||||
## 建群与基础资料
|
||||
|
||||
<!-- dws-intent: chat.create.group -->基础建群使用 `dws chat +chat-create`。已知成员 ID 传 `--users`,
|
||||
姓名/花名传 `--member-query`;群主默认当前用户,也可传 `--owner-open-dingtalk-id` 或
|
||||
`--owner-query`。任一自然身份未唯一解析时,创建前整体停止。
|
||||
|
||||
```bash
|
||||
dws chat +chat-create --name "项目冲刺群" --member-query "测试用户甲,测试用户乙" --format json
|
||||
dws chat +chat-create --name "合作群" --member-query "测试用户甲" \
|
||||
--owner-query "测试用户乙" --type EXTERNAL --format json
|
||||
```
|
||||
|
||||
修改群名称优先使用接受群名或稳定 ID 的 `+chat-update`:
|
||||
|
||||
```bash
|
||||
dws chat +chat-update --group <群名或openConversationId> --name "新群名" --format json
|
||||
```
|
||||
|
||||
群头像和管理员级群开关使用 `+chat-update-icon`、`+chat-update-settings`;只有 Shortcut
|
||||
尚未发布真实必需字段时才评估原子 `group rename/update-icon/update-settings`。
|
||||
|
||||
原子 `chat group create` 只用于 `+chat-create` 未发布的真实底层字段,并先读取精确 leaf
|
||||
Schema。普通内部/外部群、话题群和显式群主已经由 `+chat-create` 覆盖,不回流到手工
|
||||
`aisearch → group create` 链路。
|
||||
|
||||
## 成员与机器人写入
|
||||
|
||||
| 动作 | 入口与关键参数 |
|
||||
|---|---|
|
||||
| 添加成员 | `group members add --id <cid> --users <userIds>` |
|
||||
| 移除成员 | `group members remove --id <cid> --users <userIds>` |
|
||||
| 添加已知机器人 | `+chat-add-bot` 或精确原子 `group members add-bot` |
|
||||
| 查看群内机器人 | `+chat-bots --group <群名或cid>` |
|
||||
| 移除群内机器人 | `+chat-remove-bot` 或精确原子 `group members remove-bot` |
|
||||
|
||||
普通成员增删的 `--users` 只接受组织 `userId`,必须来自真实人员解析结果;不得把
|
||||
`+chat-members-list` / `+chat-members-get` 返回的 `openDingTalkId` 直接传入。添加已知机器人
|
||||
使用 `robotCode`;移除机器人使用当前群 `+chat-bots` 返回的真实 `openBotId`,两者不能互换。
|
||||
缺少 `openBotId` 时在同一流程中先执行 `+chat-bots`,不必额外读取群发现 reference。只有需要
|
||||
搜索未知机器人、区分 `bot search` / `bot find`、机器人发送或撤回、Webhook 时,才读取
|
||||
[chat-bot.md](chat-bot.md)。
|
||||
|
||||
## 邀请卡片、群主、管理员与禁言
|
||||
|
||||
邀请链接只读走 `+chat-invite-url`。实际分享邀请卡片使用 `group share-invite`:`--source`
|
||||
是被分享群,接收端在 `--target` 会话和 `--receiver` 单聊用户之间二选一。
|
||||
|
||||
```bash
|
||||
dws chat group share-invite --source <sourceCid> --target <targetCid> --format json
|
||||
dws chat group share-invite --source <sourceCid> --receiver <openDingTalkId> --format json
|
||||
```
|
||||
|
||||
| 动作 | 入口与关键参数 |
|
||||
|---|---|
|
||||
| 转让群主 | `+chat-transfer-owner --group <cid> --new-owner <稳定ID>` |
|
||||
| 设置/取消管理员 | `group set-admin --group <cid> --users <ids> [--off]` |
|
||||
| 全员禁言/解除 | `group-mute --group <cid> [--off]` |
|
||||
| 成员禁言/解除 | `+chat-mute-member` 或 `group-mute-member` |
|
||||
| 查询禁言配置 | `group get-mute-config --group <cid>` |
|
||||
|
||||
原子 `group-mute-member --mute-time` 单位为毫秒。不要用展示名称代替稳定用户 ID,也不要
|
||||
在未确认影响时执行转让、踢人或禁言。
|
||||
|
||||
## 群设置与当前用户偏好
|
||||
|
||||
管理员级群开关使用 `+chat-update-settings` 或原子 `group update-settings`。常见 settingKey
|
||||
包括 `authority`、`joinValidation`、`onlyAdminCanAtAll`、`searchable`、
|
||||
`addFriendForbidden`、`onlyAdminCanDING`、`onlyAdminCanPinMsg` 和
|
||||
`onlyAdminCanSendFile`、`groupEmailDisabled`、`groupLiveAuthority`、
|
||||
`groupBillAuthority`;只修改用户明确要求的字段。
|
||||
|
||||
新成员历史消息可见范围使用 `group set-history --group <cid> --option <值>`;`option` 只取
|
||||
精确 leaf Schema 发布值,不按自然语言猜枚举。
|
||||
|
||||
当前登录用户自己的置顶、免打扰、群昵称和群备注使用 `group user-settings query/set`,
|
||||
不是管理员群开关。单个群昵称/备注优先 `group update-nick/update-alias`。
|
||||
|
||||
```bash
|
||||
dws chat group user-settings query --groups <cid1>,<cid2> --format json
|
||||
dws chat group user-settings set \
|
||||
--items '[{"openConversationId":"cid1","top":true,"mute":false}]' --format json
|
||||
```
|
||||
|
||||
批量设置只传本次要改的字段;空字符串清除昵称或备注,不补用户未要求的值。
|
||||
|
||||
## 群公告
|
||||
|
||||
| 动作 | 原子入口 |
|
||||
|---|---|
|
||||
| 发布公告 | `group notice create --group <cid> --content <完整Markdown>` |
|
||||
| 修改公告 | `group notice edit --group <cid> --notice-id <id> --content <完整Markdown>` |
|
||||
| 查询公告 | `group notice get/list` |
|
||||
|
||||
定时公告 `--run-at` 使用带时区时间;`notice list --scheduled` 查询待发布公告。分页时沿真实
|
||||
`nextPageCursor` 继续。修改前必须取得完整替换正文,不把增量片段当整篇公告。
|
||||
|
||||
## 入群审批与群身份
|
||||
|
||||
先用 `group list-join-validations` 取得真实 `record-id/applicant/inviter`,再执行
|
||||
`group audit-join-validation` 或 `+chat-audit-join`。审批状态只使用精确 leaf Schema 发布值。
|
||||
|
||||
群身份使用 `group-role` / `+chat-role-*`:
|
||||
|
||||
- `list/add/update/remove` 管理身份定义;
|
||||
- `set-user/remove-user/query-user` 管理成员身份;
|
||||
- `openRoleId` 必须来自真实身份列表。
|
||||
|
||||
覆盖或清除成员身份前确认用户、群和完整角色集合,不能用展示名称猜 `openRoleId`。
|
||||
|
||||
## 退出、解散与外部群升级
|
||||
|
||||
- 当前用户退出群:`+chat-quit` 或精确原子 `group quit`。
|
||||
- 解散群:`group dismiss`,不可逆。
|
||||
- 普通群升级外部群:`group upgrade-to-external`,不可逆。
|
||||
|
||||
这些动作必须以最终 Runtime gate 为准,不把示例中的确认参数当固定事实。
|
||||
|
||||
## 完成与错误
|
||||
|
||||
- 创建或更新后保留真实 `openConversationId` 和任务结果;只对查询结果真实返回的字段执行读回验证。
|
||||
- 写接口成功但现有查询未返回目标设置时,报告真实写入回执和不可独立读回的边界;不用群名、
|
||||
成员数等其他字段代替验证,也不猜未发布的读回命令。
|
||||
- 任一自然目标零命中或多候选时,在写入前整体停止。
|
||||
- 逐项写入保留 succeeded/failed/unknown ledger,不用重试抹掉失败项。
|
||||
- 分享邀请时 `--target` 与 `--receiver` 只能二选一;接收对象不明确时先确认。
|
||||
- 机器人进群失败时确认机器人身份和当前用户管理权限,不连续切换同义原子命令。
|
||||
@@ -0,0 +1,121 @@
|
||||
# group-discovery:群发现、列表与成员读取
|
||||
|
||||
> 返回入口:[DingTalk Chat Skill](../../SKILL.md)
|
||||
|
||||
用于只读的群列表、群搜索、共同群、群成员、群机器人和邀请链接。建群、改群、成员增删、
|
||||
邀请卡片分享、公告、禁言和其他群管理写操作读取 [group-admin.md](group-admin.md)。
|
||||
|
||||
## 入口选择
|
||||
|
||||
| 用户终点 | 唯一推荐入口 |
|
||||
|---|---|
|
||||
| 我加入的全部群 | `dws chat +my-groups --page-all` |
|
||||
| 我创建或管理的群 | `dws chat +chat-list-mine` |
|
||||
| 只看群主群或管理员群 | `+chat-list-mine --role OWNER|ADMIN` |
|
||||
| 按关键词搜索群 | `dws chat +chat-search --query <关键词>` |
|
||||
| 查看指定群全部成员 | `dws chat +chat-members-list --group <群名或ID>` |
|
||||
| 已知成员 openDingTalkId 批量查群内详情 | `dws chat +chat-members-get --id <cid> --users <ids>` |
|
||||
| 获取群邀请链接 | `dws chat +chat-invite-url --group <群名或ID>` |
|
||||
| 查看群机器人 | `dws chat +chat-bots --group <群名或ID>` |
|
||||
|
||||
“全部群”与“全部会话”不同:`+my-groups` 只列当前用户加入的群;
|
||||
`+conversation-list --page-all` 可能同时包含单聊和群聊,不能替代群成员关系。
|
||||
|
||||
## 群列表、分页与角色
|
||||
|
||||
`+my-groups` 返回当前用户加入的群,包括作为群主、管理员和普通成员加入的群:
|
||||
|
||||
- 要求完整列表时使用 `--page-all`;Runtime 沿真实 `nextCursor` 读取后续页,并按
|
||||
`openConversationId` 合并去重,读完后再应用可选 `--type` 本地过滤。
|
||||
- `--limit` 是每页数量,不是最终结果上限;`--cursor` 只用于从已有 `nextCursor` 手工续读。
|
||||
- `--page-limit` 只与 `--page-all` 一起使用,用于限制最多读取页数。达到上限后仍有下一页时,
|
||||
结果不完整。
|
||||
- 只有 `complete=true` 且 `hasMore=false` 才能声称已经读取全部;否则保留 `nextCursor`、
|
||||
`stopReason` 和 `failures` 并说明结果不完整。
|
||||
|
||||
```bash
|
||||
dws chat +my-groups --page-all --page-limit 50 --format json
|
||||
```
|
||||
|
||||
`+chat-list-mine` 只返回当前用户作为群主或管理员的群。只要群集合时不传 `--role`,
|
||||
一次取得 OWNER 和 ADMIN。要求逐项标明身份时,直接分别查询 `--role OWNER` 和
|
||||
`--role ADMIN`,不先执行无角色查询或读取 Help;按 `openConversationId` 合并去重后,
|
||||
再应用一次全局数量上限,不得把两个分支直接拼接。
|
||||
|
||||
```bash
|
||||
dws chat +chat-list-mine --limit 20 --format json
|
||||
dws chat +chat-list-mine --role OWNER --format json
|
||||
dws chat +chat-list-mine --role ADMIN --exclude-muted --format json
|
||||
```
|
||||
|
||||
`+my-groups` 不提供当前用户角色。用户明确要求普通成员群时,使用
|
||||
`chat group list-all --limit 200`;返回 `hasMore=true` 时,必须把真实 `nextCursor`
|
||||
传给下一次调用并继续读取,直到 `hasMore=false`,不得把继续翻页交给用户。读完后按
|
||||
`openConversationId` 去重,仅筛选真实返回的 `myRole=普通成员`;不得给 `+my-groups`
|
||||
编造 `--role MEMBER`,也不得用“全部群减去 OWNER/ADMIN 群”推断。
|
||||
|
||||
## 群搜索与稳定 ID
|
||||
|
||||
群搜索默认使用 `+chat-search`。要求全部候选时加 `--page-all`;可用
|
||||
`--page-size/--page-token` 或兼容 `--limit/--cursor`。零命中或多候选时停止并展示候选,
|
||||
不要选择第一项。
|
||||
|
||||
```bash
|
||||
dws chat +chat-search --query "项目冲刺" --page-all --format json
|
||||
```
|
||||
|
||||
只有数字群号时,使用 `chat group get-by-group-id --group-id <数字>` 转换为
|
||||
`openConversationId`。需要搜索共同群时使用原子 `chat search-common`;`AND` 表示所有人
|
||||
都在群里,`OR` 表示任一人在群里。自然人员必须先解析为当前 profile 的真实身份。
|
||||
|
||||
```bash
|
||||
dws chat search-common --nicks "测试用户甲,测试用户乙" --match-mode AND --limit 20 --cursor 0
|
||||
```
|
||||
|
||||
## 群成员
|
||||
|
||||
`+chat-members-list` 接受群名或 `openConversationId`,唯一解析后全量读取,并把用户与机器人
|
||||
分桶。结果必须检查 `buckets/complete/failures`。
|
||||
|
||||
```bash
|
||||
dws chat +chat-members-list --group "项目群" --format json
|
||||
dws chat +chat-members-list --conversation-id <openConversationId> --format json
|
||||
```
|
||||
|
||||
先检查 `+chat-members-list` 的稳定结果。只有结果未包含用户要求的群昵称、角色或其他群内字段时,
|
||||
才使用其中的真实 `openDingTalkId` 批量调用:
|
||||
|
||||
```bash
|
||||
dws chat +chat-members-get --id <openConversationId> \
|
||||
--users <openDingTalkId1>,<openDingTalkId2> --format json
|
||||
```
|
||||
|
||||
不要为了群内详情默认切换到企业通讯录;只有用户明确要求部门、岗位、直属主管等企业资料时,
|
||||
才把真实 userId 交给 `dingtalk-contact`。
|
||||
|
||||
## 邀请链接与机器人
|
||||
|
||||
`+chat-invite-url` 是只读获取链接,可选 `--expires-seconds`;`group share-invite` 会实际把
|
||||
邀请卡片发送给另一个会话或用户,属于 [group-admin.md](group-admin.md)。
|
||||
|
||||
`+chat-bots` 返回稳定 `bots[]` 和 `openBotId`,供后续移除。搜索可用机器人、机器人发送和
|
||||
撤回读取 [chat-bot.md](chat-bot.md)。
|
||||
|
||||
## 原子 fallback
|
||||
|
||||
| 原子命令 | 仅用于 |
|
||||
|---|---|
|
||||
| `chat search` / `search-common` | Shortcut 未发布的搜索字段或共同群 |
|
||||
| `chat group get-by-group-id` | 数字群号转换 |
|
||||
| `chat group members` | 需要原始成员分页响应 |
|
||||
| `chat group members list-by-ids` | 需要原始批量成员详情 |
|
||||
| `chat group list-all` / `list-my-groups` | 需要 Shortcut 未投影的真实底层字段 |
|
||||
|
||||
使用原子 fallback 前读取精确 leaf Schema;不得把 fallback 写成与 Shortcut 并列的默认路线。
|
||||
|
||||
## 完成与错误
|
||||
|
||||
- 分页完成只以真实 `complete/hasMore/nextCursor/failures` 判断,不看过滤后的 `count` 猜测。
|
||||
- 所有稳定 ID 必须来自同一 profile 的真实返回。
|
||||
- 找不到群或出现多候选时停止,不臆测 `openConversationId`。
|
||||
- 任务从只读发现转为写操作时,使用 [group-admin.md](group-admin.md) 的目标和安全规则。
|
||||
@@ -0,0 +1,132 @@
|
||||
# message-actions:消息编辑、撤回、回复与对象操作
|
||||
|
||||
> 返回入口:[DingTalk Chat Skill](../../SKILL.md)
|
||||
|
||||
用于对真实消息执行编辑、撤回、引用回复、转发、Pin、Top、Favorite 和表情回应写操作,
|
||||
并包含必要的紧邻验证。需要跨多个阶段传递真实结果的组合流程由
|
||||
[01-messaging.md](../01-messaging.md) 说明;本文件不重复完整工作流。
|
||||
|
||||
## 入口选择
|
||||
|
||||
| 用户终点 | 推荐入口 |
|
||||
|---|---|
|
||||
| 撤回当前用户消息 | `dws chat +messages-recall --msg-id <openMessageId>` |
|
||||
| <!-- dws-intent: chat.reply.quote -->引用回复 | `dws chat +messages-reply` |
|
||||
| 编辑已发送消息 | `dws chat message edit` |
|
||||
| 单条/合并/话题转发 | `+messages-forward` / `+messages-combine-forward` / `+messages-forward-topic` |
|
||||
| Pin / Unpin | `+messages-set-pin` / `+messages-unset-pin` |
|
||||
| 消息 Top / 取消 Top | `+messages-set-top` / `+messages-unset-top` |
|
||||
| Favorite / 取消 Favorite | `+flag-create` / `+flag-cancel` |
|
||||
| 默认 emoji 回应 | `+messages-add-emoji` / `+messages-remove-emoji` |
|
||||
|
||||
所有写操作以最终 Runtime gate 和精确 leaf Schema 为准。确认对象、消息和影响后再执行;
|
||||
不要因为文档示例自行制造或省略 confirmation。
|
||||
|
||||
## 稳定 ID 规则
|
||||
|
||||
- `openTaskId` 是发送任务 ID,不是消息 ID。
|
||||
- 撤回、编辑、回复、转发、Pin、Top 和 reaction 使用真实查询结果中的 `messageId`。
|
||||
- 同时保留消息的 `conversationId`、thread、发送者和引用上下文。
|
||||
- 子消息使用自己的 `messageId`;只在缺会话 ID 时继承父消息 `conversationId`。
|
||||
- Bot 撤回使用 `processQueryKey`,不使用本文件的 `openMessageId` 路线。
|
||||
|
||||
刚由用户身份发送的消息如果只得到 `openTaskId`,先查询发送状态:
|
||||
|
||||
```text
|
||||
+messages-send 或 message send
|
||||
→ openTaskId
|
||||
→ message query-send-status
|
||||
→ openMessageId + openConversationId
|
||||
→ 编辑或撤回
|
||||
```
|
||||
|
||||
## 撤回与编辑
|
||||
|
||||
`+messages-recall` 可只传 `--msg-id`;省略会话 ID 时 CLI 会通过只读消息详情补齐。
|
||||
兼容单值 `--message-ids`,但不要把 `processQueryKey` 当消息 ID。
|
||||
|
||||
```bash
|
||||
dws chat +messages-recall --msg-id <openMessageId> --format json
|
||||
dws chat +messages-recall --conversation-id <openConversationId> --msg-id <openMessageId> --format json
|
||||
```
|
||||
|
||||
编辑使用 `message edit --conversation-id <cid> --msg-id <id>`,并在 `--text` 与 `--content`
|
||||
中二选一。`--text` 由 CLI 生成 Markdown content;`--content` 必须是完整 content JSON。
|
||||
|
||||
```bash
|
||||
dws chat message edit --conversation-id <openConversationId> --msg-id <openMessageId> --text "更新后的内容"
|
||||
dws chat message edit --conversation-id <openConversationId> --msg-id <openMessageId> --content '{"title":"标题","text":"更新后的内容"}'
|
||||
```
|
||||
|
||||
群聊 @所有人使用 `--at-all`;指定人员使用 `--at-open-dingtalk-ids`。正文中的占位符以
|
||||
Runtime 规范化结果为准,不把裸展示名当稳定身份。
|
||||
|
||||
## 引用回复与转发
|
||||
|
||||
引用回复默认使用 `+messages-reply`。`--conversation-id` 和消息 ID 来自真实查询;
|
||||
`--ref-sender` 可省略时让 CLI 只读补齐,不手工猜发送者身份。
|
||||
|
||||
```bash
|
||||
dws chat +messages-reply --conversation-id <openConversationId> \
|
||||
--message-id <openMessageId> --text "收到" --format json
|
||||
```
|
||||
|
||||
| 动作 | 入口 | 关键上下文 |
|
||||
|---|---|---|
|
||||
| 单条转发 | `+messages-forward` | 源消息 ID、源会话、目标会话 |
|
||||
| 合并转发 | `+messages-combine-forward` | 多个真实消息 ID、源/目标会话 |
|
||||
| 话题转发 | `+messages-forward-topic` | 源消息、源会话、源 thread、目标会话 |
|
||||
|
||||
只有 Shortcut 尚未发布真实必需字段时,才评估原子 `message reply`、`forward`、
|
||||
`combine-forward` 或 `forward-topic`,并先读取精确 leaf Schema。不要复制正文伪装原生转发。
|
||||
|
||||
## Pin、Top 与 Favorite
|
||||
|
||||
| 对象 | 写入入口 | 说明 |
|
||||
|---|---|---|
|
||||
| 消息 Pin | `+messages-set-pin` / `+messages-unset-pin` | 作用于一条消息 |
|
||||
| 消息 Top | `+messages-set-top` / `+messages-unset-top` | 作用于会话内一条消息 |
|
||||
| Favorite | `+flag-create` / `+flag-cancel` | 当前用户收藏 |
|
||||
| 会话 Top | `+conversation-set-top` | 作用于整个会话,不属于本文件 |
|
||||
|
||||
用户要求确认 Pin 已生效时,使用 `+messages-list-pin` 检查真实结果中的 `messageId`;取消 Pin
|
||||
后仅在用户要求确认取消结果时再次查询。典型短链为:`+messages-set-pin` →
|
||||
`+messages-list-pin` → `+messages-unset-pin`。
|
||||
|
||||
需要原子 fallback 时,消息 Pin 对应 `message set-pin-msg/unset-pin-msg`,消息 Top 对应
|
||||
`message set-top-msg/unset-top-msg`,Favorite 对应 `message add-favorite/remove-favorite`。
|
||||
四种对象不能互换,即使用户都使用“收藏、钉住、置顶”等自然语言。
|
||||
|
||||
## 表情回应
|
||||
|
||||
优先在 [chat-emoji-list.md](../chat-emoji-list.md) 按表情名称查默认 emoji,不必全文理解表格。
|
||||
|
||||
| 场景 | 入口 |
|
||||
|---|---|
|
||||
| 添加/移除默认 emoji | `+messages-add-emoji` / `+messages-remove-emoji` |
|
||||
| 默认表情无合适项时创建文字表情 | `+messages-create-text-emotion` |
|
||||
| 添加/移除文字表情 | `+messages-add-text-emotion` / `+messages-remove-text-emotion` |
|
||||
| 替换文字表情 | `message update-text-emotion` |
|
||||
|
||||
reaction 查询属于 [message-query.md](message-query.md),不要为了查看回应执行写命令。
|
||||
|
||||
## 流式卡片与文本工具
|
||||
|
||||
流式卡片使用根 Skill 直接链接的 [card/create.md](../card/create.md)、
|
||||
[card/update.md](../card/update.md) 和 [card/schema.md](../card/schema.md);本文件不复制卡片参数。
|
||||
|
||||
纯文本翻译使用:
|
||||
|
||||
```bash
|
||||
dws chat text translate --query "你好世界" --to en_US
|
||||
```
|
||||
|
||||
用户只要求翻译文本时不要误走消息发送。
|
||||
|
||||
## 完成与错误
|
||||
|
||||
- 写操作检查任务级结果、投递状态和失败项,不只看退出码。
|
||||
- 投递状态 unknown 时保留幂等键,不自动换目标重发。
|
||||
- `unknown flag` 时读取精确 leaf Help,最多修正一次。
|
||||
- 目标消息不存在、会话不匹配或发送者上下文缺失时停止,不猜 ID。
|
||||
- 已知稳定消息 ID 的单一动作及其紧邻验证均在本文件完成。
|
||||
@@ -0,0 +1,76 @@
|
||||
# message-media:特殊消息与资源下载
|
||||
|
||||
> 返回入口:[DingTalk Chat Skill](../../SKILL.md)
|
||||
|
||||
只用于位置、联系人名片、底层 mediaId/fileId 和消息资源下载。普通文本、Markdown、文件、
|
||||
图片、音频和视频发送继续按根 Skill 使用 `+dm`、`+send-to-group` 或 `+messages-send --file`,
|
||||
不读取本文件。
|
||||
|
||||
## 默认边界
|
||||
|
||||
- 用户身份普通文件/音视频:`dws chat +messages-send --as user --file <相对路径>`。
|
||||
- 已知资源引用单独下载:`dws chat +messages-resource-download`。
|
||||
- 从消息中定位并下载资源:在定位消息的 `+chat-messages`、`+search-msg` 或
|
||||
`+messages-mget` 同一次调用中加 `--download-resources`。
|
||||
- 只有 Shortcut 尚未发布的位置、联系人名片或真实底层媒体字段,才使用原子 fallback。
|
||||
|
||||
<!-- dws-intent: chat.send.advanced -->`dws chat +messages-send` 的 user 文件能力不能外推给 Bot/Webhook;
|
||||
机器人富媒体边界读取 [chat-bot.md](chat-bot.md),不得静默改成当前用户身份。
|
||||
|
||||
## 位置与联系人名片
|
||||
|
||||
位置消息必须确认纬度、经度、地址名称和地图缩略图 mediaId:
|
||||
|
||||
```bash
|
||||
dws chat message send --group <openConversationId> --msg-type location \
|
||||
--latitude <纬度> --longitude <经度> --location-name <地址名称> \
|
||||
--map-thumbnail-url "@mediaId"
|
||||
```
|
||||
|
||||
联系人名片的 `--contact-id` 必须是联系人 `openDingTalkId`,不能把 userId 直接代入:
|
||||
|
||||
```bash
|
||||
dws chat message send --group <openConversationId> \
|
||||
--msg-type profile --contact-id <openDingTalkId>
|
||||
```
|
||||
|
||||
用户要求真实发送结果时,保留发送返回的 `openTaskId`,再执行:
|
||||
|
||||
```bash
|
||||
dws chat message query-send-status --open-task-id <openTaskId> --format json
|
||||
```
|
||||
|
||||
检查真实 `sendStatus`、`openMessageId` 和 `openConversationId`。
|
||||
|
||||
原子 `message send` 只在 Shortcut 缺少真实必需字段时使用。群聊目标用 `--group`;单聊目标
|
||||
用 `--user` 或 `--open-dingtalk-id`,三者通常互斥。发送前核对接收对象、消息类型和资源来源。
|
||||
|
||||
## 资源下载
|
||||
|
||||
公开 `+messages-resource-download` 使用工作目录内安全相对路径,默认不覆盖;完整文件先写入
|
||||
临时落盘再原子发布。覆盖必须由用户显式传 `--overwrite`,读取和下载不需要 `--yes`。
|
||||
|
||||
任务要求从某条消息中定位并下载资源时,优先在限定会话、消息或时间范围的查询中加
|
||||
`--download-resources --output-dir <目录>`,并检查下载 ledger。`+messages-resource-download`
|
||||
只用于已经持有完整、真实且属于当前组织/profile 的独立资源引用、无需再定位消息的场景。
|
||||
若 `fileId` 返回 `RESOURCE_NOT_FOUND`,不得把同一个 ID 改称 `mediaId` 重试,也不得原样
|
||||
重复调用;应回到消息查询并使用 `--download-resources`。
|
||||
|
||||
底层 fallback:
|
||||
|
||||
```bash
|
||||
dws chat message download-media --type mediaId --resource-id <mediaId> \
|
||||
--message-id <openMessageId> --open-conversation-id <openConversationId> \
|
||||
--output ./downloads/
|
||||
```
|
||||
|
||||
`resource-id`、`message-id` 和会话 ID 必须来自同一 profile 下的真实消息查询结果。
|
||||
当前没有 Range/断点续传;失败时保留 ledger 或错误,显式重试整个文件,不拼接残片。
|
||||
|
||||
## 完成与错误
|
||||
|
||||
- 查询并下载时同时检查消息完整性和每项下载 ledger;单项失败不抹掉已取得消息。
|
||||
- 文件/音视频发送失败先确认工作目录内相对路径可读,不恢复独立上传再提取 mediaId 的旧默认链路。
|
||||
- 位置参数不完整时先向用户确认,不猜经纬度或缩略图。
|
||||
- 名片发送失败时确认 `--contact-id` 是 openDingTalkId。
|
||||
- 下载目标存在时默认停止;只有用户明确允许覆盖时才传 `--overwrite`。
|
||||
@@ -0,0 +1,132 @@
|
||||
# message-query:消息读取、搜索与查询
|
||||
|
||||
> 返回入口:[DingTalk Chat Skill](../../SKILL.md)
|
||||
|
||||
用于浏览或导出指定会话、按条件搜索消息、按消息 ID 读取详情、查看 @我、话题回复、
|
||||
Favorite、Pin 和 reaction。只读任务优先使用 Shortcut;只有 Shortcut 未发布所需底层字段、
|
||||
原始响应或手工 continuation 时才读取精确原子 leaf Schema。
|
||||
|
||||
## 入口选择
|
||||
|
||||
| 用户终点 | 唯一推荐入口 |
|
||||
|---|---|
|
||||
| <!-- dws-intent: chat.read.conversation -->浏览或导出一个指定群聊/单聊 | `dws chat +chat-messages` |
|
||||
| <!-- dws-intent: chat.search.filtered -->发送者、关键词、@对象或消息类型是主要条件 | `dws chat +search-msg` |
|
||||
| 已知消息 IDs 读取详情 | `dws chat +messages-mget` |
|
||||
| 查看 @我的消息 | `dws chat +at-me` |
|
||||
| 查看 Favorite | `dws chat +flag-list` |
|
||||
| 已知话题主消息或 thread/topic ID 读取回复 | `dws chat +thread-replies` |
|
||||
|
||||
`+chat-messages` 是指定会话的粗粒度读取;`+search-msg` 是目标条件明确的单/跨会话检索。
|
||||
不要先读完整会话再补跑搜索,也不要把群名或姓名直接填入只接受稳定 ID 的参数。
|
||||
|
||||
## 指定会话读取
|
||||
|
||||
群聊 `--group` 可传群名或 `openConversationId`;也可用 `--chat-query` 显式解析群名、
|
||||
用 `--conversation-id` 显式传稳定 ID。单聊使用 `--user` 或 `--open-dingtalk-id`。
|
||||
|
||||
```bash
|
||||
dws chat +chat-messages --group <群名或openConversationId> --format json
|
||||
dws chat +chat-messages --group <openConversationId> --page-all --page-limit 50 --format json
|
||||
```
|
||||
|
||||
可附带非必填的 `--sender-query <姓名>`:未传时返回全部消息;未解析出稳定 ID 时保留
|
||||
全部消息并记录失败;唯一解析出 userId/openDingTalkId 后,按消息 `senderId` 筛选同一次
|
||||
读取结果,覆盖最终 `messages/count` 并返回 `resolvedFilters`。不得用展示名字符串比较否定
|
||||
已经成功解析的稳定发送者身份。
|
||||
|
||||
```bash
|
||||
dws chat +chat-messages --group "项目群" --sender-query "测试用户甲" --page-all --format json
|
||||
```
|
||||
|
||||
时间范围使用公开可选的 `--start`、`--end`、`--order asc|desc`,兼容别名为
|
||||
`--start-time/--end-time/--sort`。范围为 `[start,end)`;仅开始时间表示到本次执行当前时间;
|
||||
仅结束时间只支持 `desc`,`asc` 必须提供开始时间。旧 `--time/--direction` 只用于兼容的
|
||||
单边界模式,不能与范围模式混用。
|
||||
|
||||
```bash
|
||||
dws chat +chat-messages --group <openConversationId> \
|
||||
--start "2026-08-01T00:00:00+08:00" --end "2026-08-02T00:00:00+08:00" \
|
||||
--order asc --page-all --format json
|
||||
```
|
||||
|
||||
完整读取后只需消息字段可判断的子集时,在同一次调用中使用全局 `--jq`,保留根信封并
|
||||
同步改写 `messages/count`;不得丢失 `complete`、`hasMore`、`failures` 等 ledger。
|
||||
发送者姓名仍使用 `--sender-query` 解析稳定身份,不用 `--jq` 比较展示名。
|
||||
|
||||
```bash
|
||||
dws chat +chat-messages --group "项目群" --page-all --format json \
|
||||
--jq '. as $root | [.messages[] | select((.reactions // []) | length > 0)] as $matched | $root | .messages = $matched | .count = ($matched | length)'
|
||||
```
|
||||
|
||||
要求导出时用 `--output <工作目录内相对.json>` 原子写入;需要资源时在读取命令上加
|
||||
`--download-resources`,不要让 Agent 先输出全量 JSON 再手工遍历资源引用。
|
||||
|
||||
## 多维度搜索
|
||||
|
||||
- 关键词使用公开 `--query`。
|
||||
- 已知稳定会话 ID 使用 `--group` / `--groups`;稳定发送者 ID 使用 `--senders`。
|
||||
- 只有群名时使用 `--chat-query`,由 CLI 唯一解析会话。
|
||||
- 只有发送者姓名时使用 `--sender-query`,由 CLI 唯一解析人员。
|
||||
- 不传会话过滤时搜索全部会话;默认时间范围为最近 7 天。
|
||||
- `--page-all` 只翻完当前时间范围内的游标页;精确范围使用成对的 `--start/--end`。
|
||||
- `--order` 只稳定排列已经取得的结果;未全量或 `complete=false` 时不得称为完整范围全局排序。
|
||||
|
||||
```bash
|
||||
dws chat +search-msg --chat-query "项目群" --sender-query "测试用户甲" --page-all --format json
|
||||
dws chat +search-msg --chat-query "项目群" --query "发布计划" --page-all --format json
|
||||
dws chat +search-msg --sender-query "测试用户甲" --page-all --format json
|
||||
```
|
||||
|
||||
需要 Shortcut 未发布的原始过滤字段或响应时,才评估 `message search-advanced`。它支持
|
||||
发送者、@对象、多个会话、消息类型、会话类型、机器人消息和时间范围,但不是默认入口。
|
||||
至少提供一种真实过滤条件,完整遍历只有 `--page-all` 会触发。
|
||||
|
||||
## 其他查询
|
||||
|
||||
### 已知消息、@我与话题回复
|
||||
|
||||
- `+messages-mget --msg-ids <id...>`:最多 50 条;结果可直接用于回复、转发、撤回和资源下载。
|
||||
- `+at-me [--group <群名或ID>] --page-all`:群内或跨全部会话查看 @我的消息。
|
||||
- `+thread-replies --message-id <rootMessageId>`:自动只读解析 conversation/thread。
|
||||
- `+thread-replies --group <cid> --thread-id <threadId>`:显式稳定上下文。
|
||||
|
||||
话题回复默认 `desc`;`asc` 必须与 `--page-all` 一起使用。自动续页使用下层毫秒级
|
||||
`nextCursor`,不得使用只有秒精度的展示时间手工拼 continuation。检查 `complete`、
|
||||
`hasMore`、`stopReason` 和 `failures`。
|
||||
|
||||
### Favorite、Pin 与 reaction 查询
|
||||
|
||||
| 任务 | 入口 |
|
||||
|---|---|
|
||||
| Favorite 列表 | `+flag-list`;要求全部时加 `--page-all`,页大小 1–30 |
|
||||
| 消息 Pin 列表 | `message list-pin-msg --open-conversation-id <cid>` |
|
||||
| 批量 reaction/文字回应 | `message list-emotion-replies --msg-ids <id...>` |
|
||||
| 已读/未读状态 | `message read-status --group <cid> --message-id <id>` |
|
||||
|
||||
Favorite、消息 Pin、消息 Top 和会话 Top 是不同对象。写入或取消这些状态读取
|
||||
[message-actions.md](message-actions.md),这里只负责查询。
|
||||
|
||||
## 原子 fallback
|
||||
|
||||
| 原子命令 | 仅用于 |
|
||||
|---|---|
|
||||
| `message list` | 指定会话原始响应或显式手工 continuation |
|
||||
| `message list-all` | 时间范围内全部会话的原始分页响应 |
|
||||
| `message list-by-sender` | 已有稳定发送者 ID 且需要底层原始响应 |
|
||||
| `message list-mentions` / `list-focused` | @我或特别关注的原始列表 |
|
||||
| `message search` / `search-advanced` | Shortcut 未发布的真实过滤字段 |
|
||||
| `message list-topic-replies` | 已知 conversation/thread 的原始话题回复 |
|
||||
| `message list-by-ids` | 已知消息 ID 的原始详情响应 |
|
||||
|
||||
Typed `chat message` 自动翻页只由 `--page-all` 触发;只传 `--page-limit`、`--max-items`
|
||||
或 `--page-delay` 仍是单页。非第一页失败时保留 partial 结果、失败页和 continuation,不能
|
||||
把 partial result 表述成完整成功。
|
||||
|
||||
## 完成与错误
|
||||
|
||||
- 查询必须检查 `complete`、`hasMore`、`stopReason`、`failures` 和下载 ledger。
|
||||
- 发送者/群名零命中或多候选时停止,不选择第一项。
|
||||
- `unknown flag` 时读取精确 leaf Help,修正后最多重试一次。
|
||||
- 子消息优先使用自己的 `messageId`;只在缺会话 ID 时继承父消息的 `conversationId`。
|
||||
- 查到真实消息后需要写操作时,使用 [message-actions.md](message-actions.md) 中的稳定 ID 规则。
|
||||
@@ -22,12 +22,12 @@
|
||||
|
||||
| 用户终点 | 对象 | Reference |
|
||||
|---|---|---|
|
||||
| 收藏或取消收藏 | 当前用户的 Favorite | [chat-message.md](chat/chat-message.md) |
|
||||
| Pin/Unpin 一条消息 | 消息 Pin | [chat-message.md](chat/chat-message.md) |
|
||||
| 置顶/取消置顶一条消息 | 消息 Top | [chat-message.md](chat/chat-message.md) |
|
||||
| 收藏或取消收藏 | 当前用户的 Favorite | [message-actions.md](chat/message-actions.md) |
|
||||
| Pin/Unpin 一条消息 | 消息 Pin | [message-actions.md](chat/message-actions.md) |
|
||||
| 置顶/取消置顶一条消息 | 消息 Top | [message-actions.md](chat/message-actions.md) |
|
||||
| 置顶/取消置顶整个会话 | 会话 Top | [chat-conversation.md](chat/chat-conversation.md) |
|
||||
| 查看置顶会话 | 会话列表 | `+conversation-list-top` |
|
||||
| 标记消息已读 | 消息读取状态 | [chat-message.md](chat/chat-message.md) |
|
||||
| 标记消息已读 | 消息读取状态 | [message-actions.md](chat/message-actions.md) |
|
||||
| 清红点、标记会话未读 | 会话状态 | [chat-conversation.md](chat/chat-conversation.md) |
|
||||
|
||||
Favorite、消息 Pin、消息 Top 和会话 Top 不能互换,即使用户都说“收藏/钉住/置顶”。
|
||||
@@ -37,7 +37,8 @@ Favorite、消息 Pin、消息 Top 和会话 Top 不能互换,即使用户都
|
||||
| 用户终点 | 选择 |
|
||||
|---|---|
|
||||
| 已有成员 IDs 创建群 | `+chat-create` |
|
||||
| 加人、踢人、管理员、群公告、群设置 | [chat-group.md](chat/chat-group.md) |
|
||||
| 查群、查看成员、邀请链接 | [group-discovery.md](chat/group-discovery.md) |
|
||||
| 加人、踢人、管理员、群公告、群设置 | [group-admin.md](chat/group-admin.md) |
|
||||
| 找可用机器人并取得单聊 ID | `chat bot find`,不是只查自己创建机器人的 `bot search` |
|
||||
| 已知 robotCode 发送 | `+messages-send --as bot` |
|
||||
| 机器人入群、移除、批量群发或撤回 | [chat-bot.md](chat/chat-bot.md) |
|
||||
|
||||
@@ -24,7 +24,8 @@ metadata:
|
||||
|
||||
| Shortcut | 风险 | 适用场景 |
|
||||
|---|---|---|
|
||||
| `dws wiki +space-search` | read | 搜索知识库 |
|
||||
| `dws wiki +resolve-space` | read | 按名称搜索知识空间并解析出唯一 spaceId(只读) |
|
||||
| `dws wiki +wiki-new-doc` | write | 在指定名称的知识库下新建一个文档节点(自动按空间名解析 workspaceId) |
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
|
||||
## 意图表
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package scripts_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWikiShortcutE2ERequiresInteractiveConfirmation(t *testing.T) {
|
||||
script := filepath.Join("..", "..", "scripts", "dev", "wiki-shortcut-e2e.py")
|
||||
source, err := os.ReadFile(script)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(source, []byte("--yes")) {
|
||||
t.Fatal("real-data Wiki E2E script must not embed confirmation bypass flags")
|
||||
}
|
||||
|
||||
python, err := exec.LookPath("python3")
|
||||
if err != nil {
|
||||
t.Skip("python3 is unavailable")
|
||||
}
|
||||
cmd := exec.Command(python, script)
|
||||
cmd.Stdin = strings.NewReader("")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err == nil {
|
||||
t.Fatal("non-interactive real-data E2E unexpectedly succeeded")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "run in an interactive terminal") {
|
||||
t.Fatalf("non-interactive failure = %q, want explicit terminal requirement", stderr.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "PASS ") {
|
||||
t.Fatalf("non-interactive E2E performed work before refusing: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user